From dab2e1a18f9e3983149a103bf3438a5a2ceb3078 Mon Sep 17 00:00:00 2001 From: Jezz Santos Date: Sun, 22 Sep 2024 11:43:06 +1200 Subject: [PATCH] Added support for IUrlEncodedForm requests in JsonClient --- ... => GenericOAuth2HttpServiceClientSpec.cs} | 41 ++++++++++--------- ...t.cs => GenericOAuth2HttpServiceClient.cs} | 13 +++--- .../IHasUrlEncodedForm.cs | 6 +++ ...GenericOAuth2GrantAuthorizationRequest.cs} | 6 +-- ...enericOAuth2GrantAuthorizationResponse.cs} | 2 +- .../Clients/JsonClientSpec.cs | 37 ++++++++++++++++- .../Clients/JsonClient.cs | 19 +++++++-- .../ReverseProxyApiSpec.cs | 2 +- 8 files changed, 91 insertions(+), 35 deletions(-) rename src/Infrastructure.Shared.UnitTests/ApplicationServices/External/{OAuth2HttpServiceClientSpec.cs => GenericOAuth2HttpServiceClientSpec.cs} (84%) rename src/Infrastructure.Shared/ApplicationServices/External/{OAuth2HttpServiceClient.cs => GenericOAuth2HttpServiceClient.cs} (88%) create mode 100644 src/Infrastructure.Web.Api.Interfaces/IHasUrlEncodedForm.cs rename src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/{OAuth2GrantAuthorizationRequest.cs => GenericOAuth2GrantAuthorizationRequest.cs} (77%) rename src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/{OAuth2GrantAuthorizationResponse.cs => GenericOAuth2GrantAuthorizationResponse.cs} (90%) diff --git a/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/OAuth2HttpServiceClientSpec.cs b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/GenericOAuth2HttpServiceClientSpec.cs similarity index 84% rename from src/Infrastructure.Shared.UnitTests/ApplicationServices/External/OAuth2HttpServiceClientSpec.cs rename to src/Infrastructure.Shared.UnitTests/ApplicationServices/External/GenericOAuth2HttpServiceClientSpec.cs index f177ab1e..7925f6c4 100644 --- a/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/OAuth2HttpServiceClientSpec.cs +++ b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/GenericOAuth2HttpServiceClientSpec.cs @@ -15,25 +15,26 @@ namespace Infrastructure.Shared.UnitTests.ApplicationServices.External; [Trait("Category", "Unit")] -public class OAuth2HttpServiceClientSpec +public class GenericOAuth2HttpServiceClientSpec { private readonly Mock _caller; private readonly Mock _client; - private readonly OAuth2HttpServiceClient _serviceClient; + private readonly GenericOAuth2HttpServiceClient _serviceClient; - public OAuth2HttpServiceClientSpec() + public GenericOAuth2HttpServiceClientSpec() { _caller = new Mock(); var recorder = new Mock(); _client = new Mock(); - _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(), It.IsAny(), + _client.Setup(c => c.PostAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .ThrowsAsync(new Exception("amessage")); var options = new OAuth2CodeTokenExchangeOptions("aservicename", "acode", scope: "ascope"); @@ -41,7 +42,7 @@ public async Task WhenExchangeCodeForTokensAsyncAndClientThrows_ThenReturnsError 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(req => + _client.Verify(c => c.PostAsync(_caller.Object, It.Is(req => req.GrantType == "authorization_code" && req.Code == "acode" && req.ClientId == "aclientid" @@ -55,9 +56,9 @@ public async Task WhenExchangeCodeForTokensAsyncAndClientThrows_ThenReturnsError [Fact] public async Task WhenExchangeCodeForTokensAsyncAndReceivesAllTokens_ThenReturnsAllTokens() { - _client.Setup(c => c.PostAsync(It.IsAny(), It.IsAny(), + _client.Setup(c => c.PostAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) - .ReturnsAsync(new OAuth2GrantAuthorizationResponse + .ReturnsAsync(new GenericOAuth2GrantAuthorizationResponse { AccessToken = "anaccesstoken", RefreshToken = "arefreshtoken", @@ -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(req => + _client.Verify(c => c.PostAsync(_caller.Object, It.Is(req => req.GrantType == "authorization_code" && req.Code == "acode" && req.ClientId == "aclientid" @@ -95,9 +96,9 @@ public async Task WhenExchangeCodeForTokensAsyncAndReceivesAllTokens_ThenReturns [Fact] public async Task WhenExchangeCodeForTokensAsyncAndReceivesOnlyAccessToken_ThenReturnsOnlyAccessToken() { - _client.Setup(c => c.PostAsync(It.IsAny(), It.IsAny(), + _client.Setup(c => c.PostAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) - .ReturnsAsync(new OAuth2GrantAuthorizationResponse + .ReturnsAsync(new GenericOAuth2GrantAuthorizationResponse { AccessToken = "anaccesstoken", RefreshToken = null, @@ -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(req => + _client.Verify(c => c.PostAsync(_caller.Object, It.Is(req => req.GrantType == "authorization_code" && req.Code == "acode" && req.ClientId == "aclientid" @@ -129,7 +130,7 @@ public async Task WhenExchangeCodeForTokensAsyncAndReceivesOnlyAccessToken_ThenR [Fact] public async Task WhenRefreshTokenAsyncAndClientThrows_ThenReturnsError() { - _client.Setup(c => c.PostAsync(It.IsAny(), It.IsAny(), + _client.Setup(c => c.PostAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .ThrowsAsync(new Exception("amessage")); var options = new OAuth2RefreshTokenOptions("aservicename", "arefreshtoken"); @@ -137,7 +138,7 @@ public async Task WhenRefreshTokenAsyncAndClientThrows_ThenReturnsError() 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(req => + _client.Verify(c => c.PostAsync(_caller.Object, It.Is(req => req.GrantType == "refresh_token" && req.ClientId == "aclientid" && req.ClientSecret == "aclientsecret" @@ -149,9 +150,9 @@ public async Task WhenRefreshTokenAsyncAndClientThrows_ThenReturnsError() [Fact] public async Task WhenRefreshTokenAsyncAndReceivesAllTokens_ThenReturnsAllTokens() { - _client.Setup(c => c.PostAsync(It.IsAny(), It.IsAny(), + _client.Setup(c => c.PostAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) - .ReturnsAsync(new OAuth2GrantAuthorizationResponse + .ReturnsAsync(new GenericOAuth2GrantAuthorizationResponse { AccessToken = "anaccesstoken", RefreshToken = "arefreshtoken", @@ -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(req => + _client.Verify(c => c.PostAsync(_caller.Object, It.Is(req => req.GrantType == "refresh_token" && req.ClientId == "aclientid" && req.ClientSecret == "aclientsecret" @@ -187,9 +188,9 @@ public async Task WhenRefreshTokenAsyncAndReceivesAllTokens_ThenReturnsAllTokens [Fact] public async Task WhenRefreshTokenAsyncAndReceivesOnlyAccessToken_ThenReturnsOnlyAccessToken() { - _client.Setup(c => c.PostAsync(It.IsAny(), It.IsAny(), + _client.Setup(c => c.PostAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) - .ReturnsAsync(new OAuth2GrantAuthorizationResponse + .ReturnsAsync(new GenericOAuth2GrantAuthorizationResponse { AccessToken = "anaccesstoken", RefreshToken = null, @@ -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(req => + _client.Verify(c => c.PostAsync(_caller.Object, It.Is(req => req.GrantType == "refresh_token" && req.ClientId == "aclientid" && req.ClientSecret == "aclientsecret" diff --git a/src/Infrastructure.Shared/ApplicationServices/External/OAuth2HttpServiceClient.cs b/src/Infrastructure.Shared/ApplicationServices/External/GenericOAuth2HttpServiceClient.cs similarity index 88% rename from src/Infrastructure.Shared/ApplicationServices/External/OAuth2HttpServiceClient.cs rename to src/Infrastructure.Shared/ApplicationServices/External/GenericOAuth2HttpServiceClient.cs index d1b0e6fa..3666ea4d 100644 --- a/src/Infrastructure.Shared/ApplicationServices/External/OAuth2HttpServiceClient.cs +++ b/src/Infrastructure.Shared/ApplicationServices/External/GenericOAuth2HttpServiceClient.cs @@ -10,10 +10,10 @@ namespace Infrastructure.Shared.ApplicationServices.External; /// -/// 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 The OAuth 2.0 Authorization Framework /// -public class OAuth2HttpServiceClient : IOAuth2Service +public class GenericOAuth2HttpServiceClient : IOAuth2Service { private readonly string _clientId; private readonly string? _clientSecret; @@ -21,7 +21,7 @@ public class OAuth2HttpServiceClient : IOAuth2Service 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; @@ -36,7 +36,8 @@ public async Task, 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, @@ -67,7 +68,7 @@ public async Task, 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, @@ -94,7 +95,7 @@ public async Task, Error>> RefreshTokenAsync(ICallerConte internal static class OAuth2ConversionExtensions { - public static List ToTokens(this OAuth2GrantAuthorizationResponse response) + public static List ToTokens(this GenericOAuth2GrantAuthorizationResponse response) { var tokens = new List(); var now = DateTime.UtcNow.ToNearestSecond(); diff --git a/src/Infrastructure.Web.Api.Interfaces/IHasUrlEncodedForm.cs b/src/Infrastructure.Web.Api.Interfaces/IHasUrlEncodedForm.cs new file mode 100644 index 00000000..9013b045 --- /dev/null +++ b/src/Infrastructure.Web.Api.Interfaces/IHasUrlEncodedForm.cs @@ -0,0 +1,6 @@ +namespace Infrastructure.Web.Api.Interfaces; + +/// +/// A marker interface for requests that are expected to have a URL encoded form body +/// +public interface IHasUrlEncodedForm; \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/OAuth2GrantAuthorizationRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/GenericOAuth2GrantAuthorizationRequest.cs similarity index 77% rename from src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/OAuth2GrantAuthorizationRequest.cs rename to src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/GenericOAuth2GrantAuthorizationRequest.cs index efb21979..6ca76376 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/OAuth2GrantAuthorizationRequest.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/GenericOAuth2GrantAuthorizationRequest.cs @@ -4,12 +4,12 @@ namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.OAuth2; /// -/// Makes an OAuth2 authorization grant request. +/// Makes a generic OAuth2 authorization grant request. /// [Route("/auth/token", OperationMethod.Post)] public class - OAuth2GrantAuthorizationRequest : WebRequest + GenericOAuth2GrantAuthorizationRequest : WebRequest, IHasUrlEncodedForm { [JsonPropertyName("client_id")] public string? ClientId { get; set; } diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/OAuth2GrantAuthorizationResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/GenericOAuth2GrantAuthorizationResponse.cs similarity index 90% rename from src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/OAuth2GrantAuthorizationResponse.cs rename to src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/GenericOAuth2GrantAuthorizationResponse.cs index 8e39dc2e..d6bd636f 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/OAuth2GrantAuthorizationResponse.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/OAuth2/GenericOAuth2GrantAuthorizationResponse.cs @@ -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; } diff --git a/src/Infrastructure.Web.Common.UnitTests/Clients/JsonClientSpec.cs b/src/Infrastructure.Web.Common.UnitTests/Clients/JsonClientSpec.cs index de56fc50..97726001 100644 --- a/src/Infrastructure.Web.Common.UnitTests/Clients/JsonClientSpec.cs +++ b/src/Infrastructure.Web.Common.UnitTests/Clients/JsonClientSpec.cs @@ -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(); @@ -359,6 +359,35 @@ await JsonClient.SendRequestAsync(client, HttpMethod.Post, request, file, null, ), ItExpr.IsAny()); } + + [Fact] + public async Task WhenSendRequestAsyncAndPostMethodWithIHasUrlEncodedFormRequest_ThenContentIsUrlEncodedForm() + { + var response = new HttpResponseMessage(); + var handler = new Mock(); + handler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), + ItExpr.IsAny()) + .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(req => + req.Method == HttpMethod.Post + && req.Content is FormUrlEncodedContent + ), + ItExpr.IsAny()); + } } [Trait("Category", "Unit")] @@ -620,6 +649,12 @@ public class TestMultiPartFormRequest : WebRequest, IHasMultipartFo public string AProperty { get; set; } = "avalue"; } +[Api.Interfaces.Route("/test", OperationMethod.Post)] +public class TestUrlEncodedFormRequest : WebRequest, IHasUrlEncodedForm +{ + public string AProperty { get; set; } = "avalue"; +} + public class TestResponse : IWebResponse { // ReSharper disable once UnusedAutoPropertyAccessor.Global diff --git a/src/Infrastructure.Web.Common/Clients/JsonClient.cs b/src/Infrastructure.Web.Common/Clients/JsonClient.cs index b0196a97..ed00e571 100644 --- a/src/Infrastructure.Web.Common/Clients/JsonClient.cs +++ b/src/Infrastructure.Web.Common/Clients/JsonClient.cs @@ -323,7 +323,7 @@ internal static async Task 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()) { @@ -335,9 +335,14 @@ internal static async Task 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(); @@ -354,7 +359,7 @@ internal static async Task 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 @@ -370,6 +375,14 @@ static MultipartFormDataContent ToFormData(IWebRequest body) return content; } + + static FormUrlEncodedContent ToUrlEncodedContent(IWebRequest body) + { + var requestFields = body.SerializeToJson() + .FromJson>()!; + + return new FormUrlEncodedContent(requestFields); + } } public void SetBaseUrl(string baseUrl) diff --git a/src/Infrastructure.Web.Website.IntegrationTests/ReverseProxyApiSpec.cs b/src/Infrastructure.Web.Website.IntegrationTests/ReverseProxyApiSpec.cs index 87ee60a7..19f55a30 100644 --- a/src/Infrastructure.Web.Website.IntegrationTests/ReverseProxyApiSpec.cs +++ b/src/Infrastructure.Web.Website.IntegrationTests/ReverseProxyApiSpec.cs @@ -73,7 +73,7 @@ public async Task WhenRequestARemoteApi_ThenDoesReverseProxy() var response = await result.Content.ReadFromJsonAsync(JsonOptions); response!.Profile!.IsAuthenticated.Should().BeFalse(); - response!.Profile!.Id.Should().Be(CallerConstants.AnonymousUserId); + response.Profile!.Id.Should().Be(CallerConstants.AnonymousUserId); } [Fact]