Skip to content

Commit

Permalink
Added support for IUrlEncodedForm requests in JsonClient
Browse files Browse the repository at this point in the history
  • Loading branch information
jezzsantos committed Sep 21, 2024
1 parent 252f817 commit 413f3d1
Show file tree
Hide file tree
Showing 7 changed files with 90 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,34 @@
namespace Infrastructure.Shared.UnitTests.ApplicationServices.External;

[Trait("Category", "Unit")]
public class OAuth2HttpServiceClientSpec
public class GenericOAuth2HttpServiceClientSpec
{
private readonly Mock<ICallerContext> _caller;
private readonly Mock<IServiceClient> _client;
private readonly OAuth2HttpServiceClient _serviceClient;
private readonly GenericOAuth2HttpServiceClient _serviceClient;

public OAuth2HttpServiceClientSpec()
public GenericOAuth2HttpServiceClientSpec()
{
_caller = new Mock<ICallerContext>();
var recorder = new Mock<IRecorder>();
_client = new Mock<IServiceClient>();
_serviceClient = new OAuth2HttpServiceClient(recorder.Object, _client.Object, "aclientid", "aclientsecret",
_serviceClient = new GenericOAuth2HttpServiceClient(recorder.Object, _client.Object, "aclientid",
"aclientsecret",
"aredirecturi");
}

[Fact]
public async Task WhenExchangeCodeForTokensAsyncAndClientThrows_ThenReturnsError()
{
_client.Setup(c => c.PostAsync(It.IsAny<ICallerContext>(), It.IsAny<OAuth2GrantAuthorizationRequest>(),
_client.Setup(c => c.PostAsync(It.IsAny<ICallerContext>(), It.IsAny<GenericOAuth2GrantAuthorizationRequest>(),
It.IsAny<Action<HttpRequestMessage>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("amessage"));
var options = new OAuth2CodeTokenExchangeOptions("aservicename", "acode", scope: "ascope");

var result = await _serviceClient.ExchangeCodeForTokensAsync(_caller.Object, options, CancellationToken.None);

result.Should().BeError(ErrorCode.Unexpected, "amessage");
_client.Verify(c => c.PostAsync(_caller.Object, It.Is<OAuth2GrantAuthorizationRequest>(req =>
_client.Verify(c => c.PostAsync(_caller.Object, It.Is<GenericOAuth2GrantAuthorizationRequest>(req =>
req.GrantType == "authorization_code"
&& req.Code == "acode"
&& req.ClientId == "aclientid"
Expand All @@ -55,9 +56,9 @@ public async Task WhenExchangeCodeForTokensAsyncAndClientThrows_ThenReturnsError
[Fact]
public async Task WhenExchangeCodeForTokensAsyncAndReceivesAllTokens_ThenReturnsAllTokens()
{
_client.Setup(c => c.PostAsync(It.IsAny<ICallerContext>(), It.IsAny<OAuth2GrantAuthorizationRequest>(),
_client.Setup(c => c.PostAsync(It.IsAny<ICallerContext>(), It.IsAny<GenericOAuth2GrantAuthorizationRequest>(),
It.IsAny<Action<HttpRequestMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new OAuth2GrantAuthorizationResponse
.ReturnsAsync(new GenericOAuth2GrantAuthorizationResponse
{
AccessToken = "anaccesstoken",
RefreshToken = "arefreshtoken",
Expand All @@ -81,7 +82,7 @@ public async Task WhenExchangeCodeForTokensAsyncAndReceivesAllTokens_ThenReturns
result.Value[2].Type.Should().Be(TokenType.OtherToken);
result.Value[2].Value.Should().Be("anidtoken");
result.Value[2].ExpiresOn.Should().BeNear(now.AddHours(1));
_client.Verify(c => c.PostAsync(_caller.Object, It.Is<OAuth2GrantAuthorizationRequest>(req =>
_client.Verify(c => c.PostAsync(_caller.Object, It.Is<GenericOAuth2GrantAuthorizationRequest>(req =>
req.GrantType == "authorization_code"
&& req.Code == "acode"
&& req.ClientId == "aclientid"
Expand All @@ -95,9 +96,9 @@ public async Task WhenExchangeCodeForTokensAsyncAndReceivesAllTokens_ThenReturns
[Fact]
public async Task WhenExchangeCodeForTokensAsyncAndReceivesOnlyAccessToken_ThenReturnsOnlyAccessToken()
{
_client.Setup(c => c.PostAsync(It.IsAny<ICallerContext>(), It.IsAny<OAuth2GrantAuthorizationRequest>(),
_client.Setup(c => c.PostAsync(It.IsAny<ICallerContext>(), It.IsAny<GenericOAuth2GrantAuthorizationRequest>(),
It.IsAny<Action<HttpRequestMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new OAuth2GrantAuthorizationResponse
.ReturnsAsync(new GenericOAuth2GrantAuthorizationResponse
{
AccessToken = "anaccesstoken",
RefreshToken = null,
Expand All @@ -115,7 +116,7 @@ public async Task WhenExchangeCodeForTokensAsyncAndReceivesOnlyAccessToken_ThenR
result.Value[0].Type.Should().Be(TokenType.AccessToken);
result.Value[0].Value.Should().Be("anaccesstoken");
result.Value[0].ExpiresOn.Should().BeNear(expiresOn);
_client.Verify(c => c.PostAsync(_caller.Object, It.Is<OAuth2GrantAuthorizationRequest>(req =>
_client.Verify(c => c.PostAsync(_caller.Object, It.Is<GenericOAuth2GrantAuthorizationRequest>(req =>
req.GrantType == "authorization_code"
&& req.Code == "acode"
&& req.ClientId == "aclientid"
Expand All @@ -129,15 +130,15 @@ public async Task WhenExchangeCodeForTokensAsyncAndReceivesOnlyAccessToken_ThenR
[Fact]
public async Task WhenRefreshTokenAsyncAndClientThrows_ThenReturnsError()
{
_client.Setup(c => c.PostAsync(It.IsAny<ICallerContext>(), It.IsAny<OAuth2GrantAuthorizationRequest>(),
_client.Setup(c => c.PostAsync(It.IsAny<ICallerContext>(), It.IsAny<GenericOAuth2GrantAuthorizationRequest>(),
It.IsAny<Action<HttpRequestMessage>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("amessage"));
var options = new OAuth2RefreshTokenOptions("aservicename", "arefreshtoken");

var result = await _serviceClient.RefreshTokenAsync(_caller.Object, options, CancellationToken.None);

result.Should().BeError(ErrorCode.Unexpected, "amessage");
_client.Verify(c => c.PostAsync(_caller.Object, It.Is<OAuth2GrantAuthorizationRequest>(req =>
_client.Verify(c => c.PostAsync(_caller.Object, It.Is<GenericOAuth2GrantAuthorizationRequest>(req =>
req.GrantType == "refresh_token"
&& req.ClientId == "aclientid"
&& req.ClientSecret == "aclientsecret"
Expand All @@ -149,9 +150,9 @@ public async Task WhenRefreshTokenAsyncAndClientThrows_ThenReturnsError()
[Fact]
public async Task WhenRefreshTokenAsyncAndReceivesAllTokens_ThenReturnsAllTokens()
{
_client.Setup(c => c.PostAsync(It.IsAny<ICallerContext>(), It.IsAny<OAuth2GrantAuthorizationRequest>(),
_client.Setup(c => c.PostAsync(It.IsAny<ICallerContext>(), It.IsAny<GenericOAuth2GrantAuthorizationRequest>(),
It.IsAny<Action<HttpRequestMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new OAuth2GrantAuthorizationResponse
.ReturnsAsync(new GenericOAuth2GrantAuthorizationResponse
{
AccessToken = "anaccesstoken",
RefreshToken = "arefreshtoken",
Expand All @@ -175,7 +176,7 @@ public async Task WhenRefreshTokenAsyncAndReceivesAllTokens_ThenReturnsAllTokens
result.Value[2].Type.Should().Be(TokenType.OtherToken);
result.Value[2].Value.Should().Be("anidtoken");
result.Value[2].ExpiresOn.Should().BeNear(now.AddHours(1));
_client.Verify(c => c.PostAsync(_caller.Object, It.Is<OAuth2GrantAuthorizationRequest>(req =>
_client.Verify(c => c.PostAsync(_caller.Object, It.Is<GenericOAuth2GrantAuthorizationRequest>(req =>
req.GrantType == "refresh_token"
&& req.ClientId == "aclientid"
&& req.ClientSecret == "aclientsecret"
Expand All @@ -187,9 +188,9 @@ public async Task WhenRefreshTokenAsyncAndReceivesAllTokens_ThenReturnsAllTokens
[Fact]
public async Task WhenRefreshTokenAsyncAndReceivesOnlyAccessToken_ThenReturnsOnlyAccessToken()
{
_client.Setup(c => c.PostAsync(It.IsAny<ICallerContext>(), It.IsAny<OAuth2GrantAuthorizationRequest>(),
_client.Setup(c => c.PostAsync(It.IsAny<ICallerContext>(), It.IsAny<GenericOAuth2GrantAuthorizationRequest>(),
It.IsAny<Action<HttpRequestMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new OAuth2GrantAuthorizationResponse
.ReturnsAsync(new GenericOAuth2GrantAuthorizationResponse
{
AccessToken = "anaccesstoken",
RefreshToken = null,
Expand All @@ -207,7 +208,7 @@ public async Task WhenRefreshTokenAsyncAndReceivesOnlyAccessToken_ThenReturnsOnl
result.Value[0].Type.Should().Be(TokenType.AccessToken);
result.Value[0].Value.Should().Be("anaccesstoken");
result.Value[0].ExpiresOn.Should().BeNear(expiresOn);
_client.Verify(c => c.PostAsync(_caller.Object, It.Is<OAuth2GrantAuthorizationRequest>(req =>
_client.Verify(c => c.PostAsync(_caller.Object, It.Is<GenericOAuth2GrantAuthorizationRequest>(req =>
req.GrantType == "refresh_token"
&& req.ClientId == "aclientid"
&& req.ClientSecret == "aclientsecret"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,18 @@
namespace Infrastructure.Shared.ApplicationServices.External;

/// <summary>
/// Provides a general purpose OAuth2 service client for exchanging authorization codes for tokens.
/// Provides a general purpose generic OAuth2 service client for exchanging authorization codes for tokens.
/// Assumes <see href="https://datatracker.ietf.org/doc/html/rfc6749">The OAuth 2.0 Authorization Framework</see>
/// </summary>
public class OAuth2HttpServiceClient : IOAuth2Service
public class GenericOAuth2HttpServiceClient : IOAuth2Service
{
private readonly string _clientId;
private readonly string? _clientSecret;
private readonly IRecorder _recorder;
private readonly string _redirectUri;
private readonly IServiceClient _serviceClient;

public OAuth2HttpServiceClient(IRecorder recorder, IServiceClient serviceClient, string clientId,
public GenericOAuth2HttpServiceClient(IRecorder recorder, IServiceClient serviceClient, string clientId,
string? clientSecret, string redirectUri)
{
_recorder = recorder;
Expand All @@ -36,7 +36,8 @@ public async Task<Result<List<AuthToken>, Error>> ExchangeCodeForTokensAsync(ICa
{
try
{
var response = await _serviceClient.PostAsync(caller, new OAuth2GrantAuthorizationRequest
//We want you to be able to override this and use any IOAuth2GrantAuthorizationRequest
var response = await _serviceClient.PostAsync(caller, new GenericOAuth2GrantAuthorizationRequest
{
GrantType = "authorization_code",
Code = options.Code,
Expand Down Expand Up @@ -67,7 +68,7 @@ public async Task<Result<List<AuthToken>, Error>> RefreshTokenAsync(ICallerConte
{
try
{
var response = await _serviceClient.PostAsync(caller, new OAuth2GrantAuthorizationRequest
var response = await _serviceClient.PostAsync(caller, new GenericOAuth2GrantAuthorizationRequest
{
GrantType = "refresh_token",
ClientId = _clientId,
Expand All @@ -94,7 +95,7 @@ public async Task<Result<List<AuthToken>, Error>> RefreshTokenAsync(ICallerConte

internal static class OAuth2ConversionExtensions
{
public static List<AuthToken> ToTokens(this OAuth2GrantAuthorizationResponse response)
public static List<AuthToken> ToTokens(this GenericOAuth2GrantAuthorizationResponse response)
{
var tokens = new List<AuthToken>();
var now = DateTime.UtcNow.ToNearestSecond();
Expand Down
6 changes: 6 additions & 0 deletions src/Infrastructure.Web.Api.Interfaces/IHasUrlEncodedForm.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Infrastructure.Web.Api.Interfaces;

/// <summary>
/// A marker interface for requests that are expected to have a URL encoded form body
/// </summary>
public interface IHasUrlEncodedForm;
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.OAuth2;

/// <summary>
/// Makes an OAuth2 authorization grant request.
/// Makes a generic OAuth2 authorization grant request.
/// </summary>
[Route("/auth/token", OperationMethod.Post)]
public class
OAuth2GrantAuthorizationRequest : WebRequest<OAuth2GrantAuthorizationRequest,
OAuth2GrantAuthorizationResponse>
GenericOAuth2GrantAuthorizationRequest : WebRequest<GenericOAuth2GrantAuthorizationRequest,
GenericOAuth2GrantAuthorizationResponse>, IHasUrlEncodedForm
{
[JsonPropertyName("client_id")] public string? ClientId { get; set; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.OAuth2;

public class OAuth2GrantAuthorizationResponse : IWebResponse
public class GenericOAuth2GrantAuthorizationResponse : IWebResponse
{
[JsonPropertyName("access_token")] public string? AccessToken { get; set; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ await JsonClient.SendRequestAsync(client, HttpMethod.Post, request, null, null,
}

[Fact]
public async Task WhenSendRequestAsyncAndPostMethodWithIMultiPartRequest_ThenContentIsMultiPartForm()
public async Task WhenSendRequestAsyncAndPostMethodWithIMultiPartFormRequest_ThenContentIsMultiPartForm()
{
var response = new HttpResponseMessage();
var handler = new Mock<HttpMessageHandler>();
Expand Down Expand Up @@ -359,6 +359,35 @@ await JsonClient.SendRequestAsync(client, HttpMethod.Post, request, file, null,
),
ItExpr.IsAny<CancellationToken>());
}

[Fact]
public async Task WhenSendRequestAsyncAndPostMethodWithIHasUrlEncodedFormRequest_ThenContentIsUrlEncodedForm()
{
var response = new HttpResponseMessage();
var handler = new Mock<HttpMessageHandler>();
handler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
var client = new HttpClient(handler.Object)
{
BaseAddress = new Uri("http://localhost")
};
var request = new TestUrlEncodedFormRequest();

var result =
await JsonClient.SendRequestAsync(client, HttpMethod.Post, request, null, null,
CancellationToken.None);

result.Should().Be(response);
handler.Protected()
.Verify("SendAsync", Times.Once(),
ItExpr.Is<HttpRequestMessage>(req =>
req.Method == HttpMethod.Post
&& req.Content is FormUrlEncodedContent
),
ItExpr.IsAny<CancellationToken>());
}
}

[Trait("Category", "Unit")]
Expand Down Expand Up @@ -620,6 +649,12 @@ public class TestMultiPartFormRequest : WebRequest<TestRequest>, IHasMultipartFo
public string AProperty { get; set; } = "avalue";
}

[Api.Interfaces.Route("/test", OperationMethod.Post)]
public class TestUrlEncodedFormRequest : WebRequest<TestRequest>, IHasUrlEncodedForm
{
public string AProperty { get; set; } = "avalue";
}

public class TestResponse : IWebResponse
{
// ReSharper disable once UnusedAutoPropertyAccessor.Global
Expand Down
19 changes: 16 additions & 3 deletions src/Infrastructure.Web.Common/Clients/JsonClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ internal static async Task<HttpResponseMessage> SendRequestAsync(HttpClient http
{
if (method.CanHaveBody() && file.Exists())
{
var multipart = ToFormData(body);
var multipart = ToMultiPartContent(body);
var streamContent = new StreamContent(file.Stream);
if (file.ContentType.HasValue())
{
Expand All @@ -335,9 +335,14 @@ internal static async Task<HttpResponseMessage> SendRequestAsync(HttpClient http
}
else if (method.CanHaveBody() && request is IHasMultipartForm)
{
var multipart = ToFormData(body);
var multipart = ToMultiPartContent(body);
content = multipart;
}
else if (method.CanHaveBody() && request is IHasUrlEncodedForm)
{
var urlEncoded = ToUrlEncodedContent(body);
content = urlEncoded;
}
else
{
var json = body.SerializeToJson();
Expand All @@ -354,7 +359,7 @@ internal static async Task<HttpResponseMessage> SendRequestAsync(HttpClient http
(content as IDisposable)?.Dispose();
}

static MultipartFormDataContent ToFormData(IWebRequest body)
static MultipartFormDataContent ToMultiPartContent(IWebRequest body)
{
var content = new MultipartFormDataContent();
var requestFields = //HACK: really need these values to be serialized as QueryString parameters
Expand All @@ -370,6 +375,14 @@ static MultipartFormDataContent ToFormData(IWebRequest body)

return content;
}

static FormUrlEncodedContent ToUrlEncodedContent(IWebRequest body)
{
var requestFields = body.SerializeToJson()
.FromJson<Dictionary<string, string>>()!;

return new FormUrlEncodedContent(requestFields);
}
}

public void SetBaseUrl(string baseUrl)
Expand Down

0 comments on commit 413f3d1

Please sign in to comment.