diff --git a/CHANGELOG.md b/CHANGELOG.md index ab0f9f47..e036706a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -168,3 +168,12 @@ Fixed handling of No Content responses ### Added - Added basic ability to modify the HttpClient in a request + +## 2.3.0 + +### Added + +- Added ability to scope request modifiers by using the `.WithRequestModifier()` method instead of the `.ModifyRequest` property. + This will allow consumers to modify a single request without affecting any other consumers of the client. It also allows for + multiple modifiers to be added. For example, a modifier could be added at the global level that applies to all requests and then + another modifier can be added for a single request. diff --git a/ShipEngineSDK.Test/Helpers/MockShipEngineFixture.cs b/ShipEngineSDK.Test/Helpers/MockShipEngineFixture.cs index 158a00b7..4de07ebd 100644 --- a/ShipEngineSDK.Test/Helpers/MockShipEngineFixture.cs +++ b/ShipEngineSDK.Test/Helpers/MockShipEngineFixture.cs @@ -75,11 +75,13 @@ public void AssertRequest(HttpMethod method, string path, int numberOfCalls = 1) /// The HTTP path. /// The status code to return. /// The response body to return. - public string StubRequest(HttpMethod method, string path, HttpStatusCode status, string response) + public string StubRequest(HttpMethod method, string path, HttpStatusCode status = HttpStatusCode.OK, string response = null) { var requestId = Guid.NewGuid().ToString(); - var responseMessage = new HttpResponseMessage(status); - responseMessage.Content = new StringContent(response ?? ""); + var responseMessage = new HttpResponseMessage(status) + { + Content = new StringContent(response ?? "") + }; responseMessage.Headers.Add("x-shipengine-requestid", requestId); responseMessage.Headers.Add("request-id", requestId); diff --git a/ShipEngineSDK.Test/ShipEngineClientTests.cs b/ShipEngineSDK.Test/ShipEngineClientTests.cs index 0024f1f7..601988db 100644 --- a/ShipEngineSDK.Test/ShipEngineClientTests.cs +++ b/ShipEngineSDK.Test/ShipEngineClientTests.cs @@ -1,10 +1,13 @@ namespace ShipEngineTest { + using Moq; + using Moq.Protected; using ShipEngineSDK; using ShipEngineSDK.VoidLabelWithLabelId; using System; using System.Linq; using System.Net.Http; + using System.Threading; using System.Threading.Tasks; using Xunit; @@ -173,7 +176,7 @@ public async Task SuccessResponseWithNullStringContentThrowsShipEngineExceptionW var mockShipEngineFixture = new MockShipEngineFixture(config); var shipengine = mockShipEngineFixture.ShipEngine; - // this scenario is similar to unparseable JSON - except that it is valid JSON + // this scenario is similar to unparsable JSON - except that it is valid JSON var responseBody = @"null"; var requestId = mockShipEngineFixture.StubRequest(HttpMethod.Post, "/v1/something", System.Net.HttpStatusCode.OK, responseBody); @@ -197,7 +200,7 @@ public async Task SuccessResponseWhenStringRequestedReturnsUnparsedString() var mockShipEngineFixture = new MockShipEngineFixture(config); var shipengine = mockShipEngineFixture.ShipEngine; - // this scenario is similar to unparseable JSON - except that it is valid JSON + // this scenario is similar to unparsable JSON - except that it is valid JSON var responseBody = @"The Response"; mockShipEngineFixture.StubRequest(HttpMethod.Delete, "/v1/something", System.Net.HttpStatusCode.OK, responseBody); @@ -214,14 +217,147 @@ public async Task SuccessResponseWithNoContentCanBeReturnedIfStringRequested() var mockShipEngineFixture = new MockShipEngineFixture(config); var shipengine = mockShipEngineFixture.ShipEngine; - // this scenario is similar to unparseable JSON - except that it is valid JSON - string responseBody = null; - mockShipEngineFixture.StubRequest(HttpMethod.Delete, "/v1/something", System.Net.HttpStatusCode.OK, - responseBody); + // this scenario is similar to unparsable JSON - except that it is valid JSON + mockShipEngineFixture.StubRequest(HttpMethod.Delete, "/v1/something", System.Net.HttpStatusCode.OK, null); var result = await shipengine.SendHttpRequestAsync(HttpMethod.Delete, "/v1/something", "", mockShipEngineFixture.HttpClient, config); - Assert.Null(responseBody); + Assert.Empty(result); + } + + [Fact] + public void WithRequestModifierDoesNotCreateNewHttpClient() + { + var config = new Config(apiKey: "test", timeout: TimeSpan.FromSeconds(0.5)); + var mockShipEngineFixture = new MockShipEngineFixture(config); + var httpClient = mockShipEngineFixture.HttpClient; + + var originalShipEngine = mockShipEngineFixture.ShipEngine; + + var newShipEngine = originalShipEngine.WithRequestModifier(x => x.Headers.Add("X-Test-Header", "Test")); + + Assert.Same(config, newShipEngine._config); + Assert.Same(httpClient, newShipEngine._client); + Assert.NotSame(originalShipEngine, newShipEngine); + } + + [Fact] + public void ModifyRequestDoesNotCreateNewHttpClientNorShipEngineInstance() + { + var config = new Config(apiKey: "test", timeout: TimeSpan.FromSeconds(0.5)); + var mockShipEngineFixture = new MockShipEngineFixture(config); + var httpClient = mockShipEngineFixture.HttpClient; + + var originalShipEngine = mockShipEngineFixture.ShipEngine; + + var newShipEngine = originalShipEngine.ModifyRequest(x => x.Headers.Add("X-Test-Header", "Test")); + + Assert.Same(config, newShipEngine._config); + Assert.Same(httpClient, newShipEngine._client); + Assert.Same(originalShipEngine, newShipEngine); + } + + [Fact] + public async Task WithSingleRequestModifierAppliesBeforeRequest() + { + var config = new Config(apiKey: "test", timeout: TimeSpan.FromSeconds(0.5)); + var mockShipEngineFixture = new MockShipEngineFixture(config); + var shipengine = mockShipEngineFixture.ShipEngine.WithRequestModifier(x => x.Headers.Add("X-Test-Header", "Test")); + mockShipEngineFixture.StubRequest(HttpMethod.Get, "/foo"); + + await shipengine.SendHttpRequestAsync(HttpMethod.Get, "/foo", "", mockShipEngineFixture.HttpClient, config); + + mockShipEngineFixture.MockHandler.Protected() + .Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(m => m.Headers.Any(x => x.Key == "X-Test-Header")), + ItExpr.IsAny()); + } + + [Fact] + public async Task WithRequestModifierDoesNotAffectOriginalClient() + { + var config = new Config(apiKey: "test", timeout: TimeSpan.FromSeconds(0.5)); + var mockShipEngineFixture = new MockShipEngineFixture(config); + var shipengine = mockShipEngineFixture.ShipEngine; + var modifiedShipEngine = shipengine.WithRequestModifier(x => x.Headers.Add("X-Test-Header", "Test")); + mockShipEngineFixture.StubRequest(HttpMethod.Get, "/foo"); + + await shipengine.SendHttpRequestAsync(HttpMethod.Get, "/foo", "", mockShipEngineFixture.HttpClient, config); + + mockShipEngineFixture.MockHandler.Protected() + .Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(m => !m.Headers.Any(x => x.Key == "X-Test-Header")), + ItExpr.IsAny()); + } + + [Fact] + public async Task WithTwoRequestModifierAppliesBeforeRequest() + { + var config = new Config(apiKey: "test", timeout: TimeSpan.FromSeconds(0.5)); + var mockShipEngineFixture = new MockShipEngineFixture(config); + var shipengine = mockShipEngineFixture.ShipEngine + .WithRequestModifier(x => + { + x.Headers.Add("X-Test-Header", "Test 1"); + x.Headers.Add("X-Second-Header", "Test 2"); + }) + .WithRequestModifier(x => x.Headers.Remove("X-Test-Header")); + mockShipEngineFixture.StubRequest(HttpMethod.Get, "/foo"); + + await shipengine.SendHttpRequestAsync(HttpMethod.Get, "/foo", "", mockShipEngineFixture.HttpClient, config); + + mockShipEngineFixture.MockHandler.Protected() + .Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(m => + !m.Headers.Any(x => x.Key == "X-Test-Header") && + m.Headers.Any(x => x.Key == "X-Second-Header")), + ItExpr.IsAny()); + } + + [Fact] + public async Task ModifyRequestAppliesBeforeRequest() + { + var config = new Config(apiKey: "test", timeout: TimeSpan.FromSeconds(0.5)); + var mockShipEngineFixture = new MockShipEngineFixture(config); + var shipengine = mockShipEngineFixture.ShipEngine.ModifyRequest(x => x.Headers.Add("X-Test-Header", "Test")); + mockShipEngineFixture.StubRequest(HttpMethod.Get, "/foo"); + + await shipengine.SendHttpRequestAsync(HttpMethod.Get, "/foo", "", mockShipEngineFixture.HttpClient, config); + + mockShipEngineFixture.MockHandler.Protected() + .Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(m => m.Headers.Any(x => x.Key == "X-Test-Header")), + ItExpr.IsAny()); + } + + [Fact] + public async Task ModifyRequestReplacesExistingModifiersAppliesBeforeRequest() + { + var config = new Config(apiKey: "test", timeout: TimeSpan.FromSeconds(0.5)); + var mockShipEngineFixture = new MockShipEngineFixture(config); + var shipengine = mockShipEngineFixture.ShipEngine + .WithRequestModifier(x => x.Headers.Add("X-Test-Header", "Test 1")) + .ModifyRequest(x => x.Headers.Add("X-Second-Header", "Test 2")); + mockShipEngineFixture.StubRequest(HttpMethod.Get, "/foo"); + + await shipengine.SendHttpRequestAsync(HttpMethod.Get, "/foo", "", mockShipEngineFixture.HttpClient, config); + + mockShipEngineFixture.MockHandler.Protected() + .Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(m => + m.Headers.Any(x => x.Key == "X-Second-Header") && + !m.Headers.Any(x => x.Key == "X-Test-Header")), + ItExpr.IsAny()); } } } \ No newline at end of file diff --git a/ShipEngineSDK/ShipEngine.cs b/ShipEngineSDK/ShipEngine.cs index 13dc1e37..a5ef41e3 100644 --- a/ShipEngineSDK/ShipEngine.cs +++ b/ShipEngineSDK/ShipEngine.cs @@ -1,14 +1,13 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using ShipEngineSDK.Common; -using ShipEngineSDK.Manifests; using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading.Tasks; -using Result = ShipEngineSDK.ValidateAddresses.Result; [assembly: InternalsVisibleTo("ShipEngineSDK.Test")] @@ -225,14 +224,40 @@ public ShipEngine(HttpClient httpClient) : base() _client = httpClient; } + /// + /// Copy constructor that adds a request modifier to the existing collection + /// + /// Client to use for requests + /// Config to use for the requests + /// List of request modifiers to use + private ShipEngine(HttpClient client, Config config, IEnumerable> requestModifiers) : + base(requestModifiers) + { + _client = client; + _config = config; + } + + /// + /// Gets a new instance of the ShipEngine client with the provided request modifier added to the collection + /// + /// Request modifier that will be added + /// A new instance of the ShipEngine client + /// The existing ShipEngine client is not modified + public ShipEngine WithRequestModifier(Action modifier) => + new(_client, _config, requestModifiers.Append(modifier)); + /// /// Modifies the request before it is sent to the ShipEngine API /// - /// - /// + /// Request modifier that will be used + /// The current instance of the ShipEngine client + /// + /// This method modifies the existing ShipEngine client and will replace any existing request modifiers with the one provided. + /// If you want to add a request modifier to the existing collection, use the WithRequestModifier method. + /// public ShipEngine ModifyRequest(Action modifyRequest) { - base.ModifyRequest = modifyRequest; + requestModifiers = [modifyRequest]; return this; } diff --git a/ShipEngineSDK/ShipEngineClient.cs b/ShipEngineSDK/ShipEngineClient.cs index 62163ccb..5ab37618 100644 --- a/ShipEngineSDK/ShipEngineClient.cs +++ b/ShipEngineSDK/ShipEngineClient.cs @@ -1,5 +1,6 @@ using ShipEngineSDK.Common; using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; @@ -19,6 +20,20 @@ namespace ShipEngineSDK /// public class ShipEngineClient { + /// + /// Default constructor + /// + public ShipEngineClient() { } + + /// + /// Constructor that takes a collection of request modifiers to apply to the request before it is sent + /// + /// Collection of modifiers to be used for each request + protected ShipEngineClient(IEnumerable> requestModifiers) + { + this.requestModifiers = requestModifiers; + } + /// /// Options for serializing the method call params to JSON. /// A separate inline setting is used for deserializing the response @@ -46,9 +61,13 @@ public class ShipEngineClient public CancellationToken CancellationToken { get; set; } /// - /// Modifies the client request before it is sent + /// Collections of request modifiers to apply to the request before it is sent /// - public Action? ModifyRequest { get; set; } + /// + /// This is a collection instead of a single action so that modifiers can be added at multiple levels. + /// For example, a consumer could add a modifier at the client level, and then add another at the method level. + /// + protected IEnumerable> requestModifiers = []; /// /// Sets the HttpClient User agent, the json media type, and the API key to be used @@ -209,7 +228,10 @@ public virtual async Task SendHttpRequestAsync(HttpMethod method, string p try { var request = BuildRequest(method, path, jsonContent); - ModifyRequest?.Invoke(request); + foreach (var modifier in requestModifiers ?? []) + { + modifier?.Invoke(request); + } response = await client.SendAsync(request, cancellationToken); var deserializedResult = await DeserializedResultOrThrow(response); diff --git a/ShipEngineSDK/ShipEngineSDK.csproj b/ShipEngineSDK/ShipEngineSDK.csproj index 81b539b9..b43c5f0c 100644 --- a/ShipEngineSDK/ShipEngineSDK.csproj +++ b/ShipEngineSDK/ShipEngineSDK.csproj @@ -4,7 +4,7 @@ ShipEngine sdk;rest;api;shipping;rates;label;tracking;cost;address;validation;normalization;fedex;ups;usps; - 2.2.1 + 2.3.0 ShipEngine ShipEngine The official ShipEngine C# SDK for .NET diff --git a/package.json b/package.json index 2239a9bb..94ef989e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shipengine-dotnet", - "version": "2.2.1", + "version": "2.3.0", "description": "Package primarily used to generate the API and models from OpenApi spec\"", "main": "index.js", "directories": {