Skip to content

Commit

Permalink
4th release (#2)
Browse files Browse the repository at this point in the history
* Remove constraint on ResponseErrorType, include the object in the exception

* Check for presence of DataContractAttribute to detect supported model types

* Remove redundant attribute of the test model

* Provide a more detailed comments regarding supported media/model types

* Increase version number

* Refactor SharedDisposal

* Add AsyncGuard class and refactor HttpError401Handler accordingly

---------

Co-authored-by: Kambiz Khojasteh <kambiz.khojasteh@gmail.com>
  • Loading branch information
kampute and Khojasteh authored Mar 17, 2024
1 parent dfb57d4 commit 42d1931
Show file tree
Hide file tree
Showing 18 changed files with 312 additions and 157 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Title>Kampute.HttpClient.DataContract</Title>
<Description>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.</Description>
<Authors>Kambiz Khojasteh</Authors>
<Version>1.2.0</Version>
<Version>1.3.0</Version>
<Company>Kampute</Company>
<Copyright>Copyright (c) 2024 Kampute</Copyright>
<LangVersion>latest</LangVersion>
Expand Down
19 changes: 13 additions & 6 deletions src/Kampute.HttpClient.DataContract/XmlContentDeserializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ namespace Kampute.HttpClient.DataContract
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Runtime.Serialization;
using System.Text;
using System.Threading;
Expand All @@ -34,29 +35,35 @@ public sealed class XmlContentDeserializer : IHttpContentDeserializer
/// Gets the collection of media types that this deserializer supports.
/// </summary>
/// <value>
/// The collection of media types that this deserializer supports.
/// The read-only collection of media types that this deserializer supports.
/// </value>
public IReadOnlyCollection<string> SupportedMediaTypes { get; } = [MediaTypeNames.Application.Xml];

/// <summary>
/// Retrieves a collection of supported media types for a specific model type.
/// </summary>
/// <param name="modelType">The type of the model for which to retrieve supported media types.</param>
/// <returns>A read-only collection of strings representing the media types supported for the specified model type.</returns>
/// <returns>
/// The read-only collection of media types that this deserializer supports if the model type is not <c>null</c> and
/// is marked with a <see cref="DataContractAttribute"/>; otherwise, an empty collection.
/// </returns>
public IReadOnlyCollection<string> GetSupportedMediaTypes(Type? modelType)
{
return modelType is not null ? SupportedMediaTypes : Array.Empty<string>();
return modelType?.GetCustomAttribute<DataContractAttribute>() is not null ? SupportedMediaTypes : Array.Empty<string>();
}

/// <summary>
/// Determines whether this deserializer can handle data of a specific content type and deserialize it into the specified model type.
/// </summary>
/// <param name="mediaType">The media type of the content.</param>
/// <param name="modelType">The type of the model to be deserialized.</param>
/// <returns><c>true</c> if this deserializer can handle the specified content type and model type; otherwise, <c>false</c>.</returns>
/// <param name="modelType">The target model type for deserialization.</param>
/// <returns>
/// <c>true</c> if the deserializer supports the media type and the model type is not <c>null</c> and is marked with
/// a <see cref="DataContractAttribute"/>; otherwise, <c>false</c>.
/// </returns>
public bool CanDeserialize(string mediaType, Type? modelType)
{
return modelType is not null && SupportedMediaTypes.Contains(mediaType);
return modelType?.GetCustomAttribute<DataContractAttribute>() is not null && SupportedMediaTypes.Contains(mediaType);
}

/// <summary>
Expand Down
6 changes: 3 additions & 3 deletions src/Kampute.HttpClient.Json/JsonContentDeserializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,15 @@ public sealed class JsonContentDeserializer : IHttpContentDeserializer
/// Gets the collection of media types that this deserializer supports.
/// </summary>
/// <value>
/// The collection of media types that this deserializer supports.
/// The read-only collection of media types that this deserializer supports.
/// </value>
public IReadOnlyCollection<string> SupportedMediaTypes { get; } = [MediaTypeNames.Application.Json];

/// <summary>
/// Retrieves a collection of supported media types for a specific model type.
/// </summary>
/// <param name="modelType">The type of the model for which to retrieve supported media types.</param>
/// <returns>A read-only collection of strings representing the media types supported for the specified model type.</returns>
/// <returns>The read-only collection of media types that this deserializer supports if model type is not <c>null</c>; otherwise, an empty collection.</returns>
public IReadOnlyCollection<string> GetSupportedMediaTypes(Type? modelType)
{
return modelType is not null ? SupportedMediaTypes : Array.Empty<string>();
Expand All @@ -51,7 +51,7 @@ public IReadOnlyCollection<string> GetSupportedMediaTypes(Type? modelType)
/// </summary>
/// <param name="mediaType">The media type of the content.</param>
/// <param name="modelType">The type of the model to be deserialized.</param>
/// <returns><c>true</c> if this deserializer can handle the specified content type and model type; otherwise, <c>false</c>.</returns>
/// <returns><c>true</c> if the deserializer supports the media type and the model type is not <c>null</c>; otherwise, <c>false</c>.</returns>
public bool CanDeserialize(string mediaType, Type? modelType)
{
return modelType is not null && SupportedMediaTypes.Contains(mediaType);
Expand Down
2 changes: 1 addition & 1 deletion src/Kampute.HttpClient.Json/Kampute.HttpClient.Json.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Title>Kampute.HttpClient.Json</Title>
<Description>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.</Description>
<Authors>Kambiz Khojasteh</Authors>
<Version>1.2.0</Version>
<Version>1.3.0</Version>
<Company>Kampute</Company>
<Copyright>Copyright (c) 2024 Kampute</Copyright>
<LangVersion>latest</LangVersion>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ public sealed class JsonContentDeserializer : IHttpContentDeserializer
/// Gets the collection of media types that this deserializer supports.
/// </summary>
/// <value>
/// The collection of media types that this deserializer supports.
/// The read-only collection of media types that this deserializer supports.
/// </value>
public IReadOnlyCollection<string> SupportedMediaTypes { get; } = [MediaTypeNames.Application.Json];

/// <summary>
/// Retrieves a collection of supported media types for a specific model type.
/// </summary>
/// <param name="modelType">The type of the model for which to retrieve supported media types.</param>
/// <returns>A read-only collection of strings representing the media types supported for the specified model type.</returns>
/// <returns>The read-only collection of media types that this deserializer supports if model type is not <c>null</c>; otherwise, an empty collection.</returns>
public IReadOnlyCollection<string> GetSupportedMediaTypes(Type? modelType)
{
return modelType is not null ? SupportedMediaTypes : Array.Empty<string>();
Expand All @@ -52,7 +52,7 @@ public IReadOnlyCollection<string> GetSupportedMediaTypes(Type? modelType)
/// </summary>
/// <param name="mediaType">The media type of the content.</param>
/// <param name="modelType">The type of the model to be deserialized.</param>
/// <returns><c>true</c> if this deserializer can handle the specified content type and model type; otherwise, <c>false</c>.</returns>
/// <returns><c>true</c> if the deserializer supports the media type and the model type is not <c>null</c>; otherwise, <c>false</c>.</returns>
public bool CanDeserialize(string mediaType, Type? modelType)
{
return modelType is not null && SupportedMediaTypes.Contains(mediaType);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Title>Kampute.HttpClient.NewtonsoftJson</Title>
<Description>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.</Description>
<Authors>Kambiz Khojasteh</Authors>
<Version>1.2.0</Version>
<Version>1.3.0</Version>
<Company>Kampute</Company>
<Copyright>Copyright (c) 2024 Kampute</Copyright>
<LangVersion>latest</LangVersion>
Expand Down
2 changes: 1 addition & 1 deletion src/Kampute.HttpClient.Xml/Kampute.HttpClient.Xml.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Title>Kampute.HttpClient.Xml</Title>
<Description>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.</Description>
<Authors>Kambiz Khojasteh</Authors>
<Version>1.2.0</Version>
<Version>1.3.0</Version>
<Company>Kampute</Company>
<Copyright>Copyright (c) 2024 Kampute</Copyright>
<LangVersion>latest</LangVersion>
Expand Down
8 changes: 4 additions & 4 deletions src/Kampute.HttpClient.Xml/XmlContentDeserializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@ public sealed class XmlContentDeserializer : IHttpContentDeserializer
/// Gets the collection of media types that this deserializer supports.
/// </summary>
/// <value>
/// The collection of media types that this deserializer supports.
/// The read-only collection of media types that this deserializer supports.
/// </value>
public IReadOnlyCollection<string> SupportedMediaTypes { get; } = [MediaTypeNames.Application.Xml];

/// <summary>
/// Retrieves a collection of supported media types for a specific model type.
/// </summary>
/// <param name="modelType">The type of the model for which to retrieve supported media types.</param>
/// <returns>A read-only collection of strings representing the media types supported for the specified model type.</returns>
/// <returns>The read-only collection of media types that this deserializer supports if model type is not <c>null</c>; otherwise, an empty collection.</returns>
public IReadOnlyCollection<string> GetSupportedMediaTypes(Type? modelType)
{
return modelType is not null ? SupportedMediaTypes : Array.Empty<string>();
Expand All @@ -44,8 +44,8 @@ public IReadOnlyCollection<string> GetSupportedMediaTypes(Type? modelType)
/// Determines whether this deserializer can handle data of a specific content type and deserialize it into the specified model type.
/// </summary>
/// <param name="mediaType">The media type of the content.</param>
/// <param name="modelType">The type of the model to be deserialized.</param>
/// <returns><c>true</c> if this deserializer can handle the specified content type and model type; otherwise, <c>false</c>.</returns>
/// <param name="modelType">The target model type for deserialization.</param>
/// <returns><c>true</c> if the deserializer supports the media type and the model type is not <c>null</c>; otherwise, <c>false</c>.</returns>
public bool CanDeserialize(string mediaType, Type? modelType)
{
return modelType is not null && SupportedMediaTypes.Contains(mediaType);
Expand Down
125 changes: 23 additions & 102 deletions src/Kampute.HttpClient/ErrorHandlers/HttpError401Handler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ namespace Kampute.HttpClient.ErrorHandlers
{
using Kampute.HttpClient;
using Kampute.HttpClient.Interfaces;
using Kampute.HttpClient.Utilities;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
Expand Down Expand Up @@ -47,7 +48,7 @@ public class HttpError401Handler : IHttpErrorHandler, IDisposable
{
private const string AlreadyRetried = nameof(HttpError401Handler) + "." + nameof(AlreadyRetried);

private readonly ConcurrentDictionary<HttpRestClient, AuthenticationState> _authenticationStates = new();
private readonly ConcurrentDictionary<HttpRestClient, AsyncGuard<bool>> _authenticationStates = new();
private readonly Func<HttpRestClient, IEnumerable<AuthenticationHeaderValue>, CancellationToken, Task<AuthenticationHeaderValue?>> _asyncAuthenticator;

/// <summary>
Expand Down Expand Up @@ -174,38 +175,33 @@ CancellationToken cancellationToken
var state = _authenticationStates.GetOrAdd(client, owner =>
{
owner.Disposing += ClientDisposing;
return new AuthenticationState();
return new AsyncGuard<bool>(false);
});

if (await state.TryAcquireAsync(cancellationToken).ConfigureAwait(false))
await state.TryUpdateAsync(async () =>
{
state.LastAuthenticationResult = false;
try
{
// To avoid deadlock, we use another client without this instance of UnauthorizedHttpErrorHandler.
// This is necessary because invoking the OnAuthenticationChallenge delegate could potentially lead to a recursive situation
// where an authentication request itself returns a 401 Unauthorized response. If the original client with the unauthorized error
// handler is used for this authentication request, it could trigger the error handling mechanism again, leading to a deadlock
// as the system waits indefinitely for the authentication process to complete. By cloning the client and removing this
// UnauthorizedHttpErrorHandler from the list of error handlers, we ensure that authentication requests made within the delegate
// do not re-enter the error handling process, thus preventing a deadlock situation. The helper client is a temporary
// solution used solely for the purpose of authentication and is disposed of after use to ensure resource cleanup.
using var helperClient = (HttpRestClient)client.Clone();
helperClient.ErrorHandlers.Remove(this);
// To avoid deadlock, we use another client without this instance of UnauthorizedHttpErrorHandler.
// This is necessary because invoking the OnAuthenticationChallenge delegate could potentially lead to a recursive situation
// where an authentication request itself returns a 401 Unauthorized response. If the original client with the unauthorized error
// handler is used for this authentication request, it could trigger the error handling mechanism again, leading to a deadlock
// as the system waits indefinitely for the authentication process to complete. By cloning the client and removing this
// UnauthorizedHttpErrorHandler from the list of error handlers, we ensure that authentication requests made within the delegate
// do not re-enter the error handling process, thus preventing a deadlock situation. The helper client is a temporary
// solution used solely for the purpose of authentication and is disposed of after use to ensure resource cleanup.
using var helperClient = (HttpRestClient)client.Clone();
helperClient.ErrorHandlers.Remove(this);
var authorization = await _asyncAuthenticator(helperClient, challenges, cancellationToken).ConfigureAwait(false);
if (authorization is not null)
{
client.DefaultRequestHeaders.Authorization = authorization;
state.LastAuthenticationResult = true;
}
}
finally
var authorization = await _asyncAuthenticator(helperClient, challenges, cancellationToken).ConfigureAwait(false);
if (authorization is not null)
{
state.Release();
client.DefaultRequestHeaders.Authorization = authorization;
return true;
}
}
return state.LastAuthenticationResult;
return false;
}, cancellationToken);

return state.Value;
}

/// <summary>
Expand Down Expand Up @@ -259,80 +255,5 @@ private void ClientDisposing(object sender, EventArgs e)
{
_authenticationStates.TryRemove((HttpRestClient)sender, out _);
}

#region Helper Class

/// <summary>
/// Represents the authentication state for an instance of <see cref="HttpRestClient"/>.
/// </summary>
/// <remarks>
/// This private class is designed to manage and encapsulate the state of authentication for a specific <see cref="HttpRestClient"/>.
/// It provides mechanisms to track whether authentication is currently being attempted , as well as whether the client has been
/// successfully authenticated. It ensures that authentication attempts are synchronized, preventing concurrent authentication
/// attempts on the same client instance which could lead to race conditions or other unintended behaviors.
/// </remarks>
private class AuthenticationState : IDisposable
{
private readonly SemaphoreSlim _semaphore = new(1, 1);
private volatile bool _lastAutneticationResult = false;
private long _lastAuthenticationTime = 0;

/// <summary>
/// Gets or sets a value indicating whether the client was successfully authenticated on the last authentication attempt.
/// </summary>
/// <value>
/// A <see cref="bool"/> value indicating whether the client was successfully authenticated on the last authentication attempt.
/// </value>
public bool LastAuthenticationResult
{
get => _lastAutneticationResult;
set => _lastAutneticationResult = value;
}

/// <summary>
/// Asynchronously attempts to acquire the authentication lock, indicating that an authentication process is starting.
/// </summary>
/// <param name="cancellationToken">A token for canceling the operation.</param>
/// <returns>A task that resolves to <c>true</c> if the lock was successfully acquired and authentication should proceed;
/// otherwise, <c>false</c> if another authentication process is already underway.</returns>
/// <remarks>
/// This method ensures that only one authentication process can be active at a time for a given client instance. If
/// the lock is successfully acquired, it indicates that no other authentication process is currently underway for this
/// client, and the caller can proceed with authentication.
/// </remarks>
public async Task<bool> TryAcquireAsync(CancellationToken cancellationToken)
{
var authenticationTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
await _semaphore.WaitAsync(cancellationToken);
if (Interlocked.Read(ref _lastAuthenticationTime) < authenticationTime)
return true;

_semaphore.Release();
return false;
}

/// <summary>
/// Releases the authentication lock, indicating that the authentication process has completed.
/// </summary>
/// <remarks>
/// This method should be called when an authentication attempt has finished, regardless of its outcome. It signals
/// that the current authentication process is complete, allowing other authentication attempts to proceed.
/// </remarks>
public void Release()
{
Interlocked.Exchange(ref _lastAuthenticationTime, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
_semaphore.Release();
}

/// <summary>
/// Releases all resources used by the <see cref="AuthenticationState"/>.
/// </summary>
public void Dispose()
{
_semaphore.Dispose();
}
}

#endregion
}
}
Loading

0 comments on commit 42d1931

Please sign in to comment.