diff --git a/src/Dapr.Client/DaprClient.cs b/src/Dapr.Client/DaprClient.cs index 21777105b..4f89d8668 100644 --- a/src/Dapr.Client/DaprClient.cs +++ b/src/Dapr.Client/DaprClient.cs @@ -306,6 +306,20 @@ public HttpRequestMessage CreateInvokeMethodRequest(string appId, string methodN return CreateInvokeMethodRequest(HttpMethod.Post, appId, methodName); } + /// + /// Creates an that can be used to perform service invocation for the + /// application identified by and invokes the method specified by + /// with the POST HTTP method. + /// + /// The Dapr application id to invoke the method on. + /// The name of the method to invoke. + /// A collection of key/value pairs to populate the query string from. + /// An for use with SendInvokeMethodRequestAsync. + public HttpRequestMessage CreateInvokeMethodRequest(string appId, string methodName, IReadOnlyCollection> queryStringParameters) + { + return CreateInvokeMethodRequest(HttpMethod.Post, appId, methodName, queryStringParameters); + } + /// /// Creates an that can be used to perform service invocation for the /// application identified by and invokes the method specified by @@ -317,6 +331,19 @@ public HttpRequestMessage CreateInvokeMethodRequest(string appId, string methodN /// An for use with SendInvokeMethodRequestAsync. public abstract HttpRequestMessage CreateInvokeMethodRequest(HttpMethod httpMethod, string appId, string methodName); + /// + /// Creates an that can be used to perform service invocation for the + /// application identified by and invokes the method specified by + /// with the HTTP method specified by . + /// + /// The to use for the invocation request. + /// The Dapr application id to invoke the method on. + /// The name of the method to invoke. + /// A collection of key/value pairs to populate the query string from. + /// An for use with SendInvokeMethodRequestAsync. + public abstract HttpRequestMessage CreateInvokeMethodRequest(HttpMethod httpMethod, string appId, + string methodName, IReadOnlyCollection> queryStringParameters); + /// /// Creates an that can be used to perform service invocation for the /// application identified by and invokes the method specified by @@ -329,9 +356,9 @@ public HttpRequestMessage CreateInvokeMethodRequest(string appId, string methodN /// An for use with SendInvokeMethodRequestAsync. public HttpRequestMessage CreateInvokeMethodRequest(string appId, string methodName, TRequest data) { - return CreateInvokeMethodRequest(HttpMethod.Post, appId, methodName, data); + return CreateInvokeMethodRequest(HttpMethod.Post, appId, methodName, new List>(), data); } - + /// /// Creates an that can be used to perform service invocation for the /// application identified by and invokes the method specified by @@ -343,9 +370,10 @@ public HttpRequestMessage CreateInvokeMethodRequest(string appId, stri /// The Dapr application id to invoke the method on. /// The name of the method to invoke. /// The data that will be JSON serialized and provided as the request body. + /// A collection of key/value pairs to populate the query string from. /// An for use with SendInvokeMethodRequestAsync. - public abstract HttpRequestMessage CreateInvokeMethodRequest(HttpMethod httpMethod, string appId, string methodName, TRequest data); - + public abstract HttpRequestMessage CreateInvokeMethodRequest(HttpMethod httpMethod, string appId, string methodName, IReadOnlyCollection> queryStringParameters, TRequest data); + /// /// Perform health-check of Dapr sidecar. Return 'true' if sidecar is healthy. Otherwise 'false'. /// CheckHealthAsync handle and will return 'false' if error will occur on transport level @@ -526,7 +554,7 @@ public Task InvokeMethodAsync( TRequest data, CancellationToken cancellationToken = default) { - var request = CreateInvokeMethodRequest(httpMethod, appId, methodName, data); + var request = CreateInvokeMethodRequest(httpMethod, appId, methodName, new List>(), data); return InvokeMethodAsync(request, cancellationToken); } @@ -620,7 +648,7 @@ public Task InvokeMethodAsync( TRequest data, CancellationToken cancellationToken = default) { - var request = CreateInvokeMethodRequest(httpMethod, appId, methodName, data); + var request = CreateInvokeMethodRequest(httpMethod, appId, methodName, new List>(), data); return InvokeMethodAsync(request, cancellationToken); } diff --git a/src/Dapr.Client/DaprClientGrpc.cs b/src/Dapr.Client/DaprClientGrpc.cs index 3cd7de526..af245afc3 100644 --- a/src/Dapr.Client/DaprClientGrpc.cs +++ b/src/Dapr.Client/DaprClientGrpc.cs @@ -345,7 +345,32 @@ public override async Task InvokeBindingAsync(BindingRequest re #region InvokeMethod Apis + /// + /// Creates an that can be used to perform service invocation for the + /// application identified by and invokes the method specified by + /// with the HTTP method specified by . + /// + /// The to use for the invocation request. + /// The Dapr application id to invoke the method on. + /// The name of the method to invoke. + /// An for use with SendInvokeMethodRequestAsync. public override HttpRequestMessage CreateInvokeMethodRequest(HttpMethod httpMethod, string appId, string methodName) + { + return CreateInvokeMethodRequest(httpMethod, appId, methodName, new List>()); + } + + /// + /// Creates an that can be used to perform service invocation for the + /// application identified by and invokes the method specified by + /// with the HTTP method specified by . + /// + /// The to use for the invocation request. + /// The Dapr application id to invoke the method on. + /// The name of the method to invoke. + /// A collection of key/value pairs to populate the query string from. + /// An for use with SendInvokeMethodRequestAsync. + public override HttpRequestMessage CreateInvokeMethodRequest(HttpMethod httpMethod, string appId, string methodName, + IReadOnlyCollection> queryStringParameters) { ArgumentVerifier.ThrowIfNull(httpMethod, nameof(httpMethod)); ArgumentVerifier.ThrowIfNullOrEmpty(appId, nameof(appId)); @@ -356,7 +381,8 @@ public override HttpRequestMessage CreateInvokeMethodRequest(HttpMethod httpMeth // // This approach avoids some common pitfalls that could lead to undesired encoding. var path = $"/v1.0/invoke/{appId}/method/{methodName.TrimStart('/')}"; - var request = new HttpRequestMessage(httpMethod, new Uri(this.httpEndpoint, path)); + var requestUri = new Uri(this.httpEndpoint, path).AddQueryParameters(queryStringParameters); + var request = new HttpRequestMessage(httpMethod, requestUri); request.Options.Set(new HttpRequestOptionsKey(AppIdKey), appId); request.Options.Set(new HttpRequestOptionsKey(MethodNameKey), methodName); @@ -369,13 +395,27 @@ public override HttpRequestMessage CreateInvokeMethodRequest(HttpMethod httpMeth return request; } - public override HttpRequestMessage CreateInvokeMethodRequest(HttpMethod httpMethod, string appId, string methodName, TRequest data) + /// + /// Creates an that can be used to perform service invocation for the + /// application identified by and invokes the method specified by + /// with the HTTP method specified by and a JSON serialized request body specified by + /// . + /// + /// The type of the data that will be JSON serialized and provided as the request body. + /// The to use for the invocation request. + /// The Dapr application id to invoke the method on. + /// The name of the method to invoke. + /// The data that will be JSON serialized and provided as the request body. + /// A collection of key/value pairs to populate the query string from. + /// An for use with SendInvokeMethodRequestAsync. + public override HttpRequestMessage CreateInvokeMethodRequest(HttpMethod httpMethod, string appId, string methodName, + IReadOnlyCollection> queryStringParameters, TRequest data) { ArgumentVerifier.ThrowIfNull(httpMethod, nameof(httpMethod)); ArgumentVerifier.ThrowIfNullOrEmpty(appId, nameof(appId)); ArgumentVerifier.ThrowIfNull(methodName, nameof(methodName)); - var request = CreateInvokeMethodRequest(httpMethod, appId, methodName); + var request = CreateInvokeMethodRequest(httpMethod, appId, methodName, queryStringParameters); request.Content = JsonContent.Create(data, options: this.JsonSerializerOptions); return request; } diff --git a/src/Dapr.Client/EnumExtensions.cs b/src/Dapr.Client/Extensions/EnumExtensions.cs similarity index 88% rename from src/Dapr.Client/EnumExtensions.cs rename to src/Dapr.Client/Extensions/EnumExtensions.cs index 6b058ca77..df9c9ad33 100644 --- a/src/Dapr.Client/EnumExtensions.cs +++ b/src/Dapr.Client/Extensions/EnumExtensions.cs @@ -11,6 +11,7 @@ // limitations under the License. // ------------------------------------------------------------------------ +#nullable enable using System; using System.Reflection; using System.Runtime.Serialization; @@ -27,12 +28,14 @@ internal static class EnumExtensions /// public static string GetValueFromEnumMember(this T value) where T : Enum { + ArgumentNullException.ThrowIfNull(value, nameof(value)); + var memberInfo = typeof(T).GetMember(value.ToString(), BindingFlags.Static | BindingFlags.Public | BindingFlags.DeclaredOnly); if (memberInfo.Length <= 0) return value.ToString(); var attributes = memberInfo[0].GetCustomAttributes(typeof(EnumMemberAttribute), false); - return attributes.Length > 0 ? ((EnumMemberAttribute)attributes[0]).Value : value.ToString(); + return (attributes.Length > 0 ? ((EnumMemberAttribute)attributes[0]).Value : value.ToString()) ?? value.ToString(); } } } diff --git a/src/Dapr.Client/Extensions/HttpExtensions.cs b/src/Dapr.Client/Extensions/HttpExtensions.cs new file mode 100644 index 000000000..259d2747d --- /dev/null +++ b/src/Dapr.Client/Extensions/HttpExtensions.cs @@ -0,0 +1,51 @@ +// ------------------------------------------------------------------------ +// Copyright 2024 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +#nullable enable +using System; +using System.Collections.Generic; +using System.Text; + +namespace Dapr.Client +{ + /// + /// Provides extensions specific to HTTP types. + /// + internal static class HttpExtensions + { + /// + /// Appends key/value pairs to the query string on an HttpRequestMessage. + /// + /// The uri to append the query string parameters to. + /// The key/value pairs to populate the query string with. + public static Uri AddQueryParameters(this Uri? uri, + IReadOnlyCollection>? queryStringParameters) + { + ArgumentNullException.ThrowIfNull(uri, nameof(uri)); + if (queryStringParameters is null) + return uri; + + var uriBuilder = new UriBuilder(uri); + var qsBuilder = new StringBuilder(uriBuilder.Query); + foreach (var kvParam in queryStringParameters) + { + if (qsBuilder.Length > 0) + qsBuilder.Append('&'); + qsBuilder.Append($"{Uri.EscapeDataString(kvParam.Key)}={Uri.EscapeDataString(kvParam.Value)}"); + } + + uriBuilder.Query = qsBuilder.ToString(); + return uriBuilder.Uri; + } + } +} diff --git a/test/Dapr.Client.Test/DaprClientTest.InvokeMethodAsync.cs b/test/Dapr.Client.Test/DaprClientTest.InvokeMethodAsync.cs index 5d46000a1..484f327d0 100644 --- a/test/Dapr.Client.Test/DaprClientTest.InvokeMethodAsync.cs +++ b/test/Dapr.Client.Test/DaprClientTest.InvokeMethodAsync.cs @@ -518,6 +518,18 @@ public async Task CreateInvokeMethodRequest_TransformsUrlCorrectly(string method Assert.Equal(new Uri(expected).AbsoluteUri, request.RequestUri.AbsoluteUri); } + [Fact] + public async Task CreateInvokeMethodRequest_AppendQueryStringValuesCorrectly() + { + await using var client = TestClient.CreateForDaprClient(c => + { + c.UseGrpcEndpoint("http://localhost").UseHttpEndpoint("https://test-endpoint:3501").UseJsonSerializationOptions(this.jsonSerializerOptions); + }); + + var request = client.InnerClient.CreateInvokeMethodRequest("test-app", "mymethod", (IReadOnlyCollection>)new List> { new("a", "0"), new("b", "1") }); + Assert.Equal(new Uri("https://test-endpoint:3501/v1.0/invoke/test-app/method/mymethod?a=0&b=1").AbsoluteUri, request.RequestUri.AbsoluteUri); + } + [Fact] public async Task CreateInvokeMethodRequest_WithoutApiToken_CreatesHttpRequestWithoutApiTokenHeader() { @@ -617,6 +629,34 @@ public async Task CreateInvokeMethodRequest_WithData_CreatesJsonContent() Assert.Equal(data.Color, actual.Color); } + [Fact] + public async Task CreateInvokeMethodRequest_WithData_CreatesJsonContentWithQueryString() + { + await using var client = TestClient.CreateForDaprClient(c => + { + c.UseGrpcEndpoint("http://localhost").UseHttpEndpoint("https://test-endpoint:3501").UseJsonSerializationOptions(this.jsonSerializerOptions); + }); + + var data = new Widget + { + Color = "red", + }; + + var request = client.InnerClient.CreateInvokeMethodRequest(HttpMethod.Post, "test-app", "test", new List> { new("a", "0"), new("b", "1") }, data); + + Assert.Equal(new Uri("https://test-endpoint:3501/v1.0/invoke/test-app/method/test?a=0&b=1").AbsoluteUri, request.RequestUri.AbsoluteUri); + + var content = Assert.IsType(request.Content); + Assert.Equal(typeof(Widget), content.ObjectType); + Assert.Same(data, content.Value); + + // the best way to verify the usage of the correct settings object + var actual = await content.ReadFromJsonAsync(this.jsonSerializerOptions); + Assert.Equal(data.Color, actual.Color); + } + + + [Fact] public async Task InvokeMethodWithResponseAsync_ReturnsMessageWithoutCheckingStatus() { diff --git a/test/Dapr.Client.Test/EnumExtensionTest.cs b/test/Dapr.Client.Test/Extensions/EnumExtensionTest.cs similarity index 87% rename from test/Dapr.Client.Test/EnumExtensionTest.cs rename to test/Dapr.Client.Test/Extensions/EnumExtensionTest.cs index be78c3861..83c4354f9 100644 --- a/test/Dapr.Client.Test/EnumExtensionTest.cs +++ b/test/Dapr.Client.Test/Extensions/EnumExtensionTest.cs @@ -1,7 +1,7 @@ using System.Runtime.Serialization; using Xunit; -namespace Dapr.Client.Test +namespace Dapr.Client.Test.Extensions { public class EnumExtensionTest { @@ -29,9 +29,9 @@ public void GetValueFromEnumMember_BlueResolvesAsExpected() public enum TestEnum { - [EnumMember(Value="red")] + [EnumMember(Value = "red")] Red, - [EnumMember(Value="YELLOW")] + [EnumMember(Value = "YELLOW")] Yellow, Blue } diff --git a/test/Dapr.Client.Test/Extensions/HttpExtensionTest.cs b/test/Dapr.Client.Test/Extensions/HttpExtensionTest.cs new file mode 100644 index 000000000..7b93c1c91 --- /dev/null +++ b/test/Dapr.Client.Test/Extensions/HttpExtensionTest.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Net.Http; +using Xunit; + +namespace Dapr.Client.Test.Extensions +{ + public class HttpExtensionTest + { + [Fact] + public void AddQueryParameters_ReturnsEmptyQueryStringWithNullParameters() + { + const string uri = "https://localhost/mypath"; + var httpRq = new HttpRequestMessage(HttpMethod.Get, uri); + var updatedUri = httpRq.RequestUri.AddQueryParameters(null); + Assert.Equal(uri, updatedUri.AbsoluteUri); + } + + [Fact] + public void AddQueryParameters_ReturnsOriginalQueryStringWithNullParameters() + { + const string uri = "https://localhost/mypath?a=0&b=1"; + var httpRq = new HttpRequestMessage(HttpMethod.Get, uri); + var updatedUri = httpRq.RequestUri.AddQueryParameters(null); + Assert.Equal(uri, updatedUri.AbsoluteUri); + } + + [Fact] + public void AddQueryParameters_BuildsQueryString() + { + var httpRq = new HttpRequestMessage(HttpMethod.Get, "https://localhost/mypath?a=0"); + var updatedUri = httpRq.RequestUri.AddQueryParameters(new List> + { + new("test", "value") + }); + Assert.Equal("https://localhost/mypath?a=0&test=value", updatedUri.AbsoluteUri); + } + + [Fact] + public void AddQueryParameters_BuildQueryStringWithDuplicateKeys() + { + var httpRq = new HttpRequestMessage(HttpMethod.Get, "https://localhost/mypath"); + var updatedUri = httpRq.RequestUri.AddQueryParameters(new List> + { + new("test", "1"), + new("test", "2"), + new("test", "3") + }); + Assert.Equal("https://localhost/mypath?test=1&test=2&test=3", updatedUri.AbsoluteUri); + } + + [Fact] + public void AddQueryParameters_EscapeSpacesInValues() + { + var httpRq = new HttpRequestMessage(HttpMethod.Get, "https://localhost/mypath"); + var updatedUri = httpRq.RequestUri.AddQueryParameters(new List> + { + new("name1", "John Doe"), + new("name2", "Jane Doe") + }); + Assert.Equal("https://localhost/mypath?name1=John%20Doe&name2=Jane%20Doe", updatedUri.AbsoluteUri); + } + } +}