Repository: dapr/dotnet-sdk (csharp)

Issue:
Passing query string parameters on method invocation
The [service invocation documentation](https://docs.dapr.io/reference/api/service_invocation_api/) speaks to a body-based payload (via POST) and doesn't mention query strings anywhere.

I was looking to use Dapr to invoke a method on an API controller that required query string parameters. On a whim, I jammed the query string to the end of the request URL to see what it'd do and to my surprise, it worked precisely as expected. To that end, I thought I'd file this ticket to confirm this is happening as it was expected to and if so, see if there's any interest in a PR that adds some overloads to allow query strings to be more readily appended to the URI.

In the short term, here's what I'm currently doing that's working:
```cs
//API - MyController.cs
[ApiController]
[Route("[controller]")]
public class MyController() : ControllerBase
{
  [HttpGet]
  public IEnumerable<MyObject> Get([FromQuery(Name="c")] int count)
  {
    //Return something
  }
}

// Extensions
    public static void AddQueryParameters(
        this HttpRequestMessage? message,
        Dictionary<string, string>? queryStringParameters)
    {
        ArgumentNullException.ThrowIfNull((object)message, nameof(message));
        if (queryStringParameters == null)
            return;
        UriBuilder uriBuilder = new UriBuilder(message.RequestUri);
        string str = uriBuilder.Query;
        foreach (KeyValuePair<string, string> queryStringParameter in queryStringParameters)
        {
            if (!string.IsNullOrEmpty(str))
                str += "&";
            str = str + Uri.EscapeDataString(queryStringParameter.Key) + "=" + Uri.EscapeDataString(queryStringParameter.Value);
        }
        uriBuilder.Query = str;
        message.RequestUri = uriBuilder.Uri;
    }

//Service - Program.cs
app.MapGet("/api/data", async (DaprClient client) => 
{
  var req = daprClient.CreateInvokeMethodRequest(HttpMethod.Get, "api-service", "Get");
  req.AddQueryParameters(new Dictionary<string,string>
  {
    {"c", "2"}
  });

  return await client.InvokeMethodAsync<int[]>(req);  
});
```

Can anyone confirm this is working as fully expected?

Pull request:
# Description
When a method is invoked via the Dapr service invocation building block, a query string can be passed through on the invocation URL without issue. However, when using the .NET SDK, this behavior isn't exposed anywhere. Existing overloads allow the app ID to be specified, the method named, the payload to be provided, but that's it - it's up to the developer to realize that a query string is supported, then augment the `RequestUri` property of the returned `HttpRequestMessage` to append their query string values, and proceed with the invocation.

This PR provides overloads to the invocation methods to allow an `IReadOnlyCollection<KeyValuePair<string,string>>` to be provided as the collection of key/value pairs to build/supplement the query string with. This uses a static extension method I've contributed (and added tests for) that appends the encoded key/value pairs provided in the collection to build a standards-matching query string so developers don't have to maintain the same on their own.

Why an `IReadOnlyCollection` instead of a `Dictionary<string,string>`? Because identical keys [can exist more than once in a query string](https://url.spec.whatwg.org/#example-searchparams-sort) with different values, so this needs to be more of a collection and less of a mapping. And because at the end of the day, this is being written to a string-based URI, these methods expect string-based keys and values, leaving it to the developer to figure out how to serialize accordingly before calling this.

Finally, I had the existing concrete implementations point to the new overloads and pass empty collections so as to avoid code duplication.

## Issue reference
Please reference the issue this PR will close: #1303 

## Checklist

Please make sure you've completed the relevant tasks for this PR, out of the following list:

* [X] Code compiles correctly
* [X] Created/updated tests
* [X] Extended the documentation

Documentation PR extended here, though generically and not in a C#-specific way: [https://github.com/dapr/docs/pull/4217](https://github.com/dapr/docs/pull/4217)

Patch:
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);
         }
 
+        /// <summary>
+        /// Creates an <see cref="HttpRequestMessage" /> that can be used to perform service invocation for the
+        /// application identified by <paramref name="appId" /> and invokes the method specified by <paramref name="methodName" />
+        /// with the <c>POST</c> HTTP method.
+        /// </summary>
+        /// <param name="appId">The Dapr application id to invoke the method on.</param>
+        /// <param name="methodName">The name of the method to invoke.</param>
+        /// <param name="queryStringParameters">A collection of key/value pairs to populate the query string from.</param>
+        /// <returns>An <see cref="HttpRequestMessage" /> for use with <c>SendInvokeMethodRequestAsync</c>.</returns>
+        public HttpRequestMessage CreateInvokeMethodRequest(string appId, string methodName, IReadOnlyCollection<KeyValuePair<string,string>> queryStringParameters)
+        {
+            return CreateInvokeMethodRequest(HttpMethod.Post, appId, methodName, queryStringParameters);
+        }
+        
         /// <summary>
         /// Creates an <see cref="HttpRequestMessage" /> that can be used to perform service invocation for the
         /// application identified by <paramref name="appId" /> and invokes the method specified by <paramref name="methodName" />
@@ -317,6 +331,19 @@ public HttpRequestMessage CreateInvokeMethodRequest(string appId, string methodN
         /// <returns>An <see cref="HttpRequestMessage" /> for use with <c>SendInvokeMethodRequestAsync</c>.</returns>
         public abstract HttpRequestMessage CreateInvokeMethodRequest(HttpMethod httpMethod, string appId, string methodName);
 
+        /// <summary>
+        /// Creates an <see cref="HttpRequestMessage" /> that can be used to perform service invocation for the
+        /// application identified by <paramref name="appId" /> and invokes the method specified by <paramref name="methodName" />
+        /// with the HTTP method specified by <paramref name="httpMethod" />.
+        /// </summary>
+        /// <param name="httpMethod">The <see cref="HttpMethod" /> to use for the invocation request.</param>
+        /// <param name="appId">The Dapr application id to invoke the method on.</param>
+        /// <param name="methodName">The name of the method to invoke.</param>
+        /// <param name="queryStringParameters">A collection of key/value pairs to populate the query string from.</param>
+        /// <returns>An <see cref="HttpRequestMessage" /> for use with <c>SendInvokeMethodRequestAsync</c>.</returns>
+        public abstract HttpRequestMessage CreateInvokeMethodRequest(HttpMethod httpMethod, string appId,
+            string methodName, IReadOnlyCollection<KeyValuePair<string, string>> queryStringParameters);
+
         /// <summary>
         /// Creates an <see cref="HttpRequestMessage" /> that can be used to perform service invocation for the
         /// application identified by <paramref name="appId" /> and invokes the method specified by <paramref name="methodName" />
@@ -329,9 +356,9 @@ public HttpRequestMessage CreateInvokeMethodRequest(string appId, string methodN
         /// <returns>An <see cref="HttpRequestMessage" /> for use with <c>SendInvokeMethodRequestAsync</c>.</returns>
         public HttpRequestMessage CreateInvokeMethodRequest<TRequest>(string appId, string methodName, TRequest data)
         {
-            return CreateInvokeMethodRequest<TRequest>(HttpMethod.Post, appId, methodName, data);
+            return CreateInvokeMethodRequest(HttpMethod.Post, appId, methodName, new List<KeyValuePair<string, string>>(), data);
         }
-
+        
         /// <summary>
         /// Creates an <see cref="HttpRequestMessage" /> that can be used to perform service invocation for the
         /// application identified by <paramref name="appId" /> and invokes the method specified by <paramref name="methodName" />
@@ -343,9 +370,10 @@ public HttpRequestMessage CreateInvokeMethodRequest<TRequest>(string appId, stri
         /// <param name="appId">The Dapr application id to invoke the method on.</param>
         /// <param name="methodName">The name of the method to invoke.</param>
         /// <param name="data">The data that will be JSON serialized and provided as the request body.</param>
+        /// <param name="queryStringParameters">A collection of key/value pairs to populate the query string from.</param>
         /// <returns>An <see cref="HttpRequestMessage" /> for use with <c>SendInvokeMethodRequestAsync</c>.</returns>
-        public abstract HttpRequestMessage CreateInvokeMethodRequest<TRequest>(HttpMethod httpMethod, string appId, string methodName, TRequest data);
-
+        public abstract HttpRequestMessage CreateInvokeMethodRequest<TRequest>(HttpMethod httpMethod, string appId, string methodName, IReadOnlyCollection<KeyValuePair<string,string>> queryStringParameters, TRequest data);
+        
         /// <summary>
         /// Perform health-check of Dapr sidecar. Return 'true' if sidecar is healthy. Otherwise 'false'.
         /// CheckHealthAsync handle <see cref="HttpRequestException"/> and will return 'false' if error will occur on transport level
@@ -526,7 +554,7 @@ public Task InvokeMethodAsync<TRequest>(
             TRequest data,
             CancellationToken cancellationToken = default)
         {
-            var request = CreateInvokeMethodRequest<TRequest>(httpMethod, appId, methodName, data);
+            var request = CreateInvokeMethodRequest<TRequest>(httpMethod, appId, methodName, new List<KeyValuePair<string, string>>(), data);
             return InvokeMethodAsync(request, cancellationToken);
         }
 
@@ -620,7 +648,7 @@ public Task<TResponse> InvokeMethodAsync<TRequest, TResponse>(
             TRequest data,
             CancellationToken cancellationToken = default)
         {
-            var request = CreateInvokeMethodRequest<TRequest>(httpMethod, appId, methodName, data);
+            var request = CreateInvokeMethodRequest<TRequest>(httpMethod, appId, methodName, new List<KeyValuePair<string, string>>(), data);
             return InvokeMethodAsync<TResponse>(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<BindingResponse> InvokeBindingAsync(BindingRequest re
 
         #region InvokeMethod Apis
 
+        /// <summary>
+        /// Creates an <see cref="HttpRequestMessage" /> that can be used to perform service invocation for the
+        /// application identified by <paramref name="appId" /> and invokes the method specified by <paramref name="methodName" />
+        /// with the HTTP method specified by <paramref name="httpMethod" />.
+        /// </summary>
+        /// <param name="httpMethod">The <see cref="HttpMethod" /> to use for the invocation request.</param>
+        /// <param name="appId">The Dapr application id to invoke the method on.</param>
+        /// <param name="methodName">The name of the method to invoke.</param>
+        /// <returns>An <see cref="HttpRequestMessage" /> for use with <c>SendInvokeMethodRequestAsync</c>.</returns>
         public override HttpRequestMessage CreateInvokeMethodRequest(HttpMethod httpMethod, string appId, string methodName)
+        {
+            return CreateInvokeMethodRequest(httpMethod, appId, methodName, new List<KeyValuePair<string, string>>());
+        }
+
+        /// <summary>
+        /// Creates an <see cref="HttpRequestMessage" /> that can be used to perform service invocation for the
+        /// application identified by <paramref name="appId" /> and invokes the method specified by <paramref name="methodName" />
+        /// with the HTTP method specified by <paramref name="httpMethod" />.
+        /// </summary>
+        /// <param name="httpMethod">The <see cref="HttpMethod" /> to use for the invocation request.</param>
+        /// <param name="appId">The Dapr application id to invoke the method on.</param>
+        /// <param name="methodName">The name of the method to invoke.</param>
+        /// <param name="queryStringParameters">A collection of key/value pairs to populate the query string from.</param>
+        /// <returns>An <see cref="HttpRequestMessage" /> for use with <c>SendInvokeMethodRequestAsync</c>.</returns>
+        public override HttpRequestMessage CreateInvokeMethodRequest(HttpMethod httpMethod, string appId, string methodName,
+            IReadOnlyCollection<KeyValuePair<string, string>> 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<string>(AppIdKey), appId);
             request.Options.Set(new HttpRequestOptionsKey<string>(MethodNameKey), methodName);
@@ -369,13 +395,27 @@ public override HttpRequestMessage CreateInvokeMethodRequest(HttpMethod httpMeth
             return request;
         }
 
-        public override HttpRequestMessage CreateInvokeMethodRequest<TRequest>(HttpMethod httpMethod, string appId, string methodName, TRequest data)
+        /// <summary>
+        /// Creates an <see cref="HttpRequestMessage" /> that can be used to perform service invocation for the
+        /// application identified by <paramref name="appId" /> and invokes the method specified by <paramref name="methodName" />
+        /// with the HTTP method specified by <paramref name="httpMethod" /> and a JSON serialized request body specified by 
+        /// <paramref name="data" />.
+        /// </summary>
+        /// <typeparam name="TRequest">The type of the data that will be JSON serialized and provided as the request body.</typeparam>
+        /// <param name="httpMethod">The <see cref="HttpMethod" /> to use for the invocation request.</param>
+        /// <param name="appId">The Dapr application id to invoke the method on.</param>
+        /// <param name="methodName">The name of the method to invoke.</param>
+        /// <param name="data">The data that will be JSON serialized and provided as the request body.</param>
+        /// <param name="queryStringParameters">A collection of key/value pairs to populate the query string from.</param>
+        /// <returns>An <see cref="HttpRequestMessage" /> for use with <c>SendInvokeMethodRequestAsync</c>.</returns>
+        public override HttpRequestMessage CreateInvokeMethodRequest<TRequest>(HttpMethod httpMethod, string appId, string methodName,
+            IReadOnlyCollection<KeyValuePair<string, string>> 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<TRequest>(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
         /// <returns></returns>
         public static string GetValueFromEnumMember<T>(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
+{
+    /// <summary>
+    /// Provides extensions specific to HTTP types.
+    /// </summary>
+    internal static class HttpExtensions
+    {
+        /// <summary>
+        /// Appends key/value pairs to the query string on an HttpRequestMessage.
+        /// </summary>
+        /// <param name="uri">The uri to append the query string parameters to.</param>
+        /// <param name="queryStringParameters">The key/value pairs to populate the query string with.</param>
+        public static Uri AddQueryParameters(this Uri? uri,
+            IReadOnlyCollection<KeyValuePair<string, string>>? 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;
+        }
+    }
+}


Test patch:
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<KeyValuePair<string,string>>)new List<KeyValuePair<string, string>> { 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<KeyValuePair<string, string>> { 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<JsonContent>(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<Widget>(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<KeyValuePair<string,string>>
+            {
+                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<KeyValuePair<string,string>>
+            {
+                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<KeyValuePair<string,string>>
+            {
+                new("name1", "John Doe"),
+                new("name2", "Jane Doe")
+            });
+            Assert.Equal("https://localhost/mypath?name1=John%20Doe&name2=Jane%20Doe", updatedUri.AbsoluteUri);
+        }
+    }
+}