Skip to content

Commit

Permalink
Update request modifiers so they can be scoped (#109)
Browse files Browse the repository at this point in the history
  • Loading branch information
AndrewBenzSW committed Aug 26, 2024
1 parent 464cfc9 commit ce007a6
Show file tree
Hide file tree
Showing 7 changed files with 214 additions and 20 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 5 additions & 3 deletions ShipEngineSDK.Test/Helpers/MockShipEngineFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,13 @@ public void AssertRequest(HttpMethod method, string path, int numberOfCalls = 1)
/// <param name="path">The HTTP path.</param>
/// <param name="status">The status code to return.</param>
/// <param name="response">The response body to return.</param>
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);

Expand Down
150 changes: 143 additions & 7 deletions ShipEngineSDK.Test/ShipEngineClientTests.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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<string>(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<string>(HttpMethod.Get, "/foo", "", mockShipEngineFixture.HttpClient, config);

mockShipEngineFixture.MockHandler.Protected()
.Verify(
"SendAsync",
Times.Once(),
ItExpr.Is<HttpRequestMessage>(m => m.Headers.Any(x => x.Key == "X-Test-Header")),
ItExpr.IsAny<CancellationToken>());
}

[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<string>(HttpMethod.Get, "/foo", "", mockShipEngineFixture.HttpClient, config);

mockShipEngineFixture.MockHandler.Protected()
.Verify(
"SendAsync",
Times.Once(),
ItExpr.Is<HttpRequestMessage>(m => !m.Headers.Any(x => x.Key == "X-Test-Header")),
ItExpr.IsAny<CancellationToken>());
}

[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<string>(HttpMethod.Get, "/foo", "", mockShipEngineFixture.HttpClient, config);

mockShipEngineFixture.MockHandler.Protected()
.Verify(
"SendAsync",
Times.Once(),
ItExpr.Is<HttpRequestMessage>(m =>
!m.Headers.Any(x => x.Key == "X-Test-Header") &&
m.Headers.Any(x => x.Key == "X-Second-Header")),
ItExpr.IsAny<CancellationToken>());
}

[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<string>(HttpMethod.Get, "/foo", "", mockShipEngineFixture.HttpClient, config);

mockShipEngineFixture.MockHandler.Protected()
.Verify(
"SendAsync",
Times.Once(),
ItExpr.Is<HttpRequestMessage>(m => m.Headers.Any(x => x.Key == "X-Test-Header")),
ItExpr.IsAny<CancellationToken>());
}

[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<string>(HttpMethod.Get, "/foo", "", mockShipEngineFixture.HttpClient, config);

mockShipEngineFixture.MockHandler.Protected()
.Verify(
"SendAsync",
Times.Once(),
ItExpr.Is<HttpRequestMessage>(m =>
m.Headers.Any(x => x.Key == "X-Second-Header") &&
!m.Headers.Any(x => x.Key == "X-Test-Header")),
ItExpr.IsAny<CancellationToken>());
}
}
}
35 changes: 30 additions & 5 deletions ShipEngineSDK/ShipEngine.cs
Original file line number Diff line number Diff line change
@@ -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")]

Expand Down Expand Up @@ -225,14 +224,40 @@ public ShipEngine(HttpClient httpClient) : base()
_client = httpClient;
}

/// <summary>
/// Copy constructor that adds a request modifier to the existing collection
/// </summary>
/// <param name="client">Client to use for requests</param>
/// <param name="config">Config to use for the requests</param>
/// <param name="requestModifiers">List of request modifiers to use</param>
private ShipEngine(HttpClient client, Config config, IEnumerable<Action<HttpRequestMessage>> requestModifiers) :
base(requestModifiers)
{
_client = client;
_config = config;
}

/// <summary>
/// Gets a new instance of the ShipEngine client with the provided request modifier added to the collection
/// </summary>
/// <param name="modifier">Request modifier that will be added</param>
/// <returns>A new instance of the ShipEngine client</returns>
/// <remarks>The existing ShipEngine client is not modified</remarks>
public ShipEngine WithRequestModifier(Action<HttpRequestMessage> modifier) =>
new(_client, _config, requestModifiers.Append(modifier));

/// <summary>
/// Modifies the request before it is sent to the ShipEngine API
/// </summary>
/// <param name="modifyRequest"></param>
/// <returns></returns>
/// <param name="modifyRequest">Request modifier that will be used</param>
/// <returns>The current instance of the ShipEngine client</returns>
/// <remarks>
/// 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.
/// </remarks>
public ShipEngine ModifyRequest(Action<HttpRequestMessage> modifyRequest)
{
base.ModifyRequest = modifyRequest;
requestModifiers = [modifyRequest];
return this;
}

Expand Down
28 changes: 25 additions & 3 deletions ShipEngineSDK/ShipEngineClient.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using ShipEngineSDK.Common;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
Expand All @@ -19,6 +20,20 @@ namespace ShipEngineSDK
/// </summary>
public class ShipEngineClient
{
/// <summary>
/// Default constructor
/// </summary>
public ShipEngineClient() { }

/// <summary>
/// Constructor that takes a collection of request modifiers to apply to the request before it is sent
/// </summary>
/// <param name="requestModifiers">Collection of modifiers to be used for each request</param>
protected ShipEngineClient(IEnumerable<Action<HttpRequestMessage>> requestModifiers)

Check warning on line 32 in ShipEngineSDK/ShipEngineClient.cs

View workflow job for this annotation

GitHub Actions / .Net 8.0 on windows-latest

Check warning on line 32 in ShipEngineSDK/ShipEngineClient.cs

View workflow job for this annotation

GitHub Actions / nuget-deploy

{
this.requestModifiers = requestModifiers;
}

/// <summary>
/// Options for serializing the method call params to JSON.
/// A separate inline setting is used for deserializing the response
Expand Down Expand Up @@ -46,9 +61,13 @@ public class ShipEngineClient
public CancellationToken CancellationToken { get; set; }

/// <summary>
/// Modifies the client request before it is sent
/// Collections of request modifiers to apply to the request before it is sent
/// </summary>
public Action<HttpRequestMessage>? ModifyRequest { get; set; }
/// <remarks>
/// 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.
/// </remarks>
protected IEnumerable<Action<HttpRequestMessage>> requestModifiers = [];

Check warning on line 70 in ShipEngineSDK/ShipEngineClient.cs

View workflow job for this annotation

GitHub Actions / .Net 8.0 on windows-latest

Check warning on line 70 in ShipEngineSDK/ShipEngineClient.cs

View workflow job for this annotation

GitHub Actions / .Net 8.0 on windows-latest

Check warning on line 70 in ShipEngineSDK/ShipEngineClient.cs

View workflow job for this annotation

GitHub Actions / nuget-deploy


/// <summary>
/// Sets the HttpClient User agent, the json media type, and the API key to be used
Expand Down Expand Up @@ -209,7 +228,10 @@ public virtual async Task<T> SendHttpRequestAsync<T>(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<T>(response);
Expand Down
2 changes: 1 addition & 1 deletion ShipEngineSDK/ShipEngineSDK.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<PackageId>ShipEngine</PackageId>
<PackageTags>sdk;rest;api;shipping;rates;label;tracking;cost;address;validation;normalization;fedex;ups;usps;</PackageTags>

<Version>2.2.1</Version>
<Version>2.3.0</Version>
<Authors>ShipEngine</Authors>
<Company>ShipEngine</Company>
<Summary>The official ShipEngine C# SDK for .NET</Summary>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down

0 comments on commit ce007a6

Please sign in to comment.