diff --git a/docs/agents/architecture.md b/docs/agents/architecture.md index 06088ad..6b54d4b 100644 --- a/docs/agents/architecture.md +++ b/docs/agents/architecture.md @@ -11,7 +11,7 @@ created: 2026-05-10 - `IConfiguration` is registered via factory during `Build()`, so the DI container owns its lifetime. Handler `Dispose`/`DisposeAsync` only disposes the owned service provider (injected providers are never disposed); configuration is disposed transitively. `DisposeAsync` is required when async-only-disposable singletons are registered; the per-request scope is always disposed asynchronously. - The pipeline is built lazily on first `InvokeAsync()` — `Use()` calls after that point throw `InvalidOperationException`. - `RequestHandler.Middleware` exposes registration metadata only (`MiddlewareDescriptor`: the middleware type for `Use()`; null type for delegates, with `DisplayName` = method name for method groups or `MiddlewareDescriptor.DelegateDisplayName` for lambdas) — the component delegates and the compiled pipeline stay private. List order is registration order, which is inbound execution order. -- `TRequest` is `where TRequest : notnull` (not `class`) so value-type requests work. +- `TRequest` and `TResponse` are both `where … : notnull` (not `class`) so value-type requests/responses work. `RequestContext.Response` is non-nullable (`TResponse`, initialized to `default!`) and `InvokeAsync` returns `Task` — middleware is expected to assign `Response` before the pipeline returns; for event-style pipelines `TResponse` is `Unit`, whose `default` is its sole value. - Cross-middleware state goes through `RequestContext.Data`. `Unit` is the response type for event-style pipelines. - `RequestContext` is single-threaded per request — the pipeline invokes middleware sequentially, and the context (including `Data` and `Response`) is deliberately unsynchronized, matching `HttpContext` semantics. Middleware that fans out parallel work must not touch the context concurrently. Don't "fix" the lazy `data ??= []` init. - Class middleware: `RequestMiddleware next` must be the first ctor parameter; `RequestContext` must be the first `InvokeAsync` parameter (additional parameters resolve from the scoped service provider). diff --git a/src/Plumber.Diagnostics/RequestHandlerDiagnosticsExtensions.cs b/src/Plumber.Diagnostics/RequestHandlerDiagnosticsExtensions.cs index 6f35bda..d9ca322 100644 --- a/src/Plumber.Diagnostics/RequestHandlerDiagnosticsExtensions.cs +++ b/src/Plumber.Diagnostics/RequestHandlerDiagnosticsExtensions.cs @@ -22,7 +22,8 @@ public static class RequestHandlerDiagnosticsExtensions /// public static RequestHandler UseRequestTracing( this RequestHandler handler) - where TRequest : class + where TRequest : notnull + where TResponse : notnull { ArgumentNullException.ThrowIfNull(handler); return handler.Use>(); @@ -43,7 +44,8 @@ public static RequestHandler UseRequestTracing UseRequestTracing( this RequestHandler handler, Action> configureOptions) - where TRequest : class + where TRequest : notnull + where TResponse : notnull { ArgumentNullException.ThrowIfNull(handler); ArgumentNullException.ThrowIfNull(configureOptions); @@ -67,7 +69,8 @@ public static RequestHandler UseRequestTracing public static RequestHandler UseRequestMetrics( this RequestHandler handler) - where TRequest : class + where TRequest : notnull + where TResponse : notnull { ArgumentNullException.ThrowIfNull(handler); return handler.Use>(); @@ -88,7 +91,8 @@ public static RequestHandler UseRequestMetrics UseRequestMetrics( this RequestHandler handler, Action> configureOptions) - where TRequest : class + where TRequest : notnull + where TResponse : notnull { ArgumentNullException.ThrowIfNull(handler); ArgumentNullException.ThrowIfNull(configureOptions); @@ -107,7 +111,8 @@ public static RequestHandler UseRequestMetricsThe same for chaining. public static RequestHandler UseRequestDiagnostics( this RequestHandler handler) - where TRequest : class + where TRequest : notnull + where TResponse : notnull { ArgumentNullException.ThrowIfNull(handler); return handler @@ -129,7 +134,8 @@ public static RequestHandler UseRequestDiagnostics handler, Action> configureTracingOptions, Action> configureMetricsOptions) - where TRequest : class + where TRequest : notnull + where TResponse : notnull { ArgumentNullException.ThrowIfNull(handler); ArgumentNullException.ThrowIfNull(configureTracingOptions); diff --git a/src/Plumber.Diagnostics/RequestMetricsMiddleware{TRequest, TResponse}.cs b/src/Plumber.Diagnostics/RequestMetricsMiddleware{TRequest, TResponse}.cs index c3f927e..a7e805e 100644 --- a/src/Plumber.Diagnostics/RequestMetricsMiddleware{TRequest, TResponse}.cs +++ b/src/Plumber.Diagnostics/RequestMetricsMiddleware{TRequest, TResponse}.cs @@ -4,7 +4,8 @@ namespace Plumber.Diagnostics; internal sealed class RequestMetricsMiddleware - where TRequest : class + where TRequest : notnull + where TResponse : notnull { private static readonly Meter Meter = new(PlumberDiagnostics.MeterName); diff --git a/src/Plumber.Diagnostics/RequestMetricsOptions.cs b/src/Plumber.Diagnostics/RequestMetricsOptions.cs index ed4df70..05cd25d 100644 --- a/src/Plumber.Diagnostics/RequestMetricsOptions.cs +++ b/src/Plumber.Diagnostics/RequestMetricsOptions.cs @@ -6,7 +6,8 @@ namespace Plumber.Diagnostics; /// The pipeline request type. /// The pipeline response type. public sealed class RequestMetricsOptions - where TRequest : class + where TRequest : notnull + where TResponse : notnull { /// /// ThrowOnException lets you set whether the middleware should rethrow exceptions thrown by downstream middleware components. diff --git a/src/Plumber.Diagnostics/RequestTracingMiddleware{TRequest, TResponse}.cs b/src/Plumber.Diagnostics/RequestTracingMiddleware{TRequest, TResponse}.cs index 742cf6e..2ab2a13 100644 --- a/src/Plumber.Diagnostics/RequestTracingMiddleware{TRequest, TResponse}.cs +++ b/src/Plumber.Diagnostics/RequestTracingMiddleware{TRequest, TResponse}.cs @@ -4,7 +4,8 @@ namespace Plumber.Diagnostics; internal sealed class RequestTracingMiddleware - where TRequest : class + where TRequest : notnull + where TResponse : notnull { private static readonly ActivitySource ActivitySource = new(PlumberDiagnostics.ActivitySourceName); diff --git a/src/Plumber.Diagnostics/RequestTracingOptions.cs b/src/Plumber.Diagnostics/RequestTracingOptions.cs index 82515e9..25aad67 100644 --- a/src/Plumber.Diagnostics/RequestTracingOptions.cs +++ b/src/Plumber.Diagnostics/RequestTracingOptions.cs @@ -8,7 +8,8 @@ namespace Plumber.Diagnostics; /// The pipeline request type. /// The pipeline response type. public sealed class RequestTracingOptions - where TRequest : class + where TRequest : notnull + where TResponse : notnull { private const string DefaultOperationName = "Plumber.HandleRequest"; diff --git a/src/Plumber.Diagnostics/ServiceCollectionDiagnosticsExtensions.cs b/src/Plumber.Diagnostics/ServiceCollectionDiagnosticsExtensions.cs index e1dcc48..04b966a 100644 --- a/src/Plumber.Diagnostics/ServiceCollectionDiagnosticsExtensions.cs +++ b/src/Plumber.Diagnostics/ServiceCollectionDiagnosticsExtensions.cs @@ -30,7 +30,8 @@ public static IServiceCollection AddPlumberDiagnostics( this IServiceCollection services, Action>? configureTracing = null, Action>? configureMetrics = null) - where TRequest : class + where TRequest : notnull + where TResponse : notnull { ArgumentNullException.ThrowIfNull(services); diff --git a/src/Plumber.Serilog.Extensions/RequestHandlerSerilogExtensions.cs b/src/Plumber.Serilog.Extensions/RequestHandlerSerilogExtensions.cs index 2493d26..13de21f 100644 --- a/src/Plumber.Serilog.Extensions/RequestHandlerSerilogExtensions.cs +++ b/src/Plumber.Serilog.Extensions/RequestHandlerSerilogExtensions.cs @@ -21,7 +21,8 @@ public static class RequestHandlerSerilogExtensions /// public static RequestHandler UseSerilogRequestLogging( this RequestHandler handler) - where TRequest : class + where TRequest : notnull + where TResponse : notnull { ArgumentNullException.ThrowIfNull(handler); return handler.Use>(); @@ -42,7 +43,8 @@ public static RequestHandler UseSerilogRequestLogging UseSerilogRequestLogging( this RequestHandler handler, Action> configureOptions) - where TRequest : class + where TRequest : notnull + where TResponse : notnull { ArgumentNullException.ThrowIfNull(handler); ArgumentNullException.ThrowIfNull(configureOptions); diff --git a/src/Plumber.Serilog.Extensions/RequestLoggerMiddleware{TRequest, TResponse}.cs b/src/Plumber.Serilog.Extensions/RequestLoggerMiddleware{TRequest, TResponse}.cs index 6d61216..1f6825e 100644 --- a/src/Plumber.Serilog.Extensions/RequestLoggerMiddleware{TRequest, TResponse}.cs +++ b/src/Plumber.Serilog.Extensions/RequestLoggerMiddleware{TRequest, TResponse}.cs @@ -8,7 +8,8 @@ namespace Plumber.Serilog.Extensions; internal sealed class RequestLoggerMiddleware - where TRequest : class + where TRequest : notnull + where TResponse : notnull { private readonly RequestMiddleware next; private readonly DiagnosticContext diagnosticContext; diff --git a/src/Plumber.Serilog.Extensions/RequestLoggerOptions.cs b/src/Plumber.Serilog.Extensions/RequestLoggerOptions.cs index fabcaa2..2de3ca9 100644 --- a/src/Plumber.Serilog.Extensions/RequestLoggerOptions.cs +++ b/src/Plumber.Serilog.Extensions/RequestLoggerOptions.cs @@ -9,7 +9,8 @@ namespace Plumber.Serilog.Extensions; /// The pipeline request type. /// The pipeline response type. public sealed class RequestLoggerOptions - where TRequest : class + where TRequest : notnull + where TResponse : notnull { private const string DefaultCompletedMessage = "Request {RequestId} completed in {Elapsed:0.0000} ms"; diff --git a/src/Plumber.Serilog.Extensions/ServiceCollectionSerilogExtensions.cs b/src/Plumber.Serilog.Extensions/ServiceCollectionSerilogExtensions.cs index 8d847bb..ecf4aea 100644 --- a/src/Plumber.Serilog.Extensions/ServiceCollectionSerilogExtensions.cs +++ b/src/Plumber.Serilog.Extensions/ServiceCollectionSerilogExtensions.cs @@ -29,7 +29,8 @@ public static IServiceCollection AddSerilogRequestLogging( this IServiceCollection services, Action configureLogger, Action>? configureOptions = null) - where TRequest : class + where TRequest : notnull + where TResponse : notnull { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configureLogger); diff --git a/src/Plumber.Testing/PlumberApplicationFactory.cs b/src/Plumber.Testing/PlumberApplicationFactory.cs index 14945f9..98a7ac7 100644 --- a/src/Plumber.Testing/PlumberApplicationFactory.cs +++ b/src/Plumber.Testing/PlumberApplicationFactory.cs @@ -15,6 +15,7 @@ namespace Plumber.Testing; /// The type of response handled by the pipeline. public sealed class PlumberApplicationFactory : IDisposable, IAsyncDisposable where TRequest : notnull + where TResponse : notnull { private readonly string[] args; private readonly Func> createBuilder; @@ -188,8 +189,8 @@ private RequestHandler BuildHandler() /// /// The request value flowed through the pipeline. /// Caller-supplied cancellation token forwarded to . - /// A task that completes with the pipeline's response, or if no middleware assigned Response. - public Task InvokeAsync(TRequest request, CancellationToken cancellationToken = default) => + /// A task that completes with the pipeline's response. + public Task InvokeAsync(TRequest request, CancellationToken cancellationToken = default) => CreateHandler().InvokeAsync(request, cancellationToken); /// diff --git a/src/Plumber/RequestContext{TRequest, TResponse}.cs b/src/Plumber/RequestContext{TRequest, TResponse}.cs index 528f258..5ff17ae 100644 --- a/src/Plumber/RequestContext{TRequest, TResponse}.cs +++ b/src/Plumber/RequestContext{TRequest, TResponse}.cs @@ -26,6 +26,7 @@ public sealed class RequestContext( IServiceProvider services, CancellationToken cancellationToken) where TRequest : notnull + where TResponse : notnull { // Stopwatch tick captured at construction so Elapsed uses the monotonic, high-resolution clock // rather than DateTime arithmetic (which has ~15.6ms resolution on Windows and is exposed to @@ -100,9 +101,9 @@ public bool TryGetValue(string key, [NotNullWhen(true)] out T? item) } /// - /// The response. + /// The response. Initialized to and expected to be assigned by middleware before the pipeline returns. /// - public TResponse? Response { get; set; } + public TResponse Response { get; set; } = default!; /// /// Time since the request was created, measured against the monotonic clock exposed by the configured . diff --git a/src/Plumber/RequestHandler.cs b/src/Plumber/RequestHandler.cs index 29c0b3d..7bb85f1 100644 --- a/src/Plumber/RequestHandler.cs +++ b/src/Plumber/RequestHandler.cs @@ -23,7 +23,8 @@ public static class RequestHandler /// If a is registered in it is used; otherwise is used. /// public static RequestHandler Create(IServiceProvider services) - where TRequest : notnull => + where TRequest : notnull + where TResponse : notnull => Create(services, Timeout.InfiniteTimeSpan); /// @@ -40,6 +41,7 @@ public static RequestHandler Create(IS /// public static RequestHandler Create(IServiceProvider services, TimeSpan timeout) where TRequest : notnull + where TResponse : notnull { ArgumentNullException.ThrowIfNull(services); return new RequestHandler(services, timeout); diff --git a/src/Plumber/RequestHandlerBuilder.cs b/src/Plumber/RequestHandlerBuilder.cs index ff6dbd8..d1daf1c 100644 --- a/src/Plumber/RequestHandlerBuilder.cs +++ b/src/Plumber/RequestHandlerBuilder.cs @@ -16,7 +16,8 @@ public static class RequestHandlerBuilder /// The type of response handled by the pipeline. /// A new . public static RequestHandlerBuilder Create() - where TRequest : notnull => + where TRequest : notnull + where TResponse : notnull => new([]); /// @@ -29,6 +30,7 @@ public static RequestHandlerBuilder CreateProgram args passed into Main. Appended to the per-build configuration via at the end of . /// A new . public static RequestHandlerBuilder Create(string[] args) - where TRequest : notnull => + where TRequest : notnull + where TResponse : notnull => new(args); } diff --git a/src/Plumber/RequestHandlerBuilder{TRequest, TResponse}.cs b/src/Plumber/RequestHandlerBuilder{TRequest, TResponse}.cs index dcc1673..3beac8e 100644 --- a/src/Plumber/RequestHandlerBuilder{TRequest, TResponse}.cs +++ b/src/Plumber/RequestHandlerBuilder{TRequest, TResponse}.cs @@ -14,6 +14,7 @@ namespace Plumber; /// The type of response handled by the pipeline. public sealed class RequestHandlerBuilder where TRequest : notnull + where TResponse : notnull { private const string ProductionEnv = "Production"; private readonly string[] args; diff --git a/src/Plumber/RequestHandler{TRequest, TResponse}.cs b/src/Plumber/RequestHandler{TRequest, TResponse}.cs index 3844bae..11ef43c 100644 --- a/src/Plumber/RequestHandler{TRequest, TResponse}.cs +++ b/src/Plumber/RequestHandler{TRequest, TResponse}.cs @@ -14,6 +14,7 @@ public sealed class RequestHandler : IDisposable , IAsyncDisposable where TRequest : notnull + where TResponse : notnull { private readonly List, RequestMiddleware>> components = []; private readonly List descriptors = []; @@ -99,14 +100,14 @@ internal RequestHandler( /// Invokes the request handler's pipeline. /// /// The request value flowed through the pipeline as . - /// A task that completes with the value of after the pipeline returns; if no middleware assigned Response. + /// A task that completes with the value of after the pipeline returns. /// /// Each invocation creates a new DI scope; is the per-request scoped provider and is disposed when the pipeline returns. /// Consumers of RequestContext.Services do not need to call CreateScope(). /// /// Thrown when is . /// Thrown when elapses before the pipeline completes. - public Task InvokeAsync(TRequest request) + public Task InvokeAsync(TRequest request) { ThrowIfRequestNull(request); return ThrowIfDisposed() @@ -120,7 +121,7 @@ internal RequestHandler( /// /// The request value flowed through the pipeline as . /// Caller-supplied cancellation token. Linked with the internal timeout source when is finite. - /// A task that completes with the value of after the pipeline returns; if no middleware assigned Response. + /// A task that completes with the value of after the pipeline returns. /// /// Each invocation creates a new DI scope; is the per-request scoped provider and is disposed when the pipeline returns. /// Consumers of RequestContext.Services do not need to call CreateScope(). @@ -129,7 +130,7 @@ internal RequestHandler( /// Thrown when is . /// Thrown when elapses before the pipeline completes and was not cancelled. /// Thrown when is cancelled. - public Task InvokeAsync(TRequest request, CancellationToken cancellationToken) + public Task InvokeAsync(TRequest request, CancellationToken cancellationToken) { ThrowIfRequestNull(request); return ThrowIfDisposed() @@ -273,7 +274,7 @@ private RequestHandler Use( return this; } - private async Task InvokeInternalAsync(TRequest request, TimeSpan timeout) + private async Task InvokeInternalAsync(TRequest request, TimeSpan timeout) { using var timeoutTokenSource = new CancellationTokenSource(timeout, timeProvider); try @@ -286,7 +287,7 @@ private RequestHandler Use( } } - private async Task InvokeInternalAsync(TRequest request, TimeSpan timeout, CancellationToken cancellationToken) + private async Task InvokeInternalAsync(TRequest request, TimeSpan timeout, CancellationToken cancellationToken) { using var timeoutTokenSource = new CancellationTokenSource(timeout, timeProvider); using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource( @@ -303,7 +304,7 @@ private RequestHandler Use( } } - private async Task InvokeInternalAsync(TRequest request, CancellationToken cancellationToken) + private async Task InvokeInternalAsync(TRequest request, CancellationToken cancellationToken) { await using var serviceScope = Services.CreateAsyncScope(); var context = new RequestContext( diff --git a/src/Plumber/RequestMiddleware{TRequest, TResponse}.cs b/src/Plumber/RequestMiddleware{TRequest, TResponse}.cs index 917c85d..4bf5f06 100644 --- a/src/Plumber/RequestMiddleware{TRequest, TResponse}.cs +++ b/src/Plumber/RequestMiddleware{TRequest, TResponse}.cs @@ -9,4 +9,5 @@ /// A that completes when this middleware (and any downstream middleware it calls) finishes. public delegate Task RequestMiddleware( RequestContext context) - where TRequest : notnull; + where TRequest : notnull + where TResponse : notnull;