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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/agents/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>()`; 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<TResponse>` — 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<TReq, TRes> next` must be the first ctor parameter; `RequestContext` must be the first `InvokeAsync` parameter (additional parameters resolve from the scoped service provider).
Expand Down
18 changes: 12 additions & 6 deletions src/Plumber.Diagnostics/RequestHandlerDiagnosticsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ public static class RequestHandlerDiagnosticsExtensions
/// </remarks>
public static RequestHandler<TRequest, TResponse> UseRequestTracing<TRequest, TResponse>(
this RequestHandler<TRequest, TResponse> handler)
where TRequest : class
where TRequest : notnull
where TResponse : notnull
{
ArgumentNullException.ThrowIfNull(handler);
return handler.Use<RequestTracingMiddleware<TRequest, TResponse>>();
Expand All @@ -43,7 +44,8 @@ public static RequestHandler<TRequest, TResponse> UseRequestTracing<TRequest, TR
public static RequestHandler<TRequest, TResponse> UseRequestTracing<TRequest, TResponse>(
this RequestHandler<TRequest, TResponse> handler,
Action<RequestTracingOptions<TRequest, TResponse>> configureOptions)
where TRequest : class
where TRequest : notnull
where TResponse : notnull
{
ArgumentNullException.ThrowIfNull(handler);
ArgumentNullException.ThrowIfNull(configureOptions);
Expand All @@ -67,7 +69,8 @@ public static RequestHandler<TRequest, TResponse> UseRequestTracing<TRequest, TR
/// </remarks>
public static RequestHandler<TRequest, TResponse> UseRequestMetrics<TRequest, TResponse>(
this RequestHandler<TRequest, TResponse> handler)
where TRequest : class
where TRequest : notnull
where TResponse : notnull
{
ArgumentNullException.ThrowIfNull(handler);
return handler.Use<RequestMetricsMiddleware<TRequest, TResponse>>();
Expand All @@ -88,7 +91,8 @@ public static RequestHandler<TRequest, TResponse> UseRequestMetrics<TRequest, TR
public static RequestHandler<TRequest, TResponse> UseRequestMetrics<TRequest, TResponse>(
this RequestHandler<TRequest, TResponse> handler,
Action<RequestMetricsOptions<TRequest, TResponse>> configureOptions)
where TRequest : class
where TRequest : notnull
where TResponse : notnull
{
ArgumentNullException.ThrowIfNull(handler);
ArgumentNullException.ThrowIfNull(configureOptions);
Expand All @@ -107,7 +111,8 @@ public static RequestHandler<TRequest, TResponse> UseRequestMetrics<TRequest, TR
/// <returns>The same <see cref="RequestHandler{TRequest, TResponse}"/> for chaining.</returns>
public static RequestHandler<TRequest, TResponse> UseRequestDiagnostics<TRequest, TResponse>(
this RequestHandler<TRequest, TResponse> handler)
where TRequest : class
where TRequest : notnull
where TResponse : notnull
{
ArgumentNullException.ThrowIfNull(handler);
return handler
Expand All @@ -129,7 +134,8 @@ public static RequestHandler<TRequest, TResponse> UseRequestDiagnostics<TRequest
this RequestHandler<TRequest, TResponse> handler,
Action<RequestTracingOptions<TRequest, TResponse>> configureTracingOptions,
Action<RequestMetricsOptions<TRequest, TResponse>> configureMetricsOptions)
where TRequest : class
where TRequest : notnull
where TResponse : notnull
{
ArgumentNullException.ThrowIfNull(handler);
ArgumentNullException.ThrowIfNull(configureTracingOptions);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
namespace Plumber.Diagnostics;

internal sealed class RequestMetricsMiddleware<TRequest, TResponse>
where TRequest : class
where TRequest : notnull
where TResponse : notnull
{
private static readonly Meter Meter = new(PlumberDiagnostics.MeterName);

Expand Down
3 changes: 2 additions & 1 deletion src/Plumber.Diagnostics/RequestMetricsOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ namespace Plumber.Diagnostics;
/// <typeparam name="TRequest">The pipeline request type.</typeparam>
/// <typeparam name="TResponse">The pipeline response type.</typeparam>
public sealed class RequestMetricsOptions<TRequest, TResponse>
where TRequest : class
where TRequest : notnull
where TResponse : notnull
{
/// <summary>
/// ThrowOnException lets you set whether the middleware should rethrow exceptions thrown by downstream middleware components.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
namespace Plumber.Diagnostics;

internal sealed class RequestTracingMiddleware<TRequest, TResponse>
where TRequest : class
where TRequest : notnull
where TResponse : notnull
{
private static readonly ActivitySource ActivitySource = new(PlumberDiagnostics.ActivitySourceName);

Expand Down
3 changes: 2 additions & 1 deletion src/Plumber.Diagnostics/RequestTracingOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ namespace Plumber.Diagnostics;
/// <typeparam name="TRequest">The pipeline request type.</typeparam>
/// <typeparam name="TResponse">The pipeline response type.</typeparam>
public sealed class RequestTracingOptions<TRequest, TResponse>
where TRequest : class
where TRequest : notnull
where TResponse : notnull
{
private const string DefaultOperationName = "Plumber.HandleRequest";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ public static IServiceCollection AddPlumberDiagnostics<TRequest, TResponse>(
this IServiceCollection services,
Action<RequestTracingOptions<TRequest, TResponse>>? configureTracing = null,
Action<RequestMetricsOptions<TRequest, TResponse>>? configureMetrics = null)
where TRequest : class
where TRequest : notnull
where TResponse : notnull
{
ArgumentNullException.ThrowIfNull(services);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ public static class RequestHandlerSerilogExtensions
/// </remarks>
public static RequestHandler<TRequest, TResponse> UseSerilogRequestLogging<TRequest, TResponse>(
this RequestHandler<TRequest, TResponse> handler)
where TRequest : class
where TRequest : notnull
where TResponse : notnull
{
ArgumentNullException.ThrowIfNull(handler);
return handler.Use<RequestLoggerMiddleware<TRequest, TResponse>>();
Expand All @@ -42,7 +43,8 @@ public static RequestHandler<TRequest, TResponse> UseSerilogRequestLogging<TRequ
public static RequestHandler<TRequest, TResponse> UseSerilogRequestLogging<TRequest, TResponse>(
this RequestHandler<TRequest, TResponse> handler,
Action<RequestLoggerOptions<TRequest, TResponse>> configureOptions)
where TRequest : class
where TRequest : notnull
where TResponse : notnull
{
ArgumentNullException.ThrowIfNull(handler);
ArgumentNullException.ThrowIfNull(configureOptions);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
namespace Plumber.Serilog.Extensions;

internal sealed class RequestLoggerMiddleware<TRequest, TResponse>
where TRequest : class
where TRequest : notnull
where TResponse : notnull
{
private readonly RequestMiddleware<TRequest, TResponse> next;
private readonly DiagnosticContext diagnosticContext;
Expand Down
3 changes: 2 additions & 1 deletion src/Plumber.Serilog.Extensions/RequestLoggerOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ namespace Plumber.Serilog.Extensions;
/// <typeparam name="TRequest">The pipeline request type.</typeparam>
/// <typeparam name="TResponse">The pipeline response type.</typeparam>
public sealed class RequestLoggerOptions<TRequest, TResponse>
where TRequest : class
where TRequest : notnull
where TResponse : notnull
{
private const string DefaultCompletedMessage =
"Request {RequestId} completed in {Elapsed:0.0000} ms";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ public static IServiceCollection AddSerilogRequestLogging<TRequest, TResponse>(
this IServiceCollection services,
Action<LoggerConfiguration> configureLogger,
Action<RequestLoggerOptions<TRequest, TResponse>>? configureOptions = null)
where TRequest : class
where TRequest : notnull
where TResponse : notnull
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureLogger);
Expand Down
5 changes: 3 additions & 2 deletions src/Plumber.Testing/PlumberApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ namespace Plumber.Testing;
/// <typeparam name="TResponse">The type of response handled by the pipeline.</typeparam>
public sealed class PlumberApplicationFactory<TRequest, TResponse> : IDisposable, IAsyncDisposable
where TRequest : notnull
where TResponse : notnull
{
private readonly string[] args;
private readonly Func<string[], RequestHandlerBuilder<TRequest, TResponse>> createBuilder;
Expand Down Expand Up @@ -188,8 +189,8 @@ private RequestHandler<TRequest, TResponse> BuildHandler()
/// </summary>
/// <param name="request">The request value flowed through the pipeline.</param>
/// <param name="cancellationToken">Caller-supplied cancellation token forwarded to <see cref="RequestHandler{TRequest, TResponse}.InvokeAsync(TRequest, CancellationToken)"/>.</param>
/// <returns>A task that completes with the pipeline's response, or <see langword="null"/> if no middleware assigned <c>Response</c>.</returns>
public Task<TResponse?> InvokeAsync(TRequest request, CancellationToken cancellationToken = default) =>
/// <returns>A task that completes with the pipeline's response.</returns>
public Task<TResponse> InvokeAsync(TRequest request, CancellationToken cancellationToken = default) =>
CreateHandler().InvokeAsync(request, cancellationToken);

/// <inheritdoc/>
Expand Down
5 changes: 3 additions & 2 deletions src/Plumber/RequestContext{TRequest, TResponse}.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public sealed class RequestContext<TRequest, TResponse>(
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
Expand Down Expand Up @@ -100,9 +101,9 @@ public bool TryGetValue<T>(string key, [NotNullWhen(true)] out T? item)
}

/// <summary>
/// The response.
/// The response. Initialized to <see langword="default"/> and expected to be assigned by middleware before the pipeline returns.
/// </summary>
public TResponse? Response { get; set; }
public TResponse Response { get; set; } = default!;

/// <summary>
/// Time since the request was created, measured against the monotonic clock exposed by the configured <see cref="TimeProvider"/>.
Expand Down
4 changes: 3 additions & 1 deletion src/Plumber/RequestHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ public static class RequestHandler
/// <para>If a <see cref="TimeProvider"/> is registered in <paramref name="services"/> it is used; otherwise <see cref="TimeProvider.System"/> is used.</para>
/// </remarks>
public static RequestHandler<TRequest, TResponse> Create<TRequest, TResponse>(IServiceProvider services)
where TRequest : notnull =>
where TRequest : notnull
where TResponse : notnull =>
Create<TRequest, TResponse>(services, Timeout.InfiniteTimeSpan);

/// <summary>
Expand All @@ -40,6 +41,7 @@ public static RequestHandler<TRequest, TResponse> Create<TRequest, TResponse>(IS
/// </remarks>
public static RequestHandler<TRequest, TResponse> Create<TRequest, TResponse>(IServiceProvider services, TimeSpan timeout)
where TRequest : notnull
where TResponse : notnull
{
ArgumentNullException.ThrowIfNull(services);
return new RequestHandler<TRequest, TResponse>(services, timeout);
Expand Down
6 changes: 4 additions & 2 deletions src/Plumber/RequestHandlerBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ public static class RequestHandlerBuilder
/// <typeparam name="TResponse">The type of response handled by the pipeline.</typeparam>
/// <returns>A new <see cref="RequestHandlerBuilder{TRequest, TResponse}"/>.</returns>
public static RequestHandlerBuilder<TRequest, TResponse> Create<TRequest, TResponse>()
where TRequest : notnull =>
where TRequest : notnull
where TResponse : notnull =>
new([]);

/// <summary>
Expand All @@ -29,6 +30,7 @@ public static RequestHandlerBuilder<TRequest, TResponse> Create<TRequest, TRespo
/// <param name="args">Program args passed into <c>Main</c>. Appended to the per-build configuration via <see cref="CommandLineConfigurationExtensions.AddCommandLine(IConfigurationBuilder, string[])"/> at the end of <see cref="RequestHandlerBuilder{TRequest, TResponse}.Build()"/>.</param>
/// <returns>A new <see cref="RequestHandlerBuilder{TRequest, TResponse}"/>.</returns>
public static RequestHandlerBuilder<TRequest, TResponse> Create<TRequest, TResponse>(string[] args)
where TRequest : notnull =>
where TRequest : notnull
where TResponse : notnull =>
new(args);
}
1 change: 1 addition & 0 deletions src/Plumber/RequestHandlerBuilder{TRequest, TResponse}.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ namespace Plumber;
/// <typeparam name="TResponse">The type of response handled by the pipeline.</typeparam>
public sealed class RequestHandlerBuilder<TRequest, TResponse>
where TRequest : notnull
where TResponse : notnull
{
private const string ProductionEnv = "Production";
private readonly string[] args;
Expand Down
15 changes: 8 additions & 7 deletions src/Plumber/RequestHandler{TRequest, TResponse}.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public sealed class RequestHandler<TRequest, TResponse>
: IDisposable
, IAsyncDisposable
where TRequest : notnull
where TResponse : notnull
{
private readonly List<Func<RequestMiddleware<TRequest, TResponse>, RequestMiddleware<TRequest, TResponse>>> components = [];
private readonly List<MiddlewareDescriptor> descriptors = [];
Expand Down Expand Up @@ -99,14 +100,14 @@ internal RequestHandler(
/// Invokes the request handler's pipeline.
/// </summary>
/// <param name="request">The request value flowed through the pipeline as <see cref="RequestContext{TRequest, TResponse}.Request"/>.</param>
/// <returns>A task that completes with the value of <see cref="RequestContext{TRequest, TResponse}.Response"/> after the pipeline returns; <see langword="null"/> if no middleware assigned <c>Response</c>.</returns>
/// <returns>A task that completes with the value of <see cref="RequestContext{TRequest, TResponse}.Response"/> after the pipeline returns.</returns>
/// <remarks>
/// Each invocation creates a new DI scope; <see cref="RequestContext{TRequest, TResponse}.Services"/> is the per-request scoped provider and is disposed when the pipeline returns.
/// Consumers of <c>RequestContext.Services</c> do not need to call <c>CreateScope()</c>.
/// </remarks>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="request"/> is <see langword="null"/>.</exception>
/// <exception cref="TimeoutException">Thrown when <see cref="Timeout"/> elapses before the pipeline completes.</exception>
public Task<TResponse?> InvokeAsync(TRequest request)
public Task<TResponse> InvokeAsync(TRequest request)
{
ThrowIfRequestNull(request);
return ThrowIfDisposed()
Expand All @@ -120,7 +121,7 @@ internal RequestHandler(
/// </summary>
/// <param name="request">The request value flowed through the pipeline as <see cref="RequestContext{TRequest, TResponse}.Request"/>.</param>
/// <param name="cancellationToken">Caller-supplied cancellation token. Linked with the internal timeout source when <see cref="Timeout"/> is finite.</param>
/// <returns>A task that completes with the value of <see cref="RequestContext{TRequest, TResponse}.Response"/> after the pipeline returns; <see langword="null"/> if no middleware assigned <c>Response</c>.</returns>
/// <returns>A task that completes with the value of <see cref="RequestContext{TRequest, TResponse}.Response"/> after the pipeline returns.</returns>
/// <remarks>
/// Each invocation creates a new DI scope; <see cref="RequestContext{TRequest, TResponse}.Services"/> is the per-request scoped provider and is disposed when the pipeline returns.
/// Consumers of <c>RequestContext.Services</c> do not need to call <c>CreateScope()</c>.
Expand All @@ -129,7 +130,7 @@ internal RequestHandler(
/// <exception cref="ArgumentNullException">Thrown when <paramref name="request"/> is <see langword="null"/>.</exception>
/// <exception cref="TimeoutException">Thrown when <see cref="Timeout"/> elapses before the pipeline completes and <paramref name="cancellationToken"/> was not cancelled.</exception>
/// <exception cref="OperationCanceledException">Thrown when <paramref name="cancellationToken"/> is cancelled.</exception>
public Task<TResponse?> InvokeAsync(TRequest request, CancellationToken cancellationToken)
public Task<TResponse> InvokeAsync(TRequest request, CancellationToken cancellationToken)
{
ThrowIfRequestNull(request);
return ThrowIfDisposed()
Expand Down Expand Up @@ -273,7 +274,7 @@ private RequestHandler<TRequest, TResponse> Use(
return this;
}

private async Task<TResponse?> InvokeInternalAsync(TRequest request, TimeSpan timeout)
private async Task<TResponse> InvokeInternalAsync(TRequest request, TimeSpan timeout)
{
using var timeoutTokenSource = new CancellationTokenSource(timeout, timeProvider);
try
Expand All @@ -286,7 +287,7 @@ private RequestHandler<TRequest, TResponse> Use(
}
}

private async Task<TResponse?> InvokeInternalAsync(TRequest request, TimeSpan timeout, CancellationToken cancellationToken)
private async Task<TResponse> InvokeInternalAsync(TRequest request, TimeSpan timeout, CancellationToken cancellationToken)
{
using var timeoutTokenSource = new CancellationTokenSource(timeout, timeProvider);
using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(
Expand All @@ -303,7 +304,7 @@ private RequestHandler<TRequest, TResponse> Use(
}
}

private async Task<TResponse?> InvokeInternalAsync(TRequest request, CancellationToken cancellationToken)
private async Task<TResponse> InvokeInternalAsync(TRequest request, CancellationToken cancellationToken)
{
await using var serviceScope = Services.CreateAsyncScope();
var context = new RequestContext<TRequest, TResponse>(
Expand Down
3 changes: 2 additions & 1 deletion src/Plumber/RequestMiddleware{TRequest, TResponse}.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@
/// <returns>A <see cref="Task"/> that completes when this middleware (and any downstream middleware it calls) finishes.</returns>
public delegate Task RequestMiddleware<TRequest, TResponse>(
RequestContext<TRequest, TResponse> context)
where TRequest : notnull;
where TRequest : notnull
where TResponse : notnull;
Loading