From 85418e0e218bd403a1d83855bdc9b350c782a595 Mon Sep 17 00:00:00 2001 From: otapiero Date: Fri, 6 Mar 2026 10:34:02 +0200 Subject: [PATCH 1/6] refactor: Remove unused backward-compatible aliases and update documentation references --- ...questContextServiceCollectionExtensions.cs | 25 ++----------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs b/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs index 1429002..9b16763 100644 --- a/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs +++ b/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs @@ -25,12 +25,6 @@ public static IServiceCollection AddTypedRequestContext(this IServiceCollection return services; } - /// - /// Backward-compatible alias for . - /// - public static IServiceCollection AddRequestContextFramework(this IServiceCollection services) - => services.AddTypedRequestContext(); - /// /// Registers the always-on CorrelationId module. Once registered, CorrelationId flows /// on every request unconditionally — no endpoint metadata required. @@ -47,7 +41,7 @@ public static IServiceCollection AddCorrelationId(this IServiceCollection servic /// /// Registers a typed request context with optional custom extractor/serializer via fluent builder. /// Also registers a scoped DI shortcut so handlers can inject directly. - /// Tracks the context type so can build delegate maps at startup. + /// Tracks the context type so can build delegate maps at startup. /// public static IServiceCollection AddTypedRequestContext( this IServiceCollection services, @@ -89,15 +83,6 @@ public static IServiceCollection AddTypedRequestContext( return services; } - /// - /// Backward-compatible alias for . - /// - public static IServiceCollection AddRequestContext( - this IServiceCollection services, - Action>? configure = null) - where TContext : class, ITypedRequestContext - => services.AddTypedRequestContext(configure); - /// /// Builds the internal extractor delegate map, detects correlation registration, and adds the middleware. /// Must be called AFTER UseAuthentication() and UseAuthorization(). @@ -132,12 +117,6 @@ public static IApplicationBuilder UseTypedRequestContext(this IApplicationBuilde return app; } - /// - /// Backward-compatible alias for . - /// - public static IApplicationBuilder UseRequestContext(this IApplicationBuilder app) - => app.UseTypedRequestContext(); - private static Func BuildExtractorDelegate() where TContext : class, ITypedRequestContext { @@ -151,7 +130,7 @@ private static Func BuildExtractorDelegate /// Options tracking which context types have been registered via -/// . +/// . /// public sealed class RequestContextOptions { From 83cd15ab7def67b71898f4edaaf9b35c7a0c0139 Mon Sep 17 00:00:00 2001 From: otapiero Date: Fri, 6 Mar 2026 14:21:35 +0200 Subject: [PATCH 2/6] fix: Update response status codes for missing headers to 400 and adjust related tests --- README.md | 2 +- ...ttributeBasedRequestContextDeserializer.cs | 53 +++++----------- .../PropagationHeadersProvider.cs | 37 +++++++---- .../TypedRequestContext.Propagation.csproj | 4 +- .../Infrastructure/PropertyMapper.cs | 63 ++++++++++++++----- .../Middleware/RequestContextMiddleware.cs | 58 +++++++++-------- ...questContextServiceCollectionExtensions.cs | 7 ++- ...equestContextMiddlewareIntegrationTests.cs | 4 +- .../PropagationTests.cs | 63 +++++++++++++++++++ .../RequestContextCoreTests.cs | 4 +- 10 files changed, 197 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index c0ac7e4..180e376 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ The default extractor uses cached reflection and the attributes on your context If a `[RequiredContextValue]` property is missing: - For **claims**: the middleware returns **401** -- For **headers**: the middleware returns **403** +- For **headers**: the middleware returns **400** The response body is JSON: diff --git a/src/TypedRequestContext.Propagation/Infrastructure/AttributeBasedRequestContextDeserializer.cs b/src/TypedRequestContext.Propagation/Infrastructure/AttributeBasedRequestContextDeserializer.cs index 73e723f..3f6c03f 100644 --- a/src/TypedRequestContext.Propagation/Infrastructure/AttributeBasedRequestContextDeserializer.cs +++ b/src/TypedRequestContext.Propagation/Infrastructure/AttributeBasedRequestContextDeserializer.cs @@ -1,4 +1,5 @@ using System.Reflection; +using TypedRequestContext.Infrastructure; namespace TypedRequestContext.Propagation.Infrastructure; @@ -45,17 +46,23 @@ private static PropagationKeyMapper[] BuildMappers() private sealed class PropagationKeyMapper { - private readonly PropertyInfo _property; + private readonly string _propertyName; + private readonly Type _propertyType; private readonly string _key; + private readonly Action _setter; private readonly bool _required; private PropagationKeyMapper( - PropertyInfo property, + string propertyName, + Type propertyType, string key, + Action setter, bool required) { - _property = property; + _propertyName = propertyName; + _propertyType = propertyType; _key = key; + _setter = setter; _required = required; } @@ -66,7 +73,8 @@ private PropagationKeyMapper( return null; var required = property.GetCustomAttribute() is not null; - return new PropagationKeyMapper(property, propagationKey.Key, required); + var setter = PropertyMapper.BuildSetter(property); + return new PropagationKeyMapper(property.Name, property.PropertyType, propagationKey.Key, setter, required); } public void Apply(object instance, IReadOnlyDictionary metadata) @@ -78,54 +86,25 @@ public void Apply(object instance, IReadOnlyDictionary metadata) if (_required) { throw new RequestContextDeserializationException( - $"Required context value '{_property.Name}' is missing in metadata key '{_key}'."); + $"Required context value '{_propertyName}' is missing in metadata key '{_key}'."); } return; } - var converted = ConvertValue(raw, _property.PropertyType); + var converted = PropertyMapper.ConvertValue(raw, _propertyType); if (converted is null) { if (_required) { throw new RequestContextDeserializationException( - $"Context value for '{_property.Name}' from metadata key '{_key}' is invalid."); + $"Context value for '{_propertyName}' from metadata key '{_key}' is invalid."); } return; } - _property.SetValue(instance, converted); - } - - private static object? ConvertValue(string raw, Type targetType) - { - var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType; - - if (underlying == typeof(string)) - return raw; - - if (underlying == typeof(Guid)) - return Guid.TryParse(raw, out var guid) ? guid : null; - - if (underlying.IsEnum) - return Enum.TryParse(underlying, raw, ignoreCase: true, out var enumVal) ? enumVal : null; - - var converter = System.ComponentModel.TypeDescriptor.GetConverter(underlying); - if (converter.CanConvertFrom(typeof(string))) - { - try - { - return converter.ConvertFromInvariantString(raw); - } - catch - { - return null; - } - } - - return null; + _setter(instance, converted); } } } diff --git a/src/TypedRequestContext.Propagation/Infrastructure/PropagationHeadersProvider.cs b/src/TypedRequestContext.Propagation/Infrastructure/PropagationHeadersProvider.cs index 257826b..f7d0636 100644 --- a/src/TypedRequestContext.Propagation/Infrastructure/PropagationHeadersProvider.cs +++ b/src/TypedRequestContext.Propagation/Infrastructure/PropagationHeadersProvider.cs @@ -16,11 +16,9 @@ internal sealed class PropagationHeadersProvider( IServiceScopeFactory scopeFactory, ICorrelationContext? correlationContext = null) : IPropagationHeadersProvider { - private readonly IRequestContextAccessor _contextAccessor = contextAccessor; - private readonly ICorrelationContext? _correlationContext = correlationContext; - private readonly IServiceScopeFactory _scopeFactory = scopeFactory; private readonly Lazy> _serializerRegistrations = new( () => BuildSerializerMap(options.Value)); + private readonly ConcurrentDictionary _singletonSerializerCache = new(); private static readonly ConcurrentDictionary>> _invokerCache = new(); /// @@ -28,14 +26,13 @@ public IReadOnlyDictionary GetCurrentHeaders() { var headers = new Dictionary(); - if (_correlationContext is not null) - headers["x-correlation-id"] = _correlationContext.CorrelationId; + if (correlationContext is not null) + headers["x-correlation-id"] = correlationContext.CorrelationId; - var context = _contextAccessor.Current; + var context = contextAccessor.Current; if (context is not null && _serializerRegistrations.Value.TryGetValue(context.GetType(), out var registration)) { - using var scope = _scopeFactory.CreateScope(); - var serializer = registration.Resolve(scope.ServiceProvider); + var serializer = ResolveSerializer(context.GetType(), registration); var serialize = _invokerCache.GetOrAdd(context.GetType(), CreateInvoker); foreach (var (key, value) in serialize(serializer, context)) @@ -45,6 +42,22 @@ public IReadOnlyDictionary GetCurrentHeaders() return headers; } + private object ResolveSerializer(Type contextType, SerializerRegistration registration) + { + if (registration.IsSingleton) + { + return _singletonSerializerCache.GetOrAdd(contextType, _ => + { + using var scope = scopeFactory.CreateScope(); + return registration.Resolve(scope.ServiceProvider); + }); + } + + // Custom serializers may have scoped dependencies — create a scope each time + using var scopeForCustom = scopeFactory.CreateScope(); + return registration.Resolve(scopeForCustom.ServiceProvider); + } + private static Dictionary BuildSerializerMap( RequestContextOptions options) { @@ -55,13 +68,15 @@ private static Dictionary BuildSerializerMap( if (options.SerializerTypes.TryGetValue(contextType, out var customSerializerType)) { map[contextType] = new SerializerRegistration( - serviceProvider => ActivatorUtilities.CreateInstance(serviceProvider, customSerializerType)); + serviceProvider => ActivatorUtilities.CreateInstance(serviceProvider, customSerializerType), + IsSingleton: false); } else { var serializerType = typeof(IRequestContextSerializer<>).MakeGenericType(contextType); map[contextType] = new SerializerRegistration( - serviceProvider => serviceProvider.GetRequiredService(serializerType)); + serviceProvider => serviceProvider.GetRequiredService(serializerType), + IsSingleton: true); } } @@ -83,5 +98,5 @@ private static Func ((IRequestContextSerializer)serializer).Serialize((T)context); } - private sealed record SerializerRegistration(Func Resolve); + private sealed record SerializerRegistration(Func Resolve, bool IsSingleton); } diff --git a/src/TypedRequestContext.Propagation/TypedRequestContext.Propagation.csproj b/src/TypedRequestContext.Propagation/TypedRequestContext.Propagation.csproj index 8a11dbc..64a85fc 100644 --- a/src/TypedRequestContext.Propagation/TypedRequestContext.Propagation.csproj +++ b/src/TypedRequestContext.Propagation/TypedRequestContext.Propagation.csproj @@ -20,8 +20,8 @@ - - + + diff --git a/src/TypedRequestContext/Infrastructure/PropertyMapper.cs b/src/TypedRequestContext/Infrastructure/PropertyMapper.cs index 0337f19..ddd7adc 100644 --- a/src/TypedRequestContext/Infrastructure/PropertyMapper.cs +++ b/src/TypedRequestContext/Infrastructure/PropertyMapper.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Linq.Expressions; using System.Reflection; using System.Security.Claims; @@ -16,19 +17,25 @@ private enum ContextValueSource Header } - private readonly PropertyInfo _property; + private readonly string _propertyName; + private readonly Type _propertyType; private readonly Func _extract; + private readonly Action _setter; private readonly bool _required; private readonly ContextValueSource _source; private PropertyMapper( - PropertyInfo property, + string propertyName, + Type propertyType, Func extract, + Action setter, bool required, ContextValueSource source) { - _property = property; + _propertyName = propertyName; + _propertyType = propertyType; _extract = extract; + _setter = setter; _required = required; _source = source; } @@ -60,8 +67,9 @@ private PropertyMapper( : null; var source = fromClaim is not null ? ContextValueSource.Claim : ContextValueSource.Header; + var setter = BuildSetter(property); - return new PropertyMapper(property, extract, required, source); + return new PropertyMapper(property.Name, property.PropertyType, extract, setter, required, source); } /// @@ -79,30 +87,55 @@ public PropertyMapperResult Apply(object instance, HttpContext http) if (_required) { return _source == ContextValueSource.Claim - ? PropertyMapperResult.MissingClaim(_property.Name) - : PropertyMapperResult.MissingHeader(_property.Name); + ? PropertyMapperResult.MissingClaim(_propertyName) + : PropertyMapperResult.MissingHeader(_propertyName); } return PropertyMapperResult.Success; } - var converted = ConvertValue(raw, _property.PropertyType); + var converted = ConvertValue(raw, _propertyType); if (converted is null) { if (_required) { return _source == ContextValueSource.Claim - ? PropertyMapperResult.InvalidClaim(_property.Name) - : PropertyMapperResult.InvalidHeader(_property.Name); + ? PropertyMapperResult.InvalidClaim(_propertyName) + : PropertyMapperResult.InvalidHeader(_propertyName); } return PropertyMapperResult.Success; } - _property.SetValue(instance, converted); + _setter(instance, converted); return PropertyMapperResult.Success; } + /// + /// Builds a compiled setter delegate for the given property. + /// Falls back to reflection-based SetValue for init-only properties + /// where the set method cannot be compiled into a delegate. + /// + internal static Action BuildSetter(PropertyInfo property) + { + try + { + var instanceParam = Expression.Parameter(typeof(object), "instance"); + var valueParam = Expression.Parameter(typeof(object), "value"); + + var castInstance = Expression.Convert(instanceParam, property.DeclaringType!); + var castValue = Expression.Convert(valueParam, property.PropertyType); + var propertyAccess = Expression.Property(castInstance, property); + var assign = Expression.Assign(propertyAccess, castValue); + + return Expression.Lambda>(assign, instanceParam, valueParam).Compile(); + } + catch + { + return property.SetValue; + } + } + internal static object? ConvertValue(string raw, Type targetType) { var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType; @@ -145,22 +178,22 @@ internal readonly record struct PropertyMapperResult public static PropertyMapperResult MissingClaim(string propertyName) => new() { IsSuccess = false, StatusCode = 401, PropertyName = propertyName, FailureKind = RequestContextFailureKind.Missing }; - /// A required header was missing — should return 403. + /// A required header was missing — should return 400. public static PropertyMapperResult MissingHeader(string propertyName) - => new() { IsSuccess = false, StatusCode = 403, PropertyName = propertyName, FailureKind = RequestContextFailureKind.Missing }; + => new() { IsSuccess = false, StatusCode = 400, PropertyName = propertyName, FailureKind = RequestContextFailureKind.Missing }; /// A required claim exists but has invalid format — should return 401. public static PropertyMapperResult InvalidClaim(string propertyName) => new() { IsSuccess = false, StatusCode = 401, PropertyName = propertyName, FailureKind = RequestContextFailureKind.Invalid }; - /// A required header exists but has invalid format — should return 403. + /// A required header exists but has invalid format — should return 400. public static PropertyMapperResult InvalidHeader(string propertyName) - => new() { IsSuccess = false, StatusCode = 403, PropertyName = propertyName, FailureKind = RequestContextFailureKind.Invalid }; + => new() { IsSuccess = false, StatusCode = 400, PropertyName = propertyName, FailureKind = RequestContextFailureKind.Invalid }; /// Whether the extraction succeeded. public bool IsSuccess { get; init; } - /// HTTP status code to return on failure (401 or 403). + /// HTTP status code to return on failure (401 or 400). public int StatusCode { get; init; } /// The name of the property that failed extraction. diff --git a/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs b/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs index 5709052..2340243 100644 --- a/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs +++ b/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs @@ -22,19 +22,13 @@ public sealed class RequestContextMiddleware( RequestContextScopeFactory scopeFactory, ILogger logger) { - private readonly RequestDelegate _next = next; - private readonly Dictionary> _extractors = extractors; - private readonly bool _correlationEnabled = correlationEnabled; - private readonly RequestContextScopeFactory _scopeFactory = scopeFactory; - private readonly ILogger _logger = logger; - /// /// Processes the HTTP request: (1) correlation ID handling, (2) typed context extraction. /// public async Task InvokeAsync(HttpContext httpContext) { // Step 1: Unconditional CorrelationId handling (when enabled via AddCorrelationId()) - if (_correlationEnabled) + if (correlationEnabled) { var correlationId = httpContext.Request.Headers["x-correlation-id"].FirstOrDefault() @@ -54,17 +48,17 @@ public async Task InvokeAsync(HttpContext httpContext) if (descriptor is null) { - await _next(httpContext); + await next(httpContext); return; } try { - if (!_extractors.TryGetValue(descriptor.ContextType, out var extract)) + if (!extractors.TryGetValue(descriptor.ContextType, out var extract)) { throw new InvalidOperationException( $"No extractor registered for context type '{descriptor.ContextType.Name}'. " + - $"Ensure AddRequestContext<{descriptor.ContextType.Name}>() was called during service registration."); + $"Ensure AddTypedRequestContext<{descriptor.ContextType.Name}>() was called during service registration."); } // Invoke the pre-built extractor delegate to create the typed context @@ -72,41 +66,55 @@ public async Task InvokeAsync(HttpContext httpContext) // 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); + using var scope = scopeFactory.Begin(requestContext, httpContext.RequestServices); - _logger.LogDebug( + logger.LogDebug( "Request context set: Type={ContextType}", descriptor.ContextType.Name); - await _next(httpContext); + await next(httpContext); } catch (RequestContextValidationException ex) { - _logger.LogWarning( + logger.LogWarning( "Request context validation failed: {ErrorCount} error(s)", ex.Errors.Count); - httpContext.Response.StatusCode = 400; - await httpContext.Response.WriteAsJsonAsync(new + if (!httpContext.Response.HasStarted) + { + 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()) + }); + } + else { - message = ex.Message, - errors = ex.Errors - .GroupBy(e => e.MemberName ?? "$") - .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()) - }); + logger.LogWarning("Response already started; cannot write validation error body."); + } } catch (RequestContextCreationException ex) { - _logger.LogWarning( + logger.LogWarning( "Request context creation failed: {Message}", ex.Message); - httpContext.Response.StatusCode = ex.StatusCode; - await httpContext.Response.WriteAsJsonAsync(new { message = ex.Message, }); + if (!httpContext.Response.HasStarted) + { + httpContext.Response.StatusCode = ex.StatusCode; + await httpContext.Response.WriteAsJsonAsync(new { message = ex.Message }); + } + else + { + logger.LogWarning("Response already started; cannot write creation error body."); + } } finally { - if (_correlationEnabled) + if (correlationEnabled) CorrelationContext.Clear(); } } diff --git a/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs b/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs index 9b16763..24aa5bf 100644 --- a/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs +++ b/src/TypedRequestContext/Middleware/RequestContextServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using System.Reflection; using TypedRequestContext.Infrastructure; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; namespace TypedRequestContext; @@ -15,10 +16,10 @@ public static class RequestContextServiceCollectionExtensions /// public static IServiceCollection AddTypedRequestContext(this IServiceCollection services) { - services.AddSingleton(); - services.AddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); - services.AddSingleton( + services.TryAddSingleton( typeof(IRequestContextExtractor<>), typeof(AttributeBasedRequestContextExtractor<>)); diff --git a/tests/TypedRequestContext.AspNetCore.IntegrationTests/RequestContextMiddlewareIntegrationTests.cs b/tests/TypedRequestContext.AspNetCore.IntegrationTests/RequestContextMiddlewareIntegrationTests.cs index e8eb1fa..015fa12 100644 --- a/tests/TypedRequestContext.AspNetCore.IntegrationTests/RequestContextMiddlewareIntegrationTests.cs +++ b/tests/TypedRequestContext.AspNetCore.IntegrationTests/RequestContextMiddlewareIntegrationTests.cs @@ -83,7 +83,7 @@ public async Task MissingRequiredClaim_Returns401() } [Fact] - public async Task MissingRequiredHeader_Returns403() + public async Task MissingRequiredHeader_Returns400() { await using var app = await BuildAppAsync(services => { @@ -100,7 +100,7 @@ public async Task MissingRequiredHeader_Returns403() var response = await app.Client.SendAsync(request); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var body = await response.Content.ReadFromJsonAsync>(); Assert.NotNull(body); Assert.Contains("TenantId", body["message"], StringComparison.Ordinal); diff --git a/tests/TypedRequestContext.Propagation.UnitTests/PropagationTests.cs b/tests/TypedRequestContext.Propagation.UnitTests/PropagationTests.cs index 0cce52e..5ee1ba6 100644 --- a/tests/TypedRequestContext.Propagation.UnitTests/PropagationTests.cs +++ b/tests/TypedRequestContext.Propagation.UnitTests/PropagationTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using TypedRequestContext.Infrastructure; using TypedRequestContext.Propagation.Infrastructure; namespace TypedRequestContext.Propagation.UnitTests; @@ -197,6 +198,68 @@ public void GetCurrentHeaders_IncludesCorrelationAndSerializedContext() } } +public class PropagationValidationTests +{ + [Fact] + public void Propagate_ThrowsValidationException_WhenValidationEnabledAndContextInvalid() + { + var services = new ServiceCollection(); + services.AddTypedRequestContext(); + services.AddTypedRequestContextPropagation(); + services.AddTypedRequestContext(b => + b.UseValidation()); + + var provider = services.BuildServiceProvider(); + var propagator = provider.GetRequiredService>(); + + var metadata = new Dictionary + { + ["x-user-id"] = "11111111-1111-1111-1111-111111111111", + ["x-tenant-id"] = "22222222-2222-2222-2222-222222222222" + }; + + Assert.Throws( + () => propagator.Propagate(metadata)); + } + + [Fact] + public void Propagate_Succeeds_WhenValidationEnabledAndContextValid() + { + var services = new ServiceCollection(); + services.AddTypedRequestContext(); + services.AddTypedRequestContextPropagation(); + services.AddTypedRequestContext(b => + b.UseValidation()); + + 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); + } + } + + private sealed class AlwaysFailPropagationValidator : IRequestContextValidator + { + public IReadOnlyList Validate(TestPropagationContext context) + => [new RequestContextValidationError("UserId", "Invalid user")]; + } + + private sealed class AlwaysPassPropagationValidator : IRequestContextValidator + { + public IReadOnlyList Validate(TestPropagationContext context) => []; + } +} + public sealed class TestPropagationContext : ITypedRequestContext { [FromClaim("sub"), RequiredContextValue, PropagationKey("x-user-id")] diff --git a/tests/TypedRequestContext.UnitTests/RequestContextCoreTests.cs b/tests/TypedRequestContext.UnitTests/RequestContextCoreTests.cs index b459f12..3c137ad 100644 --- a/tests/TypedRequestContext.UnitTests/RequestContextCoreTests.cs +++ b/tests/TypedRequestContext.UnitTests/RequestContextCoreTests.cs @@ -82,7 +82,7 @@ public void Extract_Throws401_WhenRequiredClaimIsMissing() } [Fact] - public void Extract_Throws403_WhenRequiredHeaderIsMissing() + public void Extract_Throws400_WhenRequiredHeaderIsMissing() { var userId = Guid.NewGuid(); var http = BuildHttpContext( @@ -93,7 +93,7 @@ public void Extract_Throws403_WhenRequiredHeaderIsMissing() var ex = Assert.Throws(() => extractor.Extract(http)); - Assert.Equal(403, ex.StatusCode); + Assert.Equal(400, ex.StatusCode); Assert.Contains("TenantId", ex.Message, StringComparison.Ordinal); } From 5c09f92e88aff265400f1c145230295dd24e4945 Mon Sep 17 00:00:00 2001 From: otapiero Date: Sun, 8 Mar 2026 14:40:40 +0200 Subject: [PATCH 3/6] fix: Improve error logging in RequestContextMiddleware and specify exception types in PropertyMapper --- .../Infrastructure/PropagationHeadersProvider.cs | 13 +++++++------ .../Infrastructure/PropertyMapper.cs | 2 +- .../Middleware/RequestContextMiddleware.cs | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/TypedRequestContext.Propagation/Infrastructure/PropagationHeadersProvider.cs b/src/TypedRequestContext.Propagation/Infrastructure/PropagationHeadersProvider.cs index f7d0636..e34f1e6 100644 --- a/src/TypedRequestContext.Propagation/Infrastructure/PropagationHeadersProvider.cs +++ b/src/TypedRequestContext.Propagation/Infrastructure/PropagationHeadersProvider.cs @@ -13,12 +13,13 @@ namespace TypedRequestContext.Propagation.Infrastructure; internal sealed class PropagationHeadersProvider( IRequestContextAccessor contextAccessor, IOptions options, + IServiceProvider rootProvider, IServiceScopeFactory scopeFactory, ICorrelationContext? correlationContext = null) : IPropagationHeadersProvider { private readonly Lazy> _serializerRegistrations = new( () => BuildSerializerMap(options.Value)); - private readonly ConcurrentDictionary _singletonSerializerCache = new(); + private readonly ConcurrentDictionary> _singletonSerializerCache = new(); private static readonly ConcurrentDictionary>> _invokerCache = new(); /// @@ -46,11 +47,11 @@ private object ResolveSerializer(Type contextType, SerializerRegistration regist { if (registration.IsSingleton) { - return _singletonSerializerCache.GetOrAdd(contextType, _ => - { - using var scope = scopeFactory.CreateScope(); - return registration.Resolve(scope.ServiceProvider); - }); + var lazy = _singletonSerializerCache.GetOrAdd(contextType, + _ => new Lazy( + () => registration.Resolve(rootProvider), + LazyThreadSafetyMode.ExecutionAndPublication)); + return lazy.Value; } // Custom serializers may have scoped dependencies — create a scope each time diff --git a/src/TypedRequestContext/Infrastructure/PropertyMapper.cs b/src/TypedRequestContext/Infrastructure/PropertyMapper.cs index ddd7adc..1ad0706 100644 --- a/src/TypedRequestContext/Infrastructure/PropertyMapper.cs +++ b/src/TypedRequestContext/Infrastructure/PropertyMapper.cs @@ -130,7 +130,7 @@ public PropertyMapperResult Apply(object instance, HttpContext http) return Expression.Lambda>(assign, instanceParam, valueParam).Compile(); } - catch + catch (InvalidOperationException) { return property.SetValue; } diff --git a/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs b/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs index 2340243..f4ee8d7 100644 --- a/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs +++ b/src/TypedRequestContext/Middleware/RequestContextMiddleware.cs @@ -93,7 +93,7 @@ await httpContext.Response.WriteAsJsonAsync(new } else { - logger.LogWarning("Response already started; cannot write validation error body."); + logger.LogError(ex, "Response already started; cannot write validation error body."); } } catch (RequestContextCreationException ex) @@ -109,7 +109,7 @@ await httpContext.Response.WriteAsJsonAsync(new } else { - logger.LogWarning("Response already started; cannot write creation error body."); + logger.LogError(ex, "Response already started; cannot write creation error body."); } } finally From eb46caba6df9b5ca7d0587a33d07c262ad75fe06 Mon Sep 17 00:00:00 2001 From: otapiero Date: Sun, 8 Mar 2026 14:54:18 +0200 Subject: [PATCH 4/6] docs: Enhance README with validation details and context propagation examples --- README.md | 202 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 164 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 180e376..4f287ef 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ Typed request context middleware for ASP.NET Core. -Define *per-endpoint* (or per-route-group) strongly-typed “request context” objects, extract them from claims/headers, and access them anywhere in the request pipeline via an `AsyncLocal` accessor. +Define *per-endpoint* (or per-route-group) strongly-typed "request context" objects, extract them from claims/headers, validate them, and access them anywhere in the request pipeline via an `AsyncLocal` accessor — including non-HTTP flows like queues and background jobs. This repo produces two NuGet packages: -- **`TypedRequestContext`** — core middleware, attributes, accessor, correlation ID -- **`TypedRequestContext.Propagation`** — optional propagation (serialize/deserialize + header provider) +- **`TypedRequestContext`** — core middleware, attributes, accessor, validation, correlation ID +- **`TypedRequestContext.Propagation`** — optional propagation (serialize/deserialize, header provider, non-HTTP context restoration) --- @@ -15,14 +15,12 @@ This repo produces two NuGet packages: In many services you end up needing the same business identifiers everywhere (tenant, user, role, operation id, etc.). Passing them manually through every method is noisy, and extracting them ad-hoc in each endpoint is easy to get wrong. -`TypedRequestContext` gives you: +`TypedRequestContext` solves this by letting you: -- A **typed model** for the “ambient” request context (per endpoint/group) -- **One extraction pipeline** (claims/headers) with clear “required vs optional” semantics -- **Multiple context shapes in the same service** — register many context types and choose per endpoint / route group -- **Ambient access** for deep services (inject the context type directly *or* use `IRequestContextAccessor`) -- Optional **propagation across services and async boundaries** (HTTP headers, queue/event metadata) -- **Correlation ID propagation** via `x-correlation-id` when the propagation package is enabled +- **Define once, use everywhere** — declare a typed model for your request context and access it from any layer without manual plumbing +- **Fail fast on bad input** — required values are enforced at the edge; validation errors return structured responses before your handler runs +- **Support multiple context shapes** — different endpoints can require different context types in the same service +- **Propagate context across boundaries** — carry tenant, user, and correlation data to downstream HTTP calls, queues, and background jobs without ad-hoc serialization --- @@ -147,7 +145,7 @@ static IResult GetOrders([FromServices] CustomerRequestContext ctx) In deeper services you have two options: -1) Inject the typed context directly (it’s registered as scoped when you call `AddTypedRequestContext()`): +1) Inject the typed context directly (it's registered as scoped when you call `AddTypedRequestContext()`): ```csharp public sealed class BillingService(CustomerRequestContext ctx) @@ -156,7 +154,7 @@ public sealed class BillingService(CustomerRequestContext ctx) } ``` -2) Use `IRequestContextAccessor` when you need a looser coupling (e.g., interface-based access, optional access, libraries that shouldn’t depend on a specific context type): +2) Use `IRequestContextAccessor` when you need a looser coupling (e.g., interface-based access, optional access, libraries that shouldn't depend on a specific context type): ```csharp using TypedRequestContext; @@ -179,6 +177,7 @@ public sealed class AuditService(IRequestContextAccessor accessor) - You attach the desired context type to endpoints/groups via `WithRequestContext()`. - For requests hitting such endpoints, the middleware: - Creates the context via an extractor (`IRequestContextExtractor`) + - Validates it (if validation is enabled) - Stores it into `IRequestContextAccessor.Current` for the request lifetime The default extractor uses cached reflection and the attributes on your context properties. @@ -198,6 +197,98 @@ The response body is JSON: --- +## Validation + +Enable validation per context type during registration: + +```csharp +builder.Services.AddTypedRequestContext(b => + b.EnableValidation()); +``` + +By default, `EnableValidation()` uses **DataAnnotations** (`System.ComponentModel.DataAnnotations`). You can use any standard validation attributes: + +```csharp +using System.ComponentModel.DataAnnotations; +using TypedRequestContext; + +public sealed class OrderContext : ITypedRequestContext +{ + [FromHeader("x-order-code"), MaxLength(4)] + public string? Code { get; init; } + + [FromHeader("x-quantity"), Range(1, 1000)] + public int Quantity { get; init; } + + [FromClaim("email"), RegularExpression(@"^[^@]+@[^@]+\.[^@]+$")] + public string? Email { get; init; } +} +``` + +The context type can also implement `IValidatableObject` for cross-property validation: + +```csharp +public sealed class OrderContext : ITypedRequestContext, IValidatableObject +{ + [FromHeader("x-min"), RequiredContextValue] + public int Min { get; init; } + + [FromHeader("x-max"), RequiredContextValue] + public int Max { get; init; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Min > Max) + yield return new ValidationResult("Min must be <= Max", [nameof(Min), nameof(Max)]); + } +} +``` + +### Validation error response + +When validation fails, the middleware returns **400** with a structured body grouping errors by property: + +```json +{ + "message": "Request context validation failed.", + "errors": { + "Code": ["The field Code must be a string with a maximum length of 4."], + "Quantity": ["The field Quantity must be between 1 and 1000."], + "$": ["Object-level validation error message"] + } +} +``` + +### Custom validators + +For validation logic beyond DataAnnotations, implement `IRequestContextValidator`: + +```csharp +using TypedRequestContext; + +public sealed class OrderContextValidator : IRequestContextValidator +{ + public IReadOnlyList Validate(OrderContext context) + { + var errors = new List(); + + if (context.Code is not null && !IsValidCode(context.Code)) + errors.Add(new RequestContextValidationError(nameof(context.Code), "Invalid order code format.")); + + return errors; + } +} +``` + +Register it: + +```csharp +builder.Services.AddTypedRequestContext(b => + b.UseValidation()); +``` + +--- + ## Correlation ID (optional) Register once: @@ -296,52 +387,63 @@ public sealed class DownstreamClient( Because `GetCurrentHeaders()` includes `x-correlation-id` (when correlation is enabled), a downstream ASP.NET Core service using `AddCorrelationId()` will automatically pick it up on inbound requests. -### Non-HTTP: deserialize from metadata +### Non-HTTP flows: queues, events, background jobs -For queues/events/background jobs, you can serialize and carry the same headers dictionary as message metadata. +For non-HTTP consumers, `IRequestContextPropagator` handles deserialization, validation, and context lifecycle in a single call. -Producer side (create metadata): +**Producer side** — serialize current context into message metadata: ```csharp using TypedRequestContext.Propagation; -public sealed class Producer(IPropagationHeadersProvider headers) +public sealed class OrderProducer(IPropagationHeadersProvider headers) { - public IReadOnlyDictionary CreateMetadata() - => headers.GetCurrentHeaders(); + public void Publish(Order order) + { + var metadata = headers.GetCurrentHeaders(); + // Attach metadata as message headers / properties + messageBus.Publish(order, metadata); + } } ``` -Consumer side (restore context): +**Consumer side** — restore context from metadata: ```csharp using TypedRequestContext; using TypedRequestContext.Propagation; -public sealed class Handler( - IRequestContextDeserializer deserializer, +public sealed class OrderHandler( + IRequestContextPropagator propagator, IRequestContextAccessor accessor) { - public async Task HandleAsync(IReadOnlyDictionary metadata, CancellationToken ct) + public async Task HandleAsync( + IReadOnlyDictionary metadata, + CancellationToken ct) { - var ctx = deserializer.Deserialize(metadata); - - accessor.Current = ctx; - try - { - // Your handler/service code can now inject CustomerRequestContext - // or use IRequestContextAccessor.GetRequired(). - await Task.CompletedTask; - } - finally - { - accessor.Current = null; - } + using var _ = propagator.Propagate(metadata); + + // Context is now set — use it via DI or accessor + var ctx = accessor.GetRequired(); + await ProcessOrderAsync(ctx.TenantId, ctx.UserId, ct); + + // Context is automatically cleared when the scope is disposed } } ``` -The same metadata can include `x-correlation-id` (from `GetCurrentHeaders()`). How you apply correlation in a non-HTTP consumer depends on your hosting model and logging setup. +`Propagate()` will: +1. Deserialize the metadata into the typed context using `IRequestContextDeserializer` +2. Validate the context (if validation is enabled for this type) +3. Set it on `IRequestContextAccessor` +4. Return a disposable scope that clears the context on disposal + +If validation is enabled and the validator has scoped dependencies, pass a scoped `IServiceProvider`: + +```csharp +using var scope = serviceProvider.CreateScope(); +using var _ = propagator.Propagate(metadata, scope.ServiceProvider); +``` --- @@ -383,11 +485,29 @@ builder.Services.AddTypedRequestContext(b => .UseDeserializer()); ``` +Serializer interface: + +```csharp +public interface IRequestContextSerializer where T : class, ITypedRequestContext +{ + IReadOnlyDictionary Serialize(T context); +} +``` + +Deserializer interface: + +```csharp +public interface IRequestContextDeserializer where T : class, ITypedRequestContext +{ + T Deserialize(IReadOnlyDictionary metadata); +} +``` + --- ## Troubleshooting -- **My handler can’t resolve `CustomerRequestContext` from DI** +- **My handler can't resolve `CustomerRequestContext` from DI** - Ensure `AddTypedRequestContext()` was called. - Ensure the endpoint/group has `.WithRequestContext()`. @@ -395,7 +515,13 @@ builder.Services.AddTypedRequestContext(b => - Ensure `app.UseTypedRequestContext()` runs **after** auth middleware. - **I get `No extractor registered for context type ...`** - - You attached `.WithRequestContext()` but didn’t register `AddTypedRequestContext()`. + - You attached `.WithRequestContext()` but didn't register `AddTypedRequestContext()`. + +- **Validation errors return 400 but I expected a different status** + - Validation failures always return 400. For missing required values (pre-validation), claims return 401 and headers return 400. + +- **Non-HTTP consumer throws `RequestContextDeserializationException`** + - A required metadata key is missing or has an invalid format. Check that the producer serialized all `[PropagationKey]` properties. ## Contributing From 028d88b479aea9e2fffb0a31b9677fa8371c59a4 Mon Sep 17 00:00:00 2001 From: otapiero Date: Sun, 8 Mar 2026 14:55:37 +0200 Subject: [PATCH 5/6] chore: update package version to 2.0.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 7e79ce8..ca2375a 100644 --- a/src/TypedRequestContext.Propagation/TypedRequestContext.Propagation.csproj +++ b/src/TypedRequestContext.Propagation/TypedRequestContext.Propagation.csproj @@ -5,7 +5,7 @@ enable enable TypedRequestContext.Propagation - 1.1.0 + 2.0.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 07b5d72..02d5c64 100644 --- a/src/TypedRequestContext/TypedRequestContext.csproj +++ b/src/TypedRequestContext/TypedRequestContext.csproj @@ -5,7 +5,7 @@ enable enable TypedRequestContext - 1.1.0 + 2.0.0 Otapiero Typed request context middleware for ASP.NET Core with AsyncLocal accessor support. MIT From be1ae948aecde74b945bc4b1f38e479c7438582b Mon Sep 17 00:00:00 2001 From: otapiero Date: Sun, 8 Mar 2026 16:36:41 +0200 Subject: [PATCH 6/6] fix: Refactor serializer resolution to use a scope for custom serializers and improve resource management --- .../PropagationHeadersProvider.cs | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/TypedRequestContext.Propagation/Infrastructure/PropagationHeadersProvider.cs b/src/TypedRequestContext.Propagation/Infrastructure/PropagationHeadersProvider.cs index e34f1e6..e44fad7 100644 --- a/src/TypedRequestContext.Propagation/Infrastructure/PropagationHeadersProvider.cs +++ b/src/TypedRequestContext.Propagation/Infrastructure/PropagationHeadersProvider.cs @@ -33,17 +33,17 @@ public IReadOnlyDictionary GetCurrentHeaders() var context = contextAccessor.Current; if (context is not null && _serializerRegistrations.Value.TryGetValue(context.GetType(), out var registration)) { - var serializer = ResolveSerializer(context.GetType(), registration); + using var scope = ResolveSerializer(context.GetType(), registration); var serialize = _invokerCache.GetOrAdd(context.GetType(), CreateInvoker); - foreach (var (key, value) in serialize(serializer, context)) + foreach (var (key, value) in serialize(scope.Serializer, context)) headers[key] = value; } return headers; } - private object ResolveSerializer(Type contextType, SerializerRegistration registration) + private SerializerScope ResolveSerializer(Type contextType, SerializerRegistration registration) { if (registration.IsSingleton) { @@ -51,12 +51,18 @@ private object ResolveSerializer(Type contextType, SerializerRegistration regist _ => new Lazy( () => registration.Resolve(rootProvider), LazyThreadSafetyMode.ExecutionAndPublication)); - return lazy.Value; + return new SerializerScope(lazy.Value, scope: null); } - // Custom serializers may have scoped dependencies — create a scope each time - using var scopeForCustom = scopeFactory.CreateScope(); - return registration.Resolve(scopeForCustom.ServiceProvider); + // Custom serializers may have scoped dependencies — keep scope alive for serialization + var scope = scopeFactory.CreateScope(); + return new SerializerScope(registration.Resolve(scope.ServiceProvider), scope); + } + + private readonly struct SerializerScope(object serializer, IServiceScope? scope) : IDisposable + { + public object Serializer { get; } = serializer; + public void Dispose() => scope?.Dispose(); } private static Dictionary BuildSerializerMap(