From f429ecc5b593705ca1457ad69e99d8ebbc66e308 Mon Sep 17 00:00:00 2001 From: "kampute.com" <49691331+kampute@users.noreply.github.com> Date: Tue, 7 May 2024 17:24:52 +0800 Subject: [PATCH] 12th release (#10) * Enhance functionality of scoped properties and headers * Improve documentation regarding scoped headers and properties --------- 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 +- .../ErrorHandlers/HttpError401Handler.cs | 34 +++++++- .../HttpRequestMessagePropertyKeys.cs | 21 ++++- src/Kampute.HttpClient/HttpRestClient.cs | 82 +++++++++++-------- .../Kampute.HttpClient.csproj | 2 +- .../HttpRestClientTests.cs | 43 +++++----- 9 files changed, 128 insertions(+), 62 deletions(-) diff --git a/src/Kampute.HttpClient.DataContract/Kampute.HttpClient.DataContract.csproj b/src/Kampute.HttpClient.DataContract/Kampute.HttpClient.DataContract.csproj index 9220c97..c9097b6 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 - 2.1.1 + 2.2.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 15ec5c4..1a87263 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 - 2.1.1 + 2.2.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 9334235..faba952 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 - 2.1.1 + 2.2.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 d347ade..b328848 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 - 2.1.1 + 2.2.0 Kampute Copyright (c) 2024 Kampute latest diff --git a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs index 6abf093..ea42822 100644 --- a/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs +++ b/src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs @@ -102,7 +102,7 @@ public HttpError401Handler(Func { - using (ctx.Client.BeginPropertyScope(new Dictionary { [HttpRequestMessagePropertyKeys.SkipUnauthorizedHandling] = true })) + using (ctx.Client.BeginPropertyScope(AuthorizationScope.Properties)) { return await _asyncAuthenticator(ctx, cancellationToken).ConfigureAwait(false); } @@ -114,10 +114,9 @@ await _lastAuthorization.TryUpdateAsync(async () => /// async Task IHttpErrorHandler.DecideOnRetryAsync(HttpResponseErrorContext ctx, CancellationToken cancellationToken) { - if (ctx.Request.Properties.ContainsKey(HttpRequestMessagePropertyKeys.SkipUnauthorizedHandling)) + if (ctx.Request.Properties.TryGetValue(HttpRequestMessagePropertyKeys.SkipUnauthorizedHandling, out var skip) && skip is true) return HttpErrorHandlerResult.NoRetry; - var authorization = await AuthenticateAsync(ctx, cancellationToken).ConfigureAwait(false); if (authorization is null) return HttpErrorHandlerResult.NoRetry; @@ -134,5 +133,34 @@ async Task IHttpErrorHandler.DecideOnRetryAsync(HttpResp /// Releases the unmanaged resources used by the and optionally disposes of the managed resources. /// public void Dispose() => _lastAuthorization.Dispose(); + + /// + /// Provides the request properties to be set during authorization process. + /// + /// + /// This class defines request properties that are used during the authorization process. + /// + /// + /// + /// + /// A flag indicating whether the request should skip the authorization process. + /// + /// This property is set to true for all requests initiated by the authorization process + /// to prevent the handler from reentering itself and causing potential deadlocks. + /// + /// + /// + /// + /// + private static class AuthorizationScope + { + /// + /// Gets the scoped properties of requests initiated by the authorization process. + /// + public static IEnumerable> Properties => + [ + new KeyValuePair(HttpRequestMessagePropertyKeys.SkipUnauthorizedHandling, true) + ]; + } } } diff --git a/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs b/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs index cea3fa0..cf7bedd 100644 --- a/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs +++ b/src/Kampute.HttpClient/HttpRequestMessagePropertyKeys.cs @@ -6,6 +6,7 @@ namespace Kampute.HttpClient { using Kampute.HttpClient.Interfaces; + using System; using System.Net.Http; /// @@ -15,20 +16,29 @@ public static class HttpRequestMessagePropertyKeys { /// /// A key used to store and identify the property within an that tracks - /// how many times the request has been cloned. + /// how many times the request has been cloned. /// + /// + /// The value of this property is of type . + /// public const string CloneGeneration = nameof(HttpRestClient) + "." + nameof(CloneGeneration); /// /// A key used to store and identify the property within an that identifies /// the request and its clones. /// + /// + /// The value of this property is of type . + /// public const string TransactionId = nameof(HttpRestClient) + "." + nameof(TransactionId); /// /// A key used to store and identify the property within an that identifies /// the type of expected .NET object in the response. /// + /// + /// The value of this property is of type . + /// public const string ResponseObjectType = nameof(HttpRestClient) + "." + nameof(ResponseObjectType); /// @@ -36,6 +46,9 @@ public static class HttpRequestMessagePropertyKeys /// the instance associated with the request which is responsible for scheduling /// the retry logic for transient failures. /// + /// + /// The value of this property is of type . + /// public const string RetryScheduler = nameof(HttpRestClient) + "." + nameof(RetryScheduler); /// @@ -43,12 +56,18 @@ public static class HttpRequestMessagePropertyKeys /// the instance associated with the request, which is responsible for processing /// and potentially recovering from errors in the response. /// + /// + /// The value of this property is of type . + /// public const string ErrorHandler = nameof(HttpRestClient) + "." + nameof(ErrorHandler); /// /// A key used to store and identify the property within an that indicates /// '401 Unauthorized' errors should not be automatically handled. /// + /// + /// The value of this property is of type . + /// public const string SkipUnauthorizedHandling = nameof(HttpRestClient) + "." + nameof(SkipUnauthorizedHandling); } } diff --git a/src/Kampute.HttpClient/HttpRestClient.cs b/src/Kampute.HttpClient/HttpRestClient.cs index 736be10..10c4dfd 100644 --- a/src/Kampute.HttpClient/HttpRestClient.cs +++ b/src/Kampute.HttpClient/HttpRestClient.cs @@ -57,8 +57,8 @@ private static HttpRequestHeaders CreateRequestHeaders() private readonly HttpClient _httpClient; private readonly IDisposable? _disposable; - private readonly Lazy>> _scopedHeaders = new(LazyThreadSafetyMode.ExecutionAndPublication); - private readonly Lazy>> _scopedProperties = new(LazyThreadSafetyMode.ExecutionAndPublication); + private readonly Lazy>> _scopedHeaders = new(LazyThreadSafetyMode.ExecutionAndPublication); + private readonly Lazy>> _scopedProperties = new(LazyThreadSafetyMode.ExecutionAndPublication); private IHttpBackoffProvider _backoffStrategy = BackoffStrategies.None; private Uri? _baseAddress; @@ -242,22 +242,21 @@ public void Dispose() } /// - /// Begins a new scope with the specified properties. + /// Begins a new scope with the specified request properties. /// - /// The properties to include in the new scope. - /// An representing the new scope. Disposing this object will end the scope and remove the associated properties. + /// The request properties to be applied exclusively during the lifetime of the new scope. + /// An representing the new scope. Disposing of this object will end the scope and revert changes in the request properties. /// Thrown if is null. /// /// - /// The scope created by this method will be associated with the current instance. The properties within the scope will - /// be included in the properties of all subsequent HTTP requests made by this client until the scope is disposed. + /// This method creates a scope associated with the current instance to add, modify or remove any request properties in subsequent + /// requests during the lifetime of this scope. To remove a property, use null for its value. /// /// - /// By using a scope, you can ensure that all related requests carry the same contextual information, which can be critical for tracing, logging, - /// and maintaining state across asynchronous operations or across different components of an application. + /// Upon disposing of the scope, all property adjustments are reverted, restoring the properties to their state before the scope was activated. /// /// - public virtual IDisposable BeginPropertyScope(IEnumerable> properties) + public virtual IDisposable BeginPropertyScope(IEnumerable> properties) { if (properties is null) throw new ArgumentNullException(nameof(properties)); @@ -266,21 +265,32 @@ public virtual IDisposable BeginPropertyScope(IEnumerable - /// Begins a new scope with the specified HTTP headers. + /// Begins a new scope with the specified request headers. /// - /// The HTTP headers to include in the new scope. - /// An representing the new scope. Disposing this object will end the scope and remove the associated HTTP headers. - /// Thrown if is null. + /// The request headers to be applied exclusively during the lifetime of the new scope. + /// An representing the new scope. Disposing of this object will end the scope and revert changes in the request headers. + /// Thrown if is null. /// - /// The scope created by this method will be associated with the current instance. The HTTP headers within the scope will - /// be included in the HTTP headers of all subsequent HTTP requests made by this client until the scope is disposed. + /// + /// This method creates a scope associated with the current instance to add, modify or remove any request header in subsequent + /// requests during the lifetime of this scope. To remove a header, use null for its value. + /// + /// + /// Any header modifications made within this scope take precedence over the client's default headers. Header adjustments by other active scopes are overridden + /// by those provided in this scope. However, the default request headers set on the underlying instance take precedence over the default + /// and scoped headers of the instance because they are applied later in the message handler pipeline. To avoid conflicts, it is + /// recommended to keep the default request headers of the underlying instance empty. + /// + /// + /// Upon disposing of the scope, all header adjustments are reverted, restoring the headers to their state before the scope was activated. + /// /// - public virtual IDisposable BeginHeaderScope(IEnumerable> httpHeaders) + public virtual IDisposable BeginHeaderScope(IEnumerable> headers) { - if (httpHeaders is null) - throw new ArgumentNullException(nameof(httpHeaders)); + if (headers is null) + throw new ArgumentNullException(nameof(headers)); - return _scopedHeaders.Value.BeginScope(httpHeaders); + return _scopedHeaders.Value.BeginScope(headers); } /// @@ -605,9 +615,9 @@ HttpContentException Error(string message, Exception? innerException = null) /// to ensure that context-specific modifications are respected. /// /// - /// If the underlying HTTP client's default request headers, the headers provided by the property, or any scoped - /// headers do not already include an Accept header, it is added to align with the media types supported by the content deserializers appropriate for - /// the specified . If is null, it defaults to accepting all media types. + /// If an Accept header is absent in both default and scoped headers, it is added based on the media types supported by the content deserializers + /// for the specified . If is null, the header defaults to accepting all + /// media types ("*/*"). /// /// /// This method also includes scoped properties in the HTTP request message to provide additional context and facilitate easier tracking and processing @@ -624,7 +634,7 @@ HttpContentException Error(string message, Exception? innerException = null) /// /// /// - /// Defines the .NET type expected in the response, if any. This metadata provides context that can improve debugging, enhance logging details, + /// Defines the .NET type () expected in the response, if any. This metadata provides context that can improve debugging, enhance logging details, /// and support error recovery strategies. /// /// @@ -640,8 +650,10 @@ protected virtual HttpRequestMessage CreateHttpRequest(HttpMethod method, string var requestUri = _baseAddress is null ? new Uri(uri) : new Uri(_baseAddress, uri); var request = new HttpRequestMessage(method, requestUri); + AddRequestHeaders(); AddRequestProperties(); + return request; void AddRequestHeaders() @@ -654,15 +666,12 @@ void AddRequestHeaders() foreach (var header in _scopedHeaders.Value) { request.Headers.Remove(header.Key); - request.Headers.TryAddWithoutValidation(header.Key, header.Value); + if (header.Value is not null) + request.Headers.Add(header.Key, header.Value); } } - if - ( - !_httpClient.DefaultRequestHeaders.Contains(nameof(HttpRequestHeader.Accept)) && - !request.Headers.Contains(nameof(HttpRequestHeader.Accept)) - ) + if (!request.Headers.Contains(nameof(HttpRequestHeader.Accept))) { foreach (var mediaType in ResponseDeserializers.GetAcceptableMediaTypes(responseObjectType, ResponseErrorType)) request.Headers.Accept.Add(MediaTypeHeaderValueStore.Get(mediaType)); @@ -671,14 +680,19 @@ void AddRequestHeaders() void AddRequestProperties() { + request.Properties[HttpRequestMessagePropertyKeys.TransactionId] = Guid.NewGuid(); + request.Properties[HttpRequestMessagePropertyKeys.ResponseObjectType] = responseObjectType; + if (_scopedProperties.IsValueCreated) { foreach (var property in _scopedProperties.Value) - request.Properties[property.Key] = property.Value; + { + if (property.Value is not null) + request.Properties[property.Key] = property.Value; + else + request.Properties.Remove(property.Key); + } } - - request.Properties[HttpRequestMessagePropertyKeys.TransactionId] = Guid.NewGuid(); - request.Properties[HttpRequestMessagePropertyKeys.ResponseObjectType] = responseObjectType; } } diff --git a/src/Kampute.HttpClient/Kampute.HttpClient.csproj b/src/Kampute.HttpClient/Kampute.HttpClient.csproj index 0654252..5769e7a 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 - 2.1.1 + 2.2.0 Kampute Copyright (c) 2024 Kampute latest diff --git a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs index 1378ae4..3f10640 100644 --- a/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs +++ b/tests/Kampute.HttpClient.Test/HttpRestClientTests.cs @@ -253,56 +253,61 @@ public async Task OnConnectionFailure_UsesBackoffStrategy() } [Test] - public async Task BeginPropertyScope_AddsPropertiesToRequest() + public async Task BeginPropertyScope_ModifiesRequestPropertiesCorrectly() { - var customPropName = "PROP_NAME"; - var customPropValue = "PROP_VALUE"; + var scopedProperty = new KeyValuePair("PROP_NAME", "PROP_VALUE"); var sent = false; _mockMessageHandler.MockHttpResponse(request => { - var propExists = request.Options.TryGetValue(new HttpRequestOptionsKey(customPropName), out var propValue); + var propExists = request.Options.TryGetValue(new HttpRequestOptionsKey(scopedProperty.Key), out var propValue); Assert.Multiple(() => { Assert.That(propExists, Is.True); - Assert.That(propValue, Is.EqualTo(customPropValue)); + Assert.That(propValue, Is.EqualTo(scopedProperty.Value)); }); sent = true; return new HttpResponseMessage(HttpStatusCode.OK); }); - using var scope = _client.BeginPropertyScope(new Dictionary + using (_client.BeginPropertyScope([scopedProperty])) { - [customPropName] = customPropValue - }); - - using var _ = await _client.SendAsync(TestHttpMethod, "/resource"); + using var _ = await _client.SendAsync(TestHttpMethod, "/resource"); + } Assert.That(sent, Is.True); } [Test] - public async Task BeginHeaderScope_AddsHeadersToRequest() + public async Task BeginHeaderScope_ModifiesRequestHeadersCorrectly() { - var customHeaderName = "HEADER_NAME"; - var customHeaderValue = "HEADER_VALUE"; + var scopedHeaderToAdd = new KeyValuePair("X-TO-ADD", "ADDED"); + var scopedHeaderToChange = new KeyValuePair("X-TO-ChANGE", "CHANGED"); + var scopedHeaderToDelete = new KeyValuePair("X-TO-DELETE", null); + + _client.DefaultRequestHeaders.Remove(scopedHeaderToAdd.Key); + _client.DefaultRequestHeaders.Add(scopedHeaderToChange.Key, "To be changed"); + _client.DefaultRequestHeaders.Add(scopedHeaderToDelete.Key, "To be deleted"); var sent = false; _mockMessageHandler.MockHttpResponse(request => { - Assert.That(request.Headers.GetValues(customHeaderName), Is.EquivalentTo(new[] { customHeaderValue })); + Assert.Multiple(() => + { + Assert.That(request.Headers.GetValues(scopedHeaderToAdd.Key), Is.EqualTo(new[] { scopedHeaderToAdd.Value })); + Assert.That(request.Headers.GetValues(scopedHeaderToChange.Key), Is.EqualTo(new[] { scopedHeaderToChange.Value })); + Assert.That(request.Headers.Contains(scopedHeaderToDelete.Key), Is.False); + }); sent = true; return new HttpResponseMessage(HttpStatusCode.OK); }); - using var scope = _client.BeginHeaderScope(new Dictionary + using (_client.BeginHeaderScope([scopedHeaderToAdd, scopedHeaderToChange, scopedHeaderToDelete])) { - [customHeaderName] = customHeaderValue - }); - - using var _ = await _client.SendAsync(TestHttpMethod, "/resource"); + using var _ = await _client.SendAsync(TestHttpMethod, "/resource"); + } Assert.That(sent, Is.True); }