diff --git a/README.md b/README.md index fe149b7..0dabd7a 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ # RequestFlow -A small, fast request/handler library for .NET. You define a request and its handler, register them with one call, and dispatch through a single interface. Handler lookup is validated at startup and served from a frozen map, so nothing on the dispatch path uses reflection. - -Composable stages for cross-cutting concerns (validation, logging, authorization) are the next planned piece: a request will flow through its stages, then into the handler, and the response back out. They are not in the current preview. +A small, fast request/handler library for .NET. You define a request and its handler, register them with one call, and dispatch through a single interface. All the wiring happens at runtime, once at startup, with no compiler plugin and no build-time code generation: if a project can reference a NuGet package, it can run RequestFlow. The core library stays unopinionated about how you name your requests. If you want a type-level split between commands and queries for CQRS- and DDD-style apps, install `RequestFlow.Cqrs` instead; it already contains the core package. +[![NuGet](https://img.shields.io/nuget/vpre/RequestFlow?label=nuget)](https://www.nuget.org/packages/RequestFlow) +[![Downloads](https://img.shields.io/nuget/dt/RequestFlow?label=downloads)](https://www.nuget.org/packages/RequestFlow) +[![CI](https://github.com/illia1f/RequestFlow/actions/workflows/ci.yml/badge.svg)](https://github.com/illia1f/RequestFlow/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/illia1f/RequestFlow/blob/main/LICENSE) ![Status](https://img.shields.io/badge/status-preview-orange) ![Targets](https://img.shields.io/badge/targets-netstandard2.0%20%7C%20net462%20%7C%20net8.0%20%7C%20net10.0-512BD4) -> **Status:** preview on NuGet. The request/handler core, startup validation, and the CQRS package are live; stages are still in development. Install with the `--prerelease` flag: +> **Status:** [preview on NuGet](https://www.nuget.org/packages/RequestFlow). Install with the `--prerelease` flag: > > ``` > dotnet add package RequestFlow --prerelease @@ -18,20 +19,36 @@ The core library stays unopinionated about how you name your requests. If you wa ## Why -[MediatR](https://github.com/LuckyPennySoftware/MediatR) went commercial in 2025, and it had been the default for this kind of work for years. The free options that remain fall into two camps. Some are general-purpose mediators that treat every message the same, with no distinction between a command and a query. Others are older and narrower, like Microsoft's [CQRS.Mediatr.Lite](https://github.com/microsoft/CQRS.Mediatr.Lite), which last shipped in 2021 and solved one team's problem before going quiet. +[MediatR](https://github.com/LuckyPennySoftware/MediatR) went commercial in 2025, and the search for a replacement now turns up a crowded field of free mediators. Many of the fastest are built on source generators: compiler plugins that write the dispatch code during your build. That buys speed and compile-time checks. It also ties the library to your toolchain: a recent compiler, `PackageReference`, analyzers left on, and generated code in every build. + +RequestFlow trades those requirements away and keeps everything at runtime. + +- Errors surface at startup, not in production. Discovery, validation, and the dispatch plan all finish before the first request, and a broken configuration fails the boot with one exception listing every problem. After that, dispatch is one dictionary lookup with no reflection, LINQ, or locking. +- No build step. Nothing runs inside your compiler, and there is no generated code to step through when something misbehaves. One package behaves the same from .NET 10 down to .NET Framework 4.6.2. +- MIT, permanently. This library exists because a license changed underneath its users once. It takes no dependency whose license could do the same. +- Migration is mostly renames. Requests and handlers keep their shape coming from MediatR; the mapping table below covers a typical codebase. + +Fast is a claim to prove, not to assert. A BenchmarkDotNet suite against the other mediators, raw artifacts included, is on the [roadmap](ROADMAP.md) before v1. Until it lands, this README quotes no numbers. + +## Coming from MediatR -None of them pair low-allocation dispatch with a command/query split the type system enforces. RequestFlow aims at that gap: +| MediatR | RequestFlow | +| ---------------------------------------------------- | ----------------------------------------------------- | +| `IRequest`, `IRequest` | same names, `RequestFlow` namespace | +| `IRequestHandler` with `Handle` | same interface, method is `HandleAsync` | +| `IMediator.Send(...)` | `IRequestDispatcher.SendAsync(...)` | +| void requests through `Unit` | void handlers return plain `Task`, no `Unit` anywhere | +| `IPipelineBehavior<,>` | `IRequestStage<,>` | +| `services.AddMediatR(...)` | `services.AddRequestFlow(...)` | -1. No reflection on the hot path; handler lookup is cached. -2. CQRS as an opt-in package, not a convention. -3. Stage pipeline composed once, no LINQ in dispatch (planned, not in the current preview). +What doesn't move yet: notifications (`INotification` / `Publish`) and streaming. Both are on the [roadmap](ROADMAP.md) for after v1.0. Notifications return as events, an in-process publish/subscribe (`IEvent`, `IEventHandler`, `IEventPublisher`); streaming arrives through `IAsyncEnumerable`. Neither is built today, so if your codebase leans on either, hold the migration until they land. ## Packages -- **`RequestFlow.Abstractions`** holds the contracts: `IRequest`, `IRequestHandler`, `IRequestDispatcher`, `NoResult`. Depends on nothing. `IRequestStage` joins this package when stages ship. -- **`RequestFlow`** is the runtime: dispatcher, `AddRequestFlow` with assembly scanning, startup validation. Depends on Abstractions and `Microsoft.Extensions.DependencyInjection.Abstractions`. -- **`RequestFlow.Cqrs.Abstractions`** holds the CQRS contracts: `ICommand`, `IQuery`, their handler interfaces, `ICommandDispatcher`, `IQueryDispatcher`. Depends on `RequestFlow.Abstractions` only. -- **`RequestFlow.Cqrs`** is the CQRS runtime: typed dispatcher implementations, registered with `AddRequestFlow(...).AddCqrs()`. Depends on the contracts package and the core runtime. +- **[`RequestFlow.Abstractions`](https://www.nuget.org/packages/RequestFlow.Abstractions)** holds the contracts: `IRequest`, `IRequestHandler`, `IRequestDispatcher`, `IRequestStage`, `NoResult`. Depends on nothing. +- **[`RequestFlow`](https://www.nuget.org/packages/RequestFlow)** is the runtime: dispatcher, `AddRequestFlow` with assembly scanning, startup validation. Depends on Abstractions and `Microsoft.Extensions.DependencyInjection.Abstractions`. +- **[`RequestFlow.Cqrs.Abstractions`](https://www.nuget.org/packages/RequestFlow.Cqrs.Abstractions)** holds the CQRS contracts: `ICommand`, `IQuery`, their handler interfaces, `ICommandDispatcher`, `IQueryDispatcher`. Depends on `RequestFlow.Abstractions` only. +- **[`RequestFlow.Cqrs`](https://www.nuget.org/packages/RequestFlow.Cqrs)** is the CQRS runtime: typed dispatcher implementations, registered with `AddRequestFlow(...).AddCqrs()`. Depends on the contracts package and the core runtime. Contracts live in their own packages so your domain layer, and any future add-on package, can reference the interfaces without taking a dependency on a runtime. Install a runtime package at the composition root and the matching contracts arrive transitively. Core types share the `RequestFlow` namespace; the CQRS types live in `RequestFlow.Cqrs`. @@ -39,6 +56,7 @@ Contracts live in their own packages so your domain layer, and any future add-on - [Getting started](https://github.com/illia1f/RequestFlow/blob/main/docs/getting-started.md): install, first request and handler, dispatching - [Registration](https://github.com/illia1f/RequestFlow/blob/main/docs/registration.md): every `AddRequestFlow` option, scanning, generic handlers, startup validation +- [Stages](https://github.com/illia1f/RequestFlow/blob/main/docs/stages.md): wrapping handlers, execution order, which requests a stage reaches, filters - [Service lifetimes](https://github.com/illia1f/RequestFlow/blob/main/docs/lifetimes.md): what RequestFlow registers, with which lifetime, and what you can change - [Exceptions](https://github.com/illia1f/RequestFlow/blob/main/docs/exceptions.md): every exception RequestFlow throws, when it surfaces, and how to fix it diff --git a/ROADMAP.md b/ROADMAP.md index 58028bc..a409ece 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,19 +8,17 @@ Deliberately minimal: request/response dispatch, the stage pipeline, and the CQR - [x] Core abstractions: `IRequest`, `IRequestHandler<,>`, `NoResult` - [x] `IRequestDispatcher` and the dispatcher over a frozen dispatch map -- [ ] `IRequestStage` with open, constrained, and closed generic registration +- [x] `IRequestStage` with open, constrained, and closed generic registration - [x] CQRS layer: `ICommand`/`IQuery` and handler contracts in `RequestFlow.Cqrs.Abstractions`, typed dispatchers and `AddCqrs` registration in `RequestFlow.Cqrs` - [x] `AddRequestFlow` registration with assembly scanning and generic handler closings - [x] Exceptions and startup validation (`ValidateRequestFlow`) -- [ ] NuGet publish and package ID prefix reservation +- [x] NuGet publish: all four packages are up at `1.0.0-preview.1` +- [ ] `RequestFlow.*` package ID prefix reservation +- [ ] Benchmark suite: BenchmarkDotNet against MediatR, martinothamar/Mediator, and LiteBus as pinned package references, raw artifacts committed -Targets: `netstandard2.0;net462;net8.0;net10.0`. +Targets: `netstandard2.0;net462;net8.0;net10.0`. The published `1.0.0-preview.1` predates the net462 target, so that one first ships in the next preview. ## v1.x +- Events: in-process publish/subscribe (`IEvent`, `IEventHandler`, `IEventPublisher`), RequestFlow's answer to MediatR notifications. Delivery, ordering, concurrency, failure, and cancellation semantics get written down before any code. - Streaming requests via `IAsyncEnumerable`. - -## Under consideration - -- Events: in-process publish/subscribe (`IEvent`, `IEventHandler`, `IEventPublisher`). Not planned for now; would be additive if it ever happens. -- Native adapter packages for specific DI containers, if anyone asks. diff --git a/docs/exceptions.md b/docs/exceptions.md index 2abed9d..e9eea53 100644 --- a/docs/exceptions.md +++ b/docs/exceptions.md @@ -9,6 +9,8 @@ Every exception RequestFlow throws, when it surfaces, and how to fix it. | `RequestFlowValidationException` | Startup validation | Any registration problem; one throw lists all of them | | `HandlerNotFoundException` | `SendAsync` | The dispatched request type has no registered handler | | `ResponseTypeMismatchException` | `SendAsync` | The call site's response type differs from the registered one | +| `InvalidOperationException` | `SendAsync` | A handler or stage returned a null task, or a stage overlapped two `next` calls | +| `InvalidOperationException` | `WhereHandlerImplements` | A second handler filter added to one stage | | `ArgumentNullException` | All public entry points | A required argument is null | | `ArgumentException` | `RegisterGenericHandler` | `closingTypes` contains a null element | @@ -30,6 +32,19 @@ Thrown when RequestFlow validates everything registered: the first time a dispat | `Closing type '...' ... is not a closed type.` | An open generic passed as a closing type | Close it first: `typeof(Audit)`, not `typeof(Audit<>)` | | `Generic handler '...' cannot be closed over '...'...` | The closing type violates the handler's generic constraints | Pick a closing type that satisfies the `where` clauses | +Stages registered with `AddStage` bring their own checks (see [stages.md](stages.md)); their problems land in the same exception: + +| Problem message starts with | Cause | Fix | +| -------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `'...' is an interface; only concrete stage classes...` | An interface passed to `AddStage` | Register the implementing class | +| `'...' is abstract; only concrete stage classes...` | An abstract class passed to `AddStage` | Register a concrete stage class | +| `'...' is partially closed...` | A stage type with some type parameters bound and some open | Pass the open definition or a fully closed type | +| `'...' does not implement IRequestStage...` | The registered type is not a stage | Implement `IRequestStage` or `IRequestStage` | +| `'...' declares generic parameters <...> that its IRequestStage implementation does not use...` | An open generic stage whose contract does not name its own parameters as the request | Implement the contract with the stage's own parameters, request first | +| `Stage '...' from assembly '...' is registered more than once...` | The same stage type in two `AddStage` calls | Remove the duplicate; a handler filter does not make it distinct | +| `Stages '...' and '...' both resolve to '...'` / `Stages '...' and '...' are the same stage class...` | An open definition registered next to its own closed form, or two closings of one class reaching the same request | Remove one of the two `AddStage` calls | +| `Stage '...' from assembly '...' applies to no registered request...` | `DisallowUnusedStages` is on and the stage reached nothing | Widen its constraints, scan the assembly holding its requests, or drop the opt-in | + Example: a contracts assembly scanned without its handlers fails at startup, not per request. ```csharp @@ -112,6 +127,19 @@ public sealed class SyncInventory : IRequest, IRequest { } With a handler registered as `IRequestHandler`, the natural call `SendAsync(new SyncInventory())` cannot infer `TResponse` from two candidate interfaces. It silently binds the void `SendAsync(IRequest)` overload, asks for `NoResult`, and throws. The fix belongs in the model, not the call site: give each request type exactly one `IRequest` interface, and split it in two if both shapes are needed. +## InvalidOperationException + +Plain `InvalidOperationException` signals a broken handler or stage contract. Three cases surface at dispatch, one at registration: + +| Message starts with | Thrown from | Fix | +| ----------------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------- | +| `The handler for '...' returned a null task from HandleAsync.` | `SendAsync` | Return a task from every path; use `Task.CompletedTask` or `Task.FromResult` for synchronous results | +| `Stage '...' returned a null task from HandleAsync...` | `SendAsync` | Return the task from `next`, or a completed task when short-circuiting | +| `Stage '...' called next while the task from its earlier call was still running.` | `SendAsync` | Await each `next` call before calling it again; each call runs the rest of the chain | +| `This stage already filters on '...'` | `AddStage` configure delegate | One `WhereHandlerImplements` per stage; give the target handlers one shared contract | + +The null-task checks exist so the failure names the handler or stage at fault instead of surfacing as a `NullReferenceException` at the await. The overlap check stops a stage from running the rest of the chain twice at the same time; a sequential second call, the retry shape, is allowed (see [stages.md](stages.md)). + ## Argument validation Argument checks at the public surface throw immediately at the call site: @@ -123,11 +151,12 @@ Argument checks at the public surface throw immediately at the call site: | `RegisterHandlersFromAssembly` | `ArgumentNullException` | `assembly` is null | | `RegisterGenericHandler` | `ArgumentNullException` | `handlerType` or `closingTypes` is null | | `RegisterGenericHandler` | `ArgumentException` | `closingTypes` contains a null element | +| `AddStage` | `ArgumentNullException` | `stageType` is null | | `ValidateRequestFlow` | `ArgumentNullException` | `provider` is null | ## What RequestFlow never wraps -Handler exceptions propagate as thrown. The dispatcher adds no try/catch and no wrapper exception, so `await dispatcher.SendAsync(...)` observes exactly what `HandleAsync` threw. +Handler and stage exceptions propagate as thrown. The dispatcher and the stage chain add no try/catch and no wrapper exception, so `await dispatcher.SendAsync(...)` observes exactly what the failing `HandleAsync` threw. A stage that wants to translate exceptions does so itself, in a try/catch around `next`. Cancellation follows the same rule. The token passes to `HandleAsync` untouched, and an `OperationCanceledException` surfaces from the handler like any other exception. diff --git a/docs/registration.md b/docs/registration.md index 96d01a0..4667d53 100644 --- a/docs/registration.md +++ b/docs/registration.md @@ -17,6 +17,8 @@ services.AddRequestFlow(o => o | `RegisterHandlersFromAssemblyContaining()` | Scans the assembly containing `T` | | `RegisterHandlersFromAssembly(assembly)` | Scans the given assembly | | `RegisterGenericHandler(handlerType, ...)` | Closes an open generic handler over the declared types | +| `AddStage(stageType, configure?)` | Wraps applicable handlers in a stage (see [stages.md](stages.md)) | +| `DisallowUnusedStages()` | Fails startup validation when a stage reaches no request (see [stages.md](stages.md)) | | `AllowUnhandledRequests()` | Skips the missing-handler check at startup validation | | `WithHandlerLifetime(lifetime)` | Lifetime for this call's handlers, transient by default (see [lifetimes.md](lifetimes.md)) | | `WithTransientDispatcher()` | Registers the dispatcher transient instead of scoped (see [lifetimes.md](lifetimes.md)) | diff --git a/docs/stages.md b/docs/stages.md new file mode 100644 index 0000000..305e29f --- /dev/null +++ b/docs/stages.md @@ -0,0 +1,146 @@ +# Stages + +A stage wraps the handler of every request it applies to: code before and after the handler, a replaced response, or no handler call at all. Coming from MediatR, `IRequestStage` is the `IPipelineBehavior` counterpart. + +## Writing a stage + +Implement `IRequestStage`. The `next` delegate runs the rest of the chain, ending at the handler: + +```csharp +using RequestFlow; + +public sealed class LoggingStage : IRequestStage + where TRequest : IRequest +{ + public async Task HandleAsync( + TRequest request, StageDelegate next, CancellationToken cancellationToken) + { + Console.WriteLine($"Handling {typeof(TRequest).Name}"); + TResponse response = await next(); + Console.WriteLine($"Handled {typeof(TRequest).Name}"); + return response; + } +} +``` + +A stage has three ways to use `next`: + +- Await it once and return its result: the normal pass-through. +- Return without calling it to short-circuit. The handler, and every stage inside this one, never runs. +- Call it again after its task completes to run the rest of the chain again, the shape of a retry stage. A repeated call walks the same stage instances resolved for the dispatch, so state a stage kept from the first pass is still there. Calling `next` while an earlier call is still running throws `InvalidOperationException`. + +## Registering + +`AddStage` chains on the same configure delegate as the scanning options. Registration order is execution order, outermost first: + +```csharp +services.AddRequestFlow(o => o + .RegisterHandlersFromAssemblyContaining() + .AddStage(typeof(LoggingStage<,>)) + .AddStage(typeof(ValidationStage<,>))); +``` + +Every request both stages apply to runs logging, then validation, then its handler. A closed stage type can also register through the generic form: `AddStage()`. + +One stage type belongs to a chain once. Registering the same type twice fails startup validation, whatever the two calls filtered on. So does a pair of declarations that reach one request as the same stage class, an open definition next to its own closed form for example. `AddStage` calls from separate `AddRequestFlow` calls combine into one chain, in call order, and validate together (see [registration.md](registration.md) on additive calls). + +## Which requests a stage reaches + +An open generic stage applies to every request its own generic constraints admit. Constrain `TRequest` and the chain follows: + +```csharp +public interface IAudited +{ } + +public sealed class AuditStage : IRequestStage + where TRequest : IRequest, IAudited +{ + public async Task HandleAsync( + TRequest request, StageDelegate next, CancellationToken cancellationToken) + { + TResponse response = await next(); + // write the audit record + return response; + } +} +``` + +`AuditStage` wraps every request that implements `IAudited` and no others. There is no list of types to maintain next to the registration; the constraints are checked once, at startup. + +A closed stage targets the request contract it names. `TRequest` is contravariant, so a stage closed over a base request type also wraps the requests that derive from it. + +A stage can also declare one type parameter and fix the response, the shape codebases with a shared result type use: + +```csharp +public sealed class ErrorTranslationStage : IRequestStage + where TRequest : IRequest +{ + public async Task HandleAsync( + TRequest request, StageDelegate next, CancellationToken cancellationToken) + { + try + { + return await next(); + } + catch (DomainException e) + { + return Result.Fail(e.Message); + } + } +} +``` + +It wraps every request that returns `Result` and nothing else. + +### Filtering on a handler contract + +`WhereHandlerImplements` narrows a stage to requests whose handler implements a contract: + +```csharp +services.AddRequestFlow(o => o + .RegisterHandlersFromAssemblyContaining() + .AddStage(typeof(TransactionStage<,>), s => s.WhereHandlerImplements())); +``` + +The filter looks at the handler class, not the request, so a module can mark its write handlers with one empty interface and wrap them all. A stage takes one filter; a second `WhereHandlerImplements` call throws. + +## Void requests + +A stage for void requests implements `IRequestStage`, takes the parameterless `StageDelegate`, and returns plain `Task`: + +```csharp +public sealed class CacheClearGuard : IRequestStage +{ + public Task HandleAsync(ClearCache request, StageDelegate next, CancellationToken cancellationToken) + => next(); +} +``` + +Both forms mix in one chain, in registration order. An open two-parameter stage whose constraints admit a void request wraps it too, with `NoResult` as the response. + +## Unused stages + +A stage that reaches no registered request is a silent no-op by default. `DisallowUnusedStages` makes it a startup validation problem instead: + +```csharp +services.AddRequestFlow(o => o + .RegisterHandlersFromAssemblyContaining() + .AddStage(typeof(AuditStage<,>)) + .DisallowUnusedStages()); +``` + +The setting is sticky, like `AllowUnhandledRequests`: once any call opts in, every registered stage is checked. + +## Lifetime + +RequestFlow registers each closed stage type transient and resolves the instances on every dispatch, so a stage can hold per-dispatch state. To pick a different lifetime, register the closed stage type yourself before calling `AddRequestFlow`; a stage type already in the collection is left alone. [lifetimes.md](lifetimes.md) covers the wider lifetime picture. + +## Validation + +Stage problems surface with every other registration problem, in the one `RequestFlowValidationException` thrown at first dispatcher resolution or at `ValidateRequestFlow`. The checks: + +- The stage type implements `IRequestStage` or `IRequestStage` and is a concrete class. +- An open generic stage uses its own type parameters as its contract's request, so it can close over the requests it dispatches with. +- A partially closed generic is rejected; register the open definition or a fully closed type. +- No stage type is registered twice, and no two declarations reach one request as the same stage class. +- With `DisallowUnusedStages`, every stage reaches at least one request. diff --git a/src/RequestFlow.Abstractions/IRequestStage.cs b/src/RequestFlow.Abstractions/IRequestStage.cs new file mode 100644 index 0000000..42fc6a9 --- /dev/null +++ b/src/RequestFlow.Abstractions/IRequestStage.cs @@ -0,0 +1,52 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace RequestFlow; + +/// +/// Runs the rest of the stage chain, ending at the request's handler. Call it again after +/// its task completes to run the rest of the chain again; calling it while an earlier call +/// is still running throws . +/// +/// +/// A repeated call walks the same stage instances: the chain resolves them once per +/// dispatch, not per call, so state a stage kept from the first pass is still there. +/// +/// The response the chain produces. +public delegate Task StageDelegate(); + +/// +/// The void form of , under the same rules. +/// +public delegate Task StageDelegate(); + +/// +/// Runs around the handler of every request this stage applies to. The implementing +/// class's generic constraints decide which requests those are. +/// +/// The request the stage wraps. +/// The response the wrapped handler produces. +public interface IRequestStage + where TRequest : IRequest +{ + /// + /// Wraps the rest of the chain for . Call + /// to continue, or skip it to short-circuit. + /// + Task HandleAsync(TRequest request, StageDelegate next, CancellationToken cancellationToken); +} + +/// +/// Runs around the handler of a request that returns nothing. Stages written against this +/// contract sit in the same chain, in the same registration order, as two-parameter ones. +/// +/// The void request the stage wraps. +public interface IRequestStage + where TRequest : IRequest +{ + /// + /// Wraps the rest of the chain for . Call + /// to continue, or skip it to short-circuit. + /// + Task HandleAsync(TRequest request, StageDelegate next, CancellationToken cancellationToken); +} diff --git a/src/RequestFlow/Dispatch/DispatchMap.cs b/src/RequestFlow/Dispatch/DispatchMap.cs index ef2fac0..7f57dd8 100644 --- a/src/RequestFlow/Dispatch/DispatchMap.cs +++ b/src/RequestFlow/Dispatch/DispatchMap.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; #if NET8_0_OR_GREATER using System.Collections.Frozen; #endif @@ -18,6 +19,7 @@ internal sealed class DispatchMap(Dictionary plans) private readonly Dictionary _plans = plans; #endif + [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool TryGet(Type requestType, out RequestPlanBase? plan) => _plans.TryGetValue(requestType, out plan); } diff --git a/src/RequestFlow/Dispatch/NoResultBridge.cs b/src/RequestFlow/Dispatch/NoResultBridge.cs new file mode 100644 index 0000000..8b22e47 --- /dev/null +++ b/src/RequestFlow/Dispatch/NoResultBridge.cs @@ -0,0 +1,28 @@ +using System.Runtime.CompilerServices; +using System.Threading.Tasks; + +namespace RequestFlow; + +/// +/// Completes a void handler's task as a task, reusing the cached +/// task when the handler finished synchronously. +/// +internal static class NoResultBridge +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Task Complete(Task task) + => task.Status == TaskStatus.RanToCompletion ? NoResult.Task : AwaitAsync(task); + + /// + /// for callers with a null-task guard downstream: a null task + /// passes through unchanged so the guard can name the stage or handler that returned it. + /// + public static Task CompleteOrNull(Task? task) + => task is null ? null! : Complete(task); + + private static async Task AwaitAsync(Task task) + { + await task.ConfigureAwait(false); + return NoResult.Value; + } +} diff --git a/src/RequestFlow/Dispatch/NullTaskGuard.cs b/src/RequestFlow/Dispatch/NullTaskGuard.cs new file mode 100644 index 0000000..76cc6fc --- /dev/null +++ b/src/RequestFlow/Dispatch/NullTaskGuard.cs @@ -0,0 +1,36 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; + +namespace RequestFlow; + +/// +/// Rejects a null task returned by a handler with an +/// that names the request type. +/// +internal static class NullTaskGuard +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Task ThrowIfNull(Task task, Type requestType) + { + if (task is null) + throw new InvalidOperationException(HandlerMessage(requestType)); + + return task; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Task ThrowIfNull(Task task, Type requestType) + { + if (task is null) + throw new InvalidOperationException(HandlerMessage(requestType)); + + return task; + } + + /// + /// The error message for a handler that returned a null task, shared by every dispatch path. + /// + public static string HandlerMessage(Type requestType) + => $"The handler for '{requestType.FullName}' returned a null task from HandleAsync."; +} diff --git a/src/RequestFlow/Dispatch/RequestPlan.cs b/src/RequestFlow/Dispatch/RequestPlan.cs index 1ba3505..bc4f8eb 100644 --- a/src/RequestFlow/Dispatch/RequestPlan.cs +++ b/src/RequestFlow/Dispatch/RequestPlan.cs @@ -18,6 +18,7 @@ public override Task ExecuteAsync( IRequest request, IServiceProvider services, CancellationToken cancellationToken) { var handler = services.GetRequiredService>(); - return handler.HandleAsync((TRequest)request, cancellationToken); + return NullTaskGuard.ThrowIfNull( + handler.HandleAsync((TRequest)request, cancellationToken), typeof(TRequest)); } } diff --git a/src/RequestFlow/Dispatch/VoidRequestPlan.cs b/src/RequestFlow/Dispatch/VoidRequestPlan.cs index 38bb26d..cccb1fc 100644 --- a/src/RequestFlow/Dispatch/VoidRequestPlan.cs +++ b/src/RequestFlow/Dispatch/VoidRequestPlan.cs @@ -17,13 +17,8 @@ public override Task ExecuteAsync( IRequest request, IServiceProvider services, CancellationToken cancellationToken) { var handler = services.GetRequiredService>(); - Task task = handler.HandleAsync((TRequest)request, cancellationToken); - return task.Status == TaskStatus.RanToCompletion ? NoResult.Task : AwaitAsync(task); - } - - private static async Task AwaitAsync(Task task) - { - await task.ConfigureAwait(false); - return NoResult.Value; + Task task = NullTaskGuard.ThrowIfNull( + handler.HandleAsync((TRequest)request, cancellationToken), typeof(TRequest)); + return NoResultBridge.Complete(task); } } diff --git a/src/RequestFlow/Registration/RegistrationValidator.cs b/src/RequestFlow/Registration/RegistrationValidator.cs index 8b672dd..dedae5b 100644 --- a/src/RequestFlow/Registration/RegistrationValidator.cs +++ b/src/RequestFlow/Registration/RegistrationValidator.cs @@ -97,6 +97,118 @@ public static ClosingResult ValidateClosings(IReadOnlyList + /// Checks each stage declaration's shape, stopping at that declaration's first failure. + /// Duplicate stage types need every declaration at once, so a separate check covers them. + /// + public static StageDeclarationResult ValidateStageDeclarations(IReadOnlyList declarations) + { + List validDeclarations = []; + List problems = []; + + foreach (var declaration in declarations) + { + string? problem = ValidateStageDeclaration(declaration); + if (problem is null) + validDeclarations.Add(declaration); + else + problems.Add(problem); + } + + return new StageDeclarationResult(validDeclarations, problems); + } + + private static string? ValidateStageDeclaration(StageDeclaration declaration) + { + Type stageType = declaration.StageType; + + if (stageType.IsInterface) + return $"'{stageType.FullName}' is an interface; only concrete stage classes can be registered."; + + if (stageType.IsAbstract) + return $"'{stageType.FullName}' is abstract; only concrete stage classes can be registered."; + + if (!stageType.IsGenericTypeDefinition && stageType.ContainsGenericParameters) + return $"'{stageType.FullName}' is partially closed; register either the open generic definition " + + "or a fully closed stage type."; + + if (!ImplementsStageContract(stageType)) + return $"'{stageType.FullName}' does not implement IRequestStage or " + + "IRequestStage; implement one of them or remove the AddStage call."; + + if (stageType.IsGenericTypeDefinition && !ClosesOverItsOwnParameters(stageType)) + { + string parameterNames = string.Join(", ", GetParameterNames(stageType)); + + return $"'{stageType.FullName}' declares generic parameters <{parameterNames}> that its " + + "IRequestStage implementation does not use as its request. An open generic stage " + + "implements IRequestStage with its own two parameters in that " + + "order, or declares one parameter and uses it as the request: IRequestStage " + + "for void requests, or IRequestStage with a fixed response type."; + } + + return null; + } + + private static bool ImplementsStageContract(Type stageType) + { + foreach (var iface in stageType.GetInterfaces()) + { + if (!iface.IsGenericType) + continue; + + Type definition = iface.GetGenericTypeDefinition(); + if (definition == typeof(IRequestStage<,>) || definition == typeof(IRequestStage<>)) + return true; + } + + return false; + } + + // MakeGenericType substitutes positionally and StageClosing closes a one-parameter + // definition over the request alone, so the interface's request argument has to be the + // stage's own parameter for the closed type to name the dispatched request. A stage that + // breaks this closes into a type no request can match, or drags along a parameter its contract never uses. + private static bool ClosesOverItsOwnParameters(Type stageType) + { + Type[] parameters = stageType.GetGenericArguments(); + + foreach (var iface in stageType.GetInterfaces()) + { + if (!iface.IsGenericType) + continue; + + Type definition = iface.GetGenericTypeDefinition(); + if (definition != typeof(IRequestStage<,>) && definition != typeof(IRequestStage<>)) + continue; + + Type[] arguments = iface.GetGenericArguments(); + + if (definition == typeof(IRequestStage<,>) + && parameters.Length == 2 + && arguments[0] == parameters[0] + && arguments[1] == parameters[1]) + return true; + + // One parameter naming the request: the void form, or the general form with the + // response fixed by the class, as in Stage : IRequestStage. + if (parameters.Length == 1 && arguments[0] == parameters[0]) + return true; + } + + return false; + } + + private static string[] GetParameterNames(Type stageType) + { + Type[] parameters = stageType.GetGenericArguments(); + string[] names = new string[parameters.Length]; + for (int i = 0; i < parameters.Length; i++) + names[i] = parameters[i].Name; + + return names; + } + /// /// Reports every request type covered by more than one handler. Called once at freeze. /// @@ -135,6 +247,141 @@ public static List ValidateUnhandledRequests( return problems; } + + /// + /// Reports every stage type registered more than once. Called once at freeze, so a stage + /// added by two separate AddRequestFlow calls is caught. The handler filter is not + /// part of the key: one stage type belongs to a chain once, whatever the calls filtered on. + /// + public static List ValidateDuplicateStages(IReadOnlyList declarations) + { + List problems = []; + + HashSet seenStages = []; + foreach (var declaration in declarations) + { + if (!seenStages.Add(declaration.StageType)) + problems.Add( + $"Stage '{declaration.StageType.FullName}' from assembly " + + $"'{declaration.StageType.Assembly.GetName().Name}' is registered more than once and would run " + + "twice in the same chain; remove the duplicate AddStage call. A handler filter does not make " + + "a second registration distinct."); + } + + return problems; + } + + /// + /// Reports two different stage declarations that reach one request as the same stage + /// class: an open definition next to its own closed form, or two closed forms that both + /// apply through the request's base type. Runs at freeze, where every request is known; + /// one stage type registered twice is 's job. + /// + public static List ValidateAliasedStages( + IReadOnlyList declarations, + IReadOnlyList handlers, + StageClosingCache closings) + { + List problems = []; + + // One message per colliding pair of declarations, not per request they collide on. + HashSet reported = []; + + // Keyed on the stage class rather than the closed type, because in TRequest lets two + // different closings of one class apply to the same request. + Dictionary owners = []; + + foreach (var handler in handlers) + { + owners.Clear(); + foreach (var declaration in declarations) + { + if (!closings.TryClose(declaration, handler, out Type closedStageType)) + continue; + + Type stageClass = closedStageType.IsGenericType + ? closedStageType.GetGenericTypeDefinition() + : closedStageType; + + if (!owners.TryGetValue(stageClass, out StageOwner owner)) + { + owners[stageClass] = new StageOwner(declaration, closedStageType); + continue; + } + + if (owner.Declaration.StageType == declaration.StageType) + continue; + + if (!reported.Add(new StagePair(owner.Declaration.StageType, declaration.StageType))) + continue; + + problems.Add(owner.ClosedStageType == closedStageType + ? $"Stages '{owner.Declaration.StageType.FullName}' and '{declaration.StageType.FullName}' both " + + $"resolve to '{closedStageType.FullName}' for request " + + $"'{handler.RequestType.FullName}' and would run twice in the same chain; remove " + + "one of the two AddStage calls." + : $"Stages '{owner.Declaration.StageType.FullName}' and '{declaration.StageType.FullName}' are " + + $"the same stage class and both apply to request '{handler.RequestType.FullName}'; the class " + + "would run twice in the same chain; remove one of the two AddStage calls."); + } + } + + return problems; + } + + /// + /// Reports every stage that reached no request. Runs at freeze, and only when the + /// application called DisallowUnusedStages. + /// + public static List ValidateUnusedStages( + IReadOnlyList declarations, ISet appliedStageTypes) + { + List problems = []; + + foreach (var declaration in declarations) + { + if (!appliedStageTypes.Contains(declaration.StageType)) + problems.Add( + $"Stage '{declaration.StageType.FullName}' from assembly " + + $"'{declaration.StageType.Assembly.GetName().Name}' applies to no registered request; widen its " + + "generic constraints, scan the assembly holding the requests it targets, or drop " + + "DisallowUnusedStages."); + } + + return problems; + } + + // The first declaration seen for a stage class under one handler, with the closed type it + // produced, so the collision message can say whether the pair met on one closed type or on + // two closings of the class. Spelled out because net462 has no ValueTuple and the library + // takes no dependency to get one. + private readonly struct StageOwner(StageDeclaration declaration, Type closedStageType) + { + public StageDeclaration Declaration { get; } = declaration; + + public Type ClosedStageType { get; } = closedStageType; + } + + // Two stage types reported together once. + private readonly struct StagePair(Type first, Type second) : IEquatable + { + private readonly Type _first = first; + private readonly Type _second = second; + + public bool Equals(StagePair other) + => _first == other._first && _second == other._second; + + public override bool Equals(object? obj) + => obj is StagePair other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + return (_first.GetHashCode() * 397) ^ _second.GetHashCode(); + } + } + } } /// @@ -159,3 +406,15 @@ internal sealed class ClosingResult(IReadOnlyList closedTypes, IReadOnlyLi public IReadOnlyList Problems { get; } = problems; } + +/// +/// The stage declarations that passed the shape check, plus one problem message for each +/// declaration that failed. +/// +internal sealed class StageDeclarationResult( + IReadOnlyList validDeclarations, IReadOnlyList problems) +{ + public IReadOnlyList ValidDeclarations { get; } = validDeclarations; + + public IReadOnlyList Problems { get; } = problems; +} diff --git a/src/RequestFlow/Registration/RequestFlowOptions.cs b/src/RequestFlow/Registration/RequestFlowOptions.cs index 9887c9e..a2dbf29 100644 --- a/src/RequestFlow/Registration/RequestFlowOptions.cs +++ b/src/RequestFlow/Registration/RequestFlowOptions.cs @@ -15,6 +15,10 @@ public sealed class RequestFlowOptions internal List Declarations { get; } = []; + internal List StageDeclarations { get; } = []; + + internal bool UnusedStagesDisallowed { get; private set; } + /// internal RequestFlowOptions Apply(Action configure) { @@ -114,7 +118,52 @@ public RequestFlowOptions RegisterGenericHandler(Type handlerType, params Type[] } Declarations.Add(new GenericHandlerDeclaration(handlerType, closingTypes)); - + + return this; + } + + /// + /// Registers to run around the handler of every request it + /// applies to. Pass an open generic definition such as typeof(LoggingStage<,>) + /// to let the stage's own generic constraints decide which requests it reaches, or a + /// closed stage class to target a single request contract. A closed stage is not + /// restricted to the one request type it names: TRequest is contravariant, so a + /// stage declared for a base request also wraps every request that derives from it. + /// narrows that set further. One stage type belongs to a chain + /// once, so a second call naming the same type is a duplicate whatever it filters on. + /// Registration order is execution order, outermost first. A null argument and a repeated + /// WhereHandlerImplements call throw here; an invalid stage surfaces as a + /// problem when the dispatch map is built. + /// + /// + /// + public RequestFlowOptions AddStage(Type stageType, Action? configure = null) + { + if (stageType is null) + throw new ArgumentNullException(nameof(stageType)); + + var applicability = new StageApplicability(); + configure?.Invoke(applicability); + + StageDeclarations.Add(new StageDeclaration(stageType, applicability.HandlerFilter)); + + return this; + } + + /// + /// Registers under the same rules as . + /// + public RequestFlowOptions AddStage(Action? configure = null) + where TStage : class + => AddStage(typeof(TStage), configure); + + /// + /// Reports a stage that reaches no registered request as a validation problem instead of + /// leaving it a silent no-op. Applies to all registered stages once any call opts in. + /// + public RequestFlowOptions DisallowUnusedStages() + { + UnusedStagesDisallowed = true; return this; } } diff --git a/src/RequestFlow/Registration/RequestFlowRegistry.cs b/src/RequestFlow/Registration/RequestFlowRegistry.cs index d736293..27e6613 100644 --- a/src/RequestFlow/Registration/RequestFlowRegistry.cs +++ b/src/RequestFlow/Registration/RequestFlowRegistry.cs @@ -18,6 +18,7 @@ internal sealed class RequestFlowRegistry private readonly HashSet _seenProblems = []; private readonly HashSet _assemblies = []; private readonly HashSet _closings = []; + private readonly List _stageDeclarations = []; /// /// True once any AddRequestFlow call opted out of the missing-handler check. @@ -30,6 +31,41 @@ internal sealed class RequestFlowRegistry public void AllowUnhandledRequests() => UnhandledRequestsAllowed = true; + /// + /// True once any AddRequestFlow call asked for a stage that applies to nothing to be + /// fatal. + /// + public bool UnusedStagesDisallowed { get; private set; } + + /// + /// Makes a stage that applies to nothing a validation problem; sticky across calls. + /// + public void DisallowUnusedStages() + => UnusedStagesDisallowed = true; + + /// + /// Every stage declaration accumulated so far, in registration order, which is execution + /// order. + /// + public IReadOnlyList StageDeclarations => _stageDeclarations; + + /// + /// The cached stage closings shared by registration, the freeze, and validation. + /// + public StageClosingCache ClosingCache { get; } = new(); + + /// + /// Every handler accumulated so far. + /// + public IReadOnlyList Handlers => _handlers; + + /// + /// Appends one call's shape-valid stage declarations. A stage declared twice would run + /// twice, so duplicates are reported at freeze rather than skipped here. + /// + public void AddStageDeclarations(IReadOnlyList declarations) + => _stageDeclarations.AddRange(declarations); + /// /// Adds the assemblies not registered by an earlier call and returns the newly added ones. /// @@ -87,22 +123,88 @@ public void Add( /// public DispatchMap BuildDispatchMap() { - List problems = [.. _problems, .. RegistrationValidator.ValidateDuplicateHandlers(_handlers)]; + List problems = + [ + .. _problems, + .. RegistrationValidator.ValidateDuplicateHandlers(_handlers), + .. RegistrationValidator.ValidateDuplicateStages(_stageDeclarations), + .. RegistrationValidator.ValidateAliasedStages(_stageDeclarations, _handlers, ClosingCache), + ]; if (!UnhandledRequestsAllowed) problems.AddRange(RegistrationValidator.ValidateUnhandledRequests(_handlers, _requestTypes)); + // Built before the throw, because the strict check needs to know which stages applied. + StagePlanSet stagePlans = BuildStagePlans(); + if (UnusedStagesDisallowed) + { + problems.AddRange( + RegistrationValidator.ValidateUnusedStages(_stageDeclarations, stagePlans.AppliedStageTypes)); + } + if (problems.Count > 0) throw new RequestFlowValidationException(problems); Dictionary plans = []; foreach (var handler in _handlers) + { + plans[handler.RequestType] = CreatePlan(handler, stagePlans.StageTypesByRequest[handler.RequestType]); + } + + return new DispatchMap(plans); + } + + // Ordering and chain shape are decided at freeze, never per AddRequestFlow call. + private StagePlanSet BuildStagePlans() + { + Dictionary stageTypesByRequest = []; + HashSet appliedStageTypes = []; + List ordered = []; + + foreach (var handler in _handlers) + { + ordered.Clear(); + foreach (var declaration in _stageDeclarations) + { + if (!ClosingCache.TryClose(declaration, handler, out Type closedStageType)) + continue; + + ordered.Add(closedStageType); + appliedStageTypes.Add(declaration.StageType); + } + + stageTypesByRequest[handler.RequestType] = ordered.ToArray(); + } + + return new StagePlanSet(stageTypesByRequest, appliedStageTypes); + } + + private static RequestPlanBase CreatePlan(HandlerRegistration handler, Type[] stageTypes) + { + if (stageTypes.Length == 0) { Type planType = handler.IsVoid ? typeof(VoidRequestPlan<>).MakeGenericType(handler.RequestType) : typeof(RequestPlan<,>).MakeGenericType(handler.RequestType, handler.ResponseType); - plans[handler.RequestType] = (RequestPlanBase)Activator.CreateInstance(planType)!; + return (RequestPlanBase)Activator.CreateInstance(planType)!; } - return new DispatchMap(plans); + Type stagedPlanType = handler.IsVoid + ? typeof(StagedVoidRequestPlan<>).MakeGenericType(handler.RequestType) + : typeof(StagedRequestPlan<,>).MakeGenericType(handler.RequestType, handler.ResponseType); + + // Wrapped in an object array on purpose: Type[] converts to object[], so handing + // stageTypes straight through would be read as one constructor argument per stage type. + return (RequestPlanBase)Activator.CreateInstance(stagedPlanType, [(object)stageTypes])!; } } + +/// +/// The ordered stage types for each request type, plus the stage types that reached at least +/// one request. +/// +internal sealed class StagePlanSet(Dictionary stageTypesByRequest, HashSet appliedStageTypes) +{ + public Dictionary StageTypesByRequest { get; } = stageTypesByRequest; + + public ISet AppliedStageTypes { get; } = appliedStageTypes; +} diff --git a/src/RequestFlow/Registration/ServiceCollectionExtensions.cs b/src/RequestFlow/Registration/ServiceCollectionExtensions.cs index 3fa1a52..156e175 100644 --- a/src/RequestFlow/Registration/ServiceCollectionExtensions.cs +++ b/src/RequestFlow/Registration/ServiceCollectionExtensions.cs @@ -16,11 +16,9 @@ public static class ServiceCollectionExtensions /// Registers RequestFlow: scans the configured assemblies and registers the discovered /// handlers. Calls are additive; assemblies and closings already registered by an /// earlier call are skipped. The dispatch map is validated and built once per provider, - /// on its first dispatcher resolution, throwing - /// listing every registration problem: - /// invalid generic handler declarations, constraint violations, missing handlers, and - /// duplicate handlers. Returns a for chaining optional - /// feature registrations. + /// on its first dispatcher resolution, throwing a + /// that lists every registration problem. + /// Returns a for chaining optional feature registrations. /// /// /// @@ -45,10 +43,17 @@ public static RequestFlowBuilder AddRequestFlow( ScanResult scan = HandlerScanner.Scan(newAssemblies); List handlers = CollectHandlers(scan, closed); - List problems = [.. declarations.Problems, .. closed.Problems]; + + StageDeclarationResult stages = RegistrationValidator.ValidateStageDeclarations(options.StageDeclarations); + registry.AddStageDeclarations(stages.ValidDeclarations); + if (options.UnusedStagesDisallowed) + registry.DisallowUnusedStages(); + + List problems = [.. declarations.Problems, .. closed.Problems, .. stages.Problems]; registry.Add(handlers, scan.RequestTypes, problems); RegisterHandlers(services, handlers, options.HandlerLifetime); + RegisterStages(services, registry.StageDeclarations, registry.Handlers, registry.ClosingCache); services.TryAddSingleton(_ => registry.BuildDispatchMap()); services.TryAdd(new ServiceDescriptor( @@ -103,4 +108,37 @@ private static void RegisterHandlers( services.Add(new ServiceDescriptor(service, handler.ImplementationType, lifetime)); } } + + // Walks the whole accumulated cross product on every call rather than a delta, so a stage + // declared by an earlier call reaches requests scanned by a later one; the closing cache + // makes the repeated pairs cheap. Skipping a type that is already present leaves a stage + // the consumer registered themselves on its own lifetime; a keyed descriptor is a + // different service, so it does not count as present. + private static void RegisterStages( + IServiceCollection services, + IReadOnlyList declarations, + IReadOnlyList handlers, + StageClosingCache closings) + { + HashSet registered = []; + foreach (var descriptor in services) + { + if (!descriptor.IsKeyedService) + registered.Add(descriptor.ServiceType); + } + + foreach (var declaration in declarations) + { + foreach (var handler in handlers) + { + if (!closings.TryClose(declaration, handler, out Type closedStageType)) + continue; + + if (!registered.Add(closedStageType)) + continue; + + services.Add(new ServiceDescriptor(closedStageType, closedStageType, ServiceLifetime.Transient)); + } + } + } } diff --git a/src/RequestFlow/Stages/StageApplicability.cs b/src/RequestFlow/Stages/StageApplicability.cs new file mode 100644 index 0000000..275c712 --- /dev/null +++ b/src/RequestFlow/Stages/StageApplicability.cs @@ -0,0 +1,29 @@ +using System; + +namespace RequestFlow; + +/// +/// Narrows the requests a stage applies to beyond what the stage's own generic constraints express. +/// +public sealed class StageApplicability +{ + internal Type? HandlerFilter { get; private set; } + + /// + /// Limits the stage to requests whose handler implements . + /// One filter per stage: a second call throws rather than replace the first. + /// + /// + public StageApplicability WhereHandlerImplements() + { + if (HandlerFilter is not null) + { + throw new InvalidOperationException( + $"This stage already filters on '{HandlerFilter.FullName}'. A stage takes one handler filter; " + + "to reach handlers of several contracts, give them one shared contract to implement."); + } + + HandlerFilter = typeof(TContract); + return this; + } +} diff --git a/src/RequestFlow/Stages/StageClosing.cs b/src/RequestFlow/Stages/StageClosing.cs new file mode 100644 index 0000000..5701720 --- /dev/null +++ b/src/RequestFlow/Stages/StageClosing.cs @@ -0,0 +1,100 @@ +// Startup only: nothing here runs on the dispatch path. + +using System; + +namespace RequestFlow; + +/// +/// Decides whether one stage declaration applies to one handler registration and, when it +/// does, produces the closed stage type. This is the single source of applicability: +/// registration emits closed service descriptors and the freeze orders each request's stages +/// from the same answers, cached per pair in , so a provider +/// built after the last AddRequestFlow call never sees the two disagree. +/// +internal static class StageClosing +{ + /// + /// Applicability without the explanation. + /// + public static bool TryClose(StageDeclaration declaration, HandlerRegistration handler, out Type closedStageType) + => TryClose(declaration, handler, out closedStageType, out _); + + /// + /// True when applies to , with + /// set to the type to resolve and + /// to why it applies. Both out parameters are left at their + /// defaults when it does not. + /// + public static bool TryClose( + StageDeclaration declaration, + HandlerRegistration handler, + out Type closedStageType, + out string reason) + { + closedStageType = null!; + reason = string.Empty; + + if (declaration.HandlerFilter is not null + && !declaration.HandlerFilter.IsAssignableFrom(handler.ImplementationType)) + return false; + + Type stageType = declaration.StageType; + bool isOpen = stageType.IsGenericTypeDefinition; + Type candidate; + + if (isOpen) + { + // A one-parameter definition is the void form, which names the request only. + Type[] arguments = stageType.GetGenericArguments().Length == 1 + ? [handler.RequestType] + : [handler.RequestType, handler.ResponseType]; + + try + { + candidate = stageType.MakeGenericType(arguments); + } + catch (ArgumentException) + { + // Generic constraints are how a stage states which requests it applies to, so + // excluding this one is an answer, not a registration problem. + return false; + } + } + else + { + candidate = stageType; + } + + if (!SatisfiesContract(candidate, handler)) + return false; + + reason = isOpen + ? $"generic constraints admit {Describe(handler)}" + : $"closed stage declared for {Describe(handler)}"; + + if (declaration.HandlerFilter is not null) + reason += $", handler implements {declaration.HandlerFilter.Name}"; + + closedStageType = candidate; + return true; + } + + // Honors the in TRequest variance, so a closed stage written against a base request type + // also applies to requests that inherit the contract. The void form is only ever offered + // to a void handler, since that is the only place its response type can line up. + private static bool SatisfiesContract(Type candidate, HandlerRegistration handler) + { + Type contract = typeof(IRequestStage<,>).MakeGenericType(handler.RequestType, handler.ResponseType); + if (contract.IsAssignableFrom(candidate)) + return true; + + if (!handler.IsVoid) + return false; + + Type voidContract = typeof(IRequestStage<>).MakeGenericType(handler.RequestType); + return voidContract.IsAssignableFrom(candidate); + } + + private static string Describe(HandlerRegistration handler) + => $"{handler.RequestType.Name} -> {handler.ResponseType.Name}"; +} diff --git a/src/RequestFlow/Stages/StageClosingCache.cs b/src/RequestFlow/Stages/StageClosingCache.cs new file mode 100644 index 0000000..c160bac --- /dev/null +++ b/src/RequestFlow/Stages/StageClosingCache.cs @@ -0,0 +1,64 @@ +// Startup only: nothing here runs on the dispatch path. + +using System; +using System.Collections.Generic; + +namespace RequestFlow; + +/// +/// Remembers each answer per declaration and handler pair; null +/// records "does not apply". Registration, the freeze, and the aliased-stage check all walk +/// the same cross product, so the registry owns one instance and each pair pays the +/// reflective closing once. +/// +internal sealed class StageClosingCache +{ + private readonly Dictionary _closings = []; + + /// + /// 's TryClose with the answer cached per pair. + /// + public bool TryClose(StageDeclaration declaration, HandlerRegistration handler, out Type closedStageType) + { + var key = new ClosingKey(declaration, handler); + + // Locked because every provider built from the collection freezes once, and two + // providers can freeze at the same time. Registration and the freeze are the only + // callers, so the lock never sits on the dispatch path. + lock (_closings) + { + if (!_closings.TryGetValue(key, out Type? closed)) + { + closed = StageClosing.TryClose(declaration, handler, out Type type) ? type : null; + _closings[key] = closed; + } + + closedStageType = closed!; + return closed is not null; + } + } + + // Declarations and handlers accumulate once in the registry and every caller hands the + // same instances back, so reference identity is the key. Spelled out because net462 has + // no ValueTuple and the library takes no dependency to get one. + private readonly struct ClosingKey(StageDeclaration declaration, HandlerRegistration handler) + : IEquatable + { + private readonly StageDeclaration _declaration = declaration; + private readonly HandlerRegistration _handler = handler; + + public bool Equals(ClosingKey other) + => ReferenceEquals(_declaration, other._declaration) && ReferenceEquals(_handler, other._handler); + + public override bool Equals(object? obj) + => obj is ClosingKey other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + return (_declaration.GetHashCode() * 397) ^ _handler.GetHashCode(); + } + } + } +} diff --git a/src/RequestFlow/Stages/StageDeclaration.cs b/src/RequestFlow/Stages/StageDeclaration.cs new file mode 100644 index 0000000..b62019a --- /dev/null +++ b/src/RequestFlow/Stages/StageDeclaration.cs @@ -0,0 +1,14 @@ +using System; + +namespace RequestFlow; + +/// +/// One registered stage: the stage type, plus the optional handler contract that narrows +/// which requests it reaches. Position in the registry's list is execution order. +/// +internal sealed class StageDeclaration(Type stageType, Type? handlerFilter) +{ + public Type StageType { get; } = stageType; + + public Type? HandlerFilter { get; } = handlerFilter; +} diff --git a/src/RequestFlow/Stages/StageExecutor.cs b/src/RequestFlow/Stages/StageExecutor.cs new file mode 100644 index 0000000..4ec98c7 --- /dev/null +++ b/src/RequestFlow/Stages/StageExecutor.cs @@ -0,0 +1,97 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RequestFlow; + +/// +/// Runs the stage chain by recursion: each level hands its stage a continuation that enters +/// the level below. One instance per dispatch, holding the request and token for every level. +/// +internal abstract class StageExecutor + where TRequest : IRequest +{ + private readonly int _stageCount; + private readonly TRequest _request; + private readonly CancellationToken _cancellationToken; + + protected StageExecutor(int stageCount, TRequest request, CancellationToken cancellationToken) + { + _stageCount = stageCount; + _request = request; + _cancellationToken = cancellationToken; + } + + /// + /// Runs the chain, starting at the outermost stage. + /// + public Task RunAsync() => EnterAsync(0); + + protected abstract Task InvokeStageAsync( + int index, TRequest request, StageDelegate next, CancellationToken cancellationToken); + + protected abstract Task InvokeHandlerAsync(TRequest request, CancellationToken cancellationToken); + + /// + /// The runtime type of the stage at , used to name it in errors. + /// + protected abstract Type StageTypeAt(int index); + + private Task EnterAsync(int index) + { + Task task = index < _stageCount + ? InvokeStageAsync(index, _request, new Continuation(this, index + 1).InvokeAsync, _cancellationToken) + : InvokeHandlerAsync(_request, _cancellationToken); + + if (task is null) + throw new InvalidOperationException(NullTaskMessage(index)); + + return task; + } + + // Which level a next call enters has to live in the delegate itself: the moment a stage + // suspends on anything, shared executor state stops saying which frame is calling. Each + // level therefore gets its own continuation, which also carries the state that catches a + // stage calling next again while its earlier call is still running. + private sealed class Continuation(StageExecutor executor, int index) + { + // Never completes, so callers that read it while a claim is held treat it as a call + // still in flight and throw. + private static readonly Task Claimed = new TaskCompletionSource().Task; + + private Task? _running; + + public Task InvokeAsync() + { + // The compare-exchange makes the check and the claim one atomic step; of two + // simultaneous callers, the loser sees either the sentinel or a value that moved. + Task? running = Volatile.Read(ref _running); + if (running is { IsCompleted: false } + || Interlocked.CompareExchange(ref _running, Claimed, running) != running) + throw new InvalidOperationException(executor.OverlappingNextMessage(index - 1)); + + try + { + Task task = executor.EnterAsync(index); + Volatile.Write(ref _running, task); + return task; + } + catch + { + // A synchronous throw releases the claim so an outer retry stage may call next again. + Volatile.Write(ref _running, running); + throw; + } + } + } + + private string OverlappingNextMessage(int caller) + => $"Stage '{StageTypeAt(caller).FullName}' called next while the task from its earlier call was still running. " + + "Await that task before calling next again: each call runs the rest of the chain, so overlapping calls would run it twice at once."; + + private string NullTaskMessage(int index) + => index < _stageCount + ? $"Stage '{StageTypeAt(index).FullName}' returned a null task from HandleAsync; " + + "return the task from next, or a completed task when short-circuiting." + : NullTaskGuard.HandlerMessage(typeof(TRequest)); +} diff --git a/src/RequestFlow/Stages/StagedRequestPlan.cs b/src/RequestFlow/Stages/StagedRequestPlan.cs new file mode 100644 index 0000000..c8a751f --- /dev/null +++ b/src/RequestFlow/Stages/StagedRequestPlan.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace RequestFlow; + +/// +/// Closed plan for one request/response pair wrapped in stages. The ordered stage types are +/// fixed when the dispatch map freezes; the instances resolve from the supplied provider on +/// each call, so DI lifetimes hold. +/// +internal sealed class StagedRequestPlan(Type[] stageTypes) : RequestPlan + where TRequest : IRequest +{ + /// + public override Task ExecuteAsync( + IRequest request, IServiceProvider services, CancellationToken cancellationToken) + { + var stages = new IRequestStage[stageTypes.Length]; + for (int i = 0; i < stages.Length; i++) + stages[i] = (IRequestStage)services.GetRequiredService(stageTypes[i]); + + var handler = services.GetRequiredService>(); + + return new TypedStageExecutor( + stages, handler, (TRequest)request, cancellationToken).RunAsync(); + } +} diff --git a/src/RequestFlow/Stages/StagedVoidRequestPlan.cs b/src/RequestFlow/Stages/StagedVoidRequestPlan.cs new file mode 100644 index 0000000..c9331b1 --- /dev/null +++ b/src/RequestFlow/Stages/StagedVoidRequestPlan.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace RequestFlow; + +/// +/// Closed plan for one void request wrapped in stages. The ordered stage types are fixed when +/// the dispatch map freezes; the instances resolve from the supplied provider on each call, so +/// DI lifetimes hold. +/// +internal sealed class StagedVoidRequestPlan(Type[] stageTypes) : RequestPlan + where TRequest : IRequest +{ + /// + public override Task ExecuteAsync( + IRequest request, IServiceProvider services, CancellationToken cancellationToken) + { + var stages = new object[stageTypes.Length]; + for (int i = 0; i < stages.Length; i++) + stages[i] = services.GetRequiredService(stageTypes[i]); + + var handler = services.GetRequiredService>(); + + return new VoidStageExecutor(stages, handler, (TRequest)request, cancellationToken).RunAsync(); + } +} diff --git a/src/RequestFlow/Stages/TypedStageExecutor.cs b/src/RequestFlow/Stages/TypedStageExecutor.cs new file mode 100644 index 0000000..56a056b --- /dev/null +++ b/src/RequestFlow/Stages/TypedStageExecutor.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RequestFlow; + +/// +/// Stage chain that terminates at . +/// +internal sealed class TypedStageExecutor( + IRequestStage[] stages, + IRequestHandler handler, + TRequest request, + CancellationToken cancellationToken) + : StageExecutor(stages.Length, request, cancellationToken) + where TRequest : IRequest +{ + /// + protected override Task InvokeStageAsync( + int index, TRequest request, StageDelegate next, CancellationToken cancellationToken) + => stages[index].HandleAsync(request, next, cancellationToken); + + /// + protected override Task InvokeHandlerAsync(TRequest request, CancellationToken cancellationToken) + => handler.HandleAsync(request, cancellationToken); + + /// + protected override Type StageTypeAt(int index) => stages[index].GetType(); +} diff --git a/src/RequestFlow/Stages/VoidStageExecutor.cs b/src/RequestFlow/Stages/VoidStageExecutor.cs new file mode 100644 index 0000000..b03ea26 --- /dev/null +++ b/src/RequestFlow/Stages/VoidStageExecutor.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RequestFlow; + +/// +/// Stage chain that terminates at the standalone . +/// Its stages come in both contract shapes, so the array is untyped and each level picks. +/// +internal sealed class VoidStageExecutor( + object[] stages, + IRequestHandler handler, + TRequest request, + CancellationToken cancellationToken) + : StageExecutor(stages.Length, request, cancellationToken) + where TRequest : IRequest +{ + /// + protected override Task InvokeStageAsync( + int index, TRequest request, StageDelegate next, CancellationToken cancellationToken) + { + object stage = stages[index]; + if (stage is IRequestStage typed) + return typed.HandleAsync(request, next, cancellationToken); + + // The void shape wraps the same continuation, so both forms share its guard state. + return NoResultBridge.CompleteOrNull( + ((IRequestStage)stage).HandleAsync(request, new StageDelegate(next.Invoke), cancellationToken)); + } + + /// + protected override Task InvokeHandlerAsync(TRequest request, CancellationToken cancellationToken) + => NoResultBridge.CompleteOrNull(handler.HandleAsync(request, cancellationToken)); + + /// + protected override Type StageTypeAt(int index) => stages[index].GetType(); +} diff --git a/tests/RequestFlow.Tests.Unit/RequestDispatcherTests.cs b/tests/RequestFlow.Tests.Unit/RequestDispatcherTests.cs index 2bd09da..50278e5 100644 --- a/tests/RequestFlow.Tests.Unit/RequestDispatcherTests.cs +++ b/tests/RequestFlow.Tests.Unit/RequestDispatcherTests.cs @@ -103,6 +103,32 @@ await Should.ThrowAsync( () => _sut.SendAsync((IRequest)null!)); } + [Fact] + public async Task Given_Handler_That_Returns_A_Null_Task_When_Sending_Request_Then_Throws_Naming_The_Request() + { + _pingHandler.HandleAsync(Arg.Any(), Arg.Any()) + .Returns(default(Task)!); + + var exception = await Should.ThrowAsync( + () => _sut.SendAsync(new Ping("bob"))); + + exception.Message.ShouldContain(nameof(Ping)); + exception.Message.ShouldContain("null task"); + } + + [Fact] + public async Task Given_Void_Handler_That_Returns_A_Null_Task_When_Sending_Request_Then_Throws_Naming_The_Request() + { + _logHandler.HandleAsync(Arg.Any(), Arg.Any()) + .Returns(default(Task)!); + + var exception = await Should.ThrowAsync( + () => _sut.SendAsync(new Log("hi"))); + + exception.Message.ShouldContain(nameof(Log)); + exception.Message.ShouldContain("null task"); + } + [Fact] public async Task Given_Faulted_Handler_Task_When_Sending_Request_Then_Handler_Exception_Propagates() { diff --git a/tests/RequestFlow.Tests.Unit/Stages/AddStageTests.cs b/tests/RequestFlow.Tests.Unit/Stages/AddStageTests.cs new file mode 100644 index 0000000..8a18283 --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Stages/AddStageTests.cs @@ -0,0 +1,266 @@ +using RequestFlow; + +namespace RequestFlow.Tests.Unit; + +public sealed class AddStageTests +{ + [Fact] + public void Given_Open_Generic_Stage_When_Adding_Stage_Then_Declaration_Is_Recorded() + { + _sut.AddStage(typeof(LoggingStage<,>)); + + _sut.StageDeclarations.Count.ShouldBe(1); + _sut.StageDeclarations[0].StageType.ShouldBe(typeof(LoggingStage<,>)); + _sut.StageDeclarations[0].HandlerFilter.ShouldBeNull(); + } + + [Fact] + public void Given_Closed_Stage_When_Adding_Stage_By_Type_Argument_Then_Declaration_Is_Recorded() + { + _sut.AddStage(); + + _sut.StageDeclarations.Count.ShouldBe(1); + _sut.StageDeclarations[0].StageType.ShouldBe(typeof(PingAuditStage)); + } + + [Fact] + public void Given_Handler_Filter_When_Adding_Stage_Then_Filter_Is_Recorded() + { + _sut.AddStage(typeof(LoggingStage<,>), s => s.WhereHandlerImplements()); + + _sut.StageDeclarations[0].HandlerFilter.ShouldBe(typeof(IAuditable)); + } + + [Fact] + public void Given_Handler_Filter_When_Adding_Stage_By_Type_Argument_Then_Filter_Is_Recorded() + { + _sut.AddStage(s => s.WhereHandlerImplements()); + + _sut.StageDeclarations[0].StageType.ShouldBe(typeof(PingAuditStage)); + _sut.StageDeclarations[0].HandlerFilter.ShouldBe(typeof(IAuditable)); + } + + [Fact] + public void Given_Registration_Order_When_Adding_Stages_Then_Declarations_Keep_That_Order() + { + _sut.AddStage(typeof(LoggingStage<,>)).AddStage(); + + _sut.StageDeclarations[0].StageType.ShouldBe(typeof(LoggingStage<,>)); + _sut.StageDeclarations[1].StageType.ShouldBe(typeof(PingAuditStage)); + } + + [Fact] + public void Given_Null_Stage_Type_When_Adding_Stage_Then_Throws_Argument_Null_Exception() + { + Should.Throw(() => _sut.AddStage(null!)); + } + + [Fact] + public void Given_Valid_Declarations_When_Validating_Then_All_Are_Valid_And_No_Problems() + { + StageDeclaration[] declarations = + [ + new StageDeclaration(typeof(LoggingStage<,>), null), + new StageDeclaration(typeof(PingAuditStage), null), + ]; + + StageDeclarationResult result = RegistrationValidator.ValidateStageDeclarations(declarations); + + result.ValidDeclarations.Count.ShouldBe(2); + result.Problems.ShouldBeEmpty(); + } + + [Fact] + public void Given_Stage_Whose_Parameter_Is_Not_The_Request_When_Validating_Then_Reports_The_Parameter() + { + StageDeclaration[] declarations = [new StageDeclaration(typeof(OneParameterStage<>), null)]; + + StageDeclarationResult result = RegistrationValidator.ValidateStageDeclarations(declarations); + + result.ValidDeclarations.ShouldBeEmpty(); + result.Problems.Count.ShouldBe(1); + result.Problems[0].ShouldContain("does not use as its request"); + } + + [Fact] + public void Given_Type_That_Is_Not_A_Stage_When_Validating_Then_Reports_Missing_Contract() + { + StageDeclaration[] declarations = [new StageDeclaration(typeof(NotAStage), null)]; + + StageDeclarationResult result = RegistrationValidator.ValidateStageDeclarations(declarations); + + result.ValidDeclarations.ShouldBeEmpty(); + result.Problems[0].ShouldContain("does not implement IRequestStage"); + } + + [Fact] + public void Given_Abstract_Stage_When_Validating_Then_Reports_Abstract() + { + StageDeclaration[] declarations = [new StageDeclaration(typeof(AbstractStage), null)]; + + StageDeclarationResult result = RegistrationValidator.ValidateStageDeclarations(declarations); + + result.ValidDeclarations.ShouldBeEmpty(); + result.Problems[0].ShouldContain("is abstract"); + } + + [Fact] + public void Given_Handler_Filter_Already_Set_When_Adding_A_Second_One_Then_Throws_Invalid_Operation_Exception() + { + InvalidOperationException exception = Should.Throw(() => + _sut.AddStage(typeof(LoggingStage<,>), s => s + .WhereHandlerImplements() + .WhereHandlerImplements())); + + exception.Message.ShouldContain(nameof(IAuditable)); + exception.Message.ShouldContain("one handler filter"); + } + + [Fact] + public void Given_Stage_With_Swapped_Generic_Parameters_When_Validating_Then_Reports_The_Parameter_Order() + { + StageDeclaration[] declarations = [new StageDeclaration(typeof(SwappedStage<,>), null)]; + + StageDeclarationResult result = RegistrationValidator.ValidateStageDeclarations(declarations); + + result.ValidDeclarations.ShouldBeEmpty(); + result.Problems[0].ShouldContain("in that order"); + } + + [Fact] + public void Given_Open_Void_Form_Stage_When_Validating_Then_Declaration_Is_Valid() + { + StageDeclaration[] declarations = [new StageDeclaration(typeof(VoidOnlyStage<>), null)]; + + StageDeclarationResult result = RegistrationValidator.ValidateStageDeclarations(declarations); + + result.ValidDeclarations.Count.ShouldBe(1); + result.Problems.ShouldBeEmpty(); + } + + [Fact] + public void Given_Closed_Void_Form_Stage_When_Validating_Then_Declaration_Is_Valid() + { + StageDeclaration[] declarations = [new StageDeclaration(typeof(WipeAuditStage), null)]; + + StageDeclarationResult result = RegistrationValidator.ValidateStageDeclarations(declarations); + + result.ValidDeclarations.Count.ShouldBe(1); + result.Problems.ShouldBeEmpty(); + } + + [Fact] + public void Given_Open_Response_Bound_Stage_When_Validating_Then_Declaration_Is_Valid() + { + StageDeclaration[] declarations = [new StageDeclaration(typeof(ResponseBoundStage<>), null)]; + + StageDeclarationResult result = RegistrationValidator.ValidateStageDeclarations(declarations); + + result.ValidDeclarations.Count.ShouldBe(1); + result.Problems.ShouldBeEmpty(); + } + + [Fact] + public void Given_Single_Parameter_Stage_Closed_Over_Its_Request_When_Validating_Then_Declaration_Is_Valid() + { + Type closed = typeof(ResponseBoundStage<>).MakeGenericType(typeof(Ping)); + StageDeclaration[] declarations = [new StageDeclaration(closed, null)]; + + StageDeclarationResult result = RegistrationValidator.ValidateStageDeclarations(declarations); + + result.ValidDeclarations.Count.ShouldBe(1); + result.Problems.ShouldBeEmpty(); + } + + #region Initialization + + private readonly RequestFlowOptions _sut = new(); + + #endregion + + #region Helpers + + public sealed record Ping(string Text) : IRequest; + + public sealed record Wipe : IRequest; + + // Other tests scan this assembly and demand a handler per request type, so Ping needs + // a concrete handler even though these tests never dispatch it. + public sealed class PingHandler : IRequestHandler + { + public Task HandleAsync(Ping request, CancellationToken cancellationToken) + => Task.FromResult(request.Text); + } + + public sealed class WipeHandler : IRequestHandler + { + public Task HandleAsync(Wipe request, CancellationToken cancellationToken) + => Task.CompletedTask; + } + + private interface IAuditable + { } + + private interface ITag + { } + + private sealed class LoggingStage : IRequestStage + where TRequest : IRequest + { + public Task HandleAsync(TRequest request, StageDelegate next, CancellationToken cancellationToken) + => next(); + } + + private sealed class PingAuditStage : IRequestStage + { + public Task HandleAsync(Ping request, StageDelegate next, CancellationToken cancellationToken) + => next(); + } + + // One type parameter that the contract never uses as its request, so validation rejects + // it even though it implements IRequestStage. + private sealed class OneParameterStage : IRequestStage + { + public Task HandleAsync(Ping request, StageDelegate next, CancellationToken cancellationToken) + => next(); + } + + private sealed class ResponseBoundStage : IRequestStage + where TRequest : IRequest + { + public Task HandleAsync(TRequest request, StageDelegate next, CancellationToken cancellationToken) + => next(); + } + + // Implements the contract, but with the parameters transposed, so closing it over a + // request produces a stage no request can match. + private sealed class SwappedStage : IRequestStage + where TRequest : IRequest + { + public Task HandleAsync(TRequest request, StageDelegate next, CancellationToken cancellationToken) + => next(); + } + + private sealed class VoidOnlyStage : IRequestStage + where TRequest : IRequest + { + public Task HandleAsync(TRequest request, StageDelegate next, CancellationToken cancellationToken) + => next(); + } + + private sealed class WipeAuditStage : IRequestStage + { + public Task HandleAsync(Wipe request, StageDelegate next, CancellationToken cancellationToken) + => next(); + } + + private sealed class NotAStage + { } + + private abstract class AbstractStage : IRequestStage + { + public abstract Task HandleAsync(Ping request, StageDelegate next, CancellationToken cancellationToken); + } + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Stages/StageClosingTests.cs b/tests/RequestFlow.Tests.Unit/Stages/StageClosingTests.cs new file mode 100644 index 0000000..497b474 --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Stages/StageClosingTests.cs @@ -0,0 +1,208 @@ +using Microsoft.Extensions.DependencyInjection; +using RequestFlow; +using RequestFlow.Tests.ValidationFixtures; + +namespace RequestFlow.Tests.Unit; + +public sealed class StageClosingTests +{ + [Fact] + public void Given_Unconstrained_Open_Stage_When_Closing_Over_A_Request_Then_Applies_With_Closed_Type() + { + var declaration = new StageDeclaration(typeof(LoggingStage<,>), null); + + bool applies = StageClosing.TryClose(declaration, _pingHandler, out Type closedStageType, out string reason); + + applies.ShouldBeTrue(); + closedStageType.ShouldBe(typeof(LoggingStage)); + reason.ShouldContain("generic constraints"); + } + + [Fact] + public void Given_Constrained_Open_Stage_When_Request_Violates_The_Constraint_Then_Does_Not_Apply() + { + var declaration = new StageDeclaration(typeof(TaggedOnlyStage<,>), null); + + bool applies = StageClosing.TryClose(declaration, _pingHandler, out _, out _); + + applies.ShouldBeFalse(); + } + + [Fact] + public void Given_Constrained_Open_Stage_When_Request_Satisfies_The_Constraint_Then_Applies() + { + var declaration = new StageDeclaration(typeof(TaggedOnlyStage<,>), null); + + bool applies = StageClosing.TryClose(declaration, _taggedHandler, out Type closedStageType, out _); + + applies.ShouldBeTrue(); + closedStageType.ShouldBe(typeof(TaggedOnlyStage)); + } + + [Fact] + public void Given_Closed_Stage_When_Request_Matches_Its_Contract_Then_Applies_Without_Closing() + { + var declaration = new StageDeclaration(typeof(PingAuditStage), null); + + bool applies = StageClosing.TryClose(declaration, _pingHandler, out Type closedStageType, out string reason); + + applies.ShouldBeTrue(); + closedStageType.ShouldBe(typeof(PingAuditStage)); + reason.ShouldContain("closed stage"); + } + + [Fact] + public void Given_Closed_Stage_When_Request_Does_Not_Match_Its_Contract_Then_Does_Not_Apply() + { + var declaration = new StageDeclaration(typeof(PingAuditStage), null); + + bool applies = StageClosing.TryClose(declaration, _taggedHandler, out _, out _); + + applies.ShouldBeFalse(); + } + + [Fact] + public void Given_Handler_Filter_When_Handler_Implements_The_Contract_Then_Applies() + { + var declaration = new StageDeclaration(typeof(LoggingStage<,>), typeof(IAuditable)); + + bool applies = StageClosing.TryClose(declaration, _taggedHandler, out Type closedStageType, out string reason); + + applies.ShouldBeTrue(); + closedStageType.ShouldBe(typeof(LoggingStage)); + reason.ShouldContain(nameof(IAuditable)); + } + + [Fact] + public void Given_Handler_Filter_When_Handler_Does_Not_Implement_The_Contract_Then_Does_Not_Apply() + { + var declaration = new StageDeclaration(typeof(LoggingStage<,>), typeof(IAuditable)); + + bool applies = StageClosing.TryClose(declaration, _pingHandler, out _, out _); + + applies.ShouldBeFalse(); + } + + [Fact] + public void Given_Void_Request_When_Closing_An_Open_Stage_Then_Applies_Over_No_Result() + { + var declaration = new StageDeclaration(typeof(LoggingStage<,>), null); + + bool applies = StageClosing.TryClose(declaration, _logHandler, out Type closedStageType, out _); + + applies.ShouldBeTrue(); + closedStageType.ShouldBe(typeof(LoggingStage)); + } + + [Fact] + public void Given_Stage_Registered_Before_A_Later_Assembly_Scan_When_Adding_Request_Flow_Twice_Then_Closed_Stage_Is_Registered_For_Both() + { + var services = new ServiceCollection(); + + services.AddRequestFlow(o => o + .RegisterHandlersFromAssemblyContaining() + .AddStage(typeof(LoggingStage<,>))); + services.AddRequestFlow(o => o.RegisterHandlersFromAssembly(typeof(Rooted).Assembly)); + + services.ShouldContain(d => d.ServiceType == typeof(LoggingStage)); + services.ShouldContain(d => d.ServiceType == typeof(LoggingStage)); + } + + [Fact] + public void Given_Same_Closed_Stage_Reached_By_Two_Calls_When_Adding_Request_Flow_Then_It_Is_Registered_Once() + { + var services = new ServiceCollection(); + + services.AddRequestFlow(o => o + .RegisterHandlersFromAssemblyContaining() + .AddStage(typeof(LoggingStage<,>))); + services.AddRequestFlow(o => o + .RegisterHandlersFromAssemblyContaining() + .AddStage(typeof(LoggingStage<,>))); + + services.Count(d => d.ServiceType == typeof(LoggingStage)).ShouldBe(1); + } + + [Fact] + public void Given_User_Registered_Closed_Stage_When_Adding_Request_Flow_Then_The_User_Lifetime_Wins() + { + var services = new ServiceCollection(); + services.AddSingleton>(); + + services.AddRequestFlow(o => o + .RegisterHandlersFromAssemblyContaining() + .AddStage(typeof(LoggingStage<,>))); + + ServiceDescriptor descriptor = services.Single(d => d.ServiceType == typeof(LoggingStage)); + descriptor.Lifetime.ShouldBe(ServiceLifetime.Singleton); + } + + #region Initialization + + private readonly HandlerRegistration _pingHandler = + new(typeof(PingHandler), typeof(Ping), typeof(string), isVoid: false); + + private readonly HandlerRegistration _taggedHandler = + new(typeof(TaggedHandler), typeof(Tagged), typeof(string), isVoid: false); + + private readonly HandlerRegistration _logHandler = + new(typeof(LogHandler), typeof(Log), typeof(NoResult), isVoid: true); + + #endregion + + #region Helpers + + public interface ITag + { } + + public interface IAuditable + { } + + public sealed record Ping(string Text) : IRequest; + + public sealed record Tagged(string Text) : IRequest, ITag; + + public sealed record Log : IRequest; + + // Every request type in this assembly needs exactly one handler: other test classes scan + // it and build a provider, which is where the missing-handler check runs. + public sealed class PingHandler : IRequestHandler + { + public Task HandleAsync(Ping request, CancellationToken cancellationToken) + => Task.FromResult(request.Text); + } + + public sealed class TaggedHandler : IRequestHandler, IAuditable + { + public Task HandleAsync(Tagged request, CancellationToken cancellationToken) + => Task.FromResult(request.Text); + } + + public sealed class LogHandler : IRequestHandler + { + public Task HandleAsync(Log request, CancellationToken cancellationToken) + => Task.CompletedTask; + } + + private sealed class LoggingStage : IRequestStage + where TRequest : IRequest + { + public Task HandleAsync(TRequest request, StageDelegate next, CancellationToken cancellationToken) + => next(); + } + + private sealed class TaggedOnlyStage : IRequestStage + where TRequest : IRequest, ITag + { + public Task HandleAsync(TRequest request, StageDelegate next, CancellationToken cancellationToken) + => next(); + } + + private sealed class PingAuditStage : IRequestStage + { + public Task HandleAsync(Ping request, StageDelegate next, CancellationToken cancellationToken) + => next(); + } + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Stages/StageExecutorTests.cs b/tests/RequestFlow.Tests.Unit/Stages/StageExecutorTests.cs new file mode 100644 index 0000000..abbd812 --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Stages/StageExecutorTests.cs @@ -0,0 +1,644 @@ +using RequestFlow; + +namespace RequestFlow.Tests.Unit; + +public sealed class StageExecutorTests +{ + [Fact] + public async Task Given_No_Stages_When_Running_Executor_Then_Handler_Produces_Response() + { + var sut = new TypedStageExecutor([], _pingHandler, new Ping("hi"), CancellationToken.None); + + string result = await sut.RunAsync(); + + result.ShouldBe("hi:handled"); + } + + [Fact] + public async Task Given_Two_Stages_When_Running_Executor_Then_First_Registered_Stage_Is_Outermost() + { + List log = []; + IRequestStage[] stages = [new RecordingStage("outer", log), new RecordingStage("inner", log)]; + var sut = new TypedStageExecutor(stages, _pingHandler, new Ping("hi"), CancellationToken.None); + + await sut.RunAsync(); + + log.ShouldBe(["outer:enter", "inner:enter", "inner:exit", "outer:exit"]); + } + + [Fact] + public async Task Given_Stage_That_Awaits_Before_Calling_Next_When_Running_Executor_Then_Chain_Completes() + { + List log = []; + IRequestStage[] stages = [new AwaitBeforeNextStage("outer", log)]; + var sut = new TypedStageExecutor(stages, _pingHandler, new Ping("hi"), CancellationToken.None); + + string result = await sut.RunAsync(); + + result.ShouldBe("hi:handled"); + log.ShouldBe(["outer:enter", "outer:exit"]); + } + + [Fact] + public async Task Given_Two_Stages_That_Await_Before_Calling_Next_When_Running_Executor_Then_First_Registered_Stage_Is_Outermost() + { + List log = []; + IRequestStage[] stages = [new AwaitBeforeNextStage("outer", log), new AwaitBeforeNextStage("inner", log)]; + var sut = new TypedStageExecutor(stages, _pingHandler, new Ping("hi"), CancellationToken.None); + + await sut.RunAsync(); + + log.ShouldBe(["outer:enter", "inner:enter", "inner:exit", "outer:exit"]); + } + + [Fact] + public async Task Given_Stage_That_Skips_Next_When_Running_Executor_Then_Handler_Is_Not_Invoked() + { + IRequestStage[] stages = [new ShortCircuitStage("cached")]; + var sut = new TypedStageExecutor(stages, _pingHandler, new Ping("hi"), CancellationToken.None); + + string result = await sut.RunAsync(); + + result.ShouldBe("cached"); + await _pingHandler.DidNotReceive().HandleAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Given_Throwing_Handler_When_Running_Executor_Then_Exception_Propagates_Unwrapped() + { + _pingHandler.HandleAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new InvalidTimeZoneException("no such zone"))); + IRequestStage[] stages = [new RecordingStage("outer", [])]; + var sut = new TypedStageExecutor(stages, _pingHandler, new Ping("hi"), CancellationToken.None); + + var exception = await Should.ThrowAsync(() => sut.RunAsync()); + + exception.Message.ShouldBe("no such zone"); + } + + [Fact] + public async Task Given_Cancellation_Token_When_Running_Executor_Then_Stage_And_Handler_Receive_Same_Token() + { + using var cts = new CancellationTokenSource(); + var stage = new TokenCapturingStage(); + var sut = new TypedStageExecutor([stage], _pingHandler, new Ping("hi"), cts.Token); + + await sut.RunAsync(); + + stage.CapturedToken.ShouldBe(cts.Token); + await _pingHandler.Received(1).HandleAsync(Arg.Any(), cts.Token); + } + + [Fact] + public async Task Given_Void_Handler_And_One_Stage_When_Running_Executor_Then_Handler_Runs_And_Chain_Completes() + { + var logHandler = Substitute.For>(); + List log = []; + IRequestStage[] stages = [new RecordingVoidStage(log)]; + var sut = new VoidStageExecutor(stages, logHandler, new Log("hi"), CancellationToken.None); + + NoResult result = await sut.RunAsync(); + + result.ShouldBe(NoResult.Value); + log.ShouldBe(["enter", "exit"]); + await logHandler.Received(1).HandleAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Given_Stage_That_Calls_Next_Twice_When_Running_Executor_Then_Inner_Chain_Runs_Again() + { + List log = []; + IRequestStage[] stages = [new DoubleNextStage("outer", log), new RecordingStage("inner", log)]; + var sut = new TypedStageExecutor(stages, _pingHandler, new Ping("hi"), CancellationToken.None); + + await sut.RunAsync(); + + log.ShouldBe(["outer:enter", "inner:enter", "inner:exit", "inner:enter", "inner:exit", "outer:exit"]); + await _pingHandler.Received(2).HandleAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Given_Asynchronously_Completing_Handler_When_Stage_Calls_Next_Twice_Then_Inner_Chain_Runs_Again() + { + _pingHandler.HandleAsync(Arg.Any(), Arg.Any()) + .Returns(call => YieldThenReturnAsync(call.Arg().Text + ":handled")); + List log = []; + IRequestStage[] stages = [new DoubleNextStage("outer", log), new RecordingStage("inner", log)]; + var sut = new TypedStageExecutor(stages, _pingHandler, new Ping("hi"), CancellationToken.None); + + await sut.RunAsync(); + + log.ShouldBe(["outer:enter", "inner:enter", "inner:exit", "inner:enter", "inner:exit", "outer:exit"]); + } + + [Fact] + public async Task Given_Failing_Handler_When_Outer_Stage_Retries_Then_Inner_Chain_Runs_Again() + { + int calls = 0; + _pingHandler.HandleAsync(Arg.Any(), Arg.Any()) + .Returns(_ => ++calls == 1 + ? Task.FromException(new InvalidTimeZoneException("transient")) + : Task.FromResult("second")); + List log = []; + IRequestStage[] stages = [new RetryOnceStage(log), new RecordingStage("inner", log)]; + var sut = new TypedStageExecutor(stages, _pingHandler, new Ping("hi"), CancellationToken.None); + + string result = await sut.RunAsync(); + + result.ShouldBe("second"); + log.ShouldBe(["retry:attempt", "inner:enter", "retry:attempt", "inner:enter", "inner:exit"]); + } + + [Fact] + public async Task Given_Stage_That_Throws_Before_Returning_A_Task_When_Outer_Stage_Retries_Then_It_Runs_Again() + { + var flaky = new ThrowOnFirstAttemptStage(); + IRequestStage[] stages = [new RetryOnceStage([]), flaky]; + var sut = new TypedStageExecutor(stages, _pingHandler, new Ping("hi"), CancellationToken.None); + + string result = await sut.RunAsync(); + + result.ShouldBe("hi:handled"); + flaky.Attempts.ShouldBe(2); + } + + [Fact] + public async Task Given_Stage_That_Calls_Next_Again_Before_The_First_Call_Completes_When_Running_Executor_Then_Throws() + { + var pending = new TaskCompletionSource(); + _pingHandler.HandleAsync(Arg.Any(), Arg.Any()).Returns(pending.Task); + List log = []; + IRequestStage[] stages = [new ConcurrentNextStage(), new RecordingStage("inner", log)]; + var sut = new TypedStageExecutor(stages, _pingHandler, new Ping("hi"), CancellationToken.None); + + var exception = await Should.ThrowAsync(() => sut.RunAsync()); + + exception.Message.ShouldContain(nameof(ConcurrentNextStage)); + exception.Message.ShouldContain("still running"); + log.ShouldBe(["inner:enter"]); + } + + [Fact] + public async Task Given_Stage_That_Calls_Next_From_Two_Threads_At_Once_When_Running_Executor_Then_Exactly_One_Call_Proceeds() + { + const int attempts = 1000; + int handlerRuns = 0; + int guardThrows = 0; + + // The race window depends on timing, so the test forces many synchronized collisions. + for (int i = 0; i < attempts; i++) + { + var gate = new TaskCompletionSource(); + var handler = Substitute.For>(); + handler.HandleAsync(Arg.Any(), Arg.Any()) + .Returns(_ => + { + Interlocked.Increment(ref handlerRuns); + return gate.Task; + }); + var stage = new SimultaneousNextStage(gate, () => Interlocked.Increment(ref guardThrows)); + var sut = new TypedStageExecutor([stage], handler, new Ping("hi"), CancellationToken.None); + + await sut.RunAsync(); + } + + handlerRuns.ShouldBe(attempts); + guardThrows.ShouldBe(attempts); + } + + [Fact] + public async Task Given_Void_Form_Stage_That_Calls_Next_From_Two_Threads_At_Once_When_Running_Void_Executor_Then_Exactly_One_Call_Proceeds() + { + const int attempts = 1000; + int handlerRuns = 0; + int guardThrows = 0; + + for (int i = 0; i < attempts; i++) + { + var gate = new TaskCompletionSource(); + var handler = Substitute.For>(); + handler.HandleAsync(Arg.Any(), Arg.Any()) + .Returns(_ => + { + Interlocked.Increment(ref handlerRuns); + return gate.Task; + }); + object[] stages = [new SimultaneousNextVoidStage(gate, () => Interlocked.Increment(ref guardThrows))]; + var sut = new VoidStageExecutor(stages, handler, new Log("hi"), CancellationToken.None); + + await sut.RunAsync(); + } + + handlerRuns.ShouldBe(attempts); + guardThrows.ShouldBe(attempts); + } + + [Fact] + public void Given_Stage_That_Returns_A_Null_Task_When_Running_Executor_Then_Throws_Naming_The_Stage() + { + IRequestStage[] stages = [new NullTaskStage()]; + var sut = new TypedStageExecutor(stages, _pingHandler, new Ping("hi"), CancellationToken.None); + + InvalidOperationException exception = Should.Throw(() => sut.RunAsync()); + + exception.Message.ShouldContain(nameof(NullTaskStage)); + exception.Message.ShouldContain("null task"); + } + + [Fact] + public void Given_Handler_That_Returns_A_Null_Task_When_Running_Executor_Then_Throws_Naming_The_Request() + { + var sut = new TypedStageExecutor([], new NilHandler(), new Nil(), CancellationToken.None); + + InvalidOperationException exception = Should.Throw(() => sut.RunAsync()); + + exception.Message.ShouldContain(nameof(Nil)); + exception.Message.ShouldContain("null task"); + } + + [Fact] + public async Task Given_Stage_That_Returned_A_Null_Task_When_Outer_Stage_Retries_Then_It_Runs_Again() + { + var flaky = new NullTaskOnFirstAttemptStage(); + IRequestStage[] stages = [new RetryOnceStage([]), flaky]; + var sut = new TypedStageExecutor(stages, _pingHandler, new Ping("hi"), CancellationToken.None); + + string result = await sut.RunAsync(); + + result.ShouldBe("hi:handled"); + flaky.Attempts.ShouldBe(2); + } + + [Fact] + public async Task Given_Void_Form_Stage_When_Running_Void_Executor_Then_It_Wraps_The_Handler() + { + var logHandler = Substitute.For>(); + List log = []; + object[] stages = [new VoidFormStage(log)]; + var sut = new VoidStageExecutor(stages, logHandler, new Log("hi"), CancellationToken.None); + + NoResult result = await sut.RunAsync(); + + result.ShouldBe(NoResult.Value); + log.ShouldBe(["void:enter", "void:exit"]); + await logHandler.Received(1).HandleAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Given_Void_Form_Stage_That_Awaits_Before_Calling_Next_When_Running_Void_Executor_Then_Chain_Completes() + { + var logHandler = Substitute.For>(); + List log = []; + object[] stages = [new AwaitBeforeNextVoidStage(log)]; + var sut = new VoidStageExecutor(stages, logHandler, new Log("hi"), CancellationToken.None); + + NoResult result = await sut.RunAsync(); + + result.ShouldBe(NoResult.Value); + log.ShouldBe(["void:enter", "void:exit"]); + await logHandler.Received(1).HandleAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Given_Both_Stage_Forms_When_Running_Void_Executor_Then_Array_Order_Is_Execution_Order() + { + var logHandler = Substitute.For>(); + List log = []; + object[] stages = [new RecordingVoidStage(log), new VoidFormStage(log)]; + var sut = new VoidStageExecutor(stages, logHandler, new Log("hi"), CancellationToken.None); + + await sut.RunAsync(); + + log.ShouldBe(["enter", "void:enter", "void:exit", "exit"]); + } + + [Fact] + public async Task Given_Void_Form_Stage_That_Skips_Next_When_Running_Void_Executor_Then_Handler_Is_Not_Invoked() + { + var logHandler = Substitute.For>(); + object[] stages = [new ShortCircuitVoidStage()]; + var sut = new VoidStageExecutor(stages, logHandler, new Log("hi"), CancellationToken.None); + + await sut.RunAsync(); + + await logHandler.DidNotReceive().HandleAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public void Given_Void_Form_Stage_That_Returns_A_Null_Task_When_Running_Void_Executor_Then_Throws_Naming_The_Stage() + { + var logHandler = Substitute.For>(); + object[] stages = [new NullTaskVoidStage()]; + var sut = new VoidStageExecutor(stages, logHandler, new Log("hi"), CancellationToken.None); + + InvalidOperationException exception = Should.Throw(() => sut.RunAsync()); + + exception.Message.ShouldContain(nameof(NullTaskVoidStage)); + exception.Message.ShouldContain("null task"); + } + + [Fact] + public void Given_Void_Handler_That_Returns_A_Null_Task_When_Running_Void_Executor_Then_Throws_Naming_The_Request() + { + var sut = new VoidStageExecutor([], new SilentHandler(), new Silent(), CancellationToken.None); + + InvalidOperationException exception = Should.Throw(() => sut.RunAsync()); + + exception.Message.ShouldContain(nameof(Silent)); + exception.Message.ShouldContain("null task"); + } + + [Fact] + public async Task Given_Synchronously_Completed_Task_When_Bridging_To_No_Result_Then_Returns_Cached_Task() + { + Task result = NoResultBridge.Complete(Task.CompletedTask); + + result.ShouldBeSameAs(NoResult.Task); + await result; + } + + #region Initialization + + private readonly IRequestHandler _pingHandler; + + public StageExecutorTests() + { + _pingHandler = Substitute.For>(); + _pingHandler.HandleAsync(Arg.Any(), Arg.Any()) + .Returns(call => Task.FromResult(call.Arg().Text + ":handled")); + } + + #endregion + + #region Helpers + + // Public so NSubstitute can proxy handler interfaces closed over these types. + public sealed record Ping(string Text) : IRequest; + + public sealed record Log(string Message) : IRequest; + + public sealed record Nil : IRequest; + + public sealed record Silent : IRequest; + + // Other tests scan this assembly and demand a handler per request type, so each + // fixture record needs a concrete handler even though these tests only use the mocks. + public sealed class PingHandler : IRequestHandler + { + public Task HandleAsync(Ping request, CancellationToken cancellationToken) + => Task.FromResult(request.Text); + } + + public sealed class LogHandler : IRequestHandler + { + public Task HandleAsync(Log request, CancellationToken cancellationToken) + => Task.CompletedTask; + } + + // Breaks the handler contract on purpose, which is what the executor has to report. + public sealed class NilHandler : IRequestHandler + { + public Task HandleAsync(Nil request, CancellationToken cancellationToken) + => null!; + } + + // The void form of the same broken contract, kept on its own request so the assembly scan + // still finds exactly one handler per request. + public sealed class SilentHandler : IRequestHandler + { + public Task HandleAsync(Silent request, CancellationToken cancellationToken) + => null!; + } + + private sealed class RecordingStage(string name, List log) : IRequestStage + { + public async Task HandleAsync(Ping request, StageDelegate next, CancellationToken cancellationToken) + { + log.Add($"{name}:enter"); + string response = await next(); + log.Add($"{name}:exit"); + return response; + } + } + + private sealed class RecordingVoidStage(List log) : IRequestStage + { + public async Task HandleAsync(Log request, StageDelegate next, CancellationToken cancellationToken) + { + log.Add("enter"); + NoResult response = await next(); + log.Add("exit"); + return response; + } + } + + private sealed class ShortCircuitStage(string response) : IRequestStage + { + public Task HandleAsync(Ping request, StageDelegate next, CancellationToken cancellationToken) + => Task.FromResult(response); + } + + private sealed class DoubleNextStage(string name, List log) : IRequestStage + { + public async Task HandleAsync(Ping request, StageDelegate next, CancellationToken cancellationToken) + { + log.Add($"{name}:enter"); + await next(); + string response = await next(); + log.Add($"{name}:exit"); + return response; + } + } + + private sealed class RetryOnceStage(List log) : IRequestStage + { + public async Task HandleAsync(Ping request, StageDelegate next, CancellationToken cancellationToken) + { + log.Add("retry:attempt"); + try + { + return await next(); + } + catch (Exception) + { + log.Add("retry:attempt"); + return await next(); + } + } + } + + private sealed class ThrowOnFirstAttemptStage : IRequestStage + { + public int Attempts { get; private set; } + + public Task HandleAsync(Ping request, StageDelegate next, CancellationToken cancellationToken) + { + Attempts++; + return Attempts == 1 ? throw new InvalidOperationException("sync boom") : next(); + } + } + + // Suspends on work of its own before delegating, the shape of a validation or caching stage. + private sealed class AwaitBeforeNextStage(string name, List log) : IRequestStage + { + public async Task HandleAsync(Ping request, StageDelegate next, CancellationToken cancellationToken) + { + await Task.Yield(); + log.Add($"{name}:enter"); + string response = await next(); + log.Add($"{name}:exit"); + return response; + } + } + + private sealed class AwaitBeforeNextVoidStage(List log) : IRequestStage + { + public async Task HandleAsync(Log request, StageDelegate next, CancellationToken cancellationToken) + { + await Task.Yield(); + log.Add("void:enter"); + await next(); + log.Add("void:exit"); + } + } + + // Starts a second walk of the chain while the first is still suspended on the handler. + private sealed class ConcurrentNextStage : IRequestStage + { + public async Task HandleAsync(Ping request, StageDelegate next, CancellationToken cancellationToken) + { + Task first = next(); + Task second = next(); + + return await first.ConfigureAwait(false) + await second.ConfigureAwait(false); + } + } + + // Releases both callers into next at the same instant. The gate keeps the handler's task + // incomplete until both calls have been attempted, so an overlapping call can never look + // like a legal sequential re-run: a second success means the guard let both through. + private sealed class SimultaneousNextStage(TaskCompletionSource gate, Action onGuardThrow) + : IRequestStage + { + public async Task HandleAsync(Ping request, StageDelegate next, CancellationToken cancellationToken) + { + using var barrier = new Barrier(2); + Task?[] calls = new Task?[2]; + + Task Caller(int slot) => Task.Run(() => + { + barrier.SignalAndWait(); + try + { + calls[slot] = next(); + } + catch (InvalidOperationException) + { + onGuardThrow(); + } + }); + + await Task.WhenAll(Caller(0), Caller(1)); + gate.SetResult("done"); + + string result = ""; + foreach (Task? call in calls) + { + if (call is not null) + result = await call; + } + + return result; + } + } + + private sealed class SimultaneousNextVoidStage(TaskCompletionSource gate, Action onGuardThrow) + : IRequestStage + { + public async Task HandleAsync(Log request, StageDelegate next, CancellationToken cancellationToken) + { + using var barrier = new Barrier(2); + Task?[] calls = new Task?[2]; + + Task Caller(int slot) => Task.Run(() => + { + barrier.SignalAndWait(); + try + { + calls[slot] = next(); + } + catch (InvalidOperationException) + { + onGuardThrow(); + } + }); + + await Task.WhenAll(Caller(0), Caller(1)); + gate.SetResult(NoResult.Value); + + foreach (Task? call in calls) + { + if (call is not null) + await call; + } + } + } + + private sealed class NullTaskStage : IRequestStage + { + public Task HandleAsync(Ping request, StageDelegate next, CancellationToken cancellationToken) + => null!; + } + + private sealed class NullTaskOnFirstAttemptStage : IRequestStage + { + public int Attempts { get; private set; } + + public Task HandleAsync(Ping request, StageDelegate next, CancellationToken cancellationToken) + { + Attempts++; + return Attempts == 1 ? null! : next(); + } + } + + private sealed class VoidFormStage(List log) : IRequestStage + { + public async Task HandleAsync(Log request, StageDelegate next, CancellationToken cancellationToken) + { + log.Add("void:enter"); + await next(); + log.Add("void:exit"); + } + } + + private sealed class ShortCircuitVoidStage : IRequestStage + { + public Task HandleAsync(Log request, StageDelegate next, CancellationToken cancellationToken) + => Task.CompletedTask; + } + + private sealed class NullTaskVoidStage : IRequestStage + { + public Task HandleAsync(Log request, StageDelegate next, CancellationToken cancellationToken) + => null!; + } + + private static async Task YieldThenReturnAsync(string response) + { + await Task.Yield(); + return response; + } + + private sealed class TokenCapturingStage : IRequestStage + { + public CancellationToken CapturedToken { get; private set; } + + public Task HandleAsync(Ping request, StageDelegate next, CancellationToken cancellationToken) + { + CapturedToken = cancellationToken; + return next(); + } + } + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Stages/StageLifetimeTests.cs b/tests/RequestFlow.Tests.Unit/Stages/StageLifetimeTests.cs new file mode 100644 index 0000000..a258cf4 --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Stages/StageLifetimeTests.cs @@ -0,0 +1,147 @@ +using Microsoft.Extensions.DependencyInjection; +using RequestFlow; + +namespace RequestFlow.Tests.Unit; + +public sealed class StageLifetimeTests +{ + [Fact] + public async Task Given_Stage_With_A_Constructor_Dependency_When_Sending_Request_Then_It_Is_Injected() + { + ServiceProvider provider = Build(); + + await SendAsync(provider); + + StageMarkers.Count.ShouldBe(1); + StageMarkers[0].ShouldNotBeNull(); + } + + [Fact] + public async Task Given_Scoped_Dependency_When_Sending_Request_Then_Stage_And_Handler_Share_The_Instance() + { + ServiceProvider provider = Build(); + + await SendAsync(provider); + + StageMarkers[0].ShouldBeSameAs(HandlerMarkers[0]); + } + + [Fact] + public async Task Given_Scoped_Dependency_When_Sending_From_Two_Scopes_Then_Each_Scope_Gets_Its_Own_Instance() + { + ServiceProvider provider = Build(); + + await SendTwiceInSeparateScopesAsync(provider); + + StageMarkers.Count.ShouldBe(2); + StageMarkers[0].ShouldNotBeSameAs(StageMarkers[1]); + } + + [Fact] + public async Task Given_Transient_Stage_When_Sending_Twice_In_One_Scope_Then_A_New_Stage_Instance_Runs_Each_Time() + { + ServiceProvider provider = Build(); + + await SendTwiceInOneScopeAsync(provider); + + StageInstances[0].ShouldNotBeSameAs(StageInstances[1]); + StageMarkers[0].ShouldBeSameAs(StageMarkers[1]); + } + + [Fact] + public void Given_Consumer_Registered_Stage_When_Adding_Request_Flow_Then_The_Consumer_Registration_Is_Kept() + { + var services = new ServiceCollection(); + services.AddScoped(); + services.AddSingleton>(); + + services.AddRequestFlow(o => o + .RegisterHandlersFromAssemblyContaining() + .AddStage(typeof(MarkerStage<,>))); + + List descriptors = + [.. services.Where(d => d.ServiceType == typeof(MarkerStage))]; + descriptors.Count.ShouldBe(1); + descriptors[0].Lifetime.ShouldBe(ServiceLifetime.Singleton); + } + + #region Initialization + + // The container instantiates stages, so what they saw lands in statics; the constructor + // clears them per test. + private static readonly List StageMarkers = []; + private static readonly List HandlerMarkers = []; + private static readonly List StageInstances = []; + + public StageLifetimeTests() + { + StageMarkers.Clear(); + HandlerMarkers.Clear(); + StageInstances.Clear(); + } + + #endregion + + #region Helpers + + private static ServiceProvider Build() + { + var services = new ServiceCollection(); + services.AddScoped(); + services.AddRequestFlow(o => o + .RegisterHandlersFromAssemblyContaining() + .AddStage(typeof(MarkerStage<,>))); + + return services.BuildServiceProvider(); + } + + private static async Task SendAsync(ServiceProvider provider) + { + using IServiceScope scope = provider.CreateScope(); + await scope.ServiceProvider.GetRequiredService().SendAsync(new Probe()); + } + + private static async Task SendTwiceInSeparateScopesAsync(ServiceProvider provider) + { + await SendAsync(provider); + await SendAsync(provider); + } + + private static async Task SendTwiceInOneScopeAsync(ServiceProvider provider) + { + using IServiceScope scope = provider.CreateScope(); + var dispatcher = scope.ServiceProvider.GetRequiredService(); + + await dispatcher.SendAsync(new Probe()); + await dispatcher.SendAsync(new Probe()); + } + + public sealed class ScopeMarker + { } + + public sealed record Probe : IRequest; + + public sealed class ProbeHandler(ScopeMarker marker) : IRequestHandler + { + public Task HandleAsync(Probe request, CancellationToken cancellationToken) + { + HandlerMarkers.Add(marker); + return Task.FromResult("probe"); + } + } + + public sealed class MarkerStage(ScopeMarker marker) : IRequestStage + where TRequest : IRequest + { + public Task HandleAsync( + TRequest request, StageDelegate next, CancellationToken cancellationToken) + { + StageMarkers.Add(marker); + StageInstances.Add(this); + + return next(); + } + } + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Stages/StagePipelineTests.cs b/tests/RequestFlow.Tests.Unit/Stages/StagePipelineTests.cs new file mode 100644 index 0000000..b414972 --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Stages/StagePipelineTests.cs @@ -0,0 +1,417 @@ +using Microsoft.Extensions.DependencyInjection; +using RequestFlow; + +namespace RequestFlow.Tests.Unit; + +public sealed class StagePipelineTests +{ + [Fact] + public async Task Given_Open_Stage_When_Sending_Request_Then_Stage_Wraps_The_Handler() + { + IRequestDispatcher dispatcher = Build(o => o.AddStage(typeof(RecordingStage<,>))); + + string result = await dispatcher.SendAsync(new Ping("hi")); + + result.ShouldBe("hi"); + Trace.ShouldBe(["Recording:enter", "Recording:exit"]); + } + + [Fact] + public async Task Given_Two_Stages_When_Sending_Request_Then_Registration_Order_Is_Execution_Order() + { + IRequestDispatcher dispatcher = Build(o => o + .AddStage(typeof(RecordingStage<,>)) + .AddStage(typeof(SecondStage<,>))); + + await dispatcher.SendAsync(new Ping("hi")); + + Trace.ShouldBe(["Recording:enter", "Second:enter", "Second:exit", "Recording:exit"]); + } + + [Fact] + public async Task Given_Constrained_Stage_When_Sending_A_Request_It_Excludes_Then_Stage_Does_Not_Run() + { + IRequestDispatcher dispatcher = Build(o => o.AddStage(typeof(TaggedOnlyStage<,>))); + + await dispatcher.SendAsync(new Ping("hi")); + + Trace.ShouldBeEmpty(); + } + + [Fact] + public async Task Given_Constrained_Stage_When_Sending_A_Request_It_Admits_Then_Stage_Runs() + { + IRequestDispatcher dispatcher = Build(o => o.AddStage(typeof(TaggedOnlyStage<,>))); + + await dispatcher.SendAsync(new Tagged("hi")); + + Trace.ShouldBe(["TaggedOnly:enter", "TaggedOnly:exit"]); + } + + [Fact] + public async Task Given_Closed_Stage_When_Sending_Requests_Then_Only_Its_Own_Request_Is_Wrapped() + { + IRequestDispatcher dispatcher = Build(o => o.AddStage()); + + await dispatcher.SendAsync(new Ping("hi")); + await dispatcher.SendAsync(new Tagged("hi")); + + Trace.ShouldBe(["PingOnly:enter", "PingOnly:exit"]); + } + + [Fact] + public async Task Given_Closed_Stage_For_A_Base_Request_When_Sending_A_Derived_Request_Then_The_Stage_Runs() + { + IRequestDispatcher dispatcher = Build(o => o.AddStage()); + + string result = await dispatcher.SendAsync(new EmailNotification("hi")); + + result.ShouldBe("hi"); + Trace.ShouldBe(["Notification:enter", "Notification:exit"]); + } + + [Fact] + public void Given_One_Stage_Type_With_Two_Handler_Filters_When_Resolving_Dispatcher_Then_Reports_The_Duplicate() + { + RequestFlowValidationException exception = Should.Throw(() => + Build(o => o + .AddStage(typeof(RecordingStage<,>), s => s.WhereHandlerImplements()) + .AddStage(typeof(RecordingStage<,>), s => s.WhereHandlerImplements()))); + + exception.Problems.ShouldContain(p => + p.Contains(nameof(RecordingStage)) && p.Contains("more than once")); + exception.Problems.ShouldContain(p => p.Contains("handler filter does not make")); + } + + [Fact] + public async Task Given_Void_Request_With_A_Stage_When_Sending_Request_Then_Stage_Wraps_The_Void_Handler() + { + IRequestDispatcher dispatcher = Build(o => o.AddStage(typeof(RecordingStage<,>))); + + await dispatcher.SendAsync(new Wipe()); + + Trace.ShouldBe(["Recording:enter", "Wipe:handled", "Recording:exit"]); + } + + [Fact] + public async Task Given_No_Stages_When_Sending_Request_Then_Response_Is_Unchanged() + { + IRequestDispatcher dispatcher = Build(); + + string result = await dispatcher.SendAsync(new Ping("hi")); + + result.ShouldBe("hi"); + Trace.ShouldBeEmpty(); + } + + [Fact] + public void Given_Same_Stage_Registered_By_Two_Calls_When_Resolving_Dispatcher_Then_Reports_Duplicate_Once() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o + .RegisterHandlersFromAssemblyContaining() + .AddStage(typeof(RecordingStage<,>))); + services.AddRequestFlow(o => o.AddStage(typeof(RecordingStage<,>))); + + RequestFlowValidationException exception = Should.Throw(() => + services.BuildServiceProvider().GetRequiredService()); + + exception.Problems.Count(p => p.Contains(nameof(RecordingStage))).ShouldBe(1); + } + + [Fact] + public async Task Given_Stage_That_Applies_To_Nothing_When_Not_Strict_Then_Dispatcher_Resolves_And_The_Stage_Never_Runs() + { + IRequestDispatcher dispatcher = Build(o => o.AddStage(typeof(UnreachableStage<,>))); + + string result = await dispatcher.SendAsync(new Ping("hi")); + + result.ShouldBe("hi"); + Trace.ShouldBeEmpty(); + } + + [Fact] + public void Given_Open_Stage_And_Its_Own_Closed_Form_When_Resolving_Dispatcher_Then_Reports_The_Collision() + { + RequestFlowValidationException exception = Should.Throw(() => + Build(o => o + .AddStage(typeof(RecordingStage<,>)) + .AddStage>())); + + exception.Problems.Count(p => p.Contains("resolve to")).ShouldBe(1); + exception.Problems.ShouldContain(p => p.Contains("RecordingStage") && p.Contains(nameof(Ping))); + } + + [Fact] + public void Given_Open_Stage_And_A_Closed_Form_For_A_Base_Request_When_Resolving_Dispatcher_Then_Reports_The_Collision() + { + RequestFlowValidationException exception = Should.Throw(() => + Build(o => o + .AddStage(typeof(RecordingStage<,>)) + .AddStage>())); + + exception.Problems.Count(p => p.Contains("same stage class")).ShouldBe(1); + exception.Problems.ShouldContain(p => p.Contains("RecordingStage") && p.Contains(nameof(EmailNotification))); + } + + [Fact] + public void Given_Two_Closed_Forms_Of_One_Stage_For_Base_And_Derived_Requests_When_Resolving_Dispatcher_Then_Reports_The_Collision() + { + RequestFlowValidationException exception = Should.Throw(() => + Build(o => o + .AddStage>() + .AddStage>())); + + exception.Problems.Count(p => p.Contains("same stage class")).ShouldBe(1); + exception.Problems.ShouldContain(p => p.Contains("RecordingStage") && p.Contains(nameof(EmailNotification))); + } + + [Fact] + public async Task Given_Open_Response_Bound_Stage_When_Sending_Requests_Then_Only_Matching_Responses_Are_Wrapped() + { + IRequestDispatcher dispatcher = Build(o => o.AddStage(typeof(ResponseBoundStage<>))); + + string result = await dispatcher.SendAsync(new Ping("hi")); + await dispatcher.SendAsync(new Wipe()); + + result.ShouldBe("hi"); + Trace.ShouldBe(["ResponseBound:enter", "ResponseBound:exit", "Wipe:handled"]); + } + + [Fact] + public async Task Given_Void_Form_Stage_When_Sending_A_Void_Request_Then_It_Wraps_The_Void_Handler() + { + IRequestDispatcher dispatcher = Build(o => o.AddStage(typeof(VoidRecordingStage<>))); + + await dispatcher.SendAsync(new Wipe()); + + Trace.ShouldBe(["VoidRecording:enter", "Wipe:handled", "VoidRecording:exit"]); + } + + [Fact] + public async Task Given_Void_Form_Stage_When_Sending_A_Request_That_Returns_A_Response_Then_It_Does_Not_Run() + { + IRequestDispatcher dispatcher = Build(o => o.AddStage(typeof(VoidRecordingStage<>))); + + await dispatcher.SendAsync(new Ping("hi")); + + Trace.ShouldBeEmpty(); + } + + [Fact] + public async Task Given_Both_Stage_Forms_When_Sending_A_Void_Request_Then_Registration_Order_Is_Execution_Order() + { + IRequestDispatcher dispatcher = Build(o => o + .AddStage(typeof(RecordingStage<,>)) + .AddStage(typeof(VoidRecordingStage<>))); + + await dispatcher.SendAsync(new Wipe()); + + Trace.ShouldBe( + ["Recording:enter", "VoidRecording:enter", "Wipe:handled", "VoidRecording:exit", "Recording:exit"]); + } + + [Fact] + public async Task Given_Stages_Added_By_Two_Calls_When_Sending_Request_Then_Execution_Order_Spans_The_Calls() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o + .RegisterHandlersFromAssemblyContaining() + .AddStage(typeof(RecordingStage<,>))); + services.AddRequestFlow(o => o.AddStage(typeof(SecondStage<,>))); + IRequestDispatcher dispatcher = services.BuildServiceProvider().GetRequiredService(); + + await dispatcher.SendAsync(new Ping("hi")); + + Trace.ShouldBe(["Recording:enter", "Second:enter", "Second:exit", "Recording:exit"]); + } + + [Fact] + public void Given_Stage_That_Applies_To_Nothing_When_Strict_Then_Reports_The_Stage() + { + RequestFlowValidationException exception = Should.Throw(() => + Build(o => o.AddStage(typeof(UnreachableStage<,>)).DisallowUnusedStages())); + + exception.Problems.ShouldContain(p => + p.Contains("UnreachableStage") && p.Contains("no registered request")); + } + + #region Initialization + + // The container instantiates stages, so the trace has to be static; the constructor clears + // it per test. + private static readonly List Trace = []; + + public StagePipelineTests() + => Trace.Clear(); + + #endregion + + #region Helpers + + private static IRequestDispatcher Build(Action? configure = null) + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => + { + o.RegisterHandlersFromAssemblyContaining(); + configure?.Invoke(o); + }); + + return services.BuildServiceProvider().GetRequiredService(); + } + + public interface ITag + { } + + public interface INothingImplementsThis + { } + + // Two handler contracts, so a stage can be filtered to one family of handlers. + public interface IOrdersHandler + { } + + public interface IBillingHandler + { } + + public sealed record Ping(string Text) : IRequest; + + public sealed record Tagged(string Text) : IRequest, ITag; + + public sealed record Wipe : IRequest; + + // Abstract, so the scan never counts it as a request needing a handler of its own. + public abstract record Notification(string Text) : IRequest; + + public sealed record EmailNotification(string Text) : Notification(Text); + + public sealed class PingHandler : IRequestHandler, IOrdersHandler + { + public Task HandleAsync(Ping request, CancellationToken cancellationToken) + => Task.FromResult(request.Text); + } + + public sealed class TaggedHandler : IRequestHandler, IBillingHandler + { + public Task HandleAsync(Tagged request, CancellationToken cancellationToken) + => Task.FromResult(request.Text); + } + + public sealed class EmailNotificationHandler : IRequestHandler + { + public Task HandleAsync(EmailNotification request, CancellationToken cancellationToken) + => Task.FromResult(request.Text); + } + + public sealed class WipeHandler : IRequestHandler + { + public Task HandleAsync(Wipe request, CancellationToken cancellationToken) + { + Trace.Add("Wipe:handled"); + return Task.CompletedTask; + } + } + + public sealed class RecordingStage : IRequestStage + where TRequest : IRequest + { + public async Task HandleAsync( + TRequest request, StageDelegate next, CancellationToken cancellationToken) + { + Trace.Add("Recording:enter"); + TResponse response = await next(); + Trace.Add("Recording:exit"); + return response; + } + } + + public sealed class SecondStage : IRequestStage + where TRequest : IRequest + { + public async Task HandleAsync( + TRequest request, StageDelegate next, CancellationToken cancellationToken) + { + Trace.Add("Second:enter"); + TResponse response = await next(); + Trace.Add("Second:exit"); + return response; + } + } + + public sealed class TaggedOnlyStage : IRequestStage + where TRequest : IRequest, ITag + { + public async Task HandleAsync( + TRequest request, StageDelegate next, CancellationToken cancellationToken) + { + Trace.Add("TaggedOnly:enter"); + TResponse response = await next(); + Trace.Add("TaggedOnly:exit"); + return response; + } + } + + public sealed class UnreachableStage : IRequestStage + where TRequest : IRequest, INothingImplementsThis + { + public Task HandleAsync( + TRequest request, StageDelegate next, CancellationToken cancellationToken) + { + Trace.Add("Unreachable:enter"); + return next(); + } + } + + // One type parameter with a fixed response type, the shape Result-returning codebases use. + public sealed class ResponseBoundStage : IRequestStage + where TRequest : IRequest + { + public async Task HandleAsync( + TRequest request, StageDelegate next, CancellationToken cancellationToken) + { + Trace.Add("ResponseBound:enter"); + string response = await next(); + Trace.Add("ResponseBound:exit"); + return response; + } + } + + public sealed class VoidRecordingStage : IRequestStage + where TRequest : IRequest + { + public async Task HandleAsync(TRequest request, StageDelegate next, CancellationToken cancellationToken) + { + Trace.Add("VoidRecording:enter"); + await next(); + Trace.Add("VoidRecording:exit"); + } + } + + // Declared for the base request, so contravariance on TRequest is the only thing that can + // reach EmailNotification. + public sealed class NotificationStage : IRequestStage + { + public async Task HandleAsync( + Notification request, StageDelegate next, CancellationToken cancellationToken) + { + Trace.Add("Notification:enter"); + string response = await next(); + Trace.Add("Notification:exit"); + return response; + } + } + + public sealed class PingOnlyStage : IRequestStage + { + public async Task HandleAsync( + Ping request, StageDelegate next, CancellationToken cancellationToken) + { + Trace.Add("PingOnly:enter"); + string response = await next(); + Trace.Add("PingOnly:exit"); + return response; + } + } + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Stages/StageSemanticsTests.cs b/tests/RequestFlow.Tests.Unit/Stages/StageSemanticsTests.cs new file mode 100644 index 0000000..0e67a72 --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Stages/StageSemanticsTests.cs @@ -0,0 +1,339 @@ +using Microsoft.Extensions.DependencyInjection; +using RequestFlow; + +namespace RequestFlow.Tests.Unit; + +public sealed class StageSemanticsTests +{ + [Fact] + public async Task Given_Stage_That_Throws_When_Sending_Request_Then_Exception_Propagates_Unwrapped() + { + IRequestDispatcher dispatcher = Build(o => o.AddStage(typeof(ThrowingStage<,>))); + + var exception = await Should.ThrowAsync(() => dispatcher.SendAsync(new Ping("hi"))); + + exception.Message.ShouldBe("from stage"); + } + + [Fact] + public async Task Given_Handler_That_Throws_Behind_Two_Stages_When_Sending_Request_Then_Exception_Propagates_Unwrapped() + { + IRequestDispatcher dispatcher = Build(o => o + .AddStage(typeof(PassThroughStage<,>)) + .AddStage(typeof(SecondPassThroughStage<,>))); + + var exception = await Should.ThrowAsync(() => dispatcher.SendAsync(new Boom())); + + exception.Message.ShouldBe("from handler"); + } + + [Fact] + public async Task Given_Cancelled_Token_When_Sending_Request_Then_Stage_And_Handler_See_The_Cancellation() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + IRequestDispatcher dispatcher = Build(o => o.AddStage(typeof(TokenForwardingStage<,>))); + + string result = await dispatcher.SendAsync(new Ping("hi"), cts.Token); + + result.ShouldBe("cancelled"); + Trace.ShouldBe(["Token:cancelled"]); + } + + [Fact] + public async Task Given_Cancelled_Token_When_Sending_A_Void_Request_Then_The_Stage_And_The_Void_Handler_See_It() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + IRequestDispatcher dispatcher = Build(o => o.AddStage(typeof(TokenForwardingStage<,>))); + + await dispatcher.SendAsync(new Wipe(), cts.Token); + + Trace.ShouldBe(["Token:cancelled"]); + WipeHandler.SawCancellation.ShouldBeTrue(); + } + + [Fact] + public async Task Given_Stage_That_Throws_After_Next_When_Sending_Request_Then_Exception_Propagates_Unwrapped() + { + IRequestDispatcher dispatcher = Build(o => o + .AddStage(typeof(ThrowAfterNextStage<,>)) + .AddStage(typeof(TracingStage<,>))); + + var exception = await Should.ThrowAsync(() => dispatcher.SendAsync(new Ping("hi"))); + + exception.Message.ShouldBe("after next"); + Trace.ShouldBe(["Tracing:enter", "Tracing:exit"]); + } + + [Fact] + public async Task Given_Stage_That_Awaits_Before_Calling_Next_When_Sending_Request_Then_Response_Returns() + { + IRequestDispatcher dispatcher = Build(o => o.AddStage(typeof(AwaitBeforeNextStage<,>))); + + string result = await dispatcher.SendAsync(new Ping("hi")); + + result.ShouldBe("hi"); + Trace.ShouldBe(["Await:enter", "Await:exit"]); + } + + [Fact] + public async Task Given_Outer_Stage_That_Calls_Next_Twice_When_Sending_Request_Then_The_Inner_Stage_Runs_Twice() + { + IRequestDispatcher dispatcher = Build(o => o + .AddStage(typeof(DoubleNextStage<,>)) + .AddStage(typeof(TracingStage<,>))); + + await dispatcher.SendAsync(new Ping("hi")); + + Trace.ShouldBe(["Tracing:enter", "Tracing:exit", "Tracing:enter", "Tracing:exit"]); + } + + [Fact] + public async Task Given_Failing_Handler_When_Outer_Stage_Retries_When_Sending_Request_Then_The_Second_Attempt_Runs_The_Inner_Chain() + { + IRequestDispatcher dispatcher = Build(o => o + .AddStage(typeof(RetryOnceStage<,>)) + .AddStage(typeof(TracingStage<,>))); + + string result = await dispatcher.SendAsync(new Counted()); + + result.ShouldBe("2"); + Trace.ShouldBe(["Tracing:enter", "Tracing:enter", "Tracing:exit"]); + } + + [Fact] + public async Task Given_Nested_Send_Inside_A_Handler_When_Sending_Request_Then_The_Inner_Request_Runs_Its_Own_Chain() + { + IRequestDispatcher dispatcher = Build(o => o.AddStage(typeof(CountingStage<,>))); + + await dispatcher.SendAsync(new Outer()); + + CountingStage.Entries.ShouldBe(1); + CountingStage.Entries.ShouldBe(1); + } + + [Fact] + public async Task Given_Stage_On_A_Void_Request_When_Sending_Request_Then_Returned_Task_Is_Already_Completed() + { + IRequestDispatcher dispatcher = Build(o => o.AddStage(typeof(TracingStage<,>))); + + Task task = dispatcher.SendAsync(new Wipe()); + + task.IsCompleted.ShouldBeTrue(); + Trace.ShouldBe(["Tracing:enter", "Tracing:exit"]); + await task; + } + + #region Initialization + + // The container instantiates stages, so the trace and counters have to be static; the + // constructor clears them per test. Each closed form of a generic stage carries its own. + private static readonly List Trace = []; + + public StageSemanticsTests() + { + Trace.Clear(); + WipeHandler.SawCancellation = false; + CountedHandler.Calls = 0; + CountingStage.Entries = 0; + CountingStage.Entries = 0; + } + + #endregion + + #region Helpers + + private static IRequestDispatcher Build(Action? configure = null) + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => + { + o.RegisterHandlersFromAssemblyContaining(); + configure?.Invoke(o); + }); + + return services.BuildServiceProvider().GetRequiredService(); + } + + public sealed record Ping(string Text) : IRequest; + + public sealed record Boom : IRequest; + + public sealed record Counted : IRequest; + + public sealed record Outer : IRequest; + + public sealed record Inner : IRequest; + + public sealed record Wipe : IRequest; + + public sealed class PingHandler : IRequestHandler + { + public Task HandleAsync(Ping request, CancellationToken cancellationToken) + => Task.FromResult(cancellationToken.IsCancellationRequested ? "cancelled" : request.Text); + } + + public sealed class BoomHandler : IRequestHandler + { + public Task HandleAsync(Boom request, CancellationToken cancellationToken) + => throw new NotSupportedException("from handler"); + } + + // Faults the first attempt and then succeeds, so a retrying stage has something to retry. + public sealed class CountedHandler : IRequestHandler + { + public static int Calls; + + public Task HandleAsync(Counted request, CancellationToken cancellationToken) + { + Calls++; + + return Calls == 1 + ? Task.FromException(new InvalidOperationException("first attempt fails")) + : Task.FromResult(Calls.ToString()); + } + } + + public sealed class OuterHandler(IRequestDispatcher dispatcher) : IRequestHandler + { + public Task HandleAsync(Outer request, CancellationToken cancellationToken) + => dispatcher.SendAsync(new Inner(), cancellationToken); + } + + public sealed class InnerHandler : IRequestHandler + { + public Task HandleAsync(Inner request, CancellationToken cancellationToken) + => Task.FromResult("inner"); + } + + public sealed class WipeHandler : IRequestHandler + { + public static bool SawCancellation; + + public Task HandleAsync(Wipe request, CancellationToken cancellationToken) + { + SawCancellation = cancellationToken.IsCancellationRequested; + return Task.CompletedTask; + } + } + + public sealed class ThrowAfterNextStage : IRequestStage + where TRequest : IRequest + { + public async Task HandleAsync( + TRequest request, StageDelegate next, CancellationToken cancellationToken) + { + await next(); + throw new TimeoutException("after next"); + } + } + + public sealed class ThrowingStage : IRequestStage + where TRequest : IRequest + { + public Task HandleAsync( + TRequest request, StageDelegate next, CancellationToken cancellationToken) + => throw new InvalidTimeZoneException("from stage"); + } + + public sealed class PassThroughStage : IRequestStage + where TRequest : IRequest + { + public Task HandleAsync( + TRequest request, StageDelegate next, CancellationToken cancellationToken) + => next(); + } + + public sealed class SecondPassThroughStage : IRequestStage + where TRequest : IRequest + { + public Task HandleAsync( + TRequest request, StageDelegate next, CancellationToken cancellationToken) + => next(); + } + + public sealed class TokenForwardingStage : IRequestStage + where TRequest : IRequest + { + public Task HandleAsync( + TRequest request, StageDelegate next, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + Trace.Add("Token:cancelled"); + + return next(); + } + } + + // Suspends on work of its own before delegating, the shape of a validation or caching stage. + public sealed class AwaitBeforeNextStage : IRequestStage + where TRequest : IRequest + { + public async Task HandleAsync( + TRequest request, StageDelegate next, CancellationToken cancellationToken) + { + await Task.Yield(); + Trace.Add("Await:enter"); + TResponse response = await next(); + Trace.Add("Await:exit"); + return response; + } + } + + public sealed class TracingStage : IRequestStage + where TRequest : IRequest + { + public async Task HandleAsync( + TRequest request, StageDelegate next, CancellationToken cancellationToken) + { + Trace.Add("Tracing:enter"); + TResponse response = await next(); + Trace.Add("Tracing:exit"); + return response; + } + } + + public sealed class DoubleNextStage : IRequestStage + where TRequest : IRequest + { + public async Task HandleAsync( + TRequest request, StageDelegate next, CancellationToken cancellationToken) + { + await next(); + return await next(); + } + } + + public sealed class RetryOnceStage : IRequestStage + where TRequest : IRequest + { + public async Task HandleAsync( + TRequest request, StageDelegate next, CancellationToken cancellationToken) + { + try + { + return await next(); + } + catch (InvalidOperationException) + { + return await next(); + } + } + } + + public sealed class CountingStage : IRequestStage + where TRequest : IRequest + { + public static int Entries; + + public Task HandleAsync( + TRequest request, StageDelegate next, CancellationToken cancellationToken) + { + Entries++; + return next(); + } + } + + #endregion +}