From 07ba4c8d61e522915b6086b8a509d68fce2ac925 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 16:34:10 +0000 Subject: [PATCH 1/6] Add IRequestContextPropagator to simplify non-HTTP context propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a single interface that combines deserialization and accessor management into one scoped operation, replacing the manual three-step pattern (deserialize → set accessor → finally clear) with a simple using block: using var _ = propagator.Propagate(metadata); // context accessible via DI or accessor.GetRequired() Changes: - Add IRequestContextPropagator interface (Abstractions/) - Add RequestContextPropagator implementation (Infrastructure/) - Register IRequestContextPropagator<> as open-generic singleton in AddTypedRequestContextPropagation() - Add unit tests covering: context set during scope, cleared after dispose, cleared even when handler throws, and exception on missing required keys https://claude.ai/code/session_0123zepdZJiUTyc6XLLezZCv --- .../Abstractions/IRequestContextPropagator.cs | 38 +++++++ ...tPropagationServiceCollectionExtensions.cs | 4 + .../RequestContextPropagator.cs | 45 ++++++++ .../PropagationTests.cs | 105 ++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 src/TypedRequestContext.Propagation/Abstractions/IRequestContextPropagator.cs create mode 100644 src/TypedRequestContext.Propagation/Infrastructure/RequestContextPropagator.cs diff --git a/src/TypedRequestContext.Propagation/Abstractions/IRequestContextPropagator.cs b/src/TypedRequestContext.Propagation/Abstractions/IRequestContextPropagator.cs new file mode 100644 index 0000000..147bd48 --- /dev/null +++ b/src/TypedRequestContext.Propagation/Abstractions/IRequestContextPropagator.cs @@ -0,0 +1,38 @@ +using TypedRequestContext; + +namespace TypedRequestContext.Propagation; + +/// +/// 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. +/// +/// The typed request context to propagate. +/// +/// Calling deserializes the metadata, sets the context on +/// , and returns an +/// that clears the accessor when disposed. Use a using statement or +/// await using to guarantee cleanup even if the handler throws. +/// +/// using var _ = propagator.Propagate(metadata); +/// // CustomerRequestContext is now accessible via DI or GetRequired<T>() +/// await DoWorkAsync(ct); +/// +/// +public interface IRequestContextPropagator where T : class, ITypedRequestContext +{ + /// + /// Deserializes into a context, + /// sets it as the current context on , and returns + /// a scope that clears the accessor when disposed. + /// + /// The key/value metadata (e.g. message headers) to deserialize from. + /// + /// An scope. Dispose it (or use a using block) to + /// clear the context from the accessor after the handler finishes. + /// + /// + /// Thrown when a required propagation key is missing or cannot be converted. + /// + IDisposable Propagate(IReadOnlyDictionary metadata); +} diff --git a/src/TypedRequestContext.Propagation/DependencyInjection/TypedRequestContextPropagationServiceCollectionExtensions.cs b/src/TypedRequestContext.Propagation/DependencyInjection/TypedRequestContextPropagationServiceCollectionExtensions.cs index 8b1fe5b..204e8d1 100644 --- a/src/TypedRequestContext.Propagation/DependencyInjection/TypedRequestContextPropagationServiceCollectionExtensions.cs +++ b/src/TypedRequestContext.Propagation/DependencyInjection/TypedRequestContextPropagationServiceCollectionExtensions.cs @@ -24,6 +24,10 @@ public static IServiceCollection AddTypedRequestContextPropagation(this IService typeof(IRequestContextSerializer<>), typeof(AttributeBasedRequestContextSerializer<>)); + services.AddSingleton( + typeof(IRequestContextPropagator<>), + typeof(RequestContextPropagator<>)); + return services; } } diff --git a/src/TypedRequestContext.Propagation/Infrastructure/RequestContextPropagator.cs b/src/TypedRequestContext.Propagation/Infrastructure/RequestContextPropagator.cs new file mode 100644 index 0000000..d13d495 --- /dev/null +++ b/src/TypedRequestContext.Propagation/Infrastructure/RequestContextPropagator.cs @@ -0,0 +1,45 @@ +using TypedRequestContext; +using TypedRequestContext.Propagation; + +namespace TypedRequestContext.Propagation.Infrastructure; + +/// +/// Default implementation that delegates +/// deserialization to and manages +/// accessor state via a disposable scope. +/// +/// The typed request context to propagate. +public sealed class RequestContextPropagator : IRequestContextPropagator + where T : class, ITypedRequestContext +{ + private readonly IRequestContextDeserializer _deserializer; + private readonly IRequestContextAccessor _accessor; + + /// + /// Initializes a new instance of . + /// + public RequestContextPropagator( + IRequestContextDeserializer deserializer, + IRequestContextAccessor accessor) + { + _deserializer = deserializer; + _accessor = accessor; + } + + /// + public IDisposable Propagate(IReadOnlyDictionary metadata) + { + _accessor.Current = _deserializer.Deserialize(metadata); + return new PropagationScope(_accessor); + } + + private sealed class PropagationScope : IDisposable + { + private readonly IRequestContextAccessor _accessor; + + internal PropagationScope(IRequestContextAccessor accessor) + => _accessor = accessor; + + public void Dispose() => _accessor.Current = null; + } +} diff --git a/tests/TypedRequestContext.Propagation.UnitTests/PropagationTests.cs b/tests/TypedRequestContext.Propagation.UnitTests/PropagationTests.cs index b823e6a..447dee8 100644 --- a/tests/TypedRequestContext.Propagation.UnitTests/PropagationTests.cs +++ b/tests/TypedRequestContext.Propagation.UnitTests/PropagationTests.cs @@ -63,6 +63,111 @@ public void Deserialize_Throws_WhenRequiredMetadataIsMissing() } } +public class RequestContextPropagatorTests +{ + [Fact] + public void Propagate_SetsContextOnAccessor() + { + var services = new ServiceCollection(); + services.AddTypedRequestContext(); + services.AddTypedRequestContextPropagation(); + services.AddTypedRequestContext(); + + var provider = services.BuildServiceProvider(); + var accessor = provider.GetRequiredService(); + var propagator = provider.GetRequiredService>(); + + var metadata = new Dictionary + { + ["x-user-id"] = "11111111-1111-1111-1111-111111111111", + ["x-tenant-id"] = "22222222-2222-2222-2222-222222222222" + }; + + using (propagator.Propagate(metadata)) + { + var ctx = accessor.GetRequired(); + Assert.Equal(Guid.Parse("11111111-1111-1111-1111-111111111111"), ctx.UserId); + Assert.Equal(Guid.Parse("22222222-2222-2222-2222-222222222222"), ctx.TenantId); + } + } + + [Fact] + public void Propagate_ClearsContextFromAccessorAfterDispose() + { + var services = new ServiceCollection(); + services.AddTypedRequestContext(); + services.AddTypedRequestContextPropagation(); + services.AddTypedRequestContext(); + + var provider = services.BuildServiceProvider(); + var accessor = provider.GetRequiredService(); + var propagator = provider.GetRequiredService>(); + + var metadata = new Dictionary + { + ["x-user-id"] = "11111111-1111-1111-1111-111111111111", + ["x-tenant-id"] = "22222222-2222-2222-2222-222222222222" + }; + + using (propagator.Propagate(metadata)) + { + Assert.NotNull(accessor.Current); + } + + Assert.Null(accessor.Current); + } + + [Fact] + public void Propagate_ClearsContextEvenWhenHandlerThrows() + { + var services = new ServiceCollection(); + services.AddTypedRequestContext(); + services.AddTypedRequestContextPropagation(); + services.AddTypedRequestContext(); + + var provider = services.BuildServiceProvider(); + var accessor = provider.GetRequiredService(); + var propagator = provider.GetRequiredService>(); + + var metadata = new Dictionary + { + ["x-user-id"] = "11111111-1111-1111-1111-111111111111", + ["x-tenant-id"] = "22222222-2222-2222-2222-222222222222" + }; + + try + { + using (propagator.Propagate(metadata)) + { + throw new InvalidOperationException("handler failed"); + } + } + catch (InvalidOperationException) { } + + Assert.Null(accessor.Current); + } + + [Fact] + public void Propagate_Throws_WhenRequiredMetadataIsMissing() + { + var services = new ServiceCollection(); + services.AddTypedRequestContext(); + services.AddTypedRequestContextPropagation(); + services.AddTypedRequestContext(); + + var provider = services.BuildServiceProvider(); + var propagator = provider.GetRequiredService>(); + + var metadata = new Dictionary + { + ["x-user-id"] = "11111111-1111-1111-1111-111111111111" + // x-tenant-id missing — required + }; + + Assert.Throws(() => propagator.Propagate(metadata)); + } +} + public class PropagationHeadersProviderTests { [Fact] From bd78494cc4892475bb7e7b7487ca5254faacdfc9 Mon Sep 17 00:00:00 2001 From: otapiero Date: Thu, 5 Mar 2026 22:57:24 +0200 Subject: [PATCH 2/6] refactor: Remove unnecessary using directives and simplify class constructors --- .../IRequestContextDeserializer.cs | 2 - .../Abstractions/IRequestContextPropagator.cs | 2 - .../Abstractions/IRequestContextSerializer.cs | 2 - ...tPropagationServiceCollectionExtensions.cs | 2 - ...ttributeBasedRequestContextDeserializer.cs | 2 - .../AttributeBasedRequestContextSerializer.cs | 2 - .../PropagationHeadersProvider.cs | 29 +++++--------- .../RequestContextPropagator.cs | 39 +++++-------------- .../AttributeBasedRequestContextExtractor.cs | 1 - .../Infrastructure/CorrelationContext.cs | 2 - .../Infrastructure/PropertyMapper.cs | 1 - .../Infrastructure/RequestContextAccessor.cs | 2 - .../Infrastructure/RequestContextBuilder.cs | 2 - .../Infrastructure/RequestContextScope.cs | 19 +++++++++ .../RequestContextScopeFactory.cs | 19 +++++++++ .../RequestContextEndpointExtensions.cs | 1 - .../Middleware/RequestContextMiddleware.cs | 11 ++---- ...questContextServiceCollectionExtensions.cs | 2 +- .../TypedRequestContext.csproj | 5 +++ .../PropagationTests.cs | 2 - 20 files changed, 68 insertions(+), 79 deletions(-) create mode 100644 src/TypedRequestContext/Infrastructure/RequestContextScope.cs create mode 100644 src/TypedRequestContext/Infrastructure/RequestContextScopeFactory.cs diff --git a/src/TypedRequestContext.Propagation/Abstractions/IRequestContextDeserializer.cs b/src/TypedRequestContext.Propagation/Abstractions/IRequestContextDeserializer.cs index 1edd80e..d7ea510 100644 --- a/src/TypedRequestContext.Propagation/Abstractions/IRequestContextDeserializer.cs +++ b/src/TypedRequestContext.Propagation/Abstractions/IRequestContextDeserializer.cs @@ -1,5 +1,3 @@ -using TypedRequestContext; - namespace TypedRequestContext.Propagation; /// diff --git a/src/TypedRequestContext.Propagation/Abstractions/IRequestContextPropagator.cs b/src/TypedRequestContext.Propagation/Abstractions/IRequestContextPropagator.cs index 147bd48..ec97a4b 100644 --- a/src/TypedRequestContext.Propagation/Abstractions/IRequestContextPropagator.cs +++ b/src/TypedRequestContext.Propagation/Abstractions/IRequestContextPropagator.cs @@ -1,5 +1,3 @@ -using TypedRequestContext; - namespace TypedRequestContext.Propagation; /// diff --git a/src/TypedRequestContext.Propagation/Abstractions/IRequestContextSerializer.cs b/src/TypedRequestContext.Propagation/Abstractions/IRequestContextSerializer.cs index e9d4fb6..c796491 100644 --- a/src/TypedRequestContext.Propagation/Abstractions/IRequestContextSerializer.cs +++ b/src/TypedRequestContext.Propagation/Abstractions/IRequestContextSerializer.cs @@ -1,5 +1,3 @@ -using TypedRequestContext; - namespace TypedRequestContext.Propagation; /// diff --git a/src/TypedRequestContext.Propagation/DependencyInjection/TypedRequestContextPropagationServiceCollectionExtensions.cs b/src/TypedRequestContext.Propagation/DependencyInjection/TypedRequestContextPropagationServiceCollectionExtensions.cs index 204e8d1..2f30cf1 100644 --- a/src/TypedRequestContext.Propagation/DependencyInjection/TypedRequestContextPropagationServiceCollectionExtensions.cs +++ b/src/TypedRequestContext.Propagation/DependencyInjection/TypedRequestContextPropagationServiceCollectionExtensions.cs @@ -1,5 +1,3 @@ -using TypedRequestContext; -using TypedRequestContext.Propagation; using TypedRequestContext.Propagation.Infrastructure; namespace TypedRequestContext.Propagation; diff --git a/src/TypedRequestContext.Propagation/Infrastructure/AttributeBasedRequestContextDeserializer.cs b/src/TypedRequestContext.Propagation/Infrastructure/AttributeBasedRequestContextDeserializer.cs index fab86cb..73e723f 100644 --- a/src/TypedRequestContext.Propagation/Infrastructure/AttributeBasedRequestContextDeserializer.cs +++ b/src/TypedRequestContext.Propagation/Infrastructure/AttributeBasedRequestContextDeserializer.cs @@ -1,6 +1,4 @@ using System.Reflection; -using TypedRequestContext; -using TypedRequestContext.Propagation; namespace TypedRequestContext.Propagation.Infrastructure; diff --git a/src/TypedRequestContext.Propagation/Infrastructure/AttributeBasedRequestContextSerializer.cs b/src/TypedRequestContext.Propagation/Infrastructure/AttributeBasedRequestContextSerializer.cs index 03cbd7f..54f37c7 100644 --- a/src/TypedRequestContext.Propagation/Infrastructure/AttributeBasedRequestContextSerializer.cs +++ b/src/TypedRequestContext.Propagation/Infrastructure/AttributeBasedRequestContextSerializer.cs @@ -1,7 +1,5 @@ using System.Reflection; using System.Globalization; -using TypedRequestContext; -using TypedRequestContext.Propagation; namespace TypedRequestContext.Propagation.Infrastructure; diff --git a/src/TypedRequestContext.Propagation/Infrastructure/PropagationHeadersProvider.cs b/src/TypedRequestContext.Propagation/Infrastructure/PropagationHeadersProvider.cs index 91a5183..257826b 100644 --- a/src/TypedRequestContext.Propagation/Infrastructure/PropagationHeadersProvider.cs +++ b/src/TypedRequestContext.Propagation/Infrastructure/PropagationHeadersProvider.cs @@ -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. /// -internal sealed class PropagationHeadersProvider : IPropagationHeadersProvider +internal sealed class PropagationHeadersProvider( + IRequestContextAccessor contextAccessor, + IOptions options, + IServiceScopeFactory scopeFactory, + ICorrelationContext? correlationContext = null) : IPropagationHeadersProvider { - private readonly IRequestContextAccessor _contextAccessor; - private readonly ICorrelationContext? _correlationContext; - private readonly IServiceScopeFactory _scopeFactory; - private readonly Lazy> _serializerRegistrations; - private static readonly ConcurrentDictionary>> _invokerCache = new(); - - public PropagationHeadersProvider( - IRequestContextAccessor contextAccessor, - IOptions options, - IServiceScopeFactory scopeFactory, - ICorrelationContext? correlationContext = null) - { - _contextAccessor = contextAccessor; - _correlationContext = correlationContext; - _scopeFactory = scopeFactory; - - _serializerRegistrations = new Lazy>( + private readonly IRequestContextAccessor _contextAccessor = contextAccessor; + private readonly ICorrelationContext? _correlationContext = correlationContext; + private readonly IServiceScopeFactory _scopeFactory = scopeFactory; + private readonly Lazy> _serializerRegistrations = new( () => BuildSerializerMap(options.Value)); - } + private static readonly ConcurrentDictionary>> _invokerCache = new(); /// public IReadOnlyDictionary GetCurrentHeaders() diff --git a/src/TypedRequestContext.Propagation/Infrastructure/RequestContextPropagator.cs b/src/TypedRequestContext.Propagation/Infrastructure/RequestContextPropagator.cs index d13d495..eb99a31 100644 --- a/src/TypedRequestContext.Propagation/Infrastructure/RequestContextPropagator.cs +++ b/src/TypedRequestContext.Propagation/Infrastructure/RequestContextPropagator.cs @@ -1,45 +1,26 @@ -using TypedRequestContext; -using TypedRequestContext.Propagation; +using TypedRequestContext.Infrastructure; namespace TypedRequestContext.Propagation.Infrastructure; /// /// Default implementation that delegates /// deserialization to and manages -/// accessor state via a disposable scope. +/// accessor state via . /// /// The typed request context to propagate. -public sealed class RequestContextPropagator : IRequestContextPropagator +/// +/// Initializes a new instance of . +/// +public sealed class RequestContextPropagator( + IRequestContextDeserializer deserializer, + RequestContextScopeFactory scopeFactory) : IRequestContextPropagator where T : class, ITypedRequestContext { - private readonly IRequestContextDeserializer _deserializer; - private readonly IRequestContextAccessor _accessor; - - /// - /// Initializes a new instance of . - /// - public RequestContextPropagator( - IRequestContextDeserializer deserializer, - IRequestContextAccessor accessor) - { - _deserializer = deserializer; - _accessor = accessor; - } /// public IDisposable Propagate(IReadOnlyDictionary metadata) { - _accessor.Current = _deserializer.Deserialize(metadata); - return new PropagationScope(_accessor); - } - - private sealed class PropagationScope : IDisposable - { - private readonly IRequestContextAccessor _accessor; - - internal PropagationScope(IRequestContextAccessor accessor) - => _accessor = accessor; - - public void Dispose() => _accessor.Current = null; + var context = deserializer.Deserialize(metadata); + return scopeFactory.Begin(context); } } diff --git a/src/TypedRequestContext/Infrastructure/AttributeBasedRequestContextExtractor.cs b/src/TypedRequestContext/Infrastructure/AttributeBasedRequestContextExtractor.cs index 50d5a4a..8fca828 100644 --- a/src/TypedRequestContext/Infrastructure/AttributeBasedRequestContextExtractor.cs +++ b/src/TypedRequestContext/Infrastructure/AttributeBasedRequestContextExtractor.cs @@ -1,5 +1,4 @@ using System.Reflection; -using TypedRequestContext; namespace TypedRequestContext.Infrastructure; diff --git a/src/TypedRequestContext/Infrastructure/CorrelationContext.cs b/src/TypedRequestContext/Infrastructure/CorrelationContext.cs index 11e4ab3..9880bf8 100644 --- a/src/TypedRequestContext/Infrastructure/CorrelationContext.cs +++ b/src/TypedRequestContext/Infrastructure/CorrelationContext.cs @@ -1,5 +1,3 @@ -using TypedRequestContext; - namespace TypedRequestContext.Infrastructure; /// diff --git a/src/TypedRequestContext/Infrastructure/PropertyMapper.cs b/src/TypedRequestContext/Infrastructure/PropertyMapper.cs index 48ee9b6..0337f19 100644 --- a/src/TypedRequestContext/Infrastructure/PropertyMapper.cs +++ b/src/TypedRequestContext/Infrastructure/PropertyMapper.cs @@ -1,7 +1,6 @@ using System.ComponentModel; using System.Reflection; using System.Security.Claims; -using TypedRequestContext; namespace TypedRequestContext.Infrastructure; diff --git a/src/TypedRequestContext/Infrastructure/RequestContextAccessor.cs b/src/TypedRequestContext/Infrastructure/RequestContextAccessor.cs index 7119dbb..0af5032 100644 --- a/src/TypedRequestContext/Infrastructure/RequestContextAccessor.cs +++ b/src/TypedRequestContext/Infrastructure/RequestContextAccessor.cs @@ -1,5 +1,3 @@ -using TypedRequestContext; - namespace TypedRequestContext.Infrastructure; /// diff --git a/src/TypedRequestContext/Infrastructure/RequestContextBuilder.cs b/src/TypedRequestContext/Infrastructure/RequestContextBuilder.cs index 3ce0657..8c27522 100644 --- a/src/TypedRequestContext/Infrastructure/RequestContextBuilder.cs +++ b/src/TypedRequestContext/Infrastructure/RequestContextBuilder.cs @@ -1,5 +1,3 @@ -using TypedRequestContext; - namespace TypedRequestContext.Infrastructure; /// diff --git a/src/TypedRequestContext/Infrastructure/RequestContextScope.cs b/src/TypedRequestContext/Infrastructure/RequestContextScope.cs new file mode 100644 index 0000000..c8f5a05 --- /dev/null +++ b/src/TypedRequestContext/Infrastructure/RequestContextScope.cs @@ -0,0 +1,19 @@ +namespace TypedRequestContext.Infrastructure; + +/// +/// Disposable scope that sets a typed request context on creation +/// and clears it on disposal. Used by both HTTP middleware and +/// queue/event propagation channels via . +/// +internal sealed class RequestContextScope : IDisposable +{ + private readonly IRequestContextAccessor _accessor; + + internal RequestContextScope(IRequestContextAccessor accessor, ITypedRequestContext context) + { + _accessor = accessor; + _accessor.Current = context; + } + + public void Dispose() => _accessor.Current = null; +} diff --git a/src/TypedRequestContext/Infrastructure/RequestContextScopeFactory.cs b/src/TypedRequestContext/Infrastructure/RequestContextScopeFactory.cs new file mode 100644 index 0000000..f753a69 --- /dev/null +++ b/src/TypedRequestContext/Infrastructure/RequestContextScopeFactory.cs @@ -0,0 +1,19 @@ +namespace TypedRequestContext.Infrastructure; + +/// +/// Creates instances that manage the lifecycle +/// of a typed request context on . +/// Both HTTP middleware and queue/event propagation channels use this factory, +/// ensuring a single place to manage (and later validate) context activation. +/// +public sealed class RequestContextScopeFactory(IRequestContextAccessor accessor) +{ + /// + /// Sets the given as the current request context + /// and returns a scope that clears it on disposal. + /// + public IDisposable Begin(ITypedRequestContext context) + { + return new RequestContextScope(accessor, context); + } +} diff --git a/src/TypedRequestContext/Middleware/RequestContextEndpointExtensions.cs b/src/TypedRequestContext/Middleware/RequestContextEndpointExtensions.cs index 09d20f5..62772d2 100644 --- a/src/TypedRequestContext/Middleware/RequestContextEndpointExtensions.cs +++ b/src/TypedRequestContext/Middleware/RequestContextEndpointExtensions.cs @@ -1,4 +1,3 @@ -using TypedRequestContext; using TypedRequestContext.Infrastructure; namespace TypedRequestContext; diff --git a/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs b/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs index 7fb8808..a993a42 100644 --- a/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs +++ b/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using TypedRequestContext; using TypedRequestContext.Infrastructure; namespace TypedRequestContext; @@ -20,13 +19,13 @@ public sealed class RequestContextMiddleware( RequestDelegate next, Dictionary> extractors, bool correlationEnabled, - IRequestContextAccessor requestContextAccessor, + RequestContextScopeFactory scopeFactory, ILogger logger) { private readonly RequestDelegate _next = next; private readonly Dictionary> _extractors = extractors; private readonly bool _correlationEnabled = correlationEnabled; - private readonly IRequestContextAccessor _requestContextAccessor = requestContextAccessor; + private readonly RequestContextScopeFactory _scopeFactory = scopeFactory; private readonly ILogger _logger = logger; /// @@ -71,8 +70,8 @@ public async Task InvokeAsync(HttpContext httpContext) // Invoke the pre-built extractor delegate to create the typed context var requestContext = extract(httpContext); - // Store in typed accessor — serialization happens lazily via IPropagationHeadersProvider - _requestContextAccessor.Current = requestContext; + // Store in typed accessor via scope — automatically cleared on dispose + using var scope = _scopeFactory.Begin(requestContext); _logger.LogDebug( "Request context set: Type={ContextType}", @@ -91,8 +90,6 @@ public async Task InvokeAsync(HttpContext httpContext) } finally { - _requestContextAccessor.Current = null; - if (_correlationEnabled) CorrelationContext.Clear(); } diff --git a/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs b/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs index faa40d1..1f996d2 100644 --- a/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs +++ b/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ using System.Reflection; -using TypedRequestContext; using TypedRequestContext.Infrastructure; using Microsoft.Extensions.Options; @@ -17,6 +16,7 @@ public static class RequestContextServiceCollectionExtensions public static IServiceCollection AddTypedRequestContext(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); services.AddSingleton( typeof(IRequestContextExtractor<>), diff --git a/src/TypedRequestContext/TypedRequestContext.csproj b/src/TypedRequestContext/TypedRequestContext.csproj index 0c32b1e..b825992 100644 --- a/src/TypedRequestContext/TypedRequestContext.csproj +++ b/src/TypedRequestContext/TypedRequestContext.csproj @@ -22,4 +22,9 @@ + + + + + diff --git a/tests/TypedRequestContext.Propagation.UnitTests/PropagationTests.cs b/tests/TypedRequestContext.Propagation.UnitTests/PropagationTests.cs index 447dee8..0cce52e 100644 --- a/tests/TypedRequestContext.Propagation.UnitTests/PropagationTests.cs +++ b/tests/TypedRequestContext.Propagation.UnitTests/PropagationTests.cs @@ -1,6 +1,4 @@ using Microsoft.Extensions.DependencyInjection; -using TypedRequestContext.Infrastructure; -using TypedRequestContext.Propagation; using TypedRequestContext.Propagation.Infrastructure; namespace TypedRequestContext.Propagation.UnitTests; From 1b3d35dae8b9980042d4d79f54f6887db1ad2102 Mon Sep 17 00:00:00 2001 From: otapiero Date: Fri, 6 Mar 2026 09:37:01 +0200 Subject: [PATCH 3/6] feat: Add validation support for typed request contexts with DataAnnotations --- .gitignore | 1 + .../Abstractions/IRequestContextValidator.cs | 16 ++ .../RequestContextValidationError.cs | 8 + .../DataAnnotationsRequestContextValidator.cs | 41 ++++ .../Infrastructure/RequestContextBuilder.cs | 37 +++ .../RequestContextScopeFactory.cs | 19 +- .../RequestContextValidationException.cs | 15 ++ .../Middleware/RequestContextMiddleware.cs | 15 ++ ...questContextServiceCollectionExtensions.cs | 48 +++- ...equestContextMiddlewareIntegrationTests.cs | 76 ++++++ .../RequestContextValidationTests.cs | 223 ++++++++++++++++++ 11 files changed, 495 insertions(+), 4 deletions(-) create mode 100644 src/TypedRequestContext/Abstractions/IRequestContextValidator.cs create mode 100644 src/TypedRequestContext/Abstractions/RequestContextValidationError.cs create mode 100644 src/TypedRequestContext/Infrastructure/DataAnnotationsRequestContextValidator.cs create mode 100644 src/TypedRequestContext/Infrastructure/RequestContextValidationException.cs create mode 100644 tests/TypedRequestContext.UnitTests/RequestContextValidationTests.cs diff --git a/.gitignore b/.gitignore index 13c52fa..d4d70df 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ bin/ obj/ .vscode/settings.json +.claude/settings.local.json diff --git a/src/TypedRequestContext/Abstractions/IRequestContextValidator.cs b/src/TypedRequestContext/Abstractions/IRequestContextValidator.cs new file mode 100644 index 0000000..010ff8a --- /dev/null +++ b/src/TypedRequestContext/Abstractions/IRequestContextValidator.cs @@ -0,0 +1,16 @@ +namespace TypedRequestContext; + +/// +/// Validates a typed request context after extraction. +/// Implement this interface to provide custom validation logic. +/// +/// The typed request context to validate. +public interface IRequestContextValidator + where TContext : class, ITypedRequestContext +{ + /// + /// Validates the given context and returns any validation errors. + /// An empty list indicates successful validation. + /// + IReadOnlyList Validate(TContext context); +} diff --git a/src/TypedRequestContext/Abstractions/RequestContextValidationError.cs b/src/TypedRequestContext/Abstractions/RequestContextValidationError.cs new file mode 100644 index 0000000..9f96807 --- /dev/null +++ b/src/TypedRequestContext/Abstractions/RequestContextValidationError.cs @@ -0,0 +1,8 @@ +namespace TypedRequestContext; + +/// +/// Represents a single validation error on a request context property. +/// +/// The property name that failed validation, or null for object-level errors. +/// A human-readable description of the validation failure. +public sealed record RequestContextValidationError(string? MemberName, string ErrorMessage); diff --git a/src/TypedRequestContext/Infrastructure/DataAnnotationsRequestContextValidator.cs b/src/TypedRequestContext/Infrastructure/DataAnnotationsRequestContextValidator.cs new file mode 100644 index 0000000..77268e9 --- /dev/null +++ b/src/TypedRequestContext/Infrastructure/DataAnnotationsRequestContextValidator.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; + +namespace TypedRequestContext.Infrastructure; + +/// +/// Validates a typed request context using +/// attributes. Supports standard attributes (Required, MaxLength, RegularExpression, etc.), +/// custom subclasses, and . +/// +/// The typed request context to validate. +public sealed class DataAnnotationsRequestContextValidator + : IRequestContextValidator + where TContext : class, ITypedRequestContext +{ + /// + public IReadOnlyList Validate(TContext context) + { + var validationResults = new List(); + var validationContext = new ValidationContext(context); + + if (Validator.TryValidateObject(context, validationContext, validationResults, validateAllProperties: true)) + return []; + + return [.. validationResults.SelectMany(ToErrors)]; + } + + private static IEnumerable 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); + } +} diff --git a/src/TypedRequestContext/Infrastructure/RequestContextBuilder.cs b/src/TypedRequestContext/Infrastructure/RequestContextBuilder.cs index 8c27522..5ea615b 100644 --- a/src/TypedRequestContext/Infrastructure/RequestContextBuilder.cs +++ b/src/TypedRequestContext/Infrastructure/RequestContextBuilder.cs @@ -25,6 +25,16 @@ public sealed class RequestContextBuilder /// internal Type? SerializerType { get; private set; } + /// + /// Whether validation is enabled for this context type. + /// + internal bool ValidationEnabled { get; private set; } + + /// + /// The custom validator type, or null to use the default DataAnnotations validator. + /// + internal Type? ValidatorType { get; private set; } + /// /// Registers a custom extractor for this context type. /// If not called, the default is used. @@ -67,4 +77,31 @@ public RequestContextBuilder UseSerializer(Type serializerType) /// public RequestContextBuilder UseSerializer() => UseSerializer(typeof(TSerializer)); + + /// + /// Enables validation using the default DataAnnotations validator. + /// + public RequestContextBuilder EnableValidation() + { + ValidationEnabled = true; + return this; + } + + /// + /// Enables validation using DataAnnotations attributes. + /// Equivalent to . + /// + public RequestContextBuilder UseDataAnnotationsValidation() + => EnableValidation(); + + /// + /// Enables validation using a custom validator implementation. + /// + public RequestContextBuilder UseValidation() + where TValidator : class, IRequestContextValidator + { + ValidationEnabled = true; + ValidatorType = typeof(TValidator); + return this; + } } diff --git a/src/TypedRequestContext/Infrastructure/RequestContextScopeFactory.cs b/src/TypedRequestContext/Infrastructure/RequestContextScopeFactory.cs index f753a69..74869bb 100644 --- a/src/TypedRequestContext/Infrastructure/RequestContextScopeFactory.cs +++ b/src/TypedRequestContext/Infrastructure/RequestContextScopeFactory.cs @@ -4,16 +4,29 @@ namespace TypedRequestContext.Infrastructure; /// Creates instances that manage the lifecycle /// of a typed request context on . /// Both HTTP middleware and queue/event propagation channels use this factory, -/// ensuring a single place to manage (and later validate) context activation. +/// ensuring a single place to manage and validate context activation. /// public sealed class RequestContextScopeFactory(IRequestContextAccessor accessor) { + private Dictionary>? _validators; + + /// + /// Configures the validator delegate map. Called once at startup by + /// . + /// + internal void SetValidators(Dictionary> validators) + => _validators = validators; + /// - /// Sets the given as the current request context - /// and returns a scope that clears it on disposal. + /// Validates (if configured) and sets the given as the + /// current request context. Returns a scope that clears it on disposal. /// + /// + /// Thrown when validation is enabled for the context type and the context is invalid. + /// public IDisposable Begin(ITypedRequestContext context) { + _validators?.GetValueOrDefault(context.GetType())?.Invoke(context); return new RequestContextScope(accessor, context); } } diff --git a/src/TypedRequestContext/Infrastructure/RequestContextValidationException.cs b/src/TypedRequestContext/Infrastructure/RequestContextValidationException.cs new file mode 100644 index 0000000..e1583f9 --- /dev/null +++ b/src/TypedRequestContext/Infrastructure/RequestContextValidationException.cs @@ -0,0 +1,15 @@ +namespace TypedRequestContext.Infrastructure; + +/// +/// Thrown when a typed request context fails validation. +/// Contains structured validation errors grouped by member name. +/// +public sealed class RequestContextValidationException( + IReadOnlyList errors) + : Exception("Request context validation failed.") +{ + /// + /// The validation errors that caused the failure. + /// + public IReadOnlyList Errors { get; } = errors; +} diff --git a/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs b/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs index a993a42..b635f3c 100644 --- a/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs +++ b/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs @@ -79,6 +79,21 @@ public async Task InvokeAsync(HttpContext httpContext) await _next(httpContext); } + catch (RequestContextValidationException ex) + { + _logger.LogWarning( + "Request context validation failed: {ErrorCount} error(s)", + ex.Errors.Count); + + httpContext.Response.StatusCode = 400; + await httpContext.Response.WriteAsJsonAsync(new + { + message = ex.Message, + errors = ex.Errors + .GroupBy(e => e.MemberName ?? "$") + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()) + }); + } catch (RequestContextCreationException ex) { _logger.LogWarning( diff --git a/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs b/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs index 1f996d2..6183224 100644 --- a/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs +++ b/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs @@ -60,10 +60,17 @@ public static IServiceCollection AddTypedRequestContext( if (builder.ExtractorType is not null) services.AddTransient(typeof(IRequestContextExtractor), builder.ExtractorType); - services.AddScoped(sp => + services.AddScoped(sp => sp.GetRequiredService() .GetRequired()); + if (builder.ValidationEnabled) + { + var validatorType = builder.ValidatorType + ?? typeof(DataAnnotationsRequestContextValidator); + services.AddSingleton(typeof(IRequestContextValidator), validatorType); + } + services.Configure(opts => { opts.ContextTypes.Add(typeof(TContext)); @@ -73,6 +80,10 @@ public static IServiceCollection AddTypedRequestContext( if (builder.DeserializerType is not null) opts.DeserializerTypes[typeof(TContext)] = builder.DeserializerType; + + if (builder.ValidationEnabled) + opts.ValidatorTypes[typeof(TContext)] = builder.ValidatorType + ?? typeof(DataAnnotationsRequestContextValidator); }); return services; @@ -111,6 +122,24 @@ public static IApplicationBuilder UseTypedRequestContext(this IApplicationBuilde extractors[contextType] = (Func)extractorDelegate!; } + // Build validator delegate map — one entry per context type with validation enabled. + var validators = new Dictionary>(); + + var buildValidatorMethod = typeof(RequestContextServiceCollectionExtensions) + .GetMethod(nameof(BuildValidatorDelegate), BindingFlags.NonPublic | BindingFlags.Static)!; + + foreach (var (contextType, _) in options.ValidatorTypes) + { + var validatorDelegate = buildValidatorMethod + .MakeGenericMethod(contextType) + .Invoke(null, [app.ApplicationServices]); + validators[contextType] = (Action)validatorDelegate!; + } + + // Configure the scope factory with validators + var scopeFactory = app.ApplicationServices.GetRequiredService(); + scopeFactory.SetValidators(validators); + // Detect whether AddCorrelationId() was called var correlationEnabled = app.ApplicationServices .GetService() is not null; @@ -135,6 +164,18 @@ private static Func BuildExtractorDelegate>() .Extract(httpContext); } + + private static Action BuildValidatorDelegate(IServiceProvider sp) + where TContext : class, ITypedRequestContext + { + var validator = sp.GetRequiredService>(); + return context => + { + var errors = validator.Validate((TContext)context); + if (errors is { Count: > 0 }) + throw new RequestContextValidationException(errors); + }; + } } /// @@ -157,4 +198,9 @@ public sealed class RequestContextOptions /// Optional context-type to deserializer-type mapping configured at registration time. /// public Dictionary DeserializerTypes { get; } = []; + + /// + /// Context-type to validator-type mapping for types with validation enabled. + /// + public Dictionary ValidatorTypes { get; } = []; } diff --git a/tests/TypedRequestContext.AspNetCore.IntegrationTests/RequestContextMiddlewareIntegrationTests.cs b/tests/TypedRequestContext.AspNetCore.IntegrationTests/RequestContextMiddlewareIntegrationTests.cs index 8563e29..e8eb1fa 100644 --- a/tests/TypedRequestContext.AspNetCore.IntegrationTests/RequestContextMiddlewareIntegrationTests.cs +++ b/tests/TypedRequestContext.AspNetCore.IntegrationTests/RequestContextMiddlewareIntegrationTests.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.Net; using System.Net.Http.Json; using System.Security.Claims; @@ -335,4 +336,79 @@ public CustomExtractedContext Extract(HttpContext httpContext) }; } } + + [Fact] + public async Task ValidationFailure_Returns400_WithStructuredErrors() + { + await using var app = await BuildAppAsync(services => + { + services.AddTypedRequestContext(); + services.AddTypedRequestContext(b => b.EnableValidation()); + }, app => + { + app.MapGet("/order", (ValidatedOrderContext ctx) => Results.Ok(ctx.Code)) + .WithRequestContext(); + }); + + var request = new HttpRequestMessage(HttpMethod.Get, "/order"); + request.Headers.Add("x-order-code", "TOOLONGVALUE"); + + var response = await app.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal("Request context validation failed.", body.Message); + Assert.True(body.Errors.ContainsKey("Code")); + } + + [Fact] + public async Task ValidationSuccess_ReturnsOk() + { + await using var app = await BuildAppAsync(services => + { + services.AddTypedRequestContext(); + services.AddTypedRequestContext(b => b.EnableValidation()); + }, app => + { + app.MapGet("/order", (ValidatedOrderContext ctx) => Results.Ok(ctx.Code)) + .WithRequestContext(); + }); + + var request = new HttpRequestMessage(HttpMethod.Get, "/order"); + request.Headers.Add("x-order-code", "AB12"); + + var response = await app.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task NoValidation_ExistingBehaviorUnchanged() + { + await using var app = await BuildAppAsync(services => + { + services.AddTypedRequestContext(); + services.AddTypedRequestContext(); // no validation configured + }, app => + { + app.MapGet("/order", (ValidatedOrderContext ctx) => Results.Ok(ctx.Code)) + .WithRequestContext(); + }); + + var request = new HttpRequestMessage(HttpMethod.Get, "/order"); + request.Headers.Add("x-order-code", "TOOLONGVALUE"); // would fail validation, but validation not enabled + + var response = await app.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + public sealed class ValidatedOrderContext : ITypedRequestContext + { + [FromHeader("x-order-code"), MaxLength(4)] + public string? Code { get; init; } + } + + private sealed record ValidationErrorPayload(string Message, Dictionary Errors); } diff --git a/tests/TypedRequestContext.UnitTests/RequestContextValidationTests.cs b/tests/TypedRequestContext.UnitTests/RequestContextValidationTests.cs new file mode 100644 index 0000000..2e3649e --- /dev/null +++ b/tests/TypedRequestContext.UnitTests/RequestContextValidationTests.cs @@ -0,0 +1,223 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.DependencyInjection; +using TypedRequestContext.Infrastructure; + +namespace TypedRequestContext.UnitTests; + +public class DataAnnotationsRequestContextValidatorTests +{ + [Fact] + public void Validate_ReturnsNoErrors_WhenContextIsValid() + { + var validator = new DataAnnotationsRequestContextValidator(); + var context = new ValidatedContext + { + Name = "Alice", + Code = "AB12" + }; + + var errors = validator.Validate(context); + + Assert.Empty(errors); + } + + [Fact] + public void Validate_ReturnsErrors_WhenMaxLengthExceeded() + { + var validator = new DataAnnotationsRequestContextValidator(); + var context = new ValidatedContext + { + Name = "Alice", + Code = "TOOLONG" + }; + + var errors = validator.Validate(context); + + Assert.Single(errors); + Assert.Equal("Code", errors[0].MemberName); + } + + [Fact] + public void Validate_ReturnsErrors_WhenRegexFails() + { + var validator = new DataAnnotationsRequestContextValidator(); + var context = new RegexContext + { + Eori = "invalid" + }; + + var errors = validator.Validate(context); + + Assert.Single(errors); + Assert.Equal("Eori", errors[0].MemberName); + } + + [Fact] + public void Validate_ReturnsMultipleErrors() + { + var validator = new DataAnnotationsRequestContextValidator(); + var context = new ValidatedContext + { + Name = null!, + Code = "TOOLONG" + }; + + var errors = validator.Validate(context); + + Assert.True(errors.Count >= 2); + Assert.Contains(errors, e => e.MemberName == "Name"); + Assert.Contains(errors, e => e.MemberName == "Code"); + } + + [Fact] + public void Validate_SupportsIValidatableObject() + { + var validator = new DataAnnotationsRequestContextValidator(); + var context = new ValidatableContext + { + Start = 10, + End = 5 + }; + + var errors = validator.Validate(context); + + Assert.Single(errors); + Assert.Contains("End must be greater than Start", errors[0].ErrorMessage); + } + + public sealed class ValidatedContext : ITypedRequestContext + { + [FromHeader("x-name"), Required] + public string Name { get; init; } = default!; + + [FromHeader("x-code"), MaxLength(4)] + public string? Code { get; init; } + } + + public sealed class RegexContext : ITypedRequestContext + { + [FromHeader("x-eori"), RegularExpression(@"^[A-Z]{2}[A-Z0-9]{8,15}$")] + public string? Eori { get; init; } + } + + public sealed class ValidatableContext : ITypedRequestContext, IValidatableObject + { + [FromHeader("x-start")] + public int Start { get; init; } + + [FromHeader("x-end")] + public int End { get; init; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (End <= Start) + yield return new ValidationResult("End must be greater than Start", [nameof(End)]); + } + } +} + +public class RequestContextScopeFactoryValidationTests +{ + [Fact] + public void Begin_ThrowsValidationException_WhenValidatorFails() + { + var accessor = new RequestContextAccessor(); + var factory = new RequestContextScopeFactory(accessor); + factory.SetValidators(new Dictionary> + { + [typeof(FailingContext)] = _ => + throw new RequestContextValidationException( + [new RequestContextValidationError("Prop", "bad value")]) + }); + + var context = new FailingContext(); + + Assert.Throws(() => factory.Begin(context)); + Assert.Null(accessor.Current); + } + + [Fact] + public void Begin_SetsContext_WhenNoValidatorRegistered() + { + var accessor = new RequestContextAccessor(); + var factory = new RequestContextScopeFactory(accessor); + + var context = new FailingContext(); + using var scope = factory.Begin(context); + + Assert.Same(context, accessor.Current); + } + + [Fact] + public void Begin_SetsContext_WhenValidatorPasses() + { + var accessor = new RequestContextAccessor(); + var factory = new RequestContextScopeFactory(accessor); + factory.SetValidators(new Dictionary> + { + [typeof(FailingContext)] = _ => { } // no-op = passes + }); + + var context = new FailingContext(); + using var scope = factory.Begin(context); + + Assert.Same(context, accessor.Current); + } + + public sealed class FailingContext : ITypedRequestContext; +} + +public class RequestContextValidationRegistrationTests +{ + [Fact] + public void AddTypedRequestContext_WithValidation_RegistersValidator() + { + var services = new ServiceCollection(); + services.AddTypedRequestContext(); + services.AddTypedRequestContext(b => b.EnableValidation()); + + var provider = services.BuildServiceProvider(); + + var validator = provider.GetService>(); + Assert.NotNull(validator); + Assert.IsType>(validator); + } + + [Fact] + public void AddTypedRequestContext_WithCustomValidator_RegistersCustom() + { + var services = new ServiceCollection(); + services.AddTypedRequestContext(); + services.AddTypedRequestContext(b => b.UseValidation()); + + var provider = services.BuildServiceProvider(); + + var validator = provider.GetService>(); + Assert.NotNull(validator); + Assert.IsType(validator); + } + + [Fact] + public void AddTypedRequestContext_WithoutValidation_DoesNotRegisterValidator() + { + var services = new ServiceCollection(); + services.AddTypedRequestContext(); + services.AddTypedRequestContext(); + + var provider = services.BuildServiceProvider(); + + var validator = provider.GetService>(); + Assert.Null(validator); + } + + public sealed class ValidatedCtx : ITypedRequestContext + { + [FromHeader("x-val")] + public string? Value { get; init; } + } + + public sealed class AlwaysPassValidator : IRequestContextValidator + { + public IReadOnlyList Validate(ValidatedCtx context) => []; + } +} From 7e8477c6bd5fddb9469d931b87c9f58288ce38ae Mon Sep 17 00:00:00 2001 From: otapiero Date: Fri, 6 Mar 2026 10:28:51 +0200 Subject: [PATCH 4/6] feat: Enhance validation support in IRequestContextPropagator and related classes --- .../Abstractions/IRequestContextPropagator.cs | 40 ++++++++-- .../RequestContextPropagator.cs | 8 +- .../Infrastructure/RequestContextScope.cs | 8 +- .../RequestContextScopeFactory.cs | 77 +++++++++++++++--- .../Middleware/RequestContextMiddleware.cs | 5 +- ...questContextServiceCollectionExtensions.cs | 31 +------- .../RequestContextValidationTests.cs | 79 ++++++++++++++----- 7 files changed, 176 insertions(+), 72 deletions(-) diff --git a/src/TypedRequestContext.Propagation/Abstractions/IRequestContextPropagator.cs b/src/TypedRequestContext.Propagation/Abstractions/IRequestContextPropagator.cs index ec97a4b..6d82bba 100644 --- a/src/TypedRequestContext.Propagation/Abstractions/IRequestContextPropagator.cs +++ b/src/TypedRequestContext.Propagation/Abstractions/IRequestContextPropagator.cs @@ -7,30 +7,56 @@ namespace TypedRequestContext.Propagation; /// /// The typed request context to propagate. /// -/// Calling deserializes the metadata, sets the context on -/// , and returns an -/// that clears the accessor when disposed. Use a using statement or -/// await using to guarantee cleanup even if the handler throws. +/// Calling deserializes the metadata, +/// validates and sets the context on , and returns an +/// that restores the previous context when disposed. +/// Use a using statement to guarantee cleanup even if the handler throws. /// /// using var _ = propagator.Propagate(metadata); /// // CustomerRequestContext is now accessible via DI or GetRequired<T>() /// await DoWorkAsync(ct); /// +/// For validators with scoped dependencies, use the overload accepting : +/// +/// using var scope = serviceProvider.CreateScope(); +/// using var _ = propagator.Propagate(metadata, scope.ServiceProvider); +/// /// public interface IRequestContextPropagator where T : class, ITypedRequestContext { /// /// Deserializes into a context, - /// sets it as the current context on , and returns - /// a scope that clears the accessor when disposed. + /// validates (if configured), sets it as the current context on + /// , and returns a scope that restores + /// the previous context when disposed. /// /// The key/value metadata (e.g. message headers) to deserialize from. /// /// An scope. Dispose it (or use a using block) to - /// clear the context from the accessor after the handler finishes. + /// restore the previous context after the handler finishes. /// /// /// Thrown when a required propagation key is missing or cannot be converted. /// IDisposable Propagate(IReadOnlyDictionary metadata); + + /// + /// Deserializes into a context, + /// validates (if configured) using the provided + /// (enabling scoped validator dependencies), sets it as the current context, + /// and returns a scope that restores the previous context when disposed. + /// + /// The key/value metadata (e.g. message headers) to deserialize from. + /// + /// The to use for resolving validators. + /// Pass a scoped provider when validators depend on scoped services. + /// + /// + /// An scope. Dispose it (or use a using block) to + /// restore the previous context after the handler finishes. + /// + /// + /// Thrown when a required propagation key is missing or cannot be converted. + /// + IDisposable Propagate(IReadOnlyDictionary metadata, IServiceProvider serviceProvider); } diff --git a/src/TypedRequestContext.Propagation/Infrastructure/RequestContextPropagator.cs b/src/TypedRequestContext.Propagation/Infrastructure/RequestContextPropagator.cs index eb99a31..e34a520 100644 --- a/src/TypedRequestContext.Propagation/Infrastructure/RequestContextPropagator.cs +++ b/src/TypedRequestContext.Propagation/Infrastructure/RequestContextPropagator.cs @@ -16,11 +16,17 @@ public sealed class RequestContextPropagator( RequestContextScopeFactory scopeFactory) : IRequestContextPropagator where T : class, ITypedRequestContext { - /// public IDisposable Propagate(IReadOnlyDictionary metadata) { var context = deserializer.Deserialize(metadata); return scopeFactory.Begin(context); } + + /// + public IDisposable Propagate(IReadOnlyDictionary metadata, IServiceProvider serviceProvider) + { + var context = deserializer.Deserialize(metadata); + return scopeFactory.Begin(context, serviceProvider); + } } diff --git a/src/TypedRequestContext/Infrastructure/RequestContextScope.cs b/src/TypedRequestContext/Infrastructure/RequestContextScope.cs index c8f5a05..6de7aa2 100644 --- a/src/TypedRequestContext/Infrastructure/RequestContextScope.cs +++ b/src/TypedRequestContext/Infrastructure/RequestContextScope.cs @@ -2,18 +2,20 @@ namespace TypedRequestContext.Infrastructure; /// /// Disposable scope that sets a typed request context on creation -/// and clears it on disposal. Used by both HTTP middleware and -/// queue/event propagation channels via . +/// and restores the previous context on disposal. Supports nested scopes +/// (e.g., inner propagation within an active HTTP request context). /// 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 = null; + public void Dispose() => _accessor.Current = _previous; } diff --git a/src/TypedRequestContext/Infrastructure/RequestContextScopeFactory.cs b/src/TypedRequestContext/Infrastructure/RequestContextScopeFactory.cs index 74869bb..bbf7c28 100644 --- a/src/TypedRequestContext/Infrastructure/RequestContextScopeFactory.cs +++ b/src/TypedRequestContext/Infrastructure/RequestContextScopeFactory.cs @@ -1,3 +1,6 @@ +using System.Reflection; +using Microsoft.Extensions.Options; + namespace TypedRequestContext.Infrastructure; /// @@ -5,28 +8,84 @@ namespace TypedRequestContext.Infrastructure; /// of a typed request context on . /// Both HTTP middleware and queue/event propagation channels use this factory, /// ensuring a single place to manage and validate context activation. +/// Validation works automatically in both HTTP and non-HTTP hosts. /// -public sealed class RequestContextScopeFactory(IRequestContextAccessor accessor) +/// +/// Initializes a new instance of . +/// Automatically builds the validator factory map from registered options. +/// +public sealed class RequestContextScopeFactory( + IRequestContextAccessor accessor, + IServiceProvider rootProvider, + IOptions options) { - private Dictionary>? _validators; + private readonly Dictionary>> _validatorFactories = BuildValidatorFactories(options.Value); /// - /// Configures the validator delegate map. Called once at startup by - /// . + /// Validates (if configured) and sets the given as the + /// current request context. Returns a scope that restores the previous context on disposal. + /// Uses the root to resolve validators. /// - internal void SetValidators(Dictionary> validators) - => _validators = validators; + /// + /// Thrown when validation is enabled for the context type and the context is invalid. + /// + public IDisposable Begin(ITypedRequestContext context) + => Begin(context, rootProvider); /// /// Validates (if configured) and sets the given as the - /// current request context. Returns a scope that clears it on disposal. + /// current request context. Returns a scope that restores the previous context on disposal. + /// Uses the provided to resolve validators, + /// enabling scoped validator dependencies. /// /// /// Thrown when validation is enabled for the context type and the context is invalid. /// - public IDisposable Begin(ITypedRequestContext context) + public IDisposable Begin(ITypedRequestContext context, IServiceProvider serviceProvider) { - _validators?.GetValueOrDefault(context.GetType())?.Invoke(context); + if (_validatorFactories.TryGetValue(context.GetType(), out var factory)) + { + factory(serviceProvider)(context); + } + return new RequestContextScope(accessor, context); } + + private static Dictionary>> + BuildValidatorFactories(RequestContextOptions options) + { + var factories = new Dictionary>>(); + + if (options.ValidatorTypes.Count == 0) + return factories; + + var buildMethod = typeof(RequestContextScopeFactory) + .GetMethod(nameof(CreateValidatorFactory), BindingFlags.NonPublic | BindingFlags.Static)!; + + foreach (var (contextType, _) in options.ValidatorTypes) + { + var factory = buildMethod + .MakeGenericMethod(contextType) + .Invoke(null, null); + factories[contextType] = + (Func>)factory!; + } + + return factories; + } + + private static Func> CreateValidatorFactory() + where TContext : class, ITypedRequestContext + { + return sp => + { + var validator = sp.GetRequiredService>(); + return context => + { + var errors = validator.Validate((TContext)context); + if (errors is { Count: > 0 }) + throw new RequestContextValidationException(errors); + }; + }; + } } diff --git a/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs b/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs index b635f3c..5709052 100644 --- a/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs +++ b/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs @@ -70,8 +70,9 @@ public async Task InvokeAsync(HttpContext httpContext) // Invoke the pre-built extractor delegate to create the typed context var requestContext = extract(httpContext); - // Store in typed accessor via scope — automatically cleared on dispose - using var scope = _scopeFactory.Begin(requestContext); + // Store in typed accessor via scope — automatically cleared on dispose. + // Pass request-scoped provider so validators can use scoped dependencies. + using var scope = _scopeFactory.Begin(requestContext, httpContext.RequestServices); _logger.LogDebug( "Request context set: Type={ContextType}", diff --git a/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs b/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs index 6183224..1429002 100644 --- a/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs +++ b/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs @@ -68,7 +68,7 @@ public static IServiceCollection AddTypedRequestContext( { var validatorType = builder.ValidatorType ?? typeof(DataAnnotationsRequestContextValidator); - services.AddSingleton(typeof(IRequestContextValidator), validatorType); + services.AddTransient(typeof(IRequestContextValidator), validatorType); } services.Configure(opts => @@ -122,24 +122,6 @@ public static IApplicationBuilder UseTypedRequestContext(this IApplicationBuilde extractors[contextType] = (Func)extractorDelegate!; } - // Build validator delegate map — one entry per context type with validation enabled. - var validators = new Dictionary>(); - - var buildValidatorMethod = typeof(RequestContextServiceCollectionExtensions) - .GetMethod(nameof(BuildValidatorDelegate), BindingFlags.NonPublic | BindingFlags.Static)!; - - foreach (var (contextType, _) in options.ValidatorTypes) - { - var validatorDelegate = buildValidatorMethod - .MakeGenericMethod(contextType) - .Invoke(null, [app.ApplicationServices]); - validators[contextType] = (Action)validatorDelegate!; - } - - // Configure the scope factory with validators - var scopeFactory = app.ApplicationServices.GetRequiredService(); - scopeFactory.SetValidators(validators); - // Detect whether AddCorrelationId() was called var correlationEnabled = app.ApplicationServices .GetService() is not null; @@ -165,17 +147,6 @@ private static Func BuildExtractorDelegate BuildValidatorDelegate(IServiceProvider sp) - where TContext : class, ITypedRequestContext - { - var validator = sp.GetRequiredService>(); - return context => - { - var errors = validator.Validate((TContext)context); - if (errors is { Count: > 0 }) - throw new RequestContextValidationException(errors); - }; - } } /// diff --git a/tests/TypedRequestContext.UnitTests/RequestContextValidationTests.cs b/tests/TypedRequestContext.UnitTests/RequestContextValidationTests.cs index 2e3649e..84ffd91 100644 --- a/tests/TypedRequestContext.UnitTests/RequestContextValidationTests.cs +++ b/tests/TypedRequestContext.UnitTests/RequestContextValidationTests.cs @@ -121,16 +121,12 @@ public class RequestContextScopeFactoryValidationTests [Fact] public void Begin_ThrowsValidationException_WhenValidatorFails() { - var accessor = new RequestContextAccessor(); - var factory = new RequestContextScopeFactory(accessor); - factory.SetValidators(new Dictionary> - { - [typeof(FailingContext)] = _ => - throw new RequestContextValidationException( - [new RequestContextValidationError("Prop", "bad value")]) - }); + var sp = BuildProvider(b => b.AddTypedRequestContext( + cfg => cfg.UseValidation())); - var context = new FailingContext(); + var factory = sp.GetRequiredService(); + var accessor = sp.GetRequiredService(); + var context = new InvalidContext(); Assert.Throws(() => factory.Begin(context)); Assert.Null(accessor.Current); @@ -139,10 +135,12 @@ [new RequestContextValidationError("Prop", "bad value")]) [Fact] public void Begin_SetsContext_WhenNoValidatorRegistered() { - var accessor = new RequestContextAccessor(); - var factory = new RequestContextScopeFactory(accessor); + var sp = BuildProvider(b => b.AddTypedRequestContext()); + + var factory = sp.GetRequiredService(); + var accessor = sp.GetRequiredService(); + var context = new InvalidContext(); - var context = new FailingContext(); using var scope = factory.Begin(context); Assert.Same(context, accessor.Current); @@ -151,20 +149,61 @@ public void Begin_SetsContext_WhenNoValidatorRegistered() [Fact] public void Begin_SetsContext_WhenValidatorPasses() { - var accessor = new RequestContextAccessor(); - var factory = new RequestContextScopeFactory(accessor); - factory.SetValidators(new Dictionary> - { - [typeof(FailingContext)] = _ => { } // no-op = passes - }); + var sp = BuildProvider(b => b.AddTypedRequestContext( + cfg => cfg.UseValidation())); + + var factory = sp.GetRequiredService(); + var accessor = sp.GetRequiredService(); + var context = new InvalidContext(); - var context = new FailingContext(); using var scope = factory.Begin(context); Assert.Same(context, accessor.Current); } - public sealed class FailingContext : ITypedRequestContext; + [Fact] + public void Begin_RestoresPreviousContext_OnNestedScopeDispose() + { + var sp = BuildProvider(b => b.AddTypedRequestContext()); + + var factory = sp.GetRequiredService(); + var accessor = sp.GetRequiredService(); + + var outer = new InvalidContext(); + var inner = new InvalidContext(); + + using var outerScope = factory.Begin(outer); + Assert.Same(outer, accessor.Current); + + using (var innerScope = factory.Begin(inner)) + { + Assert.Same(inner, accessor.Current); + } + + Assert.Same(outer, accessor.Current); + } + + private static ServiceProvider BuildProvider(Action configure) + { + var services = new ServiceCollection(); + services.AddOptions(); + services.AddTypedRequestContext(); + configure(services); + return services.BuildServiceProvider(); + } + + public sealed class InvalidContext : ITypedRequestContext; + + public sealed class AlwaysFailValidator : IRequestContextValidator + { + public IReadOnlyList Validate(InvalidContext context) + => [new RequestContextValidationError("Prop", "bad value")]; + } + + public sealed class AlwaysPassValidator : IRequestContextValidator + { + public IReadOnlyList Validate(InvalidContext context) => []; + } } public class RequestContextValidationRegistrationTests From 9c4a4778cb1cc3b7e8f8c981ac4aafcd53a55dc9 Mon Sep 17 00:00:00 2001 From: otapiero Date: Fri, 6 Mar 2026 14:24:50 +0200 Subject: [PATCH 5/6] chore: Update version to 1.1.0 in project files --- .../TypedRequestContext.Propagation.csproj | 2 +- src/TypedRequestContext/TypedRequestContext.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/TypedRequestContext.Propagation/TypedRequestContext.Propagation.csproj b/src/TypedRequestContext.Propagation/TypedRequestContext.Propagation.csproj index 8a11dbc..bee2e54 100644 --- a/src/TypedRequestContext.Propagation/TypedRequestContext.Propagation.csproj +++ b/src/TypedRequestContext.Propagation/TypedRequestContext.Propagation.csproj @@ -5,7 +5,7 @@ enable enable TypedRequestContext.Propagation - 1.0.0 + 1.1.0 Otapiero Optional propagation extension for TypedRequestContext with serializer/deserializer and header provider support. MIT diff --git a/src/TypedRequestContext/TypedRequestContext.csproj b/src/TypedRequestContext/TypedRequestContext.csproj index b825992..07b5d72 100644 --- a/src/TypedRequestContext/TypedRequestContext.csproj +++ b/src/TypedRequestContext/TypedRequestContext.csproj @@ -5,7 +5,7 @@ enable enable TypedRequestContext - 1.0.0 + 1.1.0 Otapiero Typed request context middleware for ASP.NET Core with AsyncLocal accessor support. MIT From 7e6a568dab5d48a9c1f3432da19110669428a0ed Mon Sep 17 00:00:00 2001 From: otapiero <80044546+otapiero@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:34:19 +0200 Subject: [PATCH 6/6] Update src/TypedRequestContext/Infrastructure/RequestContextValidationException.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Infrastructure/RequestContextValidationException.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TypedRequestContext/Infrastructure/RequestContextValidationException.cs b/src/TypedRequestContext/Infrastructure/RequestContextValidationException.cs index e1583f9..ec5a01e 100644 --- a/src/TypedRequestContext/Infrastructure/RequestContextValidationException.cs +++ b/src/TypedRequestContext/Infrastructure/RequestContextValidationException.cs @@ -2,7 +2,7 @@ namespace TypedRequestContext.Infrastructure; /// /// Thrown when a typed request context fails validation. -/// Contains structured validation errors grouped by member name. +/// Contains the validation errors that caused the failure. /// public sealed class RequestContextValidationException( IReadOnlyList errors)