From e20605fb5f5cfc22e8dd0ca0809583958a98ed8b Mon Sep 17 00:00:00 2001 From: "kampute.com" <49691331+kampute@users.noreply.github.com> Date: Tue, 2 Apr 2024 20:36:52 +0800 Subject: [PATCH] 7th release (#5) * Add DownloadAsync method to HttpRestClient * Refactor DownloadAsync --------- Co-authored-by: Kambiz Khojasteh --- .../Kampute.HttpClient.DataContract.csproj | 2 +- .../Kampute.HttpClient.Json.csproj | 2 +- .../Kampute.HttpClient.NewtonsoftJson.csproj | 2 +- .../Kampute.HttpClient.Xml.csproj | 2 +- src/Kampute.HttpClient/HttpRestClient.cs | 66 ++++++++++--------- .../HttpRestClientExtensions.cs | 59 ++++++++++++++--- .../Kampute.HttpClient.csproj | 2 +- .../HttpRestClientExtensionsTests.cs | 62 +++++++++++++---- .../HttpRestClientTests.cs | 33 +++++++--- 9 files changed, 165 insertions(+), 65 deletions(-) diff --git a/src/Kampute.HttpClient.DataContract/Kampute.HttpClient.DataContract.csproj b/src/Kampute.HttpClient.DataContract/Kampute.HttpClient.DataContract.csproj index 78dde4e..86044ee 100644 --- a/src/Kampute.HttpClient.DataContract/Kampute.HttpClient.DataContract.csproj +++ b/src/Kampute.HttpClient.DataContract/Kampute.HttpClient.DataContract.csproj @@ -5,7 +5,7 @@ Kampute.HttpClient.DataContract This package is an extension package for Kampute.HttpClient, enhancing it to manage application/xml content types, using DataContractSerializer for serialization and deserialization of XML responses and payloads. Kambiz Khojasteh - 1.4.1 + 1.5.0 Kampute Copyright (c) 2024 Kampute latest diff --git a/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj b/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj index 1f30751..8ceb73a 100644 --- a/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj +++ b/src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj @@ -5,7 +5,7 @@ Kampute.HttpClient.Json This package is an extension package for Kampute.HttpClient, enhancing it to manage application/json content types, using System.Text.Json library for serialization and deserialization of JSON responses and payloads. Kambiz Khojasteh - 1.4.1 + 1.5.0 Kampute Copyright (c) 2024 Kampute latest diff --git a/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj b/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj index 9612440..8d48c70 100644 --- a/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj +++ b/src/Kampute.HttpClient.NewtonsoftJson/Kampute.HttpClient.NewtonsoftJson.csproj @@ -5,7 +5,7 @@ Kampute.HttpClient.NewtonsoftJson This package is an extension package for Kampute.HttpClient, enhancing it to manage application/json content types, using Newtonsoft.Json library for serialization and deserialization of JSON responses and payloads. Kambiz Khojasteh - 1.4.1 + 1.5.0 Kampute Copyright (c) 2024 Kampute latest diff --git a/src/Kampute.HttpClient.Xml/Kampute.HttpClient.Xml.csproj b/src/Kampute.HttpClient.Xml/Kampute.HttpClient.Xml.csproj index 8182d95..a2def2b 100644 --- a/src/Kampute.HttpClient.Xml/Kampute.HttpClient.Xml.csproj +++ b/src/Kampute.HttpClient.Xml/Kampute.HttpClient.Xml.csproj @@ -5,7 +5,7 @@ Kampute.HttpClient.Xml This package is an extension package for Kampute.HttpClient, enhancing it to manage application/xml content types, using XmlSerializer for serialization and deserialization of XML responses and payloads. Kambiz Khojasteh - 1.4.1 + 1.5.0 Kampute Copyright (c) 2024 Kampute latest diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index 1f02d05..86f64d2 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -52,9 +52,10 @@ namespace Kampute.HttpClient public class HttpRestClient : IDisposable, ICloneable { private static readonly MediaTypeWithQualityHeaderValue AnyMediaType = new("*/*", 0.1); - private static readonly SharedDisposable SharedHttpClient = new(CreateDefaultHttpClient); + private static readonly SharedDisposable SharedHttpClient = new(CreateClient); + private static readonly Lazy EmptyContentHeaders = new(CreateEmptyContentHeaders); - private static HttpClient CreateDefaultHttpClient() + private static HttpClient CreateClient() { var messageHandler = new HttpClientHandler { @@ -69,6 +70,12 @@ private static HttpRequestHeaders CreateRequestHeaders() return request.Headers; } + private static HttpContentHeaders CreateEmptyContentHeaders() + { + using var content = new ByteArrayContent([]); + return content.Headers; + } + private readonly HttpClient _httpClient; private readonly bool _disposeClient; @@ -291,66 +298,63 @@ public void Dispose() } /// - /// Retrieves data from the specified URI and writes it to the provided stream asynchronously. + /// Sends an asynchronous HTTP request with the specified method, URI, and payload, returning the response content as a stream. /// - /// The stream to write the downloaded data to. It must be writable. /// The HTTP method to use for the request. /// The URI to which the request is sent. - /// The HTTP request payload (optional). + /// The HTTP request payload content. + /// A function that returns a based on the HTTP content headers. /// A token for canceling the request (optional). /// - /// A task that represents the asynchronous download operation. The task result contains the headers of the downloaded content. - /// If the response contains no content, null is returned. + /// A task that represents the asynchronous operation. The task result contains a that represents the response content. /// - /// Thrown if , , or is null. + /// Thrown if , , or is null. + /// Thrown if returns null. /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the operation is canceled via the cancellation token. - public async Task FetchToStreamAsync - ( - Stream stream, - HttpMethod method, - string uri, - HttpContent? payload = null, - CancellationToken cancellationToken = default - ) + public async Task DownloadAsync(HttpMethod method, string uri, HttpContent? payload, Func streamProvider, CancellationToken cancellationToken = default) { - if (stream is null) - throw new ArgumentNullException(nameof(stream)); if (method is null) throw new ArgumentNullException(nameof(method)); if (uri is null) throw new ArgumentNullException(nameof(uri)); + if (streamProvider is null) + throw new ArgumentNullException(nameof(streamProvider)); using var request = CreateHttpRequest(method, uri, null); request.Content = payload; using var response = await DispatchWithRetriesAsync(request, cancellationToken).ConfigureAwait(false); + if (response.Content is null) - return null; + return GetStream(EmptyContentHeaders.Value); + var stream = GetStream(response.Content.Headers); await response.Content.CopyToAsync(stream).ConfigureAwait(false); - return response.Content.Headers; + return stream; + + Stream GetStream(HttpContentHeaders contentHeaders) + { + return streamProvider(contentHeaders) ?? throw new InvalidOperationException("The stream provider must not return null."); + } } /// - /// Sends an asynchronous HTTP request with the specified method, URI, and payload, and returns the response body deserialized as the specified type. + /// Sends an asynchronous HTTP request with the specified method, URI, and payload, returning response body deserialized as the specified type. /// /// The type of the response object. /// The HTTP method to use for the request. /// The URI to which the request is sent. - /// The HTTP request payload (optional). + /// The HTTP request payload content (optional). /// A token for canceling the request (optional). - /// - /// A task that represents the asynchronous operation, with a result of the specified type. If the response contains no content, the default value - /// for the type is returned. - /// + /// A task that represents the asynchronous operation, with a result of the specified type. /// Thrown if or is null. /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the response body is empty or its media type is not supported. /// Thrown if the operation is canceled via the cancellation token. - public async Task SendAsync(HttpMethod method, string uri, HttpContent? payload = null, CancellationToken cancellationToken = default) + public async Task SendAsync(HttpMethod method, string uri, HttpContent? payload = default, CancellationToken cancellationToken = default) { if (method is null) throw new ArgumentNullException(nameof(method)); @@ -369,14 +373,14 @@ public async Task FetchToStreamAsync /// /// The HTTP method to use for the request. /// The URI to which the request is sent. - /// The HTTP request payload (optional). + /// The HTTP request payload content (optional). /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. The task result contains the headers of the response. /// Thrown if or is null. /// Thrown if the response status code indicates a failure. /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. /// Thrown if the operation is canceled via the cancellation token. - public async Task SendAsync(HttpMethod method, string uri, HttpContent? payload = null, CancellationToken cancellationToken = default) + public async Task SendAsync(HttpMethod method, string uri, HttpContent? payload = default, CancellationToken cancellationToken = default) { if (method is null) throw new ArgumentNullException(nameof(method)); @@ -578,7 +582,7 @@ protected async Task ToExceptionAsync(HttpResponseMessage throw new ArgumentNullException(nameof(response)); var responseObject = default(object); - if (ResponseErrorType is not null && response.Content is not null) + if (ResponseErrorType is not null && response.Content is not null && response.Content.Headers.ContentLength != 0) { try { @@ -620,7 +624,7 @@ protected async Task ToExceptionAsync(HttpResponseMessage if (objectType is null) throw new ArgumentNullException(nameof(objectType)); - if (response.Content is null) + if (response.Content is null || response.Content.Headers.ContentLength == 0) throw Error("The response body is empty."); if (response.Content.Headers.ContentType is null) diff --git a/src/Kampute.HttpClient/HttpRestClientExtensions.cs b/src/Kampute.HttpClient/HttpRestClientExtensions.cs index 5fa4380..278f9dc 100644 --- a/src/Kampute.HttpClient/HttpRestClientExtensions.cs +++ b/src/Kampute.HttpClient/HttpRestClientExtensions.cs @@ -6,7 +6,9 @@ namespace Kampute.HttpClient { using System; + using System.IO; using System.Net.Http; + using System.Net.Http.Headers; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -38,7 +40,7 @@ public static class HttpRestClientExtensions [MethodImpl(MethodImplOptions.AggressiveInlining)] public static Task GetAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - return client.SendAsync(HttpVerb.Get, uri, null, cancellationToken); + return client.SendAsync(HttpVerb.Get, uri, default, cancellationToken); } /// @@ -47,7 +49,7 @@ public static class HttpRestClientExtensions /// The type of the response object. /// The instance to be used for sending the request. /// The URI to which the request is sent. - /// The HTTP request payload. + /// The HTTP request payload content. /// A token for canceling the request (optional). /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if is null. @@ -66,7 +68,7 @@ public static class HttpRestClientExtensions /// /// The instance to be used for sending the request. /// The URI to which the request is sent. - /// The HTTP request payload. + /// The HTTP request payload content. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. /// Thrown if is null. @@ -86,7 +88,7 @@ public static Task PostAsync(this HttpRestClient client, string uri, HttpContent /// The type of the response object. /// The instance to be used for sending the request. /// The URI to which the request is sent. - /// The HTTP request payload. + /// The HTTP request payload content. /// A token for canceling the request (optional). /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if is null. @@ -105,7 +107,7 @@ public static Task PostAsync(this HttpRestClient client, string uri, HttpContent /// /// The instance to be used for sending the request. /// The URI to which the request is sent. - /// The HTTP request payload. + /// The HTTP request payload content. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. /// Thrown if is null. @@ -125,7 +127,7 @@ public static Task PutAsync(this HttpRestClient client, string uri, HttpContent? /// The type of the response object. /// The instance to be used for sending the request. /// The URI to which the request is sent. - /// The HTTP request payload. + /// The HTTP request payload content. /// A token for canceling the request (optional). /// A task representing the asynchronous operation, returning a deserialized object of type . /// Thrown if is null. @@ -144,7 +146,7 @@ public static Task PutAsync(this HttpRestClient client, string uri, HttpContent? /// /// The instance to be used for sending the request. /// The URI to which the request is sent. - /// The HTTP request payload. + /// The HTTP request payload content. /// A token for canceling the request (optional). /// A task that represents the asynchronous operation. /// Thrown if is null. @@ -174,7 +176,7 @@ public static Task PatchAsync(this HttpRestClient client, string uri, HttpConten [MethodImpl(MethodImplOptions.AggressiveInlining)] public static Task DeleteAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - return client.SendAsync(HttpVerb.Delete, uri, null, cancellationToken); + return client.SendAsync(HttpVerb.Delete, uri, default, cancellationToken); } /// @@ -192,7 +194,46 @@ public static Task PatchAsync(this HttpRestClient client, string uri, HttpConten [MethodImpl(MethodImplOptions.AggressiveInlining)] public static Task DeleteAsync(this HttpRestClient client, string uri, CancellationToken cancellationToken = default) { - return client.SendAsync(HttpVerb.Delete, uri, null, cancellationToken); + return client.SendAsync(HttpVerb.Delete, uri, default, cancellationToken); + } + + /// + /// Retrieves data from the specified URI and writes it to the provided stream asynchronously. + /// + /// The instance to be used for sending the request. + /// The stream to write the downloaded data to. It must be writable. + /// The HTTP method to use for the request. + /// The URI to which the request is sent. + /// The HTTP request payload content (optional). + /// A token for canceling the request (optional). + /// + /// A task that represents the asynchronous download operation. The task result contains the headers of the downloaded content. + /// If the response contains no content, null is returned. + /// + /// Thrown if , , or is null. + /// Thrown if the response status code indicates a failure. + /// Thrown if the request fails due to an underlying issue such as network connectivity, DNS failure, server certificate validation, or timeout. + /// Thrown if the operation is canceled via the cancellation token. + public static async Task FetchToStreamAsync + ( + this HttpRestClient client, + Stream stream, + HttpMethod method, + string uri, + HttpContent? payload = default, + CancellationToken cancellationToken = default + ) + { + if (stream is null) + throw new ArgumentNullException(nameof(stream)); + + var contentHeaders = default(HttpContentHeaders); + await client.DownloadAsync(method, uri, payload, headers => + { + contentHeaders = headers; + return stream; + }, cancellationToken); + return contentHeaders; } } } diff --git a/src/Kampute.HttpClient/Kampute.HttpClient.csproj b/src/Kampute.HttpClient/Kampute.HttpClient.csproj index 1ca001d..4555960 100644 --- a/src/Kampute.HttpClient/Kampute.HttpClient.csproj +++ b/src/Kampute.HttpClient/Kampute.HttpClient.csproj @@ -5,7 +5,7 @@ Kampute.HttpClient Kampute.HttpClient is a versatile and lightweight .NET library that simplifies RESTful API communication. Its core HttpRestClient class provides a streamlined approach to HTTP interactions, offering advanced features such as flexible serialization/deserialization, robust error handling, configurable backoff strategies, and detailed request-response processing. Striking a balance between simplicity and extensibility, Kampute.HttpClient empowers developers with a powerful yet easy-to-use client for seamless API integration across a wide range of .NET applications. Kambiz Khojasteh - 1.4.1 + 1.5.0 Kampute Copyright (c) 2024 Kampute latest diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientExtensionsTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientExtensionsTests.cs index 3b04f5c..d9a36c3 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientExtensionsTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientExtensionsTests.cs @@ -5,8 +5,10 @@ using Moq; using NUnit.Framework; using System; + using System.IO; using System.Net; using System.Net.Http; + using System.Net.Http.Headers; using System.Threading.Tasks; [TestFixture] @@ -14,12 +16,12 @@ public class HttpRestClientExtensionsTests { private readonly TestContentDeserializer _testContentFormatter = new(); private readonly Mock _mockMessageHandler = new(); - private HttpRestClient _restClient; + private HttpRestClient _client; private Uri AbsoluteUrl(string url) { - return _restClient.BaseAddress is not null - ? new Uri(_restClient.BaseAddress, url) + return _client.BaseAddress is not null + ? new Uri(_client.BaseAddress, url) : new Uri(url); } @@ -27,17 +29,17 @@ private Uri AbsoluteUrl(string url) public void Setup() { var httpClient = new HttpClient(_mockMessageHandler.Object, false); - _restClient = new HttpRestClient(httpClient) + _client = new HttpRestClient(httpClient) { BaseAddress = new Uri("http://api.test.com"), }; - _restClient.ResponseDeserializers.Add(_testContentFormatter); + _client.ResponseDeserializers.Add(_testContentFormatter); } [TearDown] public void Cleanup() { - _restClient.Dispose(); + _client.Dispose(); } [Test] @@ -60,7 +62,7 @@ public async Task GetAsync_InvokesHttpClientCorrectly() }; }); - var actualResult = await _restClient.GetAsync("/resource"); + var actualResult = await _client.GetAsync("/resource"); Assert.That(actualResult, Is.EqualTo(expectedResult)); } @@ -87,7 +89,7 @@ public async Task PostAsync_InvokesHttpClientCorrectly() }; }); - var actualResult = await _restClient.PostAsync("/resource", new TestContent(payload)); + var actualResult = await _client.PostAsync("/resource", new TestContent(payload)); Assert.That(actualResult, Is.EqualTo(expectedResult)); } @@ -114,7 +116,7 @@ public async Task PutAsync_InvokesHttpClientCorrectly() }; }); - var actualResult = await _restClient.PutAsync("/resource", new TestContent(payload)); + var actualResult = await _client.PutAsync("/resource", new TestContent(payload)); Assert.That(actualResult, Is.EqualTo(expectedResult)); } @@ -141,7 +143,7 @@ public async Task PatchAsync_InvokesHttpClientCorrectly() }; }); - var actualResult = await _restClient.PatchAsync("/resource", new TestContent(payload)); + var actualResult = await _client.PatchAsync("/resource", new TestContent(payload)); Assert.That(actualResult, Is.EqualTo(expectedResult)); } @@ -166,9 +168,45 @@ public async Task DeleteAsync_InvokesHttpClientCorrectly() }; }); - var actualResult = await _restClient.DeleteAsync("/resource"); + var actualResult = await _client.DeleteAsync("/resource"); Assert.That(actualResult, Is.EqualTo(expectedResult)); } + + [Test] + public async Task FetchToStreamAsync_LoadsStreamAndReturnsContentHeaders() + { + var payload = "This is the request content"; + using var expectedStream = new MemoryStream([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + + _mockMessageHandler.MockHttpResponse(request => + { + Assert.Multiple(() => + { + Assert.That(request.Method, Is.EqualTo(HttpMethod.Post)); + Assert.That(request.RequestUri, Is.EqualTo(AbsoluteUrl("/resource"))); + }); + + var content = new StreamContent(expectedStream); + content.Headers.ContentType = new MediaTypeHeaderValue(MediaTypeNames.Application.Octet); + + return new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = content, + }; + }); + + using var resultStream = new MemoryStream(); + var contentHeaders = await _client.FetchToStreamAsync(resultStream, HttpMethod.Post, "/resource", new TestContent(payload)); + + Assert.That(contentHeaders, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(contentHeaders.ContentType, Is.EqualTo(new MediaTypeHeaderValue(MediaTypeNames.Application.Octet))); + Assert.That(contentHeaders.ContentLength, Is.EqualTo(resultStream.Length)); + Assert.That(resultStream.ToArray(), Is.EqualTo(expectedStream.ToArray())); + }); + } } -} \ No newline at end of file +} diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs index 85d6bde..7a481b7 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs @@ -106,7 +106,7 @@ public async Task SendAsync_Generic_ReturnsResponseObject() } [Test] - public async Task FetchToStreamAsync_ReturnsContentHeaders() + public async Task DownlaodAsync_WhenResponseHasContent_ReturnsDownloadedStream() { var payload = "This is the request content"; using var expectedStream = new MemoryStream([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); @@ -121,6 +121,7 @@ public async Task FetchToStreamAsync_ReturnsContentHeaders() var content = new StreamContent(expectedStream); content.Headers.ContentType = new MediaTypeHeaderValue(MediaTypeNames.Application.Octet); + content.Headers.ContentLength = expectedStream.Length; return new HttpResponseMessage { @@ -129,16 +130,32 @@ public async Task FetchToStreamAsync_ReturnsContentHeaders() }; }); - using var actualStream = new MemoryStream(); - var contentHeaders = await _client.FetchToStreamAsync(actualStream, HttpMethod.Post, "/resource", new TestContent(payload)); + var resultStream = await _client.DownloadAsync(HttpMethod.Post, "/resource", new TestContent(payload), contentHeaders => new MemoryStream()) as MemoryStream; - Assert.That(contentHeaders, Is.Not.Null); - Assert.Multiple(() => + Assert.That(resultStream, Is.Not.Null); + Assert.That(resultStream.ToArray(), Is.EqualTo(expectedStream.ToArray())); + } + + [Test] + public async Task DownlaodAsync_WhenResponseHasNoContent_ReturnsEmptyStream() + { + var payload = "This is the request content"; + + _mockMessageHandler.MockHttpResponse(request => { - Assert.That(contentHeaders.ContentType, Is.EqualTo(new MediaTypeHeaderValue(MediaTypeNames.Application.Octet))); - Assert.That(contentHeaders.ContentLength, Is.EqualTo(actualStream.Length)); - Assert.That(actualStream.ToArray(), Is.EqualTo(expectedStream.ToArray())); + Assert.Multiple(() => + { + Assert.That(request.Method, Is.EqualTo(HttpMethod.Post)); + Assert.That(request.RequestUri, Is.EqualTo(AbsoluteUrl("/resource"))); + }); + + return new HttpResponseMessage(HttpStatusCode.NoContent); }); + + var resultStream = await _client.DownloadAsync(HttpMethod.Post, "/resource", new TestContent(payload), contentHeaders => new MemoryStream()); + + Assert.That(resultStream, Is.Not.Null); + Assert.That(resultStream.Length, Is.Zero); } [Test]