Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
bin/
obj/
.vscode/settings.json
.claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using TypedRequestContext;

namespace TypedRequestContext.Propagation;

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
namespace TypedRequestContext.Propagation;

/// <summary>
/// Combines deserialization and accessor management into a single scoped operation.
/// Intended for non-HTTP flows such as queues, events, or background processing,
/// where context arrives as a metadata dictionary rather than HTTP headers or claims.
/// </summary>
/// <typeparam name="T">The typed request context to propagate.</typeparam>
/// <remarks>
/// Calling <see cref="Propagate(IReadOnlyDictionary{string, string})"/> deserializes the metadata,
/// validates and sets the context on <see cref="IRequestContextAccessor"/>, and returns an
/// <see cref="IDisposable"/> that restores the previous context when disposed.
/// Use a <c>using</c> statement to guarantee cleanup even if the handler throws.
/// <code>
/// using var _ = propagator.Propagate(metadata);
/// // CustomerRequestContext is now accessible via DI or GetRequired&lt;T&gt;()
/// await DoWorkAsync(ct);
/// </code>
/// For validators with scoped dependencies, use the overload accepting <see cref="IServiceProvider"/>:
/// <code>
/// using var scope = serviceProvider.CreateScope();
/// using var _ = propagator.Propagate(metadata, scope.ServiceProvider);
/// </code>
/// </remarks>
public interface IRequestContextPropagator<T> where T : class, ITypedRequestContext
{
/// <summary>
/// Deserializes <paramref name="metadata"/> into a <typeparamref name="T"/> context,
/// validates (if configured), sets it as the current context on
/// <see cref="IRequestContextAccessor"/>, and returns a scope that restores
/// the previous context when disposed.
/// </summary>
/// <param name="metadata">The key/value metadata (e.g. message headers) to deserialize from.</param>
/// <returns>
/// An <see cref="IDisposable"/> scope. Dispose it (or use a <c>using</c> block) to
/// restore the previous context after the handler finishes.
/// </returns>
/// <exception cref="Infrastructure.RequestContextDeserializationException">
/// Thrown when a required propagation key is missing or cannot be converted.
/// </exception>
IDisposable Propagate(IReadOnlyDictionary<string, string> metadata);

/// <summary>
/// Deserializes <paramref name="metadata"/> into a <typeparamref name="T"/> context,
/// validates (if configured) using the provided <paramref name="serviceProvider"/>
/// (enabling scoped validator dependencies), sets it as the current context,
/// and returns a scope that restores the previous context when disposed.
/// </summary>
/// <param name="metadata">The key/value metadata (e.g. message headers) to deserialize from.</param>
/// <param name="serviceProvider">
/// The <see cref="IServiceProvider"/> to use for resolving validators.
/// Pass a scoped provider when validators depend on scoped services.
/// </param>
/// <returns>
/// An <see cref="IDisposable"/> scope. Dispose it (or use a <c>using</c> block) to
/// restore the previous context after the handler finishes.
/// </returns>
/// <exception cref="Infrastructure.RequestContextDeserializationException">
/// Thrown when a required propagation key is missing or cannot be converted.
/// </exception>
IDisposable Propagate(IReadOnlyDictionary<string, string> metadata, IServiceProvider serviceProvider);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using TypedRequestContext;

namespace TypedRequestContext.Propagation;

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using TypedRequestContext;
using TypedRequestContext.Propagation;
using TypedRequestContext.Propagation.Infrastructure;

namespace TypedRequestContext.Propagation;
Expand All @@ -24,6 +22,10 @@ public static IServiceCollection AddTypedRequestContextPropagation(this IService
typeof(IRequestContextSerializer<>),
typeof(AttributeBasedRequestContextSerializer<>));

services.AddSingleton(
typeof(IRequestContextPropagator<>),
typeof(RequestContextPropagator<>));

return services;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using System.Reflection;
using TypedRequestContext;
using TypedRequestContext.Propagation;

namespace TypedRequestContext.Propagation.Infrastructure;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
using System.Reflection;
using System.Globalization;
using TypedRequestContext;
using TypedRequestContext.Propagation;

namespace TypedRequestContext.Propagation.Infrastructure;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,18 @@ namespace TypedRequestContext.Propagation.Infrastructure;
/// resolves the correct serializer via a lazily-built delegate map,
/// and serializes on the spot — no eager pre-serialization, no second AsyncLocal.
/// </summary>
internal sealed class PropagationHeadersProvider : IPropagationHeadersProvider
internal sealed class PropagationHeadersProvider(
IRequestContextAccessor contextAccessor,
IOptions<RequestContextOptions> options,
IServiceScopeFactory scopeFactory,
ICorrelationContext? correlationContext = null) : IPropagationHeadersProvider
{
private readonly IRequestContextAccessor _contextAccessor;
private readonly ICorrelationContext? _correlationContext;
private readonly IServiceScopeFactory _scopeFactory;
private readonly Lazy<Dictionary<Type, SerializerRegistration>> _serializerRegistrations;
private static readonly ConcurrentDictionary<Type, Func<object, ITypedRequestContext, IReadOnlyDictionary<string, string>>> _invokerCache = new();

public PropagationHeadersProvider(
IRequestContextAccessor contextAccessor,
IOptions<RequestContextOptions> options,
IServiceScopeFactory scopeFactory,
ICorrelationContext? correlationContext = null)
{
_contextAccessor = contextAccessor;
_correlationContext = correlationContext;
_scopeFactory = scopeFactory;

_serializerRegistrations = new Lazy<Dictionary<Type, SerializerRegistration>>(
private readonly IRequestContextAccessor _contextAccessor = contextAccessor;
private readonly ICorrelationContext? _correlationContext = correlationContext;
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
private readonly Lazy<Dictionary<Type, SerializerRegistration>> _serializerRegistrations = new(
() => BuildSerializerMap(options.Value));
}
private static readonly ConcurrentDictionary<Type, Func<object, ITypedRequestContext, IReadOnlyDictionary<string, string>>> _invokerCache = new();

/// <inheritdoc />
public IReadOnlyDictionary<string, string> GetCurrentHeaders()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using TypedRequestContext.Infrastructure;

namespace TypedRequestContext.Propagation.Infrastructure;

/// <summary>
/// Default <see cref="IRequestContextPropagator{T}"/> implementation that delegates
/// deserialization to <see cref="IRequestContextDeserializer{T}"/> and manages
/// accessor state via <see cref="RequestContextScopeFactory"/>.
/// </summary>
/// <typeparam name="T">The typed request context to propagate.</typeparam>
/// <remarks>
/// Initializes a new instance of <see cref="RequestContextPropagator{T}"/>.
/// </remarks>
public sealed class RequestContextPropagator<T>(
IRequestContextDeserializer<T> deserializer,
RequestContextScopeFactory scopeFactory) : IRequestContextPropagator<T>
where T : class, ITypedRequestContext
{
/// <inheritdoc />
public IDisposable Propagate(IReadOnlyDictionary<string, string> metadata)
{
var context = deserializer.Deserialize(metadata);
return scopeFactory.Begin(context);
}

/// <inheritdoc />
public IDisposable Propagate(IReadOnlyDictionary<string, string> metadata, IServiceProvider serviceProvider)
{
var context = deserializer.Deserialize(metadata);
return scopeFactory.Begin(context, serviceProvider);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PackageId>TypedRequestContext.Propagation</PackageId>
<Version>1.0.0</Version>
<Version>1.1.0</Version>
<Authors>Otapiero</Authors>
<Description>Optional propagation extension for TypedRequestContext with serializer/deserializer and header provider support.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
Expand Down
16 changes: 16 additions & 0 deletions src/TypedRequestContext/Abstractions/IRequestContextValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace TypedRequestContext;

/// <summary>
/// Validates a typed request context after extraction.
/// Implement this interface to provide custom validation logic.
/// </summary>
/// <typeparam name="TContext">The typed request context to validate.</typeparam>
public interface IRequestContextValidator<in TContext>
where TContext : class, ITypedRequestContext
{
/// <summary>
/// Validates the given context and returns any validation errors.
/// An empty list indicates successful validation.
/// </summary>
IReadOnlyList<RequestContextValidationError> Validate(TContext context);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace TypedRequestContext;

/// <summary>
/// Represents a single validation error on a request context property.
/// </summary>
/// <param name="MemberName">The property name that failed validation, or null for object-level errors.</param>
/// <param name="ErrorMessage">A human-readable description of the validation failure.</param>
public sealed record RequestContextValidationError(string? MemberName, string ErrorMessage);
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Reflection;
using TypedRequestContext;

namespace TypedRequestContext.Infrastructure;

Expand Down
2 changes: 0 additions & 2 deletions src/TypedRequestContext/Infrastructure/CorrelationContext.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using TypedRequestContext;

namespace TypedRequestContext.Infrastructure;

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations;

namespace TypedRequestContext.Infrastructure;

/// <summary>
/// Validates a typed request context using <see cref="System.ComponentModel.DataAnnotations"/>
/// attributes. Supports standard attributes (Required, MaxLength, RegularExpression, etc.),
/// custom <see cref="ValidationAttribute"/> subclasses, and <see cref="IValidatableObject"/>.
/// </summary>
/// <typeparam name="TContext">The typed request context to validate.</typeparam>
public sealed class DataAnnotationsRequestContextValidator<TContext>
: IRequestContextValidator<TContext>
where TContext : class, ITypedRequestContext
{
/// <inheritdoc />
public IReadOnlyList<RequestContextValidationError> Validate(TContext context)
{
var validationResults = new List<ValidationResult>();
var validationContext = new ValidationContext(context);

if (Validator.TryValidateObject(context, validationContext, validationResults, validateAllProperties: true))
return [];

return [.. validationResults.SelectMany(ToErrors)];
}

private static IEnumerable<RequestContextValidationError> ToErrors(ValidationResult result)
{
var message = result.ErrorMessage ?? "Validation failed.";
var members = result.MemberNames?.Where(m => !string.IsNullOrEmpty(m)).ToArray();

if (members is null or { Length: 0 })
{
yield return new RequestContextValidationError(null, message);
yield break;
}

foreach (var member in members)
yield return new RequestContextValidationError(member, message);
}
}
1 change: 0 additions & 1 deletion src/TypedRequestContext/Infrastructure/PropertyMapper.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using System.ComponentModel;
using System.Reflection;
using System.Security.Claims;
using TypedRequestContext;

namespace TypedRequestContext.Infrastructure;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using TypedRequestContext;

namespace TypedRequestContext.Infrastructure;

/// <summary>
Expand Down
39 changes: 37 additions & 2 deletions src/TypedRequestContext/Infrastructure/RequestContextBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using TypedRequestContext;

namespace TypedRequestContext.Infrastructure;

/// <summary>
Expand Down Expand Up @@ -27,6 +25,16 @@ public sealed class RequestContextBuilder<TContext>
/// </summary>
internal Type? SerializerType { get; private set; }

/// <summary>
/// Whether validation is enabled for this context type.
/// </summary>
internal bool ValidationEnabled { get; private set; }

/// <summary>
/// The custom validator type, or null to use the default DataAnnotations validator.
/// </summary>
internal Type? ValidatorType { get; private set; }

/// <summary>
/// Registers a custom extractor for this context type.
/// If not called, the default <see cref="AttributeBasedRequestContextExtractor{T}"/> is used.
Expand Down Expand Up @@ -69,4 +77,31 @@ public RequestContextBuilder<TContext> UseSerializer(Type serializerType)
/// </summary>
public RequestContextBuilder<TContext> UseSerializer<TSerializer>()
=> UseSerializer(typeof(TSerializer));

/// <summary>
/// Enables validation using the default DataAnnotations validator.
/// </summary>
public RequestContextBuilder<TContext> EnableValidation()
{
ValidationEnabled = true;
return this;
}

/// <summary>
/// Enables validation using DataAnnotations attributes.
/// Equivalent to <see cref="EnableValidation"/>.
/// </summary>
public RequestContextBuilder<TContext> UseDataAnnotationsValidation()
=> EnableValidation();

/// <summary>
/// Enables validation using a custom validator implementation.
/// </summary>
public RequestContextBuilder<TContext> UseValidation<TValidator>()
where TValidator : class, IRequestContextValidator<TContext>
{
ValidationEnabled = true;
ValidatorType = typeof(TValidator);
return this;
}
}
21 changes: 21 additions & 0 deletions src/TypedRequestContext/Infrastructure/RequestContextScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace TypedRequestContext.Infrastructure;

/// <summary>
/// Disposable scope that sets a typed request context on creation
/// and restores the previous context on disposal. Supports nested scopes
/// (e.g., inner propagation within an active HTTP request context).
/// </summary>
internal sealed class RequestContextScope : IDisposable
{
private readonly IRequestContextAccessor _accessor;
private readonly ITypedRequestContext? _previous;

internal RequestContextScope(IRequestContextAccessor accessor, ITypedRequestContext context)
{
_accessor = accessor;
_previous = accessor.Current;
_accessor.Current = context;
}

public void Dispose() => _accessor.Current = _previous;
}
Loading