From 641fa897342893b47d67156c94715a3b660894e9 Mon Sep 17 00:00:00 2001 From: Evgeny Valavin <35497422+evgenyvalavin@users.noreply.github.com> Date: Thu, 1 Jun 2023 18:04:24 +0500 Subject: [PATCH] Feature. Add Request/Response to API endpoint for flexibility (#28) --- .../Arbus.Network.Demo/OrdersResponseDto.cs | 2 +- .../Arbus.Network.UnitTests.csproj | 10 ++-- src/Arbus.Network.UnitTests/TestFixture.cs | 16 ----- .../Tests/HttpClientContextTests.cs | 33 ----------- .../Tests/NativeHttpClientTests.cs | 25 ++++---- src/Arbus.Network/Abstractions/ApiEndpoint.cs | 59 +++++++++++++++++-- .../Abstractions/IHttpContext.cs | 2 +- src/Arbus.Network/Arbus.Network.csproj | 13 +++- .../DefaultJsonSerializer.cs | 37 ------------ .../JsonContentSerializer.cs | 18 ------ .../Extensions/HttpClientExtensions.cs | 4 +- .../Extensions/HttpResponseExtensions.cs | 8 --- .../GlobalJsonSerializerOptions.cs | 17 ++++++ .../HttpClientContext.cs | 24 +++----- .../{ => Implementations}/NativeHttpClient.cs | 7 +-- 15 files changed, 115 insertions(+), 160 deletions(-) delete mode 100644 src/Arbus.Network.UnitTests/TestFixture.cs delete mode 100644 src/Arbus.Network.UnitTests/Tests/HttpClientContextTests.cs delete mode 100644 src/Arbus.Network/ContentSerializers/DefaultJsonSerializer.cs delete mode 100644 src/Arbus.Network/ContentSerializers/JsonContentSerializer.cs delete mode 100644 src/Arbus.Network/Extensions/HttpResponseExtensions.cs create mode 100644 src/Arbus.Network/GlobalJsonSerializerOptions.cs rename src/Arbus.Network/{ => Implementations}/HttpClientContext.cs (64%) rename src/Arbus.Network/{ => Implementations}/NativeHttpClient.cs (93%) diff --git a/samples/Arbus.Network.Demo/OrdersResponseDto.cs b/samples/Arbus.Network.Demo/OrdersResponseDto.cs index f2eae22..bc052ce 100644 --- a/samples/Arbus.Network.Demo/OrdersResponseDto.cs +++ b/samples/Arbus.Network.Demo/OrdersResponseDto.cs @@ -1,3 +1,3 @@ namespace Arbus.Network.Demo; -public record OrdersResponseDto(List Orders); \ No newline at end of file +public record OrdersResponseDto(IReadOnlyList Orders); \ No newline at end of file diff --git a/src/Arbus.Network.UnitTests/Arbus.Network.UnitTests.csproj b/src/Arbus.Network.UnitTests/Arbus.Network.UnitTests.csproj index 358346d..d4595ae 100644 --- a/src/Arbus.Network.UnitTests/Arbus.Network.UnitTests.csproj +++ b/src/Arbus.Network.UnitTests/Arbus.Network.UnitTests.csproj @@ -9,15 +9,15 @@ - - + + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Arbus.Network.UnitTests/TestFixture.cs b/src/Arbus.Network.UnitTests/TestFixture.cs deleted file mode 100644 index d4f978b..0000000 --- a/src/Arbus.Network.UnitTests/TestFixture.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Arbus.Network.UnitTests; - -[TestFixture] -public abstract class TestFixture -{ - private readonly MockRepository _mockRepository = new(default); - - public Mock CreateMock(MockBehavior mockBehavior = MockBehavior.Strict, params object[] args) where T : class - => _mockRepository.Create(mockBehavior, args); - - [TearDown] - public void AfterEachTest() - { - _mockRepository.VerifyAll(); - } -} \ No newline at end of file diff --git a/src/Arbus.Network.UnitTests/Tests/HttpClientContextTests.cs b/src/Arbus.Network.UnitTests/Tests/HttpClientContextTests.cs deleted file mode 100644 index fd46881..0000000 --- a/src/Arbus.Network.UnitTests/Tests/HttpClientContextTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Arbus.Network.Abstractions; - -namespace Arbus.Network.UnitTests.Tests; - -public class HttpClientContextTests : TestFixture -{ - [Test] - public async Task RunEndpointInternal_InvokesSendRequestWithCancellationTokenFromEndpoint() - { - using CancellationTokenSource cts = new(); - CancellationToken cancellationToken = cts.Token; - - var mockApiEndpoint = Mock.Of(x => x.CancellationToken == cancellationToken && x.Path == "http://localhost" && x.Method == HttpMethod.Get); - var mockDefaultHttpClient = CreateMock(); - mockDefaultHttpClient.Setup(x => x.SendRequest(It.IsAny(), cancellationToken, It.IsAny())).ReturnsAsync(new HttpResponseMessage()); - - HttpClientContext httpClientContext = new(mockDefaultHttpClient.Object); - - await httpClientContext.RunEndpointInternal(mockApiEndpoint); - } - - [Test] - public async Task RunEndpointInternal_InvokesSendRequestWithDefaultCancellationToken() - { - var mockApiEndpoint = Mock.Of(x => x.Path == "http://localhost" && x.Method == HttpMethod.Get); - var mockDefaultHttpClient = CreateMock(); - mockDefaultHttpClient.Setup(x => x.SendRequest(It.IsAny(), default, It.IsAny())).ReturnsAsync(new HttpResponseMessage()); - - HttpClientContext httpClientContext = new(mockDefaultHttpClient.Object); - - await httpClientContext.RunEndpointInternal(mockApiEndpoint); - } -} \ No newline at end of file diff --git a/src/Arbus.Network.UnitTests/Tests/NativeHttpClientTests.cs b/src/Arbus.Network.UnitTests/Tests/NativeHttpClientTests.cs index 7f49d4b..8377ff2 100644 --- a/src/Arbus.Network.UnitTests/Tests/NativeHttpClientTests.cs +++ b/src/Arbus.Network.UnitTests/Tests/NativeHttpClientTests.cs @@ -1,32 +1,33 @@ using Arbus.Network.Abstractions; using Arbus.Network.Exceptions; using Arbus.Network.Extensions; +using Arbus.Network.Implementations; namespace Arbus.Network.UnitTests.Tests; -public class NativeHttpClientTests : TestFixture +public class NativeHttpClientTests { [Test] public void EnsureNoTimeout_CancellationRequested_ThrowsHttpTimeoutException() { - var canceallationTokenSource = new CancellationTokenSource(); - canceallationTokenSource.Cancel(); + var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); - Assert.Throws(() => NativeHttpClient.EnsureNoTimeout(canceallationTokenSource)); + Assert.Throws(() => NativeHttpClient.EnsureNoTimeout(cancellationTokenSource)); } [Test] public void EnsureNoTimeout_CancellationNotRequested_NoException() { - var canceallationTokenSource = new CancellationTokenSource(); + var cancellationTokenSource = new CancellationTokenSource(); - Assert.DoesNotThrow(() => NativeHttpClient.EnsureNoTimeout(canceallationTokenSource)); + Assert.DoesNotThrow(() => NativeHttpClient.EnsureNoTimeout(cancellationTokenSource)); } [Test] public void EnsureNetworkAvailable_NetworkNotAvailable_ThrowsNoNetworkConnectionAvailableException() { - var mockNetworkManager = CreateMock(); + var mockNetworkManager = new Mock(); mockNetworkManager.SetupGet(x => x.IsNetworkAvailable).Returns(false); NativeHttpClient nativeHttpClient = new(mockNetworkManager.Object); @@ -37,7 +38,7 @@ public void EnsureNetworkAvailable_NetworkNotAvailable_ThrowsNoNetworkConnection [Test] public void EnsureNetworkAvailable_NetworkAvailable_NoException() { - var mockNetworkManager = CreateMock(); + var mockNetworkManager = new Mock(); mockNetworkManager.SetupGet(x => x.IsNetworkAvailable).Returns(true); NativeHttpClient nativeHttpClient = new(mockNetworkManager.Object); @@ -53,7 +54,7 @@ public void GetTimeoutCts_InfiniteTimeSpan_AssertNullCts() var cts = NativeHttpClient.GetTimeoutCts(timeout, default); - Assert.IsNull(cts); + Assert.That(cts, Is.Null); } [Test] @@ -64,11 +65,11 @@ public void GetTimeoutCts_NotInfiniteTimeSpan_AssertNotNUllCts() using var cts = NativeHttpClient.GetTimeoutCts(timeout, default); - Assert.NotNull(cts); + Assert.That(cts, Is.Not.Null); } [Test] - public void GetTimeoutCts_CancellFirstToken_AssertSecondIsCancelled() + public void GetTimeoutCts_CancelFirstToken_AssertSecondIsCanceled() { using HttpRequestMessage timeout = new(); timeout.SetTimeout(TimeSpan.FromSeconds(1)); @@ -77,6 +78,6 @@ public void GetTimeoutCts_CancellFirstToken_AssertSecondIsCancelled() using var cts2 = NativeHttpClient.GetTimeoutCts(timeout, cts1.Token); - Assert.IsTrue(cts2?.Token.IsCancellationRequested); + Assert.That(cts2?.Token.IsCancellationRequested, Is.True); } } \ No newline at end of file diff --git a/src/Arbus.Network/Abstractions/ApiEndpoint.cs b/src/Arbus.Network/Abstractions/ApiEndpoint.cs index e2a3045..f033070 100644 --- a/src/Arbus.Network/Abstractions/ApiEndpoint.cs +++ b/src/Arbus.Network/Abstractions/ApiEndpoint.cs @@ -1,4 +1,6 @@ -using Arbus.Network.ContentSerializers; +using Arbus.Network.Extensions; +using System.Net.Http.Headers; +using System.Text; namespace Arbus.Network.Abstractions; @@ -12,14 +14,59 @@ public abstract class ApiEndpoint public virtual TimeSpan Timeout => _defaultTimeout; - public virtual Dictionary? AdditionalHeaders { get; } - public CancellationToken? CancellationToken { get; set; } - public virtual HttpContent? CreateContent() => default; + protected internal virtual HttpRequestMessage CreateRequest(Uri? baseUrl) + { + var requestUri = CreateRequestUri(baseUrl); + + var request = new HttpRequestMessage(Method, requestUri) + { + Content = CreateContent() + }; + request.SetTimeout(Timeout); + + return request; + } + + private Uri CreateRequestUri(Uri? baseUrl) + { + Uri uri; + if (baseUrl is null) + uri = new(Path); + else + uri = new(baseUrl, Path); + return uri; + } + + protected internal virtual HttpContent? CreateContent() => default; + + protected virtual void AddRequestHeaders(HttpRequestHeaders headers) + { + } + + protected virtual StringContent ToJson(object value) + { + return new( + JsonSerializer.Serialize( + value, GlobalJsonSerializerOptions.Options), Encoding.UTF8, HttpContentType.Application.Json); + } } -public abstract class ApiEndpoint : ApiEndpoint +public abstract class ApiEndpoint : ApiEndpoint { - public virtual Task GetResponse(HttpContent httpContent) => JsonContentSerializer.Deserialize(httpContent); + public virtual ValueTask GetResponse(HttpResponseMessage responseMessage) + => FromJson(responseMessage.Content); + + public static async ValueTask FromJson(HttpContent content, CancellationToken cancellationToken = default) + { + using var responseStream = await content + .ReadAsStreamAsync() + .ConfigureAwait(false); + + var deserializedObject = await JsonSerializer.DeserializeAsync( + responseStream, GlobalJsonSerializerOptions.Options, cancellationToken).ConfigureAwait(false); + + return deserializedObject ?? throw new Exception("Unable to deserialize stream."); + } } \ No newline at end of file diff --git a/src/Arbus.Network/Abstractions/IHttpContext.cs b/src/Arbus.Network/Abstractions/IHttpContext.cs index b1a82de..681f300 100644 --- a/src/Arbus.Network/Abstractions/IHttpContext.cs +++ b/src/Arbus.Network/Abstractions/IHttpContext.cs @@ -3,7 +3,7 @@ public interface IHttpClientContext { Task RunEndpoint(ApiEndpoint endpoint); - Task RunEndpoint(ApiEndpoint endpoint); + Task RunEndpoint(ApiEndpoint endpoint); Task RunStreamEndpoint(ApiEndpoint endpoint) where TStream : Stream; Task RunHttpContentEndpoint(ApiEndpoint endpoint) where THttpContent : HttpContent; } \ No newline at end of file diff --git a/src/Arbus.Network/Arbus.Network.csproj b/src/Arbus.Network/Arbus.Network.csproj index 4e511be..fb3dd58 100644 --- a/src/Arbus.Network/Arbus.Network.csproj +++ b/src/Arbus.Network/Arbus.Network.csproj @@ -1,10 +1,12 @@  + netstandard2.0;net6.0 Latest enable enable + MIT ArbusBiz @@ -12,10 +14,17 @@ https://github.com/ArbusBiz/Arbus.Network https://github.com/ArbusBiz/Arbus.Network + - + + - + + + + + + diff --git a/src/Arbus.Network/ContentSerializers/DefaultJsonSerializer.cs b/src/Arbus.Network/ContentSerializers/DefaultJsonSerializer.cs deleted file mode 100644 index 3ade46c..0000000 --- a/src/Arbus.Network/ContentSerializers/DefaultJsonSerializer.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Text.Encodings.Web; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Arbus.Network.ContentSerializers; - -public static class DefaultJsonSerializer -{ - private static TimeSpan _maxDeserializationTime = TimeSpan.FromSeconds(5); - - public static JsonSerializerOptions SerializerOptions = GetDefaultSerializerOptions(); - - public static void Serialize(Stream utf8Json, TValue value) - => JsonSerializer.Serialize(utf8Json, value, SerializerOptions); - - public static string Serialize(object? value) => JsonSerializer.Serialize(value, SerializerOptions); - - public static TValue? Deserialize(string json) - => JsonSerializer.Deserialize(json, SerializerOptions); - - public static TValue? Deserialize(Stream utf8Json) - => JsonSerializer.Deserialize(utf8Json, SerializerOptions); - - public static ValueTask DeserializeAsync(Stream utf8Json, CancellationToken? cancellationToken = default) - { - cancellationToken ??= new CancellationTokenSource(_maxDeserializationTime).Token; - return JsonSerializer.DeserializeAsync(utf8Json, SerializerOptions, cancellationToken.Value); - } - - public static JsonSerializerOptions GetDefaultSerializerOptions() => new() - { - PropertyNameCaseInsensitive = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; -} \ No newline at end of file diff --git a/src/Arbus.Network/ContentSerializers/JsonContentSerializer.cs b/src/Arbus.Network/ContentSerializers/JsonContentSerializer.cs deleted file mode 100644 index a1391b6..0000000 --- a/src/Arbus.Network/ContentSerializers/JsonContentSerializer.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Text; - -namespace Arbus.Network.ContentSerializers; - -public static class JsonContentSerializer -{ - public static HttpContent Serialize(object value) - { - return new StringContent(DefaultJsonSerializer.Serialize(value), Encoding.UTF8, HttpContentType.Application.Json); - } - - public static async Task Deserialize(HttpContent content, CancellationToken cancellationToken = default) - { - using var responseStream = await content.ReadAsStreamAsync().ConfigureAwait(false); - var deserializedObject = await DefaultJsonSerializer.DeserializeAsync(responseStream, cancellationToken).ConfigureAwait(false); - return deserializedObject ?? throw new Exception("Unable to deserialize stream."); - } -} \ No newline at end of file diff --git a/src/Arbus.Network/Extensions/HttpClientExtensions.cs b/src/Arbus.Network/Extensions/HttpClientExtensions.cs index 683a232..fa0432b 100644 --- a/src/Arbus.Network/Extensions/HttpClientExtensions.cs +++ b/src/Arbus.Network/Extensions/HttpClientExtensions.cs @@ -4,6 +4,6 @@ namespace Arbus.Network.Extensions; public static class HttpClientExtensions { - public static void SetUserAgentHeader(this HttpClient request, ProductInfoHeaderValue productInfoHeader) - => request.DefaultRequestHeaders.UserAgent.Add(productInfoHeader); + public static void SetUserAgentHeader(this HttpClient httpClient, ProductInfoHeaderValue productInfoHeader) + => httpClient.DefaultRequestHeaders.UserAgent.Add(productInfoHeader); } diff --git a/src/Arbus.Network/Extensions/HttpResponseExtensions.cs b/src/Arbus.Network/Extensions/HttpResponseExtensions.cs deleted file mode 100644 index c1b10c8..0000000 --- a/src/Arbus.Network/Extensions/HttpResponseExtensions.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Arbus.Network.Extensions; - -public static class HttpResponseExtensions -{ - [Obsolete("Do not use. Will be removed.")] - public static string ToReadableString(this HttpResponseMessage response) - => $"\n{response.RequestMessage.Method} {response.RequestMessage.RequestUri} {response.StatusCode} {(int)response.StatusCode}"; -} diff --git a/src/Arbus.Network/GlobalJsonSerializerOptions.cs b/src/Arbus.Network/GlobalJsonSerializerOptions.cs new file mode 100644 index 0000000..a05b593 --- /dev/null +++ b/src/Arbus.Network/GlobalJsonSerializerOptions.cs @@ -0,0 +1,17 @@ +using System.Text.Encodings.Web; +using System.Text.Json.Serialization; + +namespace Arbus.Network; + +public static class GlobalJsonSerializerOptions +{ + public static JsonSerializerOptions Options { get; set; } = GetDefaultSerializerOptions(); + + public static JsonSerializerOptions GetDefaultSerializerOptions() => new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; +} \ No newline at end of file diff --git a/src/Arbus.Network/HttpClientContext.cs b/src/Arbus.Network/Implementations/HttpClientContext.cs similarity index 64% rename from src/Arbus.Network/HttpClientContext.cs rename to src/Arbus.Network/Implementations/HttpClientContext.cs index 3175a51..9e93419 100644 --- a/src/Arbus.Network/HttpClientContext.cs +++ b/src/Arbus.Network/Implementations/HttpClientContext.cs @@ -1,8 +1,7 @@ using Arbus.Network.Abstractions; -using Arbus.Network.Extensions; using System.Net.Http.Headers; -namespace Arbus.Network; +namespace Arbus.Network.Implementations; public class HttpClientContext : IHttpClientContext { @@ -21,39 +20,34 @@ public async Task RunEndpoint(ApiEndpoint endpoint) public async Task RunEndpoint(ApiEndpoint endpoint) { using var response = await RunEndpointInternal(endpoint).ConfigureAwait(false); - return await endpoint.GetResponse(response.Content).ConfigureAwait(false); + return await endpoint.GetResponse(response).ConfigureAwait(false); } + [Obsolete("Create an issue on GitHub if in use")] public async Task RunStreamEndpoint(ApiEndpoint endpoint) where TStream : Stream { var response = await RunEndpointInternal(endpoint).ConfigureAwait(false); - return await endpoint.GetResponse(response.Content).ConfigureAwait(false); + return await endpoint.GetResponse(response).ConfigureAwait(false); } + [Obsolete("Create an issue on GitHub if in use")] public async Task RunHttpContentEndpoint(ApiEndpoint endpoint) where THttpContent : HttpContent { var response = await RunEndpointInternal(endpoint).ConfigureAwait(false); - return await endpoint.GetResponse(response.Content).ConfigureAwait(false); + return await endpoint.GetResponse(response).ConfigureAwait(false); } public virtual Task RunEndpointInternal(ApiEndpoint endpoint) { - var request = new HttpRequestMessage(endpoint.Method, GetUri(endpoint.Path)); - request.SetTimeout(endpoint.Timeout); - request.Content = endpoint.CreateContent(); - - if (endpoint.AdditionalHeaders is not null) - { - foreach (var header in endpoint.AdditionalHeaders) - request.Headers.TryAddWithoutValidation(header.Key, header.Value); - } + var request = endpoint.CreateRequest( + GetBaseUrl()); AddHeaders(request.Headers); return _nativeHttpClient.SendRequest(request, endpoint.CancellationToken ?? default); } - protected virtual Uri GetUri(string uri) => new(uri); + public virtual Uri? GetBaseUrl() => default; protected virtual void AddHeaders(HttpRequestHeaders headers) { diff --git a/src/Arbus.Network/NativeHttpClient.cs b/src/Arbus.Network/Implementations/NativeHttpClient.cs similarity index 93% rename from src/Arbus.Network/NativeHttpClient.cs rename to src/Arbus.Network/Implementations/NativeHttpClient.cs index 0c31e26..235630c 100644 --- a/src/Arbus.Network/NativeHttpClient.cs +++ b/src/Arbus.Network/Implementations/NativeHttpClient.cs @@ -1,14 +1,13 @@ using Arbus.Network.Abstractions; -using Arbus.Network.ContentSerializers; using Arbus.Network.Exceptions; using Arbus.Network.Extensions; using System.Net.Http.Headers; -namespace Arbus.Network; +namespace Arbus.Network.Implementations; public class NativeHttpClient : INativeHttpClient { - private static readonly HttpClient _httpClient = new() + protected static readonly HttpClient _httpClient = new() { Timeout = Timeout.InfiniteTimeSpan }; @@ -95,7 +94,7 @@ public virtual Task HandleNotSuccessStatusCode(HttpResponse public static async Task HandleProblemDetailsResponse(HttpResponseMessage response) { var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - var problemDetails = await DefaultJsonSerializer.DeserializeAsync(responseStream).ConfigureAwait(false) + var problemDetails = await JsonSerializer.DeserializeAsync(responseStream, GlobalJsonSerializerOptions.Options).ConfigureAwait(false) ?? throw new Exception("Failed to deserialize ProblemDetails."); throw NetworkExceptionFactory.Create(response.StatusCode, problemDetails); }