From db089bdf51fb934134d14706aae71de48a9f250e Mon Sep 17 00:00:00 2001 From: Illia Filippov Date: Sun, 9 Aug 2026 14:46:29 +0200 Subject: [PATCH 1/5] feat(validation): add the validation model and rule contract A rule implementing IRequestFlowValidationRule reads the whole registration picture off RequestFlowValidationContext and returns RequestFlowValidationProblem values carrying a stable code from ProblemCodes. The model reports lifetimes as RequestFlowLifetime rather than the container's ServiceLifetime, since RequestFlow.Abstractions takes no dependency on the DI package. Handlers and stages carry the contract they implement, so a kind layered on a core contract, such as ICommandHandler, comes through under its own. RequestFlowModelBuilder builds a model by hand and BuildContext wraps one in the context a rule receives, which is how a rule is tested without a container. Nothing consumes these types yet. --- .../Validation/ClosedStageModel.cs | 54 ++++ .../Validation/HandlerModel.cs | 60 ++++ .../Validation/IRequestFlowValidationRule.cs | 18 ++ .../Validation/OpenContract.cs | 57 ++++ .../Validation/ProblemCodes.cs | 29 ++ .../Validation/RequestFlowLifetime.cs | 26 ++ .../Validation/RequestFlowModel.cs | 50 +++ .../Validation/RequestFlowModelBuilder.cs | 128 ++++++++ .../RequestFlowValidationContext.cs | 44 +++ .../RequestFlowValidationProblem.cs | 45 +++ .../Validation/RequestModel.cs | 43 +++ .../Validation/RequestModelBuilder.cs | 87 +++++ .../Validation/StageDeclarationModel.cs | 58 ++++ .../Validation/StageReach.cs | 33 ++ .../Validation/ProblemCodesTests.cs | 22 ++ .../RequestFlowModelBuilderTests.cs | 304 ++++++++++++++++++ .../Validation/RequestFlowModelTests.cs | 242 ++++++++++++++ .../RequestFlowValidationProblemTests.cs | 74 +++++ 18 files changed, 1374 insertions(+) create mode 100644 src/RequestFlow.Abstractions/Validation/ClosedStageModel.cs create mode 100644 src/RequestFlow.Abstractions/Validation/HandlerModel.cs create mode 100644 src/RequestFlow.Abstractions/Validation/IRequestFlowValidationRule.cs create mode 100644 src/RequestFlow.Abstractions/Validation/OpenContract.cs create mode 100644 src/RequestFlow.Abstractions/Validation/ProblemCodes.cs create mode 100644 src/RequestFlow.Abstractions/Validation/RequestFlowLifetime.cs create mode 100644 src/RequestFlow.Abstractions/Validation/RequestFlowModel.cs create mode 100644 src/RequestFlow.Abstractions/Validation/RequestFlowModelBuilder.cs create mode 100644 src/RequestFlow.Abstractions/Validation/RequestFlowValidationContext.cs create mode 100644 src/RequestFlow.Abstractions/Validation/RequestFlowValidationProblem.cs create mode 100644 src/RequestFlow.Abstractions/Validation/RequestModel.cs create mode 100644 src/RequestFlow.Abstractions/Validation/RequestModelBuilder.cs create mode 100644 src/RequestFlow.Abstractions/Validation/StageDeclarationModel.cs create mode 100644 src/RequestFlow.Abstractions/Validation/StageReach.cs create mode 100644 tests/RequestFlow.Tests.Unit/Validation/ProblemCodesTests.cs create mode 100644 tests/RequestFlow.Tests.Unit/Validation/RequestFlowModelBuilderTests.cs create mode 100644 tests/RequestFlow.Tests.Unit/Validation/RequestFlowModelTests.cs create mode 100644 tests/RequestFlow.Tests.Unit/Validation/RequestFlowValidationProblemTests.cs diff --git a/src/RequestFlow.Abstractions/Validation/ClosedStageModel.cs b/src/RequestFlow.Abstractions/Validation/ClosedStageModel.cs new file mode 100644 index 0000000..98eb19a --- /dev/null +++ b/src/RequestFlow.Abstractions/Validation/ClosedStageModel.cs @@ -0,0 +1,54 @@ +using System; + +namespace RequestFlow; + +/// +/// One stage in a request's chain: the AddStage call it came from and the type that runs. +/// +/// +/// A rule usually needs both, and for a stage registered already closed they are the same type. +/// Compare to tell whether two entries are the same stage, since two +/// separate calls can land on one type. Name in the message, because +/// that is the AddStage call the reader has to go and change. +/// +/// Same class does not mean same . in TRequest lets a stage closed +/// over a base request cover the requests under it, so with PlaceOrder : IAudited both +/// LoggingStage<IAudited, OrderId> and LoggingStage<PlaceOrder, OrderId> +/// land in the PlaceOrder chain. +/// +/// +public sealed class ClosedStageModel +{ + /// + /// + internal ClosedStageModel(Type declaredType, Type closedType, Type? contractType = null) + { + DeclaredType = declaredType ?? throw new ArgumentNullException(nameof(declaredType)); + ClosedType = closedType ?? throw new ArgumentNullException(nameof(closedType)); + ContractType = OpenContract.OrDefault( + contractType, typeof(IRequestStage<,>), typeof(IRequestStage<>)); + } + + /// + /// The type AddStage was given, open or closed. + /// + public Type DeclaredType { get; } + + /// + /// The stage type built for this request, always closed. + /// + /// + /// The type the container resolves. A two-parameter stage over a void request closes over + /// here, so read rather than these + /// type arguments. + /// + public Type ClosedType { get; } + + /// + /// The open generic stage contract this closing satisfies. + /// + /// + /// Either IRequestStage or an interface built on one. + /// + public Type ContractType { get; } +} diff --git a/src/RequestFlow.Abstractions/Validation/HandlerModel.cs b/src/RequestFlow.Abstractions/Validation/HandlerModel.cs new file mode 100644 index 0000000..c03c1ff --- /dev/null +++ b/src/RequestFlow.Abstractions/Validation/HandlerModel.cs @@ -0,0 +1,60 @@ +using System; + +namespace RequestFlow; + +/// +/// One handler found for a request. +/// +public sealed class HandlerModel +{ + /// + /// + internal HandlerModel( + Type handlerType, + Type? responseType = null, + Type? contractType = null, + RequestFlowLifetime lifetime = RequestFlowLifetime.Transient) + { + HandlerType = handlerType ?? throw new ArgumentNullException(nameof(handlerType)); + ResponseType = responseType; + Lifetime = lifetime; + ContractType = responseType is null + ? OpenContract.OrDefault(contractType, typeof(IRequestHandler<>), typeof(IRequestHandler<,>)) + : OpenContract.OrDefault(contractType, typeof(IRequestHandler<,>), typeof(IRequestHandler<>)); + } + + /// + /// The class implementing the handler, not the interface it implements. + /// + public Type HandlerType { get; } + + /// + /// What the handler returns, or null when it handles a void request. + /// + public Type? ResponseType { get; } + + /// + /// True when the handler covers a void request. + /// + public bool IsVoid => ResponseType is null; + + /// + /// The lifetime this handler is registered with. + /// + /// + /// Each AddRequestFlow call decides for the handlers it found, so two handlers in one + /// registration can differ. A handler the application registers by hand afterwards wins at + /// resolution without changing this. + /// + public RequestFlowLifetime Lifetime { get; } + + /// + /// The open generic handler contract this handler implements. + /// + /// + /// A rather than an enum, so a package the core knows nothing about can + /// record its own contract here and its own rule can match on it. It has to be + /// IRequestHandler or an interface built on one. + /// + public Type ContractType { get; } +} diff --git a/src/RequestFlow.Abstractions/Validation/IRequestFlowValidationRule.cs b/src/RequestFlow.Abstractions/Validation/IRequestFlowValidationRule.cs new file mode 100644 index 0000000..b6f2231 --- /dev/null +++ b/src/RequestFlow.Abstractions/Validation/IRequestFlowValidationRule.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace RequestFlow; + +/// +/// A startup validation check. Runs once when the dispatch map freezes; every reported +/// problem lands in the single . +/// +/// +/// Register a rule with AddValidationRule<HandlerNamingRule>(). +/// +public interface IRequestFlowValidationRule +{ + /// + /// Inspects the context and returns every problem found; empty when all is well. + /// + IEnumerable Validate(RequestFlowValidationContext context); +} diff --git a/src/RequestFlow.Abstractions/Validation/OpenContract.cs b/src/RequestFlow.Abstractions/Validation/OpenContract.cs new file mode 100644 index 0000000..e5b678d --- /dev/null +++ b/src/RequestFlow.Abstractions/Validation/OpenContract.cs @@ -0,0 +1,57 @@ +using System; + +namespace RequestFlow; + +/// +/// Checks the contract type a model entry was given against the family it belongs to. +/// +internal static class OpenContract +{ + /// + public static Type OrDefault(Type? contractType, Type fallback, Type alternate) + { + if (contractType is null) + return fallback; + + if (contractType.IsInterface && contractType.IsGenericTypeDefinition && + (BuiltOn(contractType, fallback) || BuiltOn(contractType, alternate))) + { + return contractType; + } + + throw new ArgumentException( + $"'{contractType}' is neither {Describe(fallback)} nor {Describe(alternate)}, nor built on " + + $"either. Pass {Describe(fallback)}, or an open generic interface deriving from it.", + nameof(contractType)); + } + + // An open definition lists the contract closed over its own parameters, so compare definitions. + private static bool BuiltOn(Type definition, Type contract) + { + if (definition == contract) + return true; + + foreach (var iface in definition.GetInterfaces()) + { + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == contract) + return true; + } + + return false; + } + + private static string Describe(Type definition) + { + string name = definition.Name; + int tick = name.IndexOf('`'); + if (tick >= 0) + name = name.Substring(0, tick); + + Type[] parameters = definition.GetGenericArguments(); + string[] names = new string[parameters.Length]; + for (int i = 0; i < names.Length; i++) + names[i] = parameters[i].Name; + + return name + "<" + string.Join(", ", names) + ">"; + } +} diff --git a/src/RequestFlow.Abstractions/Validation/ProblemCodes.cs b/src/RequestFlow.Abstractions/Validation/ProblemCodes.cs new file mode 100644 index 0000000..574ca2a --- /dev/null +++ b/src/RequestFlow.Abstractions/Validation/ProblemCodes.cs @@ -0,0 +1,29 @@ +namespace RequestFlow; + +/// +/// Stable codes for every built-in validation problem. Documented in docs/validation-rules.md; +/// never renumbered. Match against these +/// instead of literal strings. +/// +public static class ProblemCodes +{ + public const string HandlerNotOpenGeneric = "RF0001"; + public const string HandlerAbstract = "RF0002"; + public const string HandlerWrongArity = "RF0003"; + public const string HandlerMissingContract = "RF0004"; + public const string NoClosingTypes = "RF0005"; + public const string ClosingTypeNotClosed = "RF0006"; + public const string ClosingViolatesConstraints = "RF0007"; + public const string StageIsInterface = "RF0008"; + public const string StageAbstract = "RF0009"; + public const string StagePartiallyClosed = "RF0010"; + public const string StageMissingContract = "RF0011"; + public const string StageParametersMisused = "RF0012"; + public const string DuplicateHandler = "RF0101"; + public const string UnhandledRequest = "RF0102"; + public const string DuplicateStage = "RF0103"; + public const string AliasedStage = "RF0104"; + public const string UnusedStage = "RF0105"; + public const string MultiContractRequest = "RF0106"; + public const string RuleFailed = "RF0107"; +} diff --git a/src/RequestFlow.Abstractions/Validation/RequestFlowLifetime.cs b/src/RequestFlow.Abstractions/Validation/RequestFlowLifetime.cs new file mode 100644 index 0000000..029bb89 --- /dev/null +++ b/src/RequestFlow.Abstractions/Validation/RequestFlowLifetime.cs @@ -0,0 +1,26 @@ +namespace RequestFlow; + +/// +/// How long a registered handler or stage instance lives. +/// +/// +/// A stage takes any of the three, since AddStage names the lifetime per call. A handler is +/// transient or scoped, chosen by the AddRequestFlow call that found it. +/// +public enum RequestFlowLifetime +{ + /// + /// A new instance for every resolution. + /// + Transient, + + /// + /// One instance per scope. + /// + Scoped, + + /// + /// One instance for the life of the container. + /// + Singleton +} diff --git a/src/RequestFlow.Abstractions/Validation/RequestFlowModel.cs b/src/RequestFlow.Abstractions/Validation/RequestFlowModel.cs new file mode 100644 index 0000000..994e616 --- /dev/null +++ b/src/RequestFlow.Abstractions/Validation/RequestFlowModel.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace RequestFlow; + +/// +/// The requests and stages registration recorded, frozen and handed to every validation rule. +/// +/// +/// One AddStage call is counted once in and again in every +/// request it applies to. A stage that fits no request is only in . +/// +/// A declaration the shape checks rejected is not here, so AddStage given a type that is +/// not a stage is reported without reaching a rule. Past that nothing is filtered. A request with +/// no handler is in the list, with an empty handler list. A stage added twice is in the list +/// twice. Reporting those is a rule's job. +/// +/// +/// The opt-in flags are not here. AllowUnhandledRequests and DisallowUnusedStages +/// pick which built-in rules run, and a rule reads them off +/// . +/// +/// +/// The library builds this. A rule test builds one with . +/// Every list on the model is read-only, so one rule cannot change what the next one reads. +/// +/// +public sealed class RequestFlowModel +{ + /// + internal RequestFlowModel(RequestModel[] requests, StageDeclarationModel[] stageDeclarations) + { + Requests = new ReadOnlyCollection( + requests ?? throw new ArgumentNullException(nameof(requests))); + StageDeclarations = new ReadOnlyCollection( + stageDeclarations ?? throw new ArgumentNullException(nameof(stageDeclarations))); + } + + /// + /// Every request type registration knows about: the scanned ones in scan order, then any type a + /// handler brought in on its own. + /// + public IReadOnlyList Requests { get; } + + /// + /// One entry per AddStage call, in the order the calls ran, duplicates included. + /// + public IReadOnlyList StageDeclarations { get; } +} diff --git a/src/RequestFlow.Abstractions/Validation/RequestFlowModelBuilder.cs b/src/RequestFlow.Abstractions/Validation/RequestFlowModelBuilder.cs new file mode 100644 index 0000000..fb9e116 --- /dev/null +++ b/src/RequestFlow.Abstractions/Validation/RequestFlowModelBuilder.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; + +namespace RequestFlow; + +/// +/// Builds a by hand, which is how a validation rule is unit +/// tested without a container. +/// +/// +/// A partial model is allowed on purpose. A rule that never reads handlers is tested against +/// requests that have none, so the builder does not require shapes the freeze would always +/// produce. +/// +public sealed class RequestFlowModelBuilder +{ + private readonly Dictionary _requests = []; + private readonly List _requestOrder = []; + private readonly List _stageDeclarations = []; + + /// + /// Adds a request type, or configures one already added. Repeated calls for one type + /// configure a single entry, the way the freeze groups handlers by request type. + /// + /// + public RequestFlowModelBuilder AddRequest(Type requestType, Action? configure = null) + { + if (requestType is null) + throw new ArgumentNullException(nameof(requestType)); + + if (!_requests.TryGetValue(requestType, out RequestModelBuilder? request)) + { + request = new RequestModelBuilder(); + _requests[requestType] = request; + _requestOrder.Add(requestType); + } + + configure?.Invoke(request); + + return this; + } + + /// + /// Adds one stage declaration, as a single AddStage call would. + /// + /// + /// Leaving null records + /// IRequestStage<TRequest, TResponse>, matching + /// . Naming one takes an open generic interface, + /// since that is what a rule compares against. + /// + /// + /// + public RequestFlowModelBuilder AddStageDeclaration(Type stageType, Type? contractType = null) + => AddStageDeclaration(stageType, RequestFlowLifetime.Transient, contractType); + + /// + /// Adds one stage declaration registered with the named lifetime. + /// + /// + /// The overload without a lifetime records , which is + /// what AddStage uses unless the application asks for something else. + /// + /// + /// + public RequestFlowModelBuilder AddStageDeclaration( + Type stageType, RequestFlowLifetime lifetime, Type? contractType = null) + { + if (stageType is null) + throw new ArgumentNullException(nameof(stageType)); + + // Resolve now, so a bad contract throws from the call that named it rather than from Build. + Type resolved = OpenContract.OrDefault( + contractType, typeof(IRequestStage<,>), typeof(IRequestStage<>)); + + _stageDeclarations.Add(new StageDeclarationInput(stageType, lifetime, resolved)); + + return this; + } + + /// + /// Produces the model, with requests in the order they were first added. + /// + /// + /// Callable more than once, with each call producing an independent model. + /// + public RequestFlowModel Build() + { + RequestModel[] requests = new RequestModel[_requestOrder.Count]; + for (int i = 0; i < requests.Length; i++) + { + Type requestType = _requestOrder[i]; + requests[i] = _requests[requestType].Build(requestType); + } + + StageDeclarationModel[] declarations = new StageDeclarationModel[_stageDeclarations.Count]; + for (int i = 0; i < declarations.Length; i++) + { + StageDeclarationInput input = _stageDeclarations[i]; + declarations[i] = new StageDeclarationModel( + input.StageType, input.Lifetime, StageReach.Of(requests, input.StageType), + input.ContractType); + } + + return new RequestFlowModel(requests, declarations); + } + + /// + /// Produces the context a rule is given at startup, wrapping a freshly built model. + /// + /// + /// The flags default to what registration does unless the application opts out: a request with + /// no handler is a problem, a stage that reached nothing is not. + /// + public RequestFlowValidationContext BuildContext( + bool unhandledRequestsAllowed = false, bool unusedStagesDisallowed = false) + => new(Build(), unhandledRequestsAllowed, unusedStagesDisallowed); + + private readonly struct StageDeclarationInput( + Type stageType, RequestFlowLifetime lifetime, Type contractType) + { + public Type StageType { get; } = stageType; + + public RequestFlowLifetime Lifetime { get; } = lifetime; + + public Type ContractType { get; } = contractType; + } +} diff --git a/src/RequestFlow.Abstractions/Validation/RequestFlowValidationContext.cs b/src/RequestFlow.Abstractions/Validation/RequestFlowValidationContext.cs new file mode 100644 index 0000000..7fa5cc1 --- /dev/null +++ b/src/RequestFlow.Abstractions/Validation/RequestFlowValidationContext.cs @@ -0,0 +1,44 @@ +using System; + +namespace RequestFlow; + +/// +/// What a validation rule reads: the frozen registration, plus the registration choices that +/// shape a finding. +/// +/// +/// Built once per freeze and handed to every rule, so a rule added with AddValidationRule +/// sees the facts a built-in one sees. +/// +/// A rule test builds one with +/// instead of standing up a container. +/// +/// +public sealed class RequestFlowValidationContext +{ + /// + internal RequestFlowValidationContext( + RequestFlowModel model, bool unhandledRequestsAllowed, bool unusedStagesDisallowed) + { + Model = model ?? throw new ArgumentNullException(nameof(model)); + UnhandledRequestsAllowed = unhandledRequestsAllowed; + UnusedStagesDisallowed = unusedStagesDisallowed; + } + + /// + /// The frozen registration: every request, its handlers, and the stages that reached it. + /// + public RequestFlowModel Model { get; } + + /// + /// True when AllowUnhandledRequests was called, so a request with no handler is + /// permitted. + /// + public bool UnhandledRequestsAllowed { get; } + + /// + /// True when DisallowUnusedStages was called, so a stage that reached no request is a + /// problem. + /// + public bool UnusedStagesDisallowed { get; } +} diff --git a/src/RequestFlow.Abstractions/Validation/RequestFlowValidationProblem.cs b/src/RequestFlow.Abstractions/Validation/RequestFlowValidationProblem.cs new file mode 100644 index 0000000..0699b89 --- /dev/null +++ b/src/RequestFlow.Abstractions/Validation/RequestFlowValidationProblem.cs @@ -0,0 +1,45 @@ +using System; + +namespace RequestFlow; + +/// +/// One startup validation finding: a stable code, a human-readable message, and the type at +/// fault when one exists. Two problems with the same code, message, and subject are equal. +/// +/// +/// A custom rule picks its own code, staying off the RF prefix the built-in ones use: +/// +/// new RequestFlowValidationProblem( +/// "APP0001", +/// $"Handler '{handlerType.FullName}' must be named ...Handler.", +/// handlerType); +/// +/// +public sealed record RequestFlowValidationProblem +{ + /// + public RequestFlowValidationProblem(string code, string message, Type? subject = null) + { + Code = code ?? throw new ArgumentNullException(nameof(code)); + Message = message ?? throw new ArgumentNullException(nameof(message)); + Subject = subject; + } + + /// + /// Stable identifier for the kind of problem; built-in codes are documented and never change. + /// + public string Code { get; } + + /// + /// What is wrong and how to fix it. + /// + public string Message { get; } + + /// + /// The offending type, when the problem points at one. + /// + public Type? Subject { get; } + + public override string ToString() + => Code + ": " + Message; +} diff --git a/src/RequestFlow.Abstractions/Validation/RequestModel.cs b/src/RequestFlow.Abstractions/Validation/RequestModel.cs new file mode 100644 index 0000000..c89bca8 --- /dev/null +++ b/src/RequestFlow.Abstractions/Validation/RequestModel.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace RequestFlow; + +/// +/// One request type, the handlers covering it, and the stage chain it would run. +/// +/// +/// Building the chain needs the response type, and only a handler supplies it, so a request no +/// handler covers always has an empty . Read that empty list as "no handler to +/// build a chain against", not as "no stage applies". A request several handlers cover, which the +/// built-in rules report on, holds what every one of them closes. +/// +public sealed class RequestModel +{ + /// + internal RequestModel(Type requestType, HandlerModel[] handlers, ClosedStageModel[] stages) + { + RequestType = requestType ?? throw new ArgumentNullException(nameof(requestType)); + Handlers = new ReadOnlyCollection( + handlers ?? throw new ArgumentNullException(nameof(handlers))); + Stages = new ReadOnlyCollection( + stages ?? throw new ArgumentNullException(nameof(stages))); + } + + /// + /// The request type as registered, always closed: PlaceOrder, never an open definition. + /// + public Type RequestType { get; } + + /// + /// The handlers covering this request, normally one. None and several are both possible. + /// + public IReadOnlyList Handlers { get; } + + /// + /// The stages closed for this request, in the order they wrap the handler. One chain while one + /// handler covers the request, which is what every registration that starts produces. + /// + public IReadOnlyList Stages { get; } +} diff --git a/src/RequestFlow.Abstractions/Validation/RequestModelBuilder.cs b/src/RequestFlow.Abstractions/Validation/RequestModelBuilder.cs new file mode 100644 index 0000000..25cf472 --- /dev/null +++ b/src/RequestFlow.Abstractions/Validation/RequestModelBuilder.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; + +namespace RequestFlow; + +/// +/// Collects the handlers and stage closings for one request type while a model is being built. +/// +/// +/// Reached through and never constructed +/// directly. +/// +public sealed class RequestModelBuilder +{ + private readonly List _handlers = []; + private readonly List _stages = []; + + internal RequestModelBuilder() + { } + + /// + /// Adds a handler covering this request. A null response type records a void handler. + /// + /// + /// Leaving null records the core handler contract: + /// IRequestHandler<TRequest> for a void handler and + /// IRequestHandler<TRequest, TResponse> otherwise. Naming one takes an open + /// generic interface, since that is what a rule compares against. + /// + /// + /// + public RequestModelBuilder AddHandler( + Type handlerType, Type? responseType = null, Type? contractType = null) + => AddHandler(handlerType, RequestFlowLifetime.Transient, responseType, contractType); + + /// + /// Adds a handler registered with the named lifetime. + /// + /// + /// The overload without a lifetime records , which is + /// what an AddRequestFlow call uses unless it calls WithScopedHandlers. + /// + /// + /// + public RequestModelBuilder AddHandler( + Type handlerType, RequestFlowLifetime lifetime, Type? responseType = null, Type? contractType = null) + { + _handlers.Add(new HandlerModel(handlerType, responseType, contractType, lifetime)); + + return this; + } + + /// + /// Adds one stage to this request's chain, in the order it wraps the handler. + /// + /// + /// Leaving null records + /// IRequestStage<TRequest, TResponse>. A void stage names + /// IRequestStage<TRequest> here and at the matching + /// call, or at neither. + /// Naming one takes an open generic interface, since that is what a rule compares against. + /// + /// A two-parameter stage over a void request keeps the typed contract, and its + /// closes over , as + /// LoggingStage<Purge, NoResult>. That is the shape the freeze produces, so a + /// test reproducing it passes the same type. + /// + /// + /// is the type its + /// call names, which is + /// what ties the declaration to this request in + /// . Naming the closed type instead leaves + /// that list empty. + /// + /// + /// + /// + public RequestModelBuilder AddStage(Type declaredType, Type closedType, Type? contractType = null) + { + _stages.Add(new ClosedStageModel(declaredType, closedType, contractType)); + + return this; + } + + internal RequestModel Build(Type requestType) + => new(requestType, [.. _handlers], [.. _stages]); +} diff --git a/src/RequestFlow.Abstractions/Validation/StageDeclarationModel.cs b/src/RequestFlow.Abstractions/Validation/StageDeclarationModel.cs new file mode 100644 index 0000000..3feb947 --- /dev/null +++ b/src/RequestFlow.Abstractions/Validation/StageDeclarationModel.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace RequestFlow; + +/// +/// One AddStage call, holding the stage type the application wrote. +/// +/// +/// Read for the requests the call landed on, and read the rest of the +/// entry for checks about the call itself, such as the same stage registered twice. +/// +public sealed class StageDeclarationModel +{ + /// + /// + internal StageDeclarationModel( + Type stageType, RequestFlowLifetime lifetime, Type[] reachedRequests, Type? contractType = null) + { + StageType = stageType ?? throw new ArgumentNullException(nameof(stageType)); + Lifetime = lifetime; + ReachedRequests = new ReadOnlyCollection( + reachedRequests ?? throw new ArgumentNullException(nameof(reachedRequests))); + ContractType = OpenContract.OrDefault( + contractType, typeof(IRequestStage<,>), typeof(IRequestStage<>)); + } + + /// + /// The type AddStage was given: an open generic definition or a closed class. + /// + public Type StageType { get; } + + /// + /// The lifetime the stage is registered with. + /// + public RequestFlowLifetime Lifetime { get; } + + /// + /// The requests this stage type reached, in request order. Empty when it reached none. + /// + /// + /// Derived from the chains the model holds, so it lists every request whose + /// contains a closing of this . Two + /// calls registering one stage type report the same requests, whatever each call filtered on. + /// That registration already fails under RF0103. + /// + public IReadOnlyList ReachedRequests { get; } + + /// + /// The open generic stage contract this declaration implements. + /// + /// + /// A void stage carries IRequestStage<TRequest> and has to say so, since nothing + /// in the declared type separates it from the typed form without reflection. + /// + public Type ContractType { get; } +} diff --git a/src/RequestFlow.Abstractions/Validation/StageReach.cs b/src/RequestFlow.Abstractions/Validation/StageReach.cs new file mode 100644 index 0000000..e97636b --- /dev/null +++ b/src/RequestFlow.Abstractions/Validation/StageReach.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; + +namespace RequestFlow; + +/// +/// Reports which requests one stage declaration reached. +/// +/// +/// The single source for , so a model the +/// library froze and one a test built by hand derive the reach the same way. Needs the requests +/// already built, since the reach is read off their chains. +/// +internal static class StageReach +{ + public static Type[] Of(IReadOnlyList requests, Type stageType) + { + List reached = []; + foreach (var request in requests) + { + foreach (var stage in request.Stages) + { + if (stage.DeclaredType == stageType) + { + reached.Add(request.RequestType); + break; + } + } + } + + return [.. reached]; + } +} diff --git a/tests/RequestFlow.Tests.Unit/Validation/ProblemCodesTests.cs b/tests/RequestFlow.Tests.Unit/Validation/ProblemCodesTests.cs new file mode 100644 index 0000000..1b5de1e --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Validation/ProblemCodesTests.cs @@ -0,0 +1,22 @@ +using RequestFlow; + +namespace RequestFlow.Tests.Unit.Validation; + +public sealed class ProblemCodesTests +{ + // docs/validation-rules.md tells a caller to match ProblemCodes constants instead of + // literal strings, which only works while the class stays public. + [Fact] + public void Given_The_Problem_Codes_Class_When_Reflecting_Then_It_Is_Public() + { + typeof(ProblemCodes).IsPublic.ShouldBeTrue(); + } + + // An assembly referencing only the abstractions catches the exception and reads the codes, + // so the constants ship beside the problem they describe. + [Fact] + public void Given_The_Problem_Codes_Class_When_Reflecting_Then_It_Ships_With_The_Problem_Type() + { + typeof(ProblemCodes).Assembly.ShouldBe(typeof(RequestFlowValidationProblem).Assembly); + } +} diff --git a/tests/RequestFlow.Tests.Unit/Validation/RequestFlowModelBuilderTests.cs b/tests/RequestFlow.Tests.Unit/Validation/RequestFlowModelBuilderTests.cs new file mode 100644 index 0000000..8a2d43d --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Validation/RequestFlowModelBuilderTests.cs @@ -0,0 +1,304 @@ +using RequestFlow; + +namespace RequestFlow.Tests.Unit.Validation; + +public sealed class RequestFlowModelBuilderTests +{ + [Fact] + public void Given_Nothing_Added_When_Building_Then_Both_Lists_Are_Empty() + { + RequestFlowModel model = new RequestFlowModelBuilder().Build(); + + model.Requests.ShouldBeEmpty(); + model.StageDeclarations.ShouldBeEmpty(); + } + + [Fact] + public void Given_A_Request_With_A_Handler_When_Building_Then_The_Parts_Round_Trip() + { + RequestFlowModel model = new RequestFlowModelBuilder() + .AddRequest(typeof(int), r => r.AddHandler(typeof(object), typeof(string))) + .AddStageDeclaration(typeof(string)) + .Build(); + + RequestModel request = model.Requests.ShouldHaveSingleItem(); + request.RequestType.ShouldBe(typeof(int)); + + HandlerModel handler = request.Handlers.ShouldHaveSingleItem(); + handler.HandlerType.ShouldBe(typeof(object)); + handler.ResponseType.ShouldBe(typeof(string)); + handler.ContractType.ShouldBe(typeof(IRequestHandler<,>)); + + model.StageDeclarations.ShouldHaveSingleItem().StageType.ShouldBe(typeof(string)); + } + + [Fact] + public void Given_Two_Calls_For_One_Request_Type_When_Building_Then_One_Entry_Holds_Both_Handlers() + { + RequestFlowModel model = new RequestFlowModelBuilder() + .AddRequest(typeof(int), r => r.AddHandler(typeof(object), typeof(string))) + .AddRequest(typeof(int), r => r.AddHandler(typeof(Uri), typeof(string))) + .Build(); + + model.Requests.ShouldHaveSingleItem().Handlers.Count.ShouldBe(2); + } + + [Fact] + public void Given_Requests_Added_In_Order_When_Building_Then_That_Order_Is_Kept() + { + RequestFlowModel model = new RequestFlowModelBuilder() + .AddRequest(typeof(long)) + .AddRequest(typeof(int)) + .AddRequest(typeof(long)) + .Build(); + + model.Requests.Select(r => r.RequestType).ShouldBe([typeof(long), typeof(int)]); + } + + [Fact] + public void Given_No_Configure_Callback_When_Adding_A_Request_Then_It_Has_No_Handlers_Or_Stages() + { + RequestFlowModel model = new RequestFlowModelBuilder().AddRequest(typeof(int)).Build(); + + RequestModel request = model.Requests.ShouldHaveSingleItem(); + request.Handlers.ShouldBeEmpty(); + request.Stages.ShouldBeEmpty(); + } + + [Fact] + public void Given_A_Class_As_The_Contract_When_Adding_A_Stage_Declaration_Then_Throws_Argument_Exception() + { + var sut = new RequestFlowModelBuilder(); + + Should.Throw(() => sut.AddStageDeclaration(typeof(object), typeof(string))); + } + + [Fact] + public void Given_A_Closed_Interface_As_The_Contract_When_Adding_A_Stage_Declaration_Then_Throws_Argument_Exception() + { + var sut = new RequestFlowModelBuilder(); + + Should.Throw(() => sut.AddStageDeclaration(typeof(object), typeof(IEquatable))); + } + + [Fact] + public void Given_A_Class_As_The_Contract_When_Adding_A_Stage_Then_Throws_Argument_Exception() + { + var sut = new RequestFlowModelBuilder(); + + Should.Throw( + () => sut.AddRequest(typeof(int), r => r.AddStage(typeof(object), typeof(object), typeof(string)))); + } + + [Fact] + public void Given_A_Class_As_The_Contract_When_Adding_A_Handler_Then_Throws_Argument_Exception() + { + var sut = new RequestFlowModelBuilder(); + + Should.Throw( + () => sut.AddRequest(typeof(int), r => r.AddHandler(typeof(object), typeof(int), typeof(string)))); + } + + [Fact] + public void Given_A_Null_Request_Type_When_Adding_A_Request_Then_Throws_Argument_Null_Exception() + { + var sut = new RequestFlowModelBuilder(); + + Should.Throw(() => sut.AddRequest(null!)); + } + + [Fact] + public void Given_A_Null_Stage_Type_When_Adding_A_Stage_Then_Throws_Argument_Null_Exception() + { + var sut = new RequestFlowModelBuilder(); + + Should.Throw(() => sut.AddStageDeclaration(null!)); + } + + [Fact] + public void Given_A_Null_Handler_Type_When_Adding_A_Handler_Then_Throws_Argument_Null_Exception() + { + var sut = new RequestFlowModelBuilder(); + + Should.Throw(() => sut.AddRequest(typeof(int), r => r.AddHandler(null!))); + } + + [Theory] + [InlineData(RequestFlowLifetime.Transient)] + [InlineData(RequestFlowLifetime.Scoped)] + [InlineData(RequestFlowLifetime.Singleton)] + public void Given_A_Handler_Added_With_A_Lifetime_When_Building_Then_It_Is_Recorded(RequestFlowLifetime lifetime) + { + RequestFlowModel model = new RequestFlowModelBuilder() + .AddRequest(typeof(int), r => r.AddHandler(typeof(object), lifetime, typeof(string))) + .Build(); + + HandlerModel handler = model.Requests.ShouldHaveSingleItem().Handlers.ShouldHaveSingleItem(); + handler.Lifetime.ShouldBe(lifetime); + handler.ResponseType.ShouldBe(typeof(string)); + } + + [Fact] + public void Given_Handlers_Added_With_Different_Lifetimes_When_Building_Then_Each_Keeps_Its_Own() + { + RequestFlowModel model = new RequestFlowModelBuilder() + .AddRequest( + typeof(int), + r => r + .AddHandler(typeof(object), RequestFlowLifetime.Scoped, typeof(string)) + .AddHandler(typeof(string), RequestFlowLifetime.Transient, typeof(string))) + .Build(); + + IReadOnlyList handlers = model.Requests.ShouldHaveSingleItem().Handlers; + handlers[0].Lifetime.ShouldBe(RequestFlowLifetime.Scoped); + handlers[1].Lifetime.ShouldBe(RequestFlowLifetime.Transient); + } + + // A response type in the second slot still binds the overload that has no lifetime. + [Fact] + public void Given_A_Handler_Added_Without_A_Lifetime_When_Building_Then_It_Is_Transient() + { + RequestFlowModel model = new RequestFlowModelBuilder() + .AddRequest(typeof(int), r => r.AddHandler(typeof(object), typeof(string))) + .Build(); + + HandlerModel handler = model.Requests.ShouldHaveSingleItem().Handlers.ShouldHaveSingleItem(); + handler.Lifetime.ShouldBe(RequestFlowLifetime.Transient); + handler.ResponseType.ShouldBe(typeof(string)); + } + + [Fact] + public void Given_A_Handler_Added_With_A_Response_Type_And_A_Contract_When_Building_Then_Both_Are_Recorded() + { + RequestFlowModel model = new RequestFlowModelBuilder() + .AddRequest( + typeof(int), + r => r.AddHandler(typeof(object), typeof(string), typeof(IAuditedHandler<,>))) + .Build(); + + HandlerModel handler = model.Requests.ShouldHaveSingleItem().Handlers.ShouldHaveSingleItem(); + handler.ResponseType.ShouldBe(typeof(string)); + handler.ContractType.ShouldBe(typeof(IAuditedHandler<,>)); + } + + [Fact] + public void Given_A_Stage_Declaration_Added_With_A_Lifetime_When_Building_Then_That_Lifetime_Is_Recorded() + { + RequestFlowModel model = new RequestFlowModelBuilder() + .AddStageDeclaration(typeof(object), RequestFlowLifetime.Singleton) + .Build(); + + model.StageDeclarations.ShouldHaveSingleItem().Lifetime.ShouldBe(RequestFlowLifetime.Singleton); + } + + [Fact] + public void Given_A_Stage_Declaration_Added_With_A_Lifetime_And_A_Contract_When_Building_Then_Both_Are_Recorded() + { + RequestFlowModel model = new RequestFlowModelBuilder() + .AddStageDeclaration(typeof(object), RequestFlowLifetime.Scoped, typeof(IAuditedStage<,>)) + .Build(); + + StageDeclarationModel declaration = model.StageDeclarations.ShouldHaveSingleItem(); + declaration.Lifetime.ShouldBe(RequestFlowLifetime.Scoped); + declaration.ContractType.ShouldBe(typeof(IAuditedStage<,>)); + } + + // A contract in the second slot still binds the overload that has no lifetime. + [Fact] + public void Given_A_Stage_Declaration_Added_With_A_Contract_Only_When_Building_Then_It_Is_Transient() + { + RequestFlowModel model = new RequestFlowModelBuilder() + .AddStageDeclaration(typeof(object), typeof(IAuditedStage<,>)) + .Build(); + + StageDeclarationModel declaration = model.StageDeclarations.ShouldHaveSingleItem(); + declaration.ContractType.ShouldBe(typeof(IAuditedStage<,>)); + declaration.Lifetime.ShouldBe(RequestFlowLifetime.Transient); + } + + [Fact] + public void Given_A_Stage_In_Two_Chains_When_Building_Then_The_Declaration_Reached_Both_In_Request_Order() + { + RequestFlowModel model = new RequestFlowModelBuilder() + .AddRequest(typeof(long), r => r.AddStage(typeof(object), typeof(string))) + .AddRequest(typeof(int), r => r.AddStage(typeof(object), typeof(string))) + .AddStageDeclaration(typeof(object)) + .Build(); + + model.StageDeclarations.ShouldHaveSingleItem() + .ReachedRequests.ShouldBe([typeof(long), typeof(int)]); + } + + [Fact] + public void Given_A_Declaration_No_Chain_Names_When_Building_Then_It_Reached_Nothing() + { + RequestFlowModel model = new RequestFlowModelBuilder() + .AddRequest(typeof(int), r => r.AddStage(typeof(object), typeof(string))) + .AddStageDeclaration(typeof(Uri)) + .Build(); + + model.StageDeclarations.ShouldHaveSingleItem().ReachedRequests.ShouldBeEmpty(); + } + + [Fact] + public void Given_A_Request_With_An_Empty_Chain_When_Building_Then_The_Declaration_Does_Not_Claim_It() + { + RequestFlowModel model = new RequestFlowModelBuilder() + .AddRequest(typeof(long), r => r.AddStage(typeof(object), typeof(string))) + .AddRequest(typeof(int)) + .AddStageDeclaration(typeof(object)) + .Build(); + + model.StageDeclarations.ShouldHaveSingleItem().ReachedRequests.ShouldBe([typeof(long)]); + } + + [Fact] + public void Given_A_Builder_Built_Twice_When_Comparing_The_Models_Then_They_Share_No_Instance() + { + var sut = new RequestFlowModelBuilder(); + sut.AddRequest(typeof(int), r => r.AddHandler(typeof(object), typeof(string))); + sut.AddStageDeclaration(typeof(Uri)); + + RequestFlowModel first = sut.Build(); + RequestFlowModel second = sut.Build(); + + second.ShouldNotBeSameAs(first); + second.Requests.ShouldNotBeSameAs(first.Requests); + second.Requests[0].ShouldNotBeSameAs(first.Requests[0]); + second.StageDeclarations.ShouldNotBeSameAs(first.StageDeclarations); + second.StageDeclarations[0].ShouldNotBeSameAs(first.StageDeclarations[0]); + } + + // The builder hands each model its own arrays, so what it collects afterwards cannot reach a + // model already handed out. + [Fact] + public void Given_A_Built_Model_When_More_Is_Added_And_Built_Again_Then_The_First_Model_Is_Unchanged() + { + var sut = new RequestFlowModelBuilder(); + sut.AddRequest(typeof(int), r => r.AddHandler(typeof(object), typeof(string))); + sut.AddStageDeclaration(typeof(Uri)); + RequestFlowModel first = sut.Build(); + + sut.AddRequest(typeof(int), r => r.AddHandler(typeof(Uri), typeof(string))); + sut.AddStageDeclaration(typeof(Uri)); + RequestFlowModel second = sut.Build(); + + first.Requests.ShouldHaveSingleItem().Handlers.Count.ShouldBe(1); + first.StageDeclarations.Count.ShouldBe(1); + second.Requests.ShouldHaveSingleItem().Handlers.Count.ShouldBe(2); + second.StageDeclarations.Count.ShouldBe(2); + } + + #region Helpers + + // Contracts of the kind a package adds on top of the core ones. + private interface IAuditedStage : IRequestStage + where TRequest : IRequest + { } + + private interface IAuditedHandler : IRequestHandler + where TRequest : IRequest + { } + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Validation/RequestFlowModelTests.cs b/tests/RequestFlow.Tests.Unit/Validation/RequestFlowModelTests.cs new file mode 100644 index 0000000..eaabc36 --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Validation/RequestFlowModelTests.cs @@ -0,0 +1,242 @@ +using RequestFlow; + +namespace RequestFlow.Tests.Unit.Validation; + +// Reaches the internal constructors through the InternalsVisibleTo grant in +// RequestFlow.Abstractions.csproj. The construction contract is what this file tests, so it +// stays on the constructors while every rule test moves to RequestFlowModelBuilder. +public sealed class RequestFlowModelTests +{ + [Fact] + public void Given_No_Response_Type_When_Creating_Handler_Model_Then_Contract_Is_The_Void_Handler() + { + var handler = new HandlerModel(typeof(object)); + + handler.ContractType.ShouldBe(typeof(IRequestHandler<>)); + } + + [Fact] + public void Given_A_Response_Type_When_Creating_Handler_Model_Then_Contract_Is_The_Typed_Handler() + { + var handler = new HandlerModel(typeof(object), typeof(int)); + + handler.ContractType.ShouldBe(typeof(IRequestHandler<,>)); + } + + [Fact] + public void Given_No_Contract_When_Creating_Stage_Declaration_Model_Then_Contract_Is_The_Typed_Stage() + { + var stage = new StageDeclarationModel(typeof(object), RequestFlowLifetime.Transient, []); + + stage.ContractType.ShouldBe(typeof(IRequestStage<,>)); + } + + [Fact] + public void Given_No_Contract_When_Creating_Closed_Stage_Model_Then_Contract_Is_The_Typed_Stage() + { + var closing = new ClosedStageModel(typeof(object), typeof(string)); + + closing.ContractType.ShouldBe(typeof(IRequestStage<,>)); + } + + [Fact] + public void Given_A_Satellite_Contract_When_Creating_Handler_Model_Then_It_Is_Kept_Unchanged() + { + var handler = new HandlerModel(typeof(object), typeof(int), typeof(IAuditedHandler<,>)); + + handler.ContractType.ShouldBe(typeof(IAuditedHandler<,>)); + } + + [Fact] + public void Given_Null_Requests_When_Creating_Model_Then_Throws_Argument_Null_Exception() + { + Should.Throw(() => new RequestFlowModel(null!, [])); + } + + [Fact] + public void Given_Null_Stage_Declarations_When_Creating_Model_Then_Throws_Argument_Null_Exception() + { + Should.Throw(() => new RequestFlowModel([], null!)); + } + + [Fact] + public void Given_No_Lifetime_When_Creating_Handler_Model_Then_It_Is_Transient() + { + var handler = new HandlerModel(typeof(object), typeof(int)); + + handler.Lifetime.ShouldBe(RequestFlowLifetime.Transient); + } + + [Fact] + public void Given_Null_Request_Type_When_Creating_Request_Model_Then_Throws_Argument_Null_Exception() + { + Should.Throw(() => new RequestModel(null!, [], [])); + } + + [Fact] + public void Given_A_Model_When_A_Rule_Casts_Its_Lists_Then_They_Reject_Mutation() + { + var request = new RequestModel( + typeof(int), [new HandlerModel(typeof(object))], []); + + var model = new RequestFlowModel( + [request], [new StageDeclarationModel(typeof(string), RequestFlowLifetime.Transient, [])]); + + Should.Throw(() => ((IList)model.Requests).Clear()); + Should.Throw(() => ((IList)model.StageDeclarations).Clear()); + Should.Throw(() => ((IList)request.Handlers).Clear()); + Should.Throw(() => ((IList)request.Stages).Clear()); + } + + [Fact] + public void Given_Parts_When_Creating_Model_Then_Properties_Round_Trip() + { + var handler = new HandlerModel(typeof(object), typeof(int)); + var closing = new ClosedStageModel(typeof(object), typeof(string)); + var request = new RequestModel(typeof(int), [handler], [closing]); + var stage = new StageDeclarationModel(typeof(string), RequestFlowLifetime.Transient, []); + + var model = new RequestFlowModel([request], [stage]); + + model.Requests.ShouldBe([request]); + model.StageDeclarations.ShouldBe([stage]); + request.Handlers.ShouldBe([handler]); + request.Stages.ShouldBe([closing]); + closing.DeclaredType.ShouldBe(typeof(object)); + closing.ClosedType.ShouldBe(typeof(string)); + } + + [Fact] + public void Given_No_Response_Type_When_Creating_Handler_Model_Then_It_Is_Void() + { + var handler = new HandlerModel(typeof(object)); + + handler.IsVoid.ShouldBeTrue(); + handler.ResponseType.ShouldBeNull(); + } + + [Fact] + public void Given_A_Response_Type_When_Creating_Handler_Model_Then_It_Is_Not_Void() + { + var handler = new HandlerModel(typeof(object), typeof(int)); + + handler.IsVoid.ShouldBeFalse(); + } + + [Fact] + public void Given_Reached_Requests_When_Creating_Stage_Declaration_Model_Then_They_Keep_Their_Order() + { + var stage = new StageDeclarationModel( + typeof(object), RequestFlowLifetime.Transient, [typeof(long), typeof(int)]); + + stage.ReachedRequests.ShouldBe([typeof(long), typeof(int)]); + } + + [Fact] + public void Given_No_Reached_Requests_When_Creating_Stage_Declaration_Model_Then_The_List_Is_Empty() + { + var stage = new StageDeclarationModel(typeof(object), RequestFlowLifetime.Transient, []); + + stage.ReachedRequests.ShouldBeEmpty(); + } + + [Fact] + public void Given_Null_Reached_Requests_When_Creating_Stage_Declaration_Model_Then_Throws_Argument_Null_Exception() + { + Should.Throw( + () => new StageDeclarationModel(typeof(object), RequestFlowLifetime.Transient, null!)); + } + + [Fact] + public void Given_A_Declaration_When_A_Rule_Casts_Its_Reached_Requests_Then_They_Reject_Mutation() + { + var stage = new StageDeclarationModel(typeof(object), RequestFlowLifetime.Transient, [typeof(int)]); + + Should.Throw(() => ((IList)stage.ReachedRequests).Clear()); + } + + // The void contracts are separate interfaces rather than derivations of the two-parameter ones, + // so naming one has to be accepted on its own. + [Fact] + public void Given_The_Void_Contract_On_A_Typed_Handler_When_Creating_Handler_Model_Then_It_Is_Kept() + { + var handler = new HandlerModel(typeof(object), typeof(int), typeof(IRequestHandler<>)); + + handler.ContractType.ShouldBe(typeof(IRequestHandler<>)); + } + + [Fact] + public void Given_The_Typed_Contract_On_A_Void_Handler_When_Creating_Handler_Model_Then_It_Is_Kept() + { + var handler = new HandlerModel( + typeof(object), responseType: null, contractType: typeof(IRequestHandler<,>)); + + handler.ContractType.ShouldBe(typeof(IRequestHandler<,>)); + } + + [Fact] + public void Given_The_Void_Contract_When_Creating_Stage_Declaration_Model_Then_It_Is_Kept() + { + var stage = new StageDeclarationModel( + typeof(object), RequestFlowLifetime.Transient, [], typeof(IRequestStage<>)); + + stage.ContractType.ShouldBe(typeof(IRequestStage<>)); + } + + [Fact] + public void Given_The_Void_Contract_When_Creating_Closed_Stage_Model_Then_It_Is_Kept() + { + var closing = new ClosedStageModel(typeof(object), typeof(string), typeof(IRequestStage<>)); + + closing.ContractType.ShouldBe(typeof(IRequestStage<>)); + } + + [Fact] + public void Given_A_Satellite_Contract_When_Creating_Closed_Stage_Model_Then_It_Is_Kept_Unchanged() + { + var closing = new ClosedStageModel(typeof(object), typeof(string), typeof(IAuditedStage<,>)); + + closing.ContractType.ShouldBe(typeof(IAuditedStage<,>)); + } + + [Fact] + public void Given_An_Unrelated_Open_Interface_When_Creating_Handler_Model_Then_Throws_Naming_The_Contract() + { + ArgumentException exception = Should.Throw( + () => new HandlerModel(typeof(object), typeof(int), typeof(IEnumerable<>))); + + exception.ParamName.ShouldBe("contractType"); + } + + [Fact] + public void Given_An_Unrelated_Open_Interface_When_Creating_Stage_Declaration_Model_Then_Throws_Naming_The_Contract() + { + ArgumentException exception = Should.Throw( + () => new StageDeclarationModel( + typeof(object), RequestFlowLifetime.Transient, [], typeof(IEnumerable<>))); + + exception.ParamName.ShouldBe("contractType"); + } + + [Fact] + public void Given_An_Unrelated_Open_Interface_When_Creating_Closed_Stage_Model_Then_Throws_Naming_The_Contract() + { + ArgumentException exception = Should.Throw( + () => new ClosedStageModel(typeof(object), typeof(string), typeof(IEnumerable<>))); + + exception.ParamName.ShouldBe("contractType"); + } + + #region Helpers + + // Contracts of the kind a package adds on top of the core ones. + private interface IAuditedHandler : IRequestHandler + where TRequest : IRequest + { } + + private interface IAuditedStage : IRequestStage + where TRequest : IRequest + { } + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Validation/RequestFlowValidationProblemTests.cs b/tests/RequestFlow.Tests.Unit/Validation/RequestFlowValidationProblemTests.cs new file mode 100644 index 0000000..7ab7cd0 --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Validation/RequestFlowValidationProblemTests.cs @@ -0,0 +1,74 @@ +using RequestFlow; + +namespace RequestFlow.Tests.Unit.Validation; + +public sealed class RequestFlowValidationProblemTests +{ + [Fact] + public void Given_Same_Code_Message_And_Subject_When_Comparing_Problems_Then_They_Are_Equal() + { + var first = new RequestFlowValidationProblem("RF0101", "duplicate", typeof(string)); + var second = new RequestFlowValidationProblem("RF0101", "duplicate", typeof(string)); + + first.Equals(second).ShouldBeTrue(); + first.GetHashCode().ShouldBe(second.GetHashCode()); + } + + [Fact] + public void Given_Different_Code_When_Comparing_Problems_Then_They_Are_Not_Equal() + { + var first = new RequestFlowValidationProblem("RF0101", "duplicate"); + var second = new RequestFlowValidationProblem("RF0102", "duplicate"); + + first.Equals(second).ShouldBeFalse(); + } + + [Fact] + public void Given_Different_Subject_When_Comparing_Problems_Then_They_Are_Not_Equal() + { + var first = new RequestFlowValidationProblem("RF0101", "duplicate", typeof(string)); + var second = new RequestFlowValidationProblem("RF0101", "duplicate", typeof(int)); + + first.Equals(second).ShouldBeFalse(); + } + + [Fact] + public void Given_Same_Code_Message_And_Subject_When_Comparing_With_Operator_Then_They_Are_Equal() + { + var first = new RequestFlowValidationProblem("RF0101", "duplicate", typeof(string)); + var second = new RequestFlowValidationProblem("RF0101", "duplicate", typeof(string)); + + (first == second).ShouldBeTrue(); + (first != second).ShouldBeFalse(); + } + + [Fact] + public void Given_Different_Code_When_Comparing_With_Operator_Then_They_Are_Not_Equal() + { + var first = new RequestFlowValidationProblem("RF0101", "duplicate"); + var second = new RequestFlowValidationProblem("RF0102", "duplicate"); + + (first == second).ShouldBeFalse(); + (first != second).ShouldBeTrue(); + } + + [Fact] + public void Given_Null_Code_When_Creating_Problem_Then_Throws_Argument_Null_Exception() + { + Should.Throw(() => new RequestFlowValidationProblem(null!, "message")); + } + + [Fact] + public void Given_Null_Message_When_Creating_Problem_Then_Throws_Argument_Null_Exception() + { + Should.Throw(() => new RequestFlowValidationProblem("RF0101", null!)); + } + + [Fact] + public void Given_Code_And_Message_When_Formatting_Then_Returns_Code_Colon_Message() + { + var problem = new RequestFlowValidationProblem("RF0101", "duplicate handler"); + + problem.ToString().ShouldBe("RF0101: duplicate handler"); + } +} From cafa770fb110d6be50d6e4e1471e83bad27abd03 Mon Sep 17 00:00:00 2001 From: Illia Filippov Date: Sun, 9 Aug 2026 14:47:21 +0200 Subject: [PATCH 2/5] feat(validation)!: run startup checks as pluggable rules The six built-in registration checks move out of RegistrationValidator and behind IRequestFlowValidationRule, so an application or a package adds its own checks with AddValidationRule() and reports into the same exception. A rule that throws is reported as RF0107 and the rules after it still run. RegistrationSnapshot projects the registry into the RequestFlowModel a rule sees, which is why HandlerRegistration now carries the lifetime chosen by the AddRequestFlow call that found it. A request implementing more than one IRequest now fails the freeze with RF0106. The extra contract used to pass the freeze and fail every dispatch under it with ResponseTypeMismatchException. BREAKING CHANGE: RequestFlowValidationException.Problems holds RequestFlowValidationProblem values instead of strings, and the public constructor takes the same list, so a call site passing strings no longer compiles. Message lines now read "RF0101: ...", so anything matching the old text breaks. Repeated registrations collapse into one problem each, where a request with three handlers used to report two identical lines. Aliased stage declarations report a single RF0104 naming every declaration in the collision, and RF0104 no longer fires for two closings of one stage class on a request with more than one handler, since that request already fails RF0101. --- .../RequestFlowValidationException.cs | 23 +- .../Registration/HandlerRegistration.cs | 30 +- .../Registration/HandlerScanner.cs | 52 +- .../Registration/RegistrationValidator.cs | 280 +++------- .../Registration/RequestFlowBuilder.cs | 26 + .../Registration/RequestFlowRegistry.cs | 85 ++- .../ServiceCollectionExtensions.cs | 24 +- src/RequestFlow/Stages/StageClosing.cs | 2 - src/RequestFlow/Stages/StageClosingCache.cs | 8 +- .../Validation/AliasedStageRule.cs | 205 +++++++ .../Validation/DuplicateHandlerRule.cs | 34 ++ .../Validation/DuplicateStageRule.cs | 41 ++ src/RequestFlow/Validation/HandlerContract.cs | 48 ++ src/RequestFlow/Validation/ModelLifetime.cs | 26 + .../Validation/MultiContractRequestRule.cs | 63 +++ .../Validation/RegistrationSnapshot.cs | 109 ++++ src/RequestFlow/Validation/StageContract.cs | 92 ++++ .../Validation/UnhandledRequestRule.cs | 31 ++ src/RequestFlow/Validation/UnusedStageRule.cs | 62 +++ .../Validation/ValidationRuleRunner.cs | 103 ++++ .../AddRequestFlowTests.cs | 14 +- .../HandlerScannerTests.cs | 5 +- .../RegisterGenericHandlerTests.cs | 36 +- .../RequestFlowRegistryTests.cs | 32 +- .../RequestFlowValidationExceptionTests.cs | 55 ++ .../Stages/AddStageTests.cs | 44 +- .../Stages/ChainExecutionTests.cs | 96 ---- .../Stages/StageClosingTests.cs | 9 +- .../Stages/StagePipelineTests.cs | 91 ++- .../Validation/AddValidationRuleTests.cs | 310 +++++++++++ .../Validation/AliasedStageRuleTests.cs | 187 +++++++ .../Validation/BuiltInRuleFailureTests.cs | 37 ++ .../Validation/DuplicateHandlerRuleTests.cs | 49 ++ .../Validation/DuplicateStageRuleTests.cs | 54 ++ .../MultiContractRequestRuleTests.cs | 92 ++++ .../Validation/RegistrationSnapshotTests.cs | 520 ++++++++++++++++++ .../RequestFlowValidationContextTests.cs | 205 +++++++ .../Validation/UnhandledRequestRuleTests.cs | 37 ++ .../Validation/UnusedStageRuleTests.cs | 115 ++++ .../Fixtures.cs | 38 ++ ...equestFlow.Tests.ValidationFixtures.csproj | 1 + 41 files changed, 2905 insertions(+), 466 deletions(-) create mode 100644 src/RequestFlow/Validation/AliasedStageRule.cs create mode 100644 src/RequestFlow/Validation/DuplicateHandlerRule.cs create mode 100644 src/RequestFlow/Validation/DuplicateStageRule.cs create mode 100644 src/RequestFlow/Validation/HandlerContract.cs create mode 100644 src/RequestFlow/Validation/ModelLifetime.cs create mode 100644 src/RequestFlow/Validation/MultiContractRequestRule.cs create mode 100644 src/RequestFlow/Validation/RegistrationSnapshot.cs create mode 100644 src/RequestFlow/Validation/StageContract.cs create mode 100644 src/RequestFlow/Validation/UnhandledRequestRule.cs create mode 100644 src/RequestFlow/Validation/UnusedStageRule.cs create mode 100644 src/RequestFlow/Validation/ValidationRuleRunner.cs create mode 100644 tests/RequestFlow.Tests.Unit/RequestFlowValidationExceptionTests.cs create mode 100644 tests/RequestFlow.Tests.Unit/Validation/AddValidationRuleTests.cs create mode 100644 tests/RequestFlow.Tests.Unit/Validation/AliasedStageRuleTests.cs create mode 100644 tests/RequestFlow.Tests.Unit/Validation/BuiltInRuleFailureTests.cs create mode 100644 tests/RequestFlow.Tests.Unit/Validation/DuplicateHandlerRuleTests.cs create mode 100644 tests/RequestFlow.Tests.Unit/Validation/DuplicateStageRuleTests.cs create mode 100644 tests/RequestFlow.Tests.Unit/Validation/MultiContractRequestRuleTests.cs create mode 100644 tests/RequestFlow.Tests.Unit/Validation/RegistrationSnapshotTests.cs create mode 100644 tests/RequestFlow.Tests.Unit/Validation/RequestFlowValidationContextTests.cs create mode 100644 tests/RequestFlow.Tests.Unit/Validation/UnhandledRequestRuleTests.cs create mode 100644 tests/RequestFlow.Tests.Unit/Validation/UnusedStageRuleTests.cs diff --git a/src/RequestFlow.Abstractions/Exceptions/RequestFlowValidationException.cs b/src/RequestFlow.Abstractions/Exceptions/RequestFlowValidationException.cs index 9c04722..722f487 100644 --- a/src/RequestFlow.Abstractions/Exceptions/RequestFlowValidationException.cs +++ b/src/RequestFlow.Abstractions/Exceptions/RequestFlowValidationException.cs @@ -7,13 +7,26 @@ namespace RequestFlow; /// Thrown when the dispatch map is built and the accumulated registrations are invalid; /// aggregates every registration problem into one failure. /// -public sealed class RequestFlowValidationException(IReadOnlyList problems) - : InvalidOperationException( - "RequestFlow registration is invalid:" + Environment.NewLine + - string.Join(Environment.NewLine, problems)) +public sealed class RequestFlowValidationException : InvalidOperationException { + /// + public RequestFlowValidationException(IReadOnlyList problems) + : base(BuildMessage(problems)) + { + Problems = problems; + } + /// /// Every registration problem found during startup validation. /// - public IReadOnlyList Problems { get; } = problems; + public IReadOnlyList Problems { get; } + + private static string BuildMessage(IReadOnlyList problems) + { + if (problems is null) + throw new ArgumentNullException(nameof(problems)); + + return "RequestFlow registration is invalid:" + Environment.NewLine + + string.Join(Environment.NewLine, problems); + } } diff --git a/src/RequestFlow/Registration/HandlerRegistration.cs b/src/RequestFlow/Registration/HandlerRegistration.cs index 1b7e3f6..61b5201 100644 --- a/src/RequestFlow/Registration/HandlerRegistration.cs +++ b/src/RequestFlow/Registration/HandlerRegistration.cs @@ -1,43 +1,39 @@ using System; -using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; namespace RequestFlow; /// -/// Container-neutral description of one discovered handler. +/// Container-neutral description of one discovered handler and the lifetime it registers under. /// -internal sealed class HandlerRegistration(Type implementationType, Type requestType, Type responseType, bool isVoid) +/// +/// The AddRequestFlow call stamps its own lifetime here, since each call decides for the +/// handlers it found. +/// +internal sealed class HandlerRegistration(HandlerDiscovery discovery, ServiceLifetime lifetime) { /// /// The concrete handler class discovered by the scan. /// - public Type ImplementationType { get; } = implementationType; + public Type ImplementationType { get; } = discovery.ImplementationType; /// /// The closed request type the handler handles. /// - public Type RequestType { get; } = requestType; + public Type RequestType { get; } = discovery.RequestType; /// /// The response type; for void handlers. /// - public Type ResponseType { get; } = responseType; + public Type ResponseType { get; } = discovery.ResponseType; /// /// True when the handler implements . /// - public bool IsVoid { get; } = isVoid; -} - -/// -/// Handlers and request types discovered by one scan pass. -/// -internal sealed class ScanResult(IReadOnlyList handlers, IReadOnlyList requestTypes) -{ - public IReadOnlyList Handlers { get; } = handlers; + public bool IsVoid { get; } = discovery.IsVoid; /// - /// Every discovered request type, handled or not; validation reports the difference. + /// The lifetime the AddRequestFlow call that found this handler registers it with. /// - public IReadOnlyList RequestTypes { get; } = requestTypes; + public ServiceLifetime Lifetime { get; } = lifetime; } diff --git a/src/RequestFlow/Registration/HandlerScanner.cs b/src/RequestFlow/Registration/HandlerScanner.cs index 5fd583b..e133ee8 100644 --- a/src/RequestFlow/Registration/HandlerScanner.cs +++ b/src/RequestFlow/Registration/HandlerScanner.cs @@ -12,7 +12,7 @@ internal static class HandlerScanner { public static ScanResult Scan(IReadOnlyList assemblies) { - List handlers = []; + List handlers = []; List requestTypes = []; foreach (var assembly in assemblies) @@ -22,7 +22,7 @@ public static ScanResult Scan(IReadOnlyList assemblies) if (type is null || type.IsAbstract || type.IsInterface || type.IsGenericTypeDefinition) continue; - handlers.AddRange(CollectHandlers(type)); + handlers.AddRange(Discover(type)); if (IsRequestType(type)) requestTypes.Add(type); @@ -45,9 +45,9 @@ public static ScanResult Scan(IReadOnlyList assemblies) } } - internal static List CollectHandlers(Type type) + internal static List Discover(Type type) { - List handlers = []; + List handlers = []; foreach (var iface in type.GetInterfaces()) { @@ -58,12 +58,12 @@ internal static List CollectHandlers(Type type) if (definition == typeof(IRequestHandler<,>)) { Type[] args = iface.GetGenericArguments(); - handlers.Add(new HandlerRegistration(type, args[0], args[1], isVoid: false)); + handlers.Add(new HandlerDiscovery(type, args[0], args[1], isVoid: false)); } else if (definition == typeof(IRequestHandler<>)) { Type[] args = iface.GetGenericArguments(); - handlers.Add(new HandlerRegistration(type, args[0], typeof(NoResult), isVoid: true)); + handlers.Add(new HandlerDiscovery(type, args[0], typeof(NoResult), isVoid: true)); } } @@ -81,3 +81,43 @@ private static bool IsRequestType(Type type) return false; } } + +/// +/// One handler the scan found, before registration decides how it lives. +/// +internal sealed class HandlerDiscovery( + Type implementationType, Type requestType, Type responseType, bool isVoid) +{ + /// + /// The concrete handler class discovered by the scan. + /// + public Type ImplementationType { get; } = implementationType; + + /// + /// The closed request type the handler handles. + /// + public Type RequestType { get; } = requestType; + + /// + /// The response type; for void handlers. + /// + public Type ResponseType { get; } = responseType; + + /// + /// True when the handler implements . + /// + public bool IsVoid { get; } = isVoid; +} + +/// +/// Handlers and request types discovered by one scan pass. +/// +internal sealed class ScanResult(IReadOnlyList handlers, IReadOnlyList requestTypes) +{ + public IReadOnlyList Handlers { get; } = handlers; + + /// + /// Every discovered request type, handled or not; validation reports the difference. + /// + public IReadOnlyList RequestTypes { get; } = requestTypes; +} diff --git a/src/RequestFlow/Registration/RegistrationValidator.cs b/src/RequestFlow/Registration/RegistrationValidator.cs index a3f5267..5f36473 100644 --- a/src/RequestFlow/Registration/RegistrationValidator.cs +++ b/src/RequestFlow/Registration/RegistrationValidator.cs @@ -4,7 +4,8 @@ namespace RequestFlow; /// -/// Detects registration problems and is the only producer of problem strings. +/// Detects registration problems. The shape checks live here; the freeze-time checks live in +/// Validation/ rules. /// internal static class RegistrationValidator { @@ -14,11 +15,11 @@ internal static class RegistrationValidator public static DeclarationResult ValidateDeclarations(IReadOnlyList declarations) { List validDeclarations = []; - List problems = []; + List problems = []; foreach (var declaration in declarations) { - string? problem = ValidateDeclaration(declaration); + RequestFlowValidationProblem? problem = ValidateDeclaration(declaration); if (problem is null) validDeclarations.Add(declaration); else @@ -28,29 +29,47 @@ public static DeclarationResult ValidateDeclarations(IReadOnlyList)."; + return new RequestFlowValidationProblem( + ProblemCodes.HandlerNotOpenGeneric, + $"'{handlerType.FullName}' is not an open generic type definition; pass e.g. typeof(AuditHandler<>).", + handlerType); if (handlerType.IsAbstract) - return $"'{handlerType.FullName}' is abstract; only concrete handler classes can be registered."; + return new RequestFlowValidationProblem( + ProblemCodes.HandlerAbstract, + $"'{handlerType.FullName}' is abstract; only concrete handler classes can be registered.", + handlerType); if (handlerType.GetGenericArguments().Length != 1) - return $"'{handlerType.FullName}' has {handlerType.GetGenericArguments().Length} generic parameters; only single-parameter generic handlers are supported."; + return new RequestFlowValidationProblem( + ProblemCodes.HandlerWrongArity, + $"'{handlerType.FullName}' has {handlerType.GetGenericArguments().Length} generic parameters; only single-parameter generic handlers are supported.", + handlerType); if (!ImplementsHandlerContract(handlerType)) - return $"'{handlerType.FullName}' does not implement IRequestHandler."; + return new RequestFlowValidationProblem( + ProblemCodes.HandlerMissingContract, + $"'{handlerType.FullName}' does not implement IRequestHandler.", + handlerType); if (declaration.ClosingTypes.Length == 0) - return $"Generic handler '{handlerType.FullName}' declares no closing types; at least one is required."; + return new RequestFlowValidationProblem( + ProblemCodes.NoClosingTypes, + $"Generic handler '{handlerType.FullName}' declares no closing types; at least one is required.", + handlerType); foreach (var closingType in declaration.ClosingTypes) { if (closingType.ContainsGenericParameters) - return $"Closing type '{closingType.FullName}' for generic handler '{handlerType.FullName}' is not a closed type."; + return new RequestFlowValidationProblem( + ProblemCodes.ClosingTypeNotClosed, + $"Closing type '{closingType.FullName}' for generic handler '{handlerType.FullName}' is not a closed type.", + closingType); } return null; @@ -78,7 +97,7 @@ private static bool ImplementsHandlerContract(Type handlerType) public static ClosingResult ValidateClosings(IReadOnlyList closings) { List closedTypes = []; - List problems = []; + List problems = []; foreach (var closing in closings) { @@ -88,9 +107,11 @@ 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 = []; + List problems = []; foreach (var declaration in declarations) { - string? problem = ValidateStageDeclaration(declaration); + RequestFlowValidationProblem? problem = ValidateStageDeclaration(declaration); if (problem is null) validDeclarations.Add(declaration); else @@ -118,33 +138,48 @@ public static StageDeclarationResult ValidateStageDeclarations(IReadOnlyList or " + - "IRequestStage; implement one of them or remove the AddStage call."; + return new RequestFlowValidationProblem( + ProblemCodes.StageMissingContract, + $"'{stageType.FullName}' does not implement IRequestStage or " + + "IRequestStage; implement one of them or remove the AddStage call.", + stageType); 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 new RequestFlowValidationProblem( + ProblemCodes.StageParametersMisused, + $"'{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.", + stageType); } return null; @@ -208,179 +243,6 @@ private static string[] GetParameterNames(Type stageType) return names; } - - /// - /// Reports every request type covered by more than one handler. Called once at freeze. - /// - public static List ValidateDuplicateHandlers(IReadOnlyList handlers) - { - List problems = []; - - HashSet handledRequests = []; - foreach (var handler in handlers) - { - if (!handledRequests.Add(handler.RequestType)) - problems.Add($"Request '{handler.RequestType.FullName}' has more than one handler; exactly one is required."); - } - - return problems; - } - - /// - /// Reports every scanned request type that no handler covers. Called once at freeze. - /// - public static List ValidateUnhandledRequests( - IReadOnlyList handlers, - IReadOnlyList requestTypes) - { - List problems = []; - - HashSet handledRequests = []; - foreach (var handler in handlers) - handledRequests.Add(handler.RequestType); - - foreach (var requestType in requestTypes) - { - if (!handledRequests.Contains(requestType)) - problems.Add($"Request '{requestType.FullName}' has no handler."); - } - - 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. - 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(); - } - } - } } /// @@ -388,32 +250,32 @@ public override int GetHashCode() /// declarations. /// internal sealed class DeclarationResult( - IReadOnlyList validDeclarations, IReadOnlyList problems) + IReadOnlyList validDeclarations, IReadOnlyList problems) { public IReadOnlyList ValidDeclarations { get; } = validDeclarations; - public IReadOnlyList Problems { get; } = problems; + public IReadOnlyList Problems { get; } = problems; } /// /// Closed handler types and constraint problems produced by validating the declared /// closings. /// -internal sealed class ClosingResult(IReadOnlyList closedTypes, IReadOnlyList problems) +internal sealed class ClosingResult(IReadOnlyList closedTypes, IReadOnlyList problems) { public IReadOnlyList ClosedTypes { get; } = closedTypes; - public IReadOnlyList Problems { get; } = problems; + public IReadOnlyList Problems { get; } = problems; } /// -/// The stage declarations that passed the shape check, plus one problem message for each -/// declaration that failed. +/// The stage declarations that passed the shape check, plus one problem for each declaration +/// that failed. /// internal sealed class StageDeclarationResult( - IReadOnlyList validDeclarations, IReadOnlyList problems) + IReadOnlyList validDeclarations, IReadOnlyList problems) { public IReadOnlyList ValidDeclarations { get; } = validDeclarations; - public IReadOnlyList Problems { get; } = problems; + public IReadOnlyList Problems { get; } = problems; } diff --git a/src/RequestFlow/Registration/RequestFlowBuilder.cs b/src/RequestFlow/Registration/RequestFlowBuilder.cs index c297d3a..db273d4 100644 --- a/src/RequestFlow/Registration/RequestFlowBuilder.cs +++ b/src/RequestFlow/Registration/RequestFlowBuilder.cs @@ -1,4 +1,6 @@ +using System; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace RequestFlow; @@ -14,4 +16,28 @@ internal RequestFlowBuilder(IServiceCollection services) /// The service collection RequestFlow is registered on. /// public IServiceCollection Services { get; } + + /// + /// Adds a validation rule to the startup pass. + /// + /// + /// Registered once per rule type however many times it is called, as a singleton, and + /// resolved from the container, so constructor dependencies work. A scoped dependency throws + /// on a provider that validates scopes, since the rule is resolved from the root provider. + /// Register the descriptor yourself for a transient rule. + /// + /// A rule must not take or a typed dispatcher. Resolving one + /// needs the dispatch map the freeze has not finished building, and the container blocks on + /// itself, so startup hangs with no exception. A provider that validates scopes throws first, + /// since the dispatcher is scoped and a rule is a singleton. Handlers and stages resolve + /// without touching the map, but a singleton rule holding one keeps that instance for the + /// provider's lifetime. + /// + /// + public RequestFlowBuilder AddValidationRule() + where TRule : class, IRequestFlowValidationRule + { + Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + return this; + } } diff --git a/src/RequestFlow/Registration/RequestFlowRegistry.cs b/src/RequestFlow/Registration/RequestFlowRegistry.cs index 9017017..6c6ab97 100644 --- a/src/RequestFlow/Registration/RequestFlowRegistry.cs +++ b/src/RequestFlow/Registration/RequestFlowRegistry.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Reflection; +using Microsoft.Extensions.DependencyInjection; namespace RequestFlow; @@ -14,8 +15,8 @@ internal sealed class RequestFlowRegistry private readonly List _requestTypes = []; // _problems keeps the report in first-seen order; _seenProblems makes the dedup O(1). - private readonly List _problems = []; - private readonly HashSet _seenProblems = []; + private readonly List _problems = []; + private readonly HashSet _seenProblems = []; private readonly HashSet _assemblies = []; private readonly HashSet _closings = []; private readonly List _stageDeclarations = []; @@ -61,8 +62,8 @@ public void DisallowUnusedStages() 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. + /// Appends one call's shape-valid stage declarations. A stage belongs to a chain once, so + /// duplicates are reported at freeze rather than skipped here. /// public void AddStageDeclarations(IReadOnlyList declarations) => _stageDeclarations.AddRange(declarations); @@ -110,13 +111,13 @@ public IReadOnlyList AddNewClosings(IReadOnlyList handlers, IReadOnlyList requestTypes, - IReadOnlyList problems) + IReadOnlyList problems) { _handlers.AddRange(handlers); _requestTypes.AddRange(requestTypes); - // The validator is the only producer of problem strings, so string identity is a - // safe dedup key for the same declaration repeated across calls. + // A problem compares by value and is deterministic per declaration, so the same + // declaration repeated across calls dedups to one entry. foreach (var problem in problems) { if (_seenProblems.Add(problem)) @@ -125,48 +126,57 @@ public void Add( } /// - /// Validates everything accumulated and builds the dispatch map for the resolving - /// provider. + /// Validates everything accumulated, including any rule registered with + /// AddValidationRule and resolved from , and builds the + /// dispatch map for the resolving provider. /// /// - public DispatchMap BuildDispatchMap() + /// + public DispatchMap BuildDispatchMap(IServiceProvider provider) { - List problems = - [ - .. _problems, - .. RegistrationValidator.ValidateDuplicateHandlers(_handlers), - .. RegistrationValidator.ValidateDuplicateStages(_stageDeclarations), - .. RegistrationValidator.ValidateAliasedStages(_stageDeclarations, _handlers, ClosingCache), - ]; - if (!UnhandledRequestsAllowed) - problems.AddRange(RegistrationValidator.ValidateUnhandledRequests(_handlers, _requestTypes)); + RequestFlowModel model = RegistrationSnapshot.Capture( + _handlers, _requestTypes, _stageDeclarations, ClosingCache); - // 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)); - } + // One context for the whole pass, so a built-in rule and a registered one read the same facts. + RequestFlowValidationContext context = new(model, UnhandledRequestsAllowed, UnusedStagesDisallowed); + + List problems = ValidationRuleRunner.Run( + context, BuiltInRules(), provider.GetServices(), _problems); if (problems.Count > 0) throw new RequestFlowValidationException(problems); + Dictionary chainsByRequest = BuildStagePlans(); + // Duplicate handlers were reported above, so one plan lands per handler here. Dictionary plans = []; foreach (var handler in _handlers) { - plans[handler.RequestType] = CreatePlan(handler, stagePlans.ChainsByRequest[handler.RequestType]); + plans[handler.RequestType] = CreatePlan(handler, chainsByRequest[handler.RequestType]); } return new DispatchMap(plans); } - // Ordering and chain shape are decided at freeze, never per AddRequestFlow call. - private StagePlanSet BuildStagePlans() + private IEnumerable BuiltInRules() + { + yield return new DuplicateHandlerRule(); + + if (!UnhandledRequestsAllowed) + yield return new UnhandledRequestRule(); + + yield return new DuplicateStageRule(); + yield return new AliasedStageRule(); + + if (UnusedStagesDisallowed) + yield return new UnusedStageRule(); + + yield return new MultiContractRequestRule(); + } + + private Dictionary BuildStagePlans() { Dictionary chainsByRequest = []; - HashSet appliedStageTypes = []; List ordered = []; foreach (var handler in _handlers) @@ -178,7 +188,6 @@ private StagePlanSet BuildStagePlans() continue; ordered.Add(closedStageType); - appliedStageTypes.Add(declaration.StageType); } Type[] stageTypes = ordered.ToArray(); @@ -187,7 +196,7 @@ private StagePlanSet BuildStagePlans() new StageChain(stageTypes, TypedShapesFor(handler, stageTypes)); } - return new StagePlanSet(chainsByRequest, appliedStageTypes); + return chainsByRequest; } // Only a void request can take stages of either contract shape, so only its chain records @@ -205,7 +214,6 @@ private static bool[] TypedShapesFor(HandlerRegistration handler, Type[] stageTy return typedShapes; } - // The staged plans build their own levels, keeping the reflection at this one call. private static RequestPlanBase CreatePlan(HandlerRegistration handler, StageChain chain) { if (chain.StageTypes.Length == 0) @@ -225,17 +233,6 @@ private static RequestPlanBase CreatePlan(HandlerRegistration handler, StageChai } } -/// -/// The stage chain for each request type, plus the stage types that reached at least one -/// request. -/// -internal sealed class StagePlanSet(Dictionary chainsByRequest, HashSet appliedStageTypes) -{ - public Dictionary ChainsByRequest { get; } = chainsByRequest; - - public ISet AppliedStageTypes { get; } = appliedStageTypes; -} - /// /// One request's stages in execution order. records, per position, /// whether the stage runs as ; it is empty for diff --git a/src/RequestFlow/Registration/ServiceCollectionExtensions.cs b/src/RequestFlow/Registration/ServiceCollectionExtensions.cs index 4ea3f76..38d5b4a 100644 --- a/src/RequestFlow/Registration/ServiceCollectionExtensions.cs +++ b/src/RequestFlow/Registration/ServiceCollectionExtensions.cs @@ -42,20 +42,20 @@ public static RequestFlowBuilder AddRequestFlow( IReadOnlyList newAssemblies = registry.AddNewAssemblies(options.Assemblies); ScanResult scan = HandlerScanner.Scan(newAssemblies); - List handlers = CollectHandlers(scan, closed); + List handlers = scan.Registrations(closed, options.HandlerLifetime); StageDeclarationResult stages = RegistrationValidator.ValidateStageDeclarations(options.StageDeclarations); registry.AddStageDeclarations(stages.ValidDeclarations); if (options.UnusedStagesDisallowed) registry.DisallowUnusedStages(); - List problems = [.. declarations.Problems, .. closed.Problems, .. stages.Problems]; + List problems = [.. declarations.Problems, .. closed.Problems, .. stages.Problems]; registry.Add(handlers, scan.RequestTypes, problems); - RegisterHandlers(services, handlers, options.HandlerLifetime); + RegisterHandlers(services, handlers); RegisterStages(services, registry); - services.TryAddSingleton(_ => registry.BuildDispatchMap()); + services.TryAddSingleton(sp => registry.BuildDispatchMap(sp)); services.TryAdd(new ServiceDescriptor( typeof(IRequestDispatcher), typeof(RequestDispatcher), options.DispatcherLifetime)); @@ -73,12 +73,17 @@ private static List Expand(IReadOnlyList CollectHandlers(ScanResult scan, ClosingResult closed) + private static List Registrations( + this ScanResult scan, ClosingResult closed, ServiceLifetime lifetime) { - List handlers = [.. scan.Handlers]; + List handlers = []; + foreach (var discovery in scan.Handlers) + handlers.Add(new HandlerRegistration(discovery, lifetime)); + foreach (var closedType in closed.ClosedTypes) { - handlers.AddRange(HandlerScanner.CollectHandlers(closedType)); + foreach (var discovery in HandlerScanner.Discover(closedType)) + handlers.Add(new HandlerRegistration(discovery, lifetime)); } return handlers; @@ -97,15 +102,14 @@ private static RequestFlowRegistry GetOrAddRegistry(IServiceCollection services) return registry; } - private static void RegisterHandlers( - IServiceCollection services, IReadOnlyList handlers, ServiceLifetime lifetime) + private static void RegisterHandlers(IServiceCollection services, IReadOnlyList handlers) { foreach (var handler in handlers) { Type service = handler.IsVoid ? typeof(IRequestHandler<>).MakeGenericType(handler.RequestType) : typeof(IRequestHandler<,>).MakeGenericType(handler.RequestType, handler.ResponseType); - services.Add(new ServiceDescriptor(service, handler.ImplementationType, lifetime)); + services.Add(new ServiceDescriptor(service, handler.ImplementationType, handler.Lifetime)); } } diff --git a/src/RequestFlow/Stages/StageClosing.cs b/src/RequestFlow/Stages/StageClosing.cs index 3a832e1..3fb2aec 100644 --- a/src/RequestFlow/Stages/StageClosing.cs +++ b/src/RequestFlow/Stages/StageClosing.cs @@ -1,5 +1,3 @@ -// Startup only: nothing here runs on the dispatch path. - using System; namespace RequestFlow; diff --git a/src/RequestFlow/Stages/StageClosingCache.cs b/src/RequestFlow/Stages/StageClosingCache.cs index c9c3c1a..30481a1 100644 --- a/src/RequestFlow/Stages/StageClosingCache.cs +++ b/src/RequestFlow/Stages/StageClosingCache.cs @@ -1,5 +1,3 @@ -// Startup only: nothing here runs on the dispatch path. - using System; using System.Collections.Generic; @@ -7,9 +5,7 @@ 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. +/// records "does not apply". /// internal sealed class StageClosingCache { @@ -36,7 +32,7 @@ public bool TryClose(StageDeclaration declaration, HandlerRegistration handler, } } - // Every caller hands back the same declaration and handler instances the registry holds, so reference identity is the key. + // Callers pass the declaration and handler instances the registry holds, so reference identity is enough for the key. private readonly struct ClosingKey(StageDeclaration declaration, HandlerRegistration handler) : IEquatable { diff --git a/src/RequestFlow/Validation/AliasedStageRule.cs b/src/RequestFlow/Validation/AliasedStageRule.cs new file mode 100644 index 0000000..d9d9e43 --- /dev/null +++ b/src/RequestFlow/Validation/AliasedStageRule.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace RequestFlow; + +/// +/// Reports declarations that reach one request as the same stage class: an open definition +/// next to its own closed form, or several closed forms that all apply. One problem names +/// every declaration in the collision, so one failed start reports the whole group. +/// +/// +/// The open definition closes to the form declared beside it: +/// +/// o.AddStage(typeof(LoggingStage<,>)) +/// .AddStage<LoggingStage<PlaceOrder, OrderId>>(); +/// +/// Or, with PlaceOrder : IAudited, in TRequest puts both closings in the chain: +/// +/// o.AddStage<LoggingStage<IAudited, OrderId>>() +/// .AddStage<LoggingStage<PlaceOrder, OrderId>>(); +/// +/// Either way LoggingStage runs twice in PlaceOrder's chain. +/// +internal sealed class AliasedStageRule : IRequestFlowValidationRule +{ + public IEnumerable Validate(RequestFlowValidationContext context) + { + List problems = []; + + // One message per colliding set of declarations, not per request they collide on. + HashSet reported = []; + + // Keyed on the stage class where one handler makes the chain, because in TRequest lets + // two different closings of one class apply to the same request. Several handlers mean + // several chains merged into one list, so the key drops to the closed type there. + Dictionary> groups = []; + + // Keeps the groups in chain order, which is declaration order. + List groupOrder = []; + + // Counted apart from the members, which drop verbatim duplicates, because a duplicate + // still occupies a slot in the chain. + Dictionary chainRuns = []; + + foreach (var request in context.Model.Requests) + { + groups.Clear(); + groupOrder.Clear(); + chainRuns.Clear(); + + // One handler is one chain, so every closing in the list shares it. Several handlers + // are several chains merged, and only two declarations landing on one closed type + // are certain to meet in the same one. + bool oneChain = request.Handlers.Count <= 1; + + foreach (var closing in request.Stages) + { + Type key = oneChain ? StageClassOf(closing.ClosedType) : closing.ClosedType; + + if (!groups.TryGetValue(key, out List? members)) + { + members = []; + groups[key] = members; + groupOrder.Add(key); + } + + chainRuns[key] = chainRuns.TryGetValue(key, out int runs) ? runs + 1 : 1; + + // A declaration repeated verbatim is DuplicateStageRule's finding, not an alias. + if (!ContainsDeclaredType(members, closing.DeclaredType)) + members.Add(closing); + } + + foreach (var key in groupOrder) + { + List members = groups[key]; + if (members.Count < 2) + continue; + + Type stageClass = StageClassOf(members[0].ClosedType); + + Type[] declaredTypes = new Type[members.Count]; + for (int i = 0; i < members.Count; i++) + declaredTypes[i] = members[i].DeclaredType; + + if (!reported.Add(new DeclarationSet(declaredTypes))) + continue; + + // The stage class, not the request: the group is reported once, so a subject + // taken from the request would follow scan order. + problems.Add(new RequestFlowValidationProblem( + ProblemCodes.AliasedStage, + BuildMessage(request.RequestType, members, chainRuns[key], oneChain), + stageClass)); + } + } + + return problems; + } + + private static Type StageClassOf(Type closedType) + => closedType.IsGenericType ? closedType.GetGenericTypeDefinition() : closedType; + + // A request with more than one handler has one chain per handler, so the closings the model + // holds never all run together and only the repeat itself is certain. + private static string BuildMessage( + Type requestType, List members, int chainRuns, bool oneChain) + { + string runs; + string fix = "keep one of the AddStage calls and remove the rest."; + if (!oneChain) + { + runs = "more than once"; + } + else if (chainRuns == 2) + { + runs = "twice"; + fix = "remove one of the two AddStage calls."; + } + else + { + runs = $"{chainRuns} times"; + } + + if (members.Count == 2) + { + ClosedStageModel first = members[0]; + ClosedStageModel second = members[1]; + + return first.ClosedType == second.ClosedType + ? $"Stages '{first.DeclaredType.FullName}' and '{second.DeclaredType.FullName}' both resolve to '{second.ClosedType.FullName}' for request " + + $"'{requestType.FullName}' and would run {runs} in the same chain; {fix}" + : $"Stages '{first.DeclaredType.FullName}' and '{second.DeclaredType.FullName}' are the same stage class " + + $"and both apply to request '{requestType.FullName}'; the class would run {runs} in the same chain; {fix}"; + } + + return $"Stages {FormatDeclaredNames(members)} are the same stage class and all apply to request " + + $"'{requestType.FullName}'; the class would run {runs} in the same chain; {fix}"; + } + + private static string FormatDeclaredNames(List members) + { + var names = new StringBuilder(); + for (int i = 0; i < members.Count; i++) + { + if (i > 0) + names.Append(i == members.Count - 1 ? " and " : ", "); + + names.Append('\'').Append(members[i].DeclaredType.FullName).Append('\''); + } + + return names.ToString(); + } + + private static bool ContainsDeclaredType(List members, Type declaredType) + { + foreach (var member in members) + { + if (member.DeclaredType == declaredType) + return true; + } + + return false; + } + + // Colliding declarations reported together once. Not a record struct: array fields would + // compare by reference, and this key needs element-wise equality. + private readonly struct DeclarationSet : IEquatable + { + private readonly Type[] _declaredTypes; + + public DeclarationSet(Type[] declaredTypes) + => _declaredTypes = declaredTypes; + + public bool Equals(DeclarationSet other) + { + if (_declaredTypes.Length != other._declaredTypes.Length) + return false; + + for (int i = 0; i < _declaredTypes.Length; i++) + { + if (_declaredTypes[i] != other._declaredTypes[i]) + return false; + } + + return true; + } + + public override bool Equals(object? obj) + => obj is DeclarationSet other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + int hash = 17; + foreach (var type in _declaredTypes) + hash = (hash * 397) ^ type.GetHashCode(); + + return hash; + } + } + } +} diff --git a/src/RequestFlow/Validation/DuplicateHandlerRule.cs b/src/RequestFlow/Validation/DuplicateHandlerRule.cs new file mode 100644 index 0000000..28439bf --- /dev/null +++ b/src/RequestFlow/Validation/DuplicateHandlerRule.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace RequestFlow; + +/// +/// Reports every request type covered by more than one handler. +/// +/// +/// The two registrations need not look alike. A scanned handler and a closing of a generic one +/// can land on the same request: +/// +/// o.RegisterHandlersFromAssemblyContaining<PlaceOrder>() +/// .RegisterGenericHandler(typeof(AuditedHandler<>), typeof(PlaceOrder)); +/// +/// +internal sealed class DuplicateHandlerRule : IRequestFlowValidationRule +{ + public IEnumerable Validate(RequestFlowValidationContext context) + { + List problems = []; + foreach (var request in context.Model.Requests) + { + if (request.Handlers.Count > 1) + { + problems.Add(new RequestFlowValidationProblem( + ProblemCodes.DuplicateHandler, + $"Request '{request.RequestType.FullName}' has more than one handler; exactly one is required.", + request.RequestType)); + } + } + + return problems; + } +} diff --git a/src/RequestFlow/Validation/DuplicateStageRule.cs b/src/RequestFlow/Validation/DuplicateStageRule.cs new file mode 100644 index 0000000..f9f1086 --- /dev/null +++ b/src/RequestFlow/Validation/DuplicateStageRule.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +namespace RequestFlow; + +/// +/// Reports every stage type registered more than once. A stage belongs to a chain once, +/// whatever each call filtered on. +/// +/// +/// A handler filter does not make the second call a different stage: +/// +/// o.AddStage(typeof(LoggingStage<,>), s => s.WhereHandlerImplements<IAudited>()) +/// .AddStage(typeof(LoggingStage<,>)); +/// +/// +internal sealed class DuplicateStageRule : IRequestFlowValidationRule +{ + public IEnumerable Validate(RequestFlowValidationContext context) + { + List problems = []; + HashSet seenStages = []; + + // A stage registered three times is one problem, not two identical lines. + HashSet reportedStages = []; + foreach (var stage in context.Model.StageDeclarations) + { + if (!seenStages.Add(stage.StageType) && reportedStages.Add(stage.StageType)) + { + problems.Add(new RequestFlowValidationProblem( + ProblemCodes.DuplicateStage, + $"Stage '{stage.StageType.FullName}' from assembly " + + $"'{stage.StageType.Assembly.GetName().Name}' is registered more than once; a stage belongs " + + "to a chain once, whatever each call filtered on. Remove the duplicate AddStage call.", + stage.StageType)); + } + } + + return problems; + } +} diff --git a/src/RequestFlow/Validation/HandlerContract.cs b/src/RequestFlow/Validation/HandlerContract.cs new file mode 100644 index 0000000..046c98a --- /dev/null +++ b/src/RequestFlow/Validation/HandlerContract.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; + +namespace RequestFlow; + +/// +/// Reports which handler contract a handler implements for one request. +/// +/// +/// A package builds its own contract on top of the core one, as ICommandHandler does, so +/// the most derived contract over the request wins. When two contracts apply and neither derives +/// from the other, the core contract is recorded instead of picking one. +/// +internal static class HandlerContract +{ + public static Type Of(Type handlerType, Type requestType, Type? responseType) + { + Type core = responseType is null + ? typeof(IRequestHandler<>).MakeGenericType(requestType) + : typeof(IRequestHandler<,>).MakeGenericType(requestType, responseType); + + List candidates = []; + foreach (var iface in handlerType.GetInterfaces()) + { + if (iface.IsGenericType && iface != core && core.IsAssignableFrom(iface)) + candidates.Add(iface); + } + + foreach (var candidate in candidates) + { + if (DerivesFromAll(candidate, candidates)) + return candidate.GetGenericTypeDefinition(); + } + + return core.GetGenericTypeDefinition(); + } + + private static bool DerivesFromAll(Type candidate, List others) + { + foreach (var other in others) + { + if (other != candidate && !other.IsAssignableFrom(candidate)) + return false; + } + + return true; + } +} diff --git a/src/RequestFlow/Validation/ModelLifetime.cs b/src/RequestFlow/Validation/ModelLifetime.cs new file mode 100644 index 0000000..d4ee4f5 --- /dev/null +++ b/src/RequestFlow/Validation/ModelLifetime.cs @@ -0,0 +1,26 @@ +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace RequestFlow; + +/// +/// Translates a container lifetime into the lifetime the validation model carries. +/// +/// +/// The model ships in the dependency-free abstractions package, so it cannot name +/// . An unknown value throws rather than passing a wrong lifetime to +/// a rule. +/// +internal static class ModelLifetime +{ + /// + public static RequestFlowLifetime Of(ServiceLifetime lifetime) + => lifetime switch + { + ServiceLifetime.Transient => RequestFlowLifetime.Transient, + ServiceLifetime.Scoped => RequestFlowLifetime.Scoped, + ServiceLifetime.Singleton => RequestFlowLifetime.Singleton, + _ => throw new ArgumentOutOfRangeException( + nameof(lifetime), lifetime, $"Unknown service lifetime '{lifetime}'."), + }; +} diff --git a/src/RequestFlow/Validation/MultiContractRequestRule.cs b/src/RequestFlow/Validation/MultiContractRequestRule.cs new file mode 100644 index 0000000..264ebe2 --- /dev/null +++ b/src/RequestFlow/Validation/MultiContractRequestRule.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace RequestFlow; + +/// +/// Reports every request type that implements more than one IRequest<TResponse> +/// contract. The dispatch map holds one plan per request type, so the extra contract could +/// only fail at dispatch, with a ResponseTypeMismatchException. +/// +/// +/// A void request carries the IRequest<NoResult> contract, so this pair collides +/// the same way IRequest<string> next to IRequest<int> does: +/// +/// public sealed record Purge : IRequest, IRequest<int>; +/// +/// +internal sealed class MultiContractRequestRule : IRequestFlowValidationRule +{ + public IEnumerable Validate(RequestFlowValidationContext context) + { + List problems = []; + + // Reused per request; GetInterfaces already returns each closed interface once. + List contracts = []; + + foreach (var request in context.Model.Requests) + { + contracts.Clear(); + foreach (var iface in request.RequestType.GetInterfaces()) + { + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(IRequest<>)) + contracts.Add(iface); + } + + if (contracts.Count < 2) + continue; + + problems.Add(new RequestFlowValidationProblem( + ProblemCodes.MultiContractRequest, + $"Request '{request.RequestType.FullName}' implements more than one request contract ({FormatContracts(contracts)}); " + + $"dispatch resolves one response type per request, so keep one contract and split the type if both responses are needed.", + request.RequestType)); + } + + return problems; + } + + private static string FormatContracts(List contracts) + { + var names = new StringBuilder(); + for (int i = 0; i < contracts.Count; i++) + { + if (i > 0) + names.Append(", "); + + names.Append("IRequest<").Append(contracts[i].GetGenericArguments()[0].FullName).Append('>'); + } + + return names.ToString(); + } +} diff --git a/src/RequestFlow/Validation/RegistrationSnapshot.cs b/src/RequestFlow/Validation/RegistrationSnapshot.cs new file mode 100644 index 0000000..e8adee1 --- /dev/null +++ b/src/RequestFlow/Validation/RegistrationSnapshot.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; + +namespace RequestFlow; + +/// +/// Reshapes the registry's raw accumulations into the frozen snapshot every validation rule +/// reads. +/// +internal static class RegistrationSnapshot +{ + public static RequestFlowModel Capture( + IReadOnlyList handlers, + IReadOnlyList requestTypes, + IReadOnlyList stageDeclarations, + StageClosingCache closings) + { + // Scanned requests first in scan order, then requests only a handler covers. + List orderedRequests = []; + HashSet seen = []; + foreach (var requestType in requestTypes) + { + if (seen.Add(requestType)) + orderedRequests.Add(requestType); + } + + foreach (var handler in handlers) + { + if (seen.Add(handler.RequestType)) + orderedRequests.Add(handler.RequestType); + } + + Dictionary> handlersByRequest = []; + foreach (var handler in handlers) + { + if (!handlersByRequest.TryGetValue(handler.RequestType, out List? covering)) + { + covering = []; + handlersByRequest[handler.RequestType] = covering; + } + + covering.Add(handler); + } + + Dictionary memoStageContracts = []; + + // Reused across declarations: one declaration reaching two handlers as the same closed type is one chain slot, not two. + HashSet closedPerDeclaration = []; + + List requests = []; + foreach (var requestType in orderedRequests) + { + List requestHandlers = []; + List chain = []; + + // Closing needs a handler registration; a request nothing handles gets no chain. + if (handlersByRequest.TryGetValue(requestType, out List? covering)) + { + foreach (var handler in covering) + { + Type? responseType = handler.IsVoid ? null : handler.ResponseType; + + requestHandlers.Add(new HandlerModel( + handler.ImplementationType, + responseType, + HandlerContract.Of(handler.ImplementationType, handler.RequestType, responseType), + ModelLifetime.Of(handler.Lifetime))); + } + + // Every handler contributes, so a rule never sees fewer closings than the runtime produces. + foreach (var declaration in stageDeclarations) + { + closedPerDeclaration.Clear(); + foreach (var handler in covering) + { + if (closings.TryClose(declaration, handler, out Type closedStageType) + && closedPerDeclaration.Add(closedStageType)) + { + chain.Add(new ClosedStageModel( + declaration.StageType, + closedStageType, + StageContract.Of(closedStageType, memoStageContracts))); + } + } + } + } + + requests.Add(new RequestModel(requestType, [.. requestHandlers], [.. chain])); + } + + // Reach is read off the chains, so the requests have to exist before the declarations do. + RequestModel[] capturedRequests = [.. requests]; + + StageDeclarationModel[] declaredStages = new StageDeclarationModel[stageDeclarations.Count]; + for (int i = 0; i < stageDeclarations.Count; i++) + { + StageDeclaration declaration = stageDeclarations[i]; + + declaredStages[i] = new StageDeclarationModel( + declaration.StageType, + ModelLifetime.Of(declaration.Lifetime), + StageReach.Of(capturedRequests, declaration.StageType), + StageContract.Of(declaration.StageType, memoStageContracts)); + } + + return new RequestFlowModel(capturedRequests, declaredStages); + } +} diff --git a/src/RequestFlow/Validation/StageContract.cs b/src/RequestFlow/Validation/StageContract.cs new file mode 100644 index 0000000..0916670 --- /dev/null +++ b/src/RequestFlow/Validation/StageContract.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; + +namespace RequestFlow; + +/// +/// Reports which stage contract a stage type implements. +/// +/// +/// The typed contract wins when a type implements both, matching the order +/// tests them in, so the model names the contract dispatch would use. +/// A package builds its own contract on top of a core one, so the most derived contract wins; +/// when two apply and neither derives from the other, the core contract is recorded instead of +/// picking one. Declarations that implement neither never reach here: registration drops them +/// before the registry records them. +/// +/// One snapshot asks about the same closed stage type once per handler per declaration, so the +/// caller passes a memo it owns for that snapshot. A shared static one would need a lock and would +/// outlive the freeze. +/// +/// +internal static class StageContract +{ + public static Type Of(Type stageType, Dictionary memo) + { + if (memo.TryGetValue(stageType, out Type? cached)) + return cached; + + Type contract = Resolve(stageType); + memo[stageType] = contract; + + return contract; + } + + private static Type Resolve(Type stageType) + { + List typed = []; + List untyped = []; + foreach (var iface in stageType.GetInterfaces()) + { + if (!iface.IsGenericType) + continue; + + Type definition = iface.GetGenericTypeDefinition(); + if (Implements(definition, typeof(IRequestStage<,>))) + typed.Add(definition); + else if (Implements(definition, typeof(IRequestStage<>))) + untyped.Add(definition); + } + + if (typed.Count > 0) + return MostDerived(typed, typeof(IRequestStage<,>)); + + return untyped.Count > 0 ? MostDerived(untyped, typeof(IRequestStage<>)) : typeof(IRequestStage<,>); + } + + private static bool Implements(Type definition, Type contract) + { + if (definition == contract) + return true; + + foreach (var iface in definition.GetInterfaces()) + { + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == contract) + return true; + } + + return false; + } + + private static Type MostDerived(List definitions, Type coreContract) + { + foreach (var definition in definitions) + { + if (definition != coreContract && DerivesFromAll(definition, definitions)) + return definition; + } + + return coreContract; + } + + private static bool DerivesFromAll(Type definition, List others) + { + foreach (var other in others) + { + if (other != definition && !Implements(definition, other)) + return false; + } + + return true; + } +} diff --git a/src/RequestFlow/Validation/UnhandledRequestRule.cs b/src/RequestFlow/Validation/UnhandledRequestRule.cs new file mode 100644 index 0000000..c49e837 --- /dev/null +++ b/src/RequestFlow/Validation/UnhandledRequestRule.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace RequestFlow; + +/// +/// Reports every request type no handler covers. The freeze skips this rule when +/// AllowUnhandledRequests was called. +/// +/// +/// The scan picks up requests and handlers one assembly at a time, so a request whose handler +/// sits in an assembly nobody scanned is reported here. +/// +internal sealed class UnhandledRequestRule : IRequestFlowValidationRule +{ + public IEnumerable Validate(RequestFlowValidationContext context) + { + List problems = []; + foreach (var request in context.Model.Requests) + { + if (request.Handlers.Count == 0) + { + problems.Add(new RequestFlowValidationProblem( + ProblemCodes.UnhandledRequest, + $"Request '{request.RequestType.FullName}' has no handler.", + request.RequestType)); + } + } + + return problems; + } +} diff --git a/src/RequestFlow/Validation/UnusedStageRule.cs b/src/RequestFlow/Validation/UnusedStageRule.cs new file mode 100644 index 0000000..ea30def --- /dev/null +++ b/src/RequestFlow/Validation/UnusedStageRule.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; + +namespace RequestFlow; + +/// +/// Reports every stage that reached no request. The freeze runs this rule only when the +/// application called DisallowUnusedStages; whether AllowUnhandledRequests was +/// called too decides how the message reads. +/// +/// +/// Constraints are how a stage picks its requests, so one nothing satisfies closes for nothing: +/// +/// class AuditStage<TRequest, TResponse> : IRequestStage<TRequest, TResponse> +/// where TRequest : IAudited, IRequest<TResponse> +/// +/// +internal sealed class UnusedStageRule : IRequestFlowValidationRule +{ + public IEnumerable Validate(RequestFlowValidationContext context) + { + // An unhandled request gets no stage chain, so a stage aimed at it looks unused here. + bool anyUnhandled = false; + + HashSet applied = []; + foreach (var request in context.Model.Requests) + { + if (request.Handlers.Count == 0) + anyUnhandled = true; + + foreach (var closing in request.Stages) + applied.Add(closing.DeclaredType); + } + + List problems = []; + HashSet reportedStages = []; + foreach (var stage in context.Model.StageDeclarations) + { + if (!applied.Contains(stage.StageType) && reportedStages.Add(stage.StageType)) + { + string message = + $"Stage '{stage.StageType.FullName}' from assembly " + + $"'{stage.StageType.Assembly.GetName().Name}' applies to no registered request; widen its " + + "generic constraints, scan the assembly holding the requests it targets, or drop " + + "DisallowUnusedStages."; + + if (anyUnhandled) + { + message += context.UnhandledRequestsAllowed + ? " Some registered requests have no handler, which AllowUnhandledRequests permits; a " + + "stage reaching only those still counts as unused." + : " Some registered requests have no handler; a stage reaching only those counts as " + + "unused, so the missing handler may be the fix."; + } + + problems.Add(new RequestFlowValidationProblem(ProblemCodes.UnusedStage, message, stage.StageType)); + } + } + + return problems; + } +} diff --git a/src/RequestFlow/Validation/ValidationRuleRunner.cs b/src/RequestFlow/Validation/ValidationRuleRunner.cs new file mode 100644 index 0000000..86ebe56 --- /dev/null +++ b/src/RequestFlow/Validation/ValidationRuleRunner.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; + +namespace RequestFlow; + +/// +/// Runs one freeze's validation rules and collects what they report. +/// +/// +/// A built-in rule runs unguarded, so a bug in RequestFlow surfaces as the exception it threw. A +/// registered rule runs guarded, so its failure becomes one more problem and the pass keeps going. +/// +internal static class ValidationRuleRunner +{ + /// + /// Collects plus everything the rules report, in that order. + /// + /// + public static List Run( + RequestFlowValidationContext context, + IEnumerable builtInRules, + IEnumerable registeredRules, + IReadOnlyList seeded) + { + // Copied, not appended to: the registry keeps its scan-time problems for the next provider built from the same service collection. + List problems = [.. seeded]; + + foreach (var rule in builtInRules) + Collect(rule, context, problems); + + // Last, so a third-party finding never sits between two of RequestFlow's own. + foreach (var rule in registeredRules) + RunGuarded(rule, context, problems); + + return problems; + } + + // Guards a registered rule. Returning null, or a null problem, is a bug in the rule and still + // throws. An exception from its own code becomes one more problem, so the pass keeps going. + private static void RunGuarded( + IRequestFlowValidationRule rule, + RequestFlowValidationContext context, + List problems) + { + // Findings reach the report only once the rule finishes, so a rule that throws halfway + // leaves none behind. + List reported = []; + try + { + Collect(rule, context, reported); + } + catch (RuleContractException contract) + { + throw new InvalidOperationException(contract.Message); + } + catch (Exception exception) + { + // The stack trace goes, every other finding stays. + problems.Add(new RequestFlowValidationProblem( + ProblemCodes.RuleFailed, + $"Validation rule '{rule.GetType().FullName}' threw {exception.GetType().FullName}: " + + $"'{exception.Message}'. Its findings were dropped and the other rules still ran; " + + "catch inside the rule and report a problem instead.", + rule.GetType())); + + return; + } + + problems.AddRange(reported); + } + + private static void Collect( + IRequestFlowValidationRule rule, + RequestFlowValidationContext context, + List reported) + { + IEnumerable? sequence = rule.Validate(context); + if (sequence is null) + { + throw new RuleContractException( + $"Validation rule '{rule.GetType().FullName}' returned null instead of an empty sequence."); + } + + foreach (var problem in sequence) + { + if (problem is null) + { + throw new RuleContractException( + $"Validation rule '{rule.GetType().FullName}' returned a null problem."); + } + + reported.Add(problem); + } + } + + /// + /// Marks the two diagnostics RequestFlow raises about a validation rule that returns null, + /// keeping them apart from an exception the rule's own code threw. + /// + private sealed class RuleContractException(string message) + : Exception(message) + { } +} diff --git a/tests/RequestFlow.Tests.Unit/AddRequestFlowTests.cs b/tests/RequestFlow.Tests.Unit/AddRequestFlowTests.cs index e409b30..0017613 100644 --- a/tests/RequestFlow.Tests.Unit/AddRequestFlowTests.cs +++ b/tests/RequestFlow.Tests.Unit/AddRequestFlowTests.cs @@ -110,7 +110,7 @@ public void Given_Request_Without_Handler_When_Resolving_Dispatcher_Then_Validat { RequestFlowValidationException exception = ScanFixtureAssembly(); - exception.Problems.ShouldContain(p => p.Contains(nameof(Lonely))); + exception.Problems.ShouldContain(p => p.Message.Contains(nameof(Lonely))); } [Fact] @@ -118,7 +118,7 @@ public void Given_Request_With_Duplicate_Handlers_When_Resolving_Dispatcher_Then { RequestFlowValidationException exception = ScanFixtureAssembly(); - exception.Problems.ShouldContain(p => p.Contains(nameof(Duplicated))); + exception.Problems.ShouldContain(p => p.Message.Contains(nameof(Duplicated))); } [Fact] @@ -126,7 +126,7 @@ public void Given_Derived_Request_Without_Own_Handler_When_Resolving_Dispatcher_ { RequestFlowValidationException exception = ScanFixtureAssembly(); - exception.Problems.ShouldContain(p => p.Contains(nameof(Orphaned))); + exception.Problems.ShouldContain(p => p.Message.Contains(nameof(Orphaned))); } [Fact] @@ -161,7 +161,7 @@ public void Given_Unhandled_Requests_Allowed_When_Resolving_Dispatcher_Then_Miss { RequestFlowValidationException exception = ScanFixtureAssembly(allowUnhandledRequests: true); - exception.Problems.ShouldNotContain(p => p.Contains(nameof(Lonely))); + exception.Problems.ShouldNotContain(p => p.Message.Contains(nameof(Lonely))); } [Fact] @@ -169,7 +169,7 @@ public void Given_Unhandled_Requests_Allowed_When_Resolving_Dispatcher_Then_Dupl { RequestFlowValidationException exception = ScanFixtureAssembly(allowUnhandledRequests: true); - exception.Problems.ShouldContain(p => p.Contains(nameof(Duplicated))); + exception.Problems.ShouldContain(p => p.Message.Contains(nameof(Duplicated))); } [Fact] @@ -182,7 +182,7 @@ public void Given_Unhandled_Requests_Allowed_In_Second_Call_When_Resolving_Dispa RequestFlowValidationException exception = Should.Throw(() => services.BuildServiceProvider().GetRequiredService()); - exception.Problems.ShouldNotContain(p => p.Contains(nameof(Lonely))); + exception.Problems.ShouldNotContain(p => p.Message.Contains(nameof(Lonely))); } [Fact] @@ -307,7 +307,7 @@ public void Given_Two_Providers_Built_From_One_Collection_When_Validation_Fails_ RequestFlowValidationException exception = Should.Throw(() => second.GetRequiredService()); - exception.Problems.ShouldContain(p => p.Contains(nameof(Lonely))); + exception.Problems.ShouldContain(p => p.Message.Contains(nameof(Lonely))); } #region Helpers diff --git a/tests/RequestFlow.Tests.Unit/HandlerScannerTests.cs b/tests/RequestFlow.Tests.Unit/HandlerScannerTests.cs index 06dadb0..7a405de 100644 --- a/tests/RequestFlow.Tests.Unit/HandlerScannerTests.cs +++ b/tests/RequestFlow.Tests.Unit/HandlerScannerTests.cs @@ -1,4 +1,5 @@ using System.Reflection; +using Microsoft.Extensions.DependencyInjection; using RequestFlow; namespace RequestFlow.Tests.Unit; @@ -10,7 +11,7 @@ public void Given_Assembly_With_Typed_Handler_When_Scanning_Then_Registration_Ca { ScanResult result = ScanSelf(); - HandlerRegistration registration = result.Handlers + HandlerDiscovery registration = result.Handlers .Where(h => h.ImplementationType == typeof(ScanPingHandler)) .ShouldHaveSingleItem(); registration.RequestType.ShouldBe(typeof(ScanPing)); @@ -23,7 +24,7 @@ public void Given_Assembly_With_Void_Handler_When_Scanning_Then_Registration_Cap { ScanResult result = ScanSelf(); - HandlerRegistration registration = result.Handlers + HandlerDiscovery registration = result.Handlers .Where(h => h.ImplementationType == typeof(ScanVoidHandler)) .ShouldHaveSingleItem(); registration.RequestType.ShouldBe(typeof(ScanVoid)); diff --git a/tests/RequestFlow.Tests.Unit/RegisterGenericHandlerTests.cs b/tests/RequestFlow.Tests.Unit/RegisterGenericHandlerTests.cs index 4d198ea..3781830 100644 --- a/tests/RequestFlow.Tests.Unit/RegisterGenericHandlerTests.cs +++ b/tests/RequestFlow.Tests.Unit/RegisterGenericHandlerTests.cs @@ -74,7 +74,9 @@ public void Given_Closing_Violating_Handler_Constraints_When_Resolving_Dispatche var exception = Should.Throw(() => services.BuildServiceProvider().GetRequiredService()); - exception.Problems.ShouldContain(p => p.Contains("ConstrainedHandler") && p.Contains(nameof(Plain))); + exception.Problems.ShouldContain(p => + p.Code == "RF0007" && p.Subject == typeof(ConstrainedHandler<>) + && p.Message.Contains("ConstrainedHandler") && p.Message.Contains(nameof(Plain))); } [Fact] @@ -90,7 +92,7 @@ public void Given_Closing_Duplicating_Concrete_Handler_When_Resolving_Dispatcher var exception = Should.Throw(() => services.BuildServiceProvider().GetRequiredService()); - exception.Problems.ShouldContain(p => p.Contains("more than one handler")); + exception.Problems.ShouldContain(p => p.Message.Contains("more than one handler")); } [Fact] @@ -115,12 +117,13 @@ public void Given_Null_Handler_Type_When_Registering_Generic_Handler_Then_Throws } [Theory] - [InlineData(typeof(string))] - [InlineData(typeof(AuditHandler))] - [InlineData(typeof(TwoParamHandler<,>))] - [InlineData(typeof(List<>))] - [InlineData(typeof(AbstractAuditHandler<>))] - public void Given_Invalid_Handler_Type_When_Resolving_Dispatcher_Then_Validation_Reports_Declaration(Type handlerType) + [InlineData(typeof(string), "RF0001")] + [InlineData(typeof(AuditHandler), "RF0001")] + [InlineData(typeof(AbstractAuditHandler<>), "RF0002")] + [InlineData(typeof(TwoParamHandler<,>), "RF0003")] + [InlineData(typeof(List<>), "RF0004")] + public void Given_Invalid_Handler_Type_When_Resolving_Dispatcher_Then_Validation_Reports_Declaration( + Type handlerType, string expectedCode) { var services = new ServiceCollection(); services.AddRequestFlow(o => o.RegisterGenericHandler(handlerType, typeof(Order))); @@ -129,7 +132,8 @@ public void Given_Invalid_Handler_Type_When_Resolving_Dispatcher_Then_Validation services.BuildServiceProvider().GetRequiredService()); string handlerName = handlerType.Name.Split('`')[0]; - exception.Problems.ShouldContain(p => p.Contains(handlerName)); + exception.Problems.ShouldContain(p => + p.Code == expectedCode && p.Subject == handlerType && p.Message.Contains(handlerName)); } [Fact] @@ -149,7 +153,8 @@ public void Given_Empty_Closing_Array_When_Resolving_Dispatcher_Then_Validation_ var exception = Should.Throw(() => services.BuildServiceProvider().GetRequiredService()); - exception.Problems.ShouldContain(p => p.Contains("no closing types")); + exception.Problems.ShouldContain(p => + p.Code == "RF0005" && p.Subject == typeof(AuditHandler<>) && p.Message.Contains("no closing types")); } [Fact] @@ -169,7 +174,8 @@ public void Given_Open_Closing_Type_When_Resolving_Dispatcher_Then_Validation_Re var exception = Should.Throw(() => services.BuildServiceProvider().GetRequiredService()); - exception.Problems.ShouldContain(p => p.Contains("is not a closed type")); + exception.Problems.ShouldContain(p => + p.Code == "RF0006" && p.Subject == typeof(List<>) && p.Message.Contains("is not a closed type")); } [Fact] @@ -186,9 +192,9 @@ public void Given_Shape_Constraint_And_Missing_Handler_Problems_When_Resolving_D var exception = Should.Throw(() => services.BuildServiceProvider().GetRequiredService()); - exception.Problems.ShouldContain(p => p.Contains("System.String")); - exception.Problems.ShouldContain(p => p.Contains("ConstrainedHandler") && p.Contains(nameof(Plain))); - exception.Problems.ShouldContain(p => p.Contains(nameof(Lonely))); + exception.Problems.ShouldContain(p => p.Message.Contains("System.String")); + exception.Problems.ShouldContain(p => p.Message.Contains("ConstrainedHandler") && p.Message.Contains(nameof(Plain))); + exception.Problems.ShouldContain(p => p.Message.Contains(nameof(Lonely))); } [Fact] @@ -201,7 +207,7 @@ public void Given_Same_Invalid_Declaration_In_Two_Calls_When_Resolving_Dispatche var exception = Should.Throw(() => services.BuildServiceProvider().GetRequiredService()); - exception.Problems.Count(p => p.Contains("System.String")).ShouldBe(1); + exception.Problems.Count(p => p.Message.Contains("System.String")).ShouldBe(1); } #region Initialization diff --git a/tests/RequestFlow.Tests.Unit/RequestFlowRegistryTests.cs b/tests/RequestFlow.Tests.Unit/RequestFlowRegistryTests.cs index df2a911..a1d8eef 100644 --- a/tests/RequestFlow.Tests.Unit/RequestFlowRegistryTests.cs +++ b/tests/RequestFlow.Tests.Unit/RequestFlowRegistryTests.cs @@ -6,7 +6,7 @@ namespace RequestFlow.Tests.Unit; public sealed class RequestFlowRegistryTests { [Fact] - public void Given_One_Applicable_Stage_When_Building_The_Dispatch_Map_Then_Request_Gets_The_Staged_Plan() + public void Given_An_Applicable_Stage_When_Building_The_Dispatch_Map_Then_Request_Gets_The_Staged_Plan() { DispatchMap map = BuildMap(o => o.AddStage(typeof(WrapStage<,>))); @@ -16,7 +16,7 @@ public void Given_One_Applicable_Stage_When_Building_The_Dispatch_Map_Then_Reque } [Fact] - public void Given_One_Applicable_Stage_When_Building_The_Dispatch_Map_Then_Void_Request_Gets_The_Staged_Void_Plan() + public void Given_An_Applicable_Stage_When_Building_The_Dispatch_Map_Then_Void_Request_Gets_The_Staged_Void_Plan() { DispatchMap map = BuildMap(o => o.AddStage(typeof(WrapStage<,>))); @@ -25,26 +25,6 @@ public void Given_One_Applicable_Stage_When_Building_The_Dispatch_Map_Then_Void_ plan.ShouldBeOfType>(); } - [Fact] - public void Given_Two_Applicable_Stages_When_Building_The_Dispatch_Map_Then_Request_Gets_The_General_Staged_Plan() - { - DispatchMap map = BuildMap(o => o.AddStage(typeof(WrapStage<,>)).AddStage(typeof(ExtraStage<,>))); - - map.TryGetPlanFor(typeof(Echo), out RequestPlanBase? plan); - - plan.ShouldBeOfType>(); - } - - [Fact] - public void Given_Two_Applicable_Stages_When_Building_The_Dispatch_Map_Then_Void_Request_Gets_The_General_Staged_Void_Plan() - { - DispatchMap map = BuildMap(o => o.AddStage(typeof(WrapStage<,>)).AddStage(typeof(ExtraStage<,>))); - - map.TryGetPlanFor(typeof(Purge), out RequestPlanBase? plan); - - plan.ShouldBeOfType>(); - } - [Fact] public void Given_No_Stages_When_Building_The_Dispatch_Map_Then_Request_Gets_The_Plain_Plan() { @@ -118,13 +98,5 @@ public Task HandleAsync( => next.InvokeAsync(); } - public sealed class ExtraStage : IRequestStage - where TRequest : IRequest - { - public Task HandleAsync( - TRequest request, Continuation next, CancellationToken cancellationToken) - => next.InvokeAsync(); - } - #endregion } diff --git a/tests/RequestFlow.Tests.Unit/RequestFlowValidationExceptionTests.cs b/tests/RequestFlow.Tests.Unit/RequestFlowValidationExceptionTests.cs new file mode 100644 index 0000000..340716b --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/RequestFlowValidationExceptionTests.cs @@ -0,0 +1,55 @@ +using RequestFlow; + +namespace RequestFlow.Tests.Unit; + +public sealed class RequestFlowValidationExceptionTests +{ + [Fact] + public void Given_Null_Problems_When_Creating_The_Exception_Then_Throws_Argument_Null_Exception() + { + ArgumentNullException exception = + Should.Throw(() => new RequestFlowValidationException(null!)); + + exception.ParamName.ShouldBe("problems"); + } + + [Fact] + public void Given_A_Problem_When_Creating_The_Exception_Then_The_Message_Starts_With_The_Registration_Prefix() + { + var exception = new RequestFlowValidationException([Problem("RF0101", "duplicate handler")]); + + exception.Message.ShouldStartWith("RequestFlow registration is invalid:"); + } + + [Fact] + public void Given_Several_Problems_When_Creating_The_Exception_Then_The_Message_Lists_Every_Problem_One_Per_Line() + { + var exception = new RequestFlowValidationException( + [Problem("RF0101", "duplicate handler"), Problem("RF0102", "no handler")]); + + exception.Message.ShouldBe( + "RequestFlow registration is invalid:" + Environment.NewLine + + "RF0101: duplicate handler" + Environment.NewLine + + "RF0102: no handler"); + } + + [Fact] + public void Given_Problems_When_Creating_The_Exception_Then_They_Are_Exposed_In_The_Order_Given() + { + RequestFlowValidationProblem first = Problem("RF0101", "duplicate handler"); + RequestFlowValidationProblem second = Problem("RF0102", "no handler"); + + var exception = new RequestFlowValidationException([first, second]); + + exception.Problems.Count.ShouldBe(2); + exception.Problems[0].ShouldBeSameAs(first); + exception.Problems[1].ShouldBeSameAs(second); + } + + #region Helpers + + private static RequestFlowValidationProblem Problem(string code, string message) + => new(code, message); + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Stages/AddStageTests.cs b/tests/RequestFlow.Tests.Unit/Stages/AddStageTests.cs index 3401a1c..85f1633 100644 --- a/tests/RequestFlow.Tests.Unit/Stages/AddStageTests.cs +++ b/tests/RequestFlow.Tests.Unit/Stages/AddStageTests.cs @@ -131,7 +131,9 @@ public void Given_Stage_Whose_Parameter_Is_Not_The_Request_When_Validating_Then_ result.ValidDeclarations.ShouldBeEmpty(); result.Problems.Count.ShouldBe(1); - result.Problems[0].ShouldContain("does not use as its request"); + result.Problems[0].Code.ShouldBe("RF0012"); + result.Problems[0].Subject.ShouldBe(typeof(OneParameterStage<>)); + result.Problems[0].Message.ShouldContain("does not use as its request"); } [Fact] @@ -142,7 +144,9 @@ public void Given_Type_That_Is_Not_A_Stage_When_Validating_Then_Reports_Missing_ StageDeclarationResult result = RegistrationValidator.ValidateStageDeclarations(declarations); result.ValidDeclarations.ShouldBeEmpty(); - result.Problems[0].ShouldContain("does not implement IRequestStage"); + result.Problems[0].Code.ShouldBe("RF0011"); + result.Problems[0].Subject.ShouldBe(typeof(NotAStage)); + result.Problems[0].Message.ShouldContain("does not implement IRequestStage"); } [Fact] @@ -153,7 +157,9 @@ public void Given_Abstract_Stage_When_Validating_Then_Reports_Abstract() StageDeclarationResult result = RegistrationValidator.ValidateStageDeclarations(declarations); result.ValidDeclarations.ShouldBeEmpty(); - result.Problems[0].ShouldContain("is abstract"); + result.Problems[0].Code.ShouldBe("RF0009"); + result.Problems[0].Subject.ShouldBe(typeof(AbstractStage)); + result.Problems[0].Message.ShouldContain("is abstract"); } [Fact] @@ -176,7 +182,9 @@ public void Given_Stage_With_Swapped_Generic_Parameters_When_Validating_Then_Rep StageDeclarationResult result = RegistrationValidator.ValidateStageDeclarations(declarations); result.ValidDeclarations.ShouldBeEmpty(); - result.Problems[0].ShouldContain("in that order"); + result.Problems[0].Code.ShouldBe("RF0012"); + result.Problems[0].Subject.ShouldBe(typeof(SwappedStage<,>)); + result.Problems[0].Message.ShouldContain("in that order"); } [Fact] @@ -224,6 +232,34 @@ public void Given_Single_Parameter_Stage_Closed_Over_Its_Request_When_Validating result.Problems.ShouldBeEmpty(); } + [Fact] + public void Given_Interface_Stage_When_Validating_Then_Reports_Interface() + { + StageDeclaration[] declarations = [new StageDeclaration(typeof(IRequestStage), null)]; + + StageDeclarationResult result = RegistrationValidator.ValidateStageDeclarations(declarations); + + result.ValidDeclarations.ShouldBeEmpty(); + result.Problems[0].Code.ShouldBe("RF0008"); + result.Problems[0].Subject.ShouldBe(typeof(IRequestStage)); + result.Problems[0].Message.ShouldContain("is an interface"); + } + + [Fact] + public void Given_Partially_Closed_Stage_When_Validating_Then_Reports_Partially_Closed() + { + Type openParameter = typeof(List<>).GetGenericArguments()[0]; + Type partiallyClosed = typeof(OneParameterStage<>).MakeGenericType(openParameter); + StageDeclaration[] declarations = [new StageDeclaration(partiallyClosed, null)]; + + StageDeclarationResult result = RegistrationValidator.ValidateStageDeclarations(declarations); + + result.ValidDeclarations.ShouldBeEmpty(); + result.Problems[0].Code.ShouldBe("RF0010"); + result.Problems[0].Subject.ShouldBe(partiallyClosed); + result.Problems[0].Message.ShouldContain("is partially closed"); + } + #region Initialization private readonly RequestFlowOptions _sut = new(); diff --git a/tests/RequestFlow.Tests.Unit/Stages/ChainExecutionTests.cs b/tests/RequestFlow.Tests.Unit/Stages/ChainExecutionTests.cs index b60e7e7..56d6ce0 100644 --- a/tests/RequestFlow.Tests.Unit/Stages/ChainExecutionTests.cs +++ b/tests/RequestFlow.Tests.Unit/Stages/ChainExecutionTests.cs @@ -581,95 +581,6 @@ public async Task Given_Synchronously_Completed_Task_When_Bridging_To_No_Result_ await result; } - [Fact] - public async Task Given_One_Stage_When_Running_The_Chain_Then_Stage_Wraps_The_Handler() - { - List log = []; - var sut = PingChain(new RecordingStage("only", log)); - - string result = await sut.RunAsync(); - - result.ShouldBe("hi:handled"); - log.ShouldBe(["only:enter", "only:exit"]); - } - - [Fact] - public async Task Given_One_Stage_That_Calls_Next_Twice_When_Running_The_Chain_Then_Handler_Runs_Again() - { - List log = []; - var sut = PingChain(new DoubleNextStage("only", log)); - - await sut.RunAsync(); - - log.ShouldBe(["only:enter", "only:exit"]); - await _pingHandler.Received(2).HandleAsync(Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task Given_One_Stage_That_Calls_Next_Again_Before_The_First_Call_Completes_When_Running_The_Chain_Then_Both_Walks_Reach_The_Handler() - { - var gate = new TaskCompletionSource(); - var handler = new GatedPingHandler(gate.Task); - var sut = PingChainFor(handler, new OverlappingNextStage(gate)); - - await sut.RunAsync(); - - handler.Calls.ShouldBe(2); - } - - [Fact] - public void Given_One_Stage_That_Returns_A_Null_Task_When_Running_The_Chain_Then_Throws_Naming_The_Stage() - { - var sut = PingChain(new NullTaskStage()); - - StageNullTaskException exception = Should.Throw(() => sut.RunAsync()); - - exception.StageType.ShouldBe(typeof(NullTaskStage)); - } - - [Fact] - public void Given_One_Stage_And_Handler_That_Returns_A_Null_Task_When_Running_The_Chain_Then_Throws_Naming_The_Request() - { - object[] stages = [new NilPassThroughStage()]; - var sut = new ChainRunner( - TypedChain(StageTypes(stages)), - new Nil(), - ChainProvider>(new NilHandler(), stages), - CancellationToken.None); - - HandlerNullTaskException exception = Should.Throw(() => sut.RunAsync()); - - exception.RequestType.ShouldBe(typeof(Nil)); - } - - [Fact] - public async Task Given_One_Typed_Form_Stage_When_Running_The_Void_Chain_Then_It_Wraps_The_Handler() - { - var logHandler = Substitute.For>(); - List log = []; - var sut = LogChain(logHandler, new RecordingVoidStage(log)); - - 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_One_Void_Form_Stage_That_Calls_Next_Twice_When_Running_The_Void_Chain_Then_Handler_Runs_Again() - { - var handler = Substitute.For>(); - handler.HandleAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); - List log = []; - var sut = LogChain(handler, new DoubleNextVoidStage(log)); - - await sut.RunAsync(); - - log.ShouldBe(["void:enter", "void:exit"]); - await handler.Received(2).HandleAsync(Arg.Any(), Arg.Any()); - } - #region Initialization private readonly IRequestHandler _pingHandler; @@ -1124,13 +1035,6 @@ public Task HandleAsync(Ping request, Continuation next, Cancell => null!; } - // Delegates straight to next, so the null task the handler returns is the one reported. - private sealed class NilPassThroughStage : IRequestStage - { - public Task HandleAsync(Nil request, Continuation next, CancellationToken cancellationToken) - => next.InvokeAsync(); - } - private sealed class NullTaskOnFirstAttemptStage : IRequestStage { public int Attempts { get; private set; } diff --git a/tests/RequestFlow.Tests.Unit/Stages/StageClosingTests.cs b/tests/RequestFlow.Tests.Unit/Stages/StageClosingTests.cs index 65ed007..cb0fef8 100644 --- a/tests/RequestFlow.Tests.Unit/Stages/StageClosingTests.cs +++ b/tests/RequestFlow.Tests.Unit/Stages/StageClosingTests.cs @@ -126,13 +126,16 @@ public void Given_Same_Closed_Stage_Reached_By_Two_Calls_When_Adding_Request_Flo #region Initialization private readonly HandlerRegistration _pingHandler = - new(typeof(PingHandler), typeof(Ping), typeof(string), isVoid: false); + new(new HandlerDiscovery(typeof(PingHandler), typeof(Ping), typeof(string), isVoid: false), + ServiceLifetime.Transient); private readonly HandlerRegistration _taggedHandler = - new(typeof(TaggedHandler), typeof(Tagged), typeof(string), isVoid: false); + new(new HandlerDiscovery(typeof(TaggedHandler), typeof(Tagged), typeof(string), isVoid: false), + ServiceLifetime.Transient); private readonly HandlerRegistration _logHandler = - new(typeof(LogHandler), typeof(Log), typeof(NoResult), isVoid: true); + new(new HandlerDiscovery(typeof(LogHandler), typeof(Log), typeof(NoResult), isVoid: true), + ServiceLifetime.Transient); #endregion diff --git a/tests/RequestFlow.Tests.Unit/Stages/StagePipelineTests.cs b/tests/RequestFlow.Tests.Unit/Stages/StagePipelineTests.cs index 063ba4b..ba63d16 100644 --- a/tests/RequestFlow.Tests.Unit/Stages/StagePipelineTests.cs +++ b/tests/RequestFlow.Tests.Unit/Stages/StagePipelineTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using RequestFlow; +using RequestFlow.Tests.ValidationFixtures; namespace RequestFlow.Tests.Unit; @@ -79,8 +80,12 @@ public void Given_One_Stage_Type_With_Two_Handler_Filters_When_Resolving_Dispatc .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")); + p.Message.Contains(nameof(RecordingStage)) && p.Message.Contains("more than once")); + + // The two filters here are disjoint, so the stage never actually lands in one chain + // twice. The message states the rule instead of predicting a double run. + exception.Problems.ShouldContain(p => p.Message.Contains("whatever each call filtered on")); + exception.Problems.ShouldNotContain(p => p.Message.Contains("would run twice")); } [Fact] @@ -116,7 +121,7 @@ public void Given_Same_Stage_Registered_By_Two_Calls_When_Resolving_Dispatcher_T RequestFlowValidationException exception = Should.Throw(() => services.BuildServiceProvider().GetRequiredService()); - exception.Problems.Count(p => p.Contains(nameof(RecordingStage))).ShouldBe(1); + exception.Problems.Count(p => p.Message.Contains(nameof(RecordingStage))).ShouldBe(1); } [Fact] @@ -138,8 +143,8 @@ public void Given_Open_Stage_And_Its_Own_Closed_Form_When_Resolving_Dispatcher_T .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))); + exception.Problems.Count(p => p.Message.Contains("resolve to")).ShouldBe(1); + exception.Problems.ShouldContain(p => p.Message.Contains("RecordingStage") && p.Message.Contains(nameof(Ping))); } [Fact] @@ -150,8 +155,8 @@ public void Given_Open_Stage_And_A_Closed_Form_For_A_Base_Request_When_Resolving .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))); + exception.Problems.Count(p => p.Message.Contains("same stage class")).ShouldBe(1); + exception.Problems.ShouldContain(p => p.Message.Contains("RecordingStage") && p.Message.Contains(nameof(EmailNotification))); } [Fact] @@ -162,8 +167,8 @@ public void Given_Two_Closed_Forms_Of_One_Stage_For_Base_And_Derived_Requests_Wh .AddStage>() .AddStage>())); - exception.Problems.Count(p => p.Contains("same stage class")).ShouldBe(1); - exception.Problems.ShouldContain(p => p.Contains("RecordingStage") && p.Contains(nameof(EmailNotification))); + exception.Problems.Count(p => p.Message.Contains("same stage class")).ShouldBe(1); + exception.Problems.ShouldContain(p => p.Message.Contains("RecordingStage") && p.Message.Contains(nameof(EmailNotification))); } [Fact] @@ -233,7 +238,55 @@ public void Given_Stage_That_Applies_To_Nothing_When_Strict_Then_Reports_The_Sta Build(o => o.AddStage(typeof(UnreachableStage<,>)).DisallowUnusedStages())); exception.Problems.ShouldContain(p => - p.Contains("UnreachableStage") && p.Contains("no registered request")); + p.Message.Contains("UnreachableStage") && p.Message.Contains("no registered request")); + } + + [Fact] + public void Given_Stage_Reaching_Only_The_Second_Handler_Of_One_Request_When_Strict_Then_Does_Not_Report_The_Stage() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o + .RegisterHandlersFromAssembly(typeof(Forked).Assembly) + .AddStage() + .DisallowUnusedStages()); + + RequestFlowValidationException exception = Should.Throw(() => + services.BuildServiceProvider().GetRequiredService()); + + exception.Problems.ShouldContain(p => p.Code == "RF0101" && p.Subject == typeof(Forked)); + exception.Problems.ShouldNotContain(p => p.Code == "RF0105"); + } + + [Fact] + public void Given_Two_Declarations_Colliding_Only_On_The_Second_Handler_When_Resolving_Dispatcher_Then_Reports_The_Collision() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o + .RegisterHandlersFromAssembly(typeof(Forked).Assembly) + .AddStage(typeof(IntBoundStage<>)) + .AddStage>()); + + RequestFlowValidationException exception = Should.Throw(() => + services.BuildServiceProvider().GetRequiredService()); + + exception.Problems.ShouldContain(p => + p.Code == "RF0104" && p.Subject == typeof(IntBoundStage<>) && p.Message.Contains(nameof(Forked))); + } + + // One closing per handler is one stage per chain, however many chains the request has. + [Fact] + public void Given_Two_Closings_Reaching_A_Handler_Each_When_Resolving_Dispatcher_Then_Reports_No_Collision() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o + .RegisterHandlersFromAssembly(typeof(Forked).Assembly) + .AddStage>() + .AddStage>()); + + RequestFlowValidationException exception = Should.Throw(() => + services.BuildServiceProvider().GetRequiredService()); + + exception.Problems.ShouldNotContain(p => p.Code == "RF0104"); } #region Initialization @@ -312,6 +365,24 @@ public Task HandleAsync(Wipe request, CancellationToken cancellationToken) } } + // Declared closed over Forked's int contract, so it reaches ForkedIntHandler and not + // ForkedStringHandler. + public sealed class ForkedIntStage : IRequestStage + { + public Task HandleAsync( + Forked request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(); + } + + // Bound to the int contract by its constraint, so on Forked it reaches ForkedIntHandler only. + public sealed class IntBoundStage : IRequestStage + where TRequest : IRequest + { + public Task HandleAsync( + TRequest request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(); + } + public sealed class RecordingStage : IRequestStage where TRequest : IRequest { diff --git a/tests/RequestFlow.Tests.Unit/Validation/AddValidationRuleTests.cs b/tests/RequestFlow.Tests.Unit/Validation/AddValidationRuleTests.cs new file mode 100644 index 0000000..53e6f31 --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Validation/AddValidationRuleTests.cs @@ -0,0 +1,310 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace RequestFlow.Tests.Unit.Validation; + +public sealed class AddValidationRuleTests +{ + [Fact] + public void Given_A_Failing_External_Rule_When_Validating_Then_Its_Problem_Is_In_The_Exception() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o.RegisterHandlersFromAssemblyContaining()) + .AddValidationRule(); + using ServiceProvider provider = services.BuildServiceProvider(); + + RequestFlowValidationException exception = + Should.Throw(() => provider.ValidateRequestFlow()); + + exception.Problems.ShouldContain(p => p.Code == "TEST0001"); + } + + [Fact] + public void Given_A_Passing_External_Rule_When_Validating_Then_Does_Not_Throw() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o.RegisterHandlersFromAssemblyContaining()) + .AddValidationRule(); + using ServiceProvider provider = services.BuildServiceProvider(); + + Should.NotThrow(() => provider.ValidateRequestFlow()); + } + + [Fact] + public void Given_A_Rule_With_A_Dependency_When_Validating_Then_The_Rule_Is_Constructor_Injected() + { + var services = new ServiceCollection(); + services.AddSingleton(new ProblemSource("TEST0002")); + services.AddRequestFlow(o => o.RegisterHandlersFromAssemblyContaining()) + .AddValidationRule(); + using ServiceProvider provider = services.BuildServiceProvider(); + + RequestFlowValidationException exception = + Should.Throw(() => provider.ValidateRequestFlow()); + + exception.Problems.ShouldContain(p => p.Code == "TEST0002"); + } + + [Fact] + public void Given_Shape_Built_In_And_External_Problems_When_Validating_Then_Problems_Are_Ordered_Shape_Then_Built_In_Then_External() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => + { + o.RegisterHandlersFromAssemblyContaining(); + o.RegisterHandlersFromAssembly(typeof(RequestFlow.Tests.ValidationFixtures.Lonely).Assembly); + o.RegisterGenericHandler(typeof(string), typeof(object)); + }) + .AddValidationRule(); + using ServiceProvider provider = services.BuildServiceProvider(); + + RequestFlowValidationException exception = + Should.Throw(() => provider.ValidateRequestFlow()); + + int shape = IndexOfCode(exception, "RF0001"); + int builtIn = IndexOfCode(exception, "RF0102"); + int external = IndexOfCode(exception, "TEST0001"); + shape.ShouldBeGreaterThanOrEqualTo(0); + builtIn.ShouldBeGreaterThan(shape); + external.ShouldBeGreaterThan(builtIn); + } + + [Fact] + public void Given_The_Same_Rule_Added_Twice_When_Validating_Then_It_Runs_Once() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o.RegisterHandlersFromAssemblyContaining()) + .AddValidationRule() + .AddValidationRule(); + using ServiceProvider provider = services.BuildServiceProvider(); + + RequestFlowValidationException exception = + Should.Throw(() => provider.ValidateRequestFlow()); + + exception.Problems.Count(p => p.Code == "TEST0001").ShouldBe(1); + } + + [Fact] + public void Given_A_Throwing_Rule_When_Validating_Then_The_Failure_Is_Reported_As_A_Problem() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o.RegisterHandlersFromAssemblyContaining()) + .AddValidationRule(); + using ServiceProvider provider = services.BuildServiceProvider(); + + RequestFlowValidationException exception = + Should.Throw(() => provider.ValidateRequestFlow()); + + exception.Problems.ShouldContain(p => p.Code == "RF0107" && p.Subject == typeof(ThrowingRule)); + } + + [Fact] + public void Given_A_Throwing_Rule_When_Validating_Then_The_Problem_Names_The_Rule_And_The_Exception() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o.RegisterHandlersFromAssemblyContaining()) + .AddValidationRule(); + using ServiceProvider provider = services.BuildServiceProvider(); + + RequestFlowValidationException exception = + Should.Throw(() => provider.ValidateRequestFlow()); + + string message = exception.Problems.Single(p => p.Code == "RF0107").Message; + message.ShouldContain(typeof(ThrowingRule).FullName!); + message.ShouldContain(typeof(FormatException).FullName!); + message.ShouldContain("rule blew up"); + } + + [Fact] + public void Given_A_Throwing_Rule_Beside_A_Built_In_Failure_When_Validating_Then_Both_Are_Reported() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => + { + o.RegisterHandlersFromAssemblyContaining(); + o.RegisterHandlersFromAssembly(typeof(RequestFlow.Tests.ValidationFixtures.Lonely).Assembly); + }) + .AddValidationRule(); + using ServiceProvider provider = services.BuildServiceProvider(); + + RequestFlowValidationException exception = + Should.Throw(() => provider.ValidateRequestFlow()); + + exception.Problems.ShouldContain(p => p.Code == "RF0102"); + exception.Problems.ShouldContain(p => p.Code == "RF0107"); + } + + [Fact] + public void Given_A_Throwing_Rule_Registered_First_When_Validating_Then_The_Later_Rule_Still_Reports() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o.RegisterHandlersFromAssemblyContaining()) + .AddValidationRule() + .AddValidationRule(); + using ServiceProvider provider = services.BuildServiceProvider(); + + RequestFlowValidationException exception = + Should.Throw(() => provider.ValidateRequestFlow()); + + int failure = IndexOfCode(exception, "RF0107"); + failure.ShouldBeGreaterThanOrEqualTo(0); + IndexOfCode(exception, "TEST0001").ShouldBeGreaterThan(failure); + } + + [Fact] + public void Given_A_Rule_Throwing_While_Its_Sequence_Is_Enumerated_When_Validating_Then_The_Failure_Is_Reported_As_A_Problem() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o.RegisterHandlersFromAssemblyContaining()) + .AddValidationRule(); + using ServiceProvider provider = services.BuildServiceProvider(); + + RequestFlowValidationException exception = + Should.Throw(() => provider.ValidateRequestFlow()); + + exception.Problems.ShouldContain(p => p.Code == "RF0107" && p.Subject == typeof(PartiallyThrowingRule)); + } + + [Fact] + public void Given_A_Rule_Throwing_After_It_Yielded_A_Problem_When_Validating_Then_That_Problem_Is_Dropped() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o.RegisterHandlersFromAssemblyContaining()) + .AddValidationRule(); + using ServiceProvider provider = services.BuildServiceProvider(); + + RequestFlowValidationException exception = + Should.Throw(() => provider.ValidateRequestFlow()); + + exception.Problems.ShouldNotContain(p => p.Code == "TEST0004"); + } + + // Only the marker type tells RequestFlow's own diagnostics apart from a rule's exception. + [Fact] + public void Given_A_Rule_Throwing_A_Lookalike_Diagnostic_When_Validating_Then_The_Failure_Is_Reported_As_A_Problem() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o.RegisterHandlersFromAssemblyContaining()) + .AddValidationRule(); + using ServiceProvider provider = services.BuildServiceProvider(); + + RequestFlowValidationException exception = + Should.Throw(() => provider.ValidateRequestFlow()); + + exception.Problems.ShouldContain(p => p.Code == "RF0107" && p.Subject == typeof(LookalikeThrowingRule)); + } + + [Fact] + public void Given_A_Null_Returning_Rule_When_Validating_Then_The_Diagnostic_Is_Not_A_Validation_Exception() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o.RegisterHandlersFromAssemblyContaining()) + .AddValidationRule(); + using ServiceProvider provider = services.BuildServiceProvider(); + + InvalidOperationException exception = + Should.Throw(() => provider.ValidateRequestFlow()); + + exception.ShouldBeOfType(); + exception.Message.ShouldBe( + $"Validation rule '{typeof(NullReturningRule).FullName}' returned null instead of an empty sequence."); + } + + [Fact] + public void Given_A_Rule_Returning_A_Null_Problem_When_Validating_Then_The_Diagnostic_Is_Not_A_Validation_Exception() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o.RegisterHandlersFromAssemblyContaining()) + .AddValidationRule(); + using ServiceProvider provider = services.BuildServiceProvider(); + + InvalidOperationException exception = + Should.Throw(() => provider.ValidateRequestFlow()); + + exception.ShouldBeOfType(); + exception.Message.ShouldBe( + $"Validation rule '{typeof(NullProblemReturningRule).FullName}' returned a null problem."); + } + + #region Helpers + + private static int IndexOfCode(RequestFlowValidationException exception, string code) + { + for (int i = 0; i < exception.Problems.Count; i++) + { + if (exception.Problems[i].Code == code) + return i; + } + + return -1; + } + + public sealed record Echo : IRequest; + + public sealed class EchoHandler : IRequestHandler + { + public Task HandleAsync(Echo request, CancellationToken cancellationToken) + => Task.FromResult(string.Empty); + } + + private sealed class AlwaysFailsRule : IRequestFlowValidationRule + { + public IEnumerable Validate(RequestFlowValidationContext context) + => [new RequestFlowValidationProblem("TEST0001", "always fails")]; + } + + private sealed class NeverFailsRule : IRequestFlowValidationRule + { + public IEnumerable Validate(RequestFlowValidationContext context) + => []; + } + + public sealed class ProblemSource(string code) + { + public string Code { get; } = code; + } + + private sealed class DependentRule(ProblemSource source) : IRequestFlowValidationRule + { + public IEnumerable Validate(RequestFlowValidationContext context) + => [new RequestFlowValidationProblem(source.Code, "from dependency")]; + } + + private sealed class ThrowingRule : IRequestFlowValidationRule + { + public IEnumerable Validate(RequestFlowValidationContext context) + => throw new FormatException("rule blew up"); + } + + // Throws from MoveNext rather than from Validate, so the first finding is already in hand. + private sealed class PartiallyThrowingRule : IRequestFlowValidationRule + { + public IEnumerable Validate(RequestFlowValidationContext context) + { + yield return new RequestFlowValidationProblem("TEST0004", "found before the throw"); + + throw new NotSupportedException("enumeration blew up"); + } + } + + // Its message copies RequestFlow's own null-sequence diagnostic word for word. + private sealed class LookalikeThrowingRule : IRequestFlowValidationRule + { + public IEnumerable Validate(RequestFlowValidationContext context) + => throw new InvalidOperationException( + $"Validation rule '{typeof(LookalikeThrowingRule).FullName}' returned null instead of an empty sequence."); + } + + private sealed class NullReturningRule : IRequestFlowValidationRule + { + public IEnumerable Validate(RequestFlowValidationContext context) + => null!; + } + + private sealed class NullProblemReturningRule : IRequestFlowValidationRule + { + public IEnumerable Validate(RequestFlowValidationContext context) + => [null!]; + } + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Validation/AliasedStageRuleTests.cs b/tests/RequestFlow.Tests.Unit/Validation/AliasedStageRuleTests.cs new file mode 100644 index 0000000..09aa34c --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Validation/AliasedStageRuleTests.cs @@ -0,0 +1,187 @@ +using RequestFlow; + +namespace RequestFlow.Tests.Unit.Validation; + +public sealed class AliasedStageRuleTests +{ + [Fact] + public void Given_Two_Declarations_Closing_To_One_Type_When_Validating_Then_Reports_Both_Resolve() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(int), r => r + .AddStage(typeof(OpenStage<>), typeof(OpenStage)) + .AddStage(typeof(OpenStage), typeof(OpenStage))) + .BuildContext(); + + List problems = [.. _sut.Validate(context)]; + + RequestFlowValidationProblem problem = problems.ShouldHaveSingleItem(); + problem.Code.ShouldBe("RF0104"); + problem.Message.ShouldContain("both resolve to"); + problem.Subject.ShouldBe(typeof(OpenStage<>)); + } + + // The pair is reported once, so a subject taken from the request would depend on which + // request the scan reached first. + [Fact] + public void Given_The_Same_Pair_On_Two_Requests_When_Validating_Then_The_Subject_Does_Not_Depend_On_Scan_Order() + { + Action chain = r => r + .AddStage(typeof(OpenStage<>), typeof(OpenStage)) + .AddStage(typeof(OpenStage), typeof(OpenStage)); + + RequestFlowValidationContext first = new RequestFlowModelBuilder() + .AddRequest(typeof(int), chain) + .AddRequest(typeof(long), chain) + .BuildContext(); + + RequestFlowValidationContext reversed = new RequestFlowModelBuilder() + .AddRequest(typeof(long), chain) + .AddRequest(typeof(int), chain) + .BuildContext(); + + Type? subject = new AliasedStageRule().Validate(first).Single().Subject; + Type? reversedSubject = new AliasedStageRule().Validate(reversed).Single().Subject; + + subject.ShouldBe(typeof(OpenStage<>)); + reversedSubject.ShouldBe(subject); + } + + [Fact] + public void Given_Two_Closings_Of_One_Class_When_Validating_Then_Reports_Same_Stage_Class() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(int), r => r + .AddStage(typeof(OpenStage), typeof(OpenStage)) + .AddStage(typeof(OpenStage), typeof(OpenStage))) + .BuildContext(); + + List problems = [.. _sut.Validate(context)]; + + problems.ShouldHaveSingleItem().Message.ShouldContain("same stage class"); + } + + [Fact] + public void Given_The_Same_Pair_On_Two_Requests_When_Validating_Then_Reports_Once() + { + Action chain = r => r + .AddStage(typeof(OpenStage<>), typeof(OpenStage)) + .AddStage(typeof(OpenStage), typeof(OpenStage)); + + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(int), chain) + .AddRequest(typeof(long), chain) + .BuildContext(); + + List problems = [.. _sut.Validate(context)]; + + problems.Count.ShouldBe(1); + } + + [Fact] + public void Given_Three_Declarations_Of_One_Class_When_Validating_Then_One_Problem_Names_All_Three() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(int), r => r + .AddStage(typeof(OpenStage<>), typeof(OpenStage)) + .AddStage(typeof(OpenStage), typeof(OpenStage)) + .AddStage(typeof(OpenStage), typeof(OpenStage))) + .BuildContext(); + + List problems = [.. _sut.Validate(context)]; + + RequestFlowValidationProblem problem = problems.ShouldHaveSingleItem(); + problem.Code.ShouldBe("RF0104"); + problem.Message.ShouldContain(typeof(OpenStage<>).FullName!); + problem.Message.ShouldContain(typeof(OpenStage).FullName!); + problem.Message.ShouldContain(typeof(OpenStage).FullName!); + problem.Message.ShouldContain("3 times"); + } + + [Fact] + public void Given_A_Verbatim_Duplicate_Beside_An_Alias_When_Validating_Then_Message_Counts_Every_Chain_Occurrence() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(int), r => r + .AddStage(typeof(OpenStage<>), typeof(OpenStage)) + .AddStage(typeof(OpenStage<>), typeof(OpenStage)) + .AddStage(typeof(OpenStage), typeof(OpenStage))) + .BuildContext(); + + List problems = [.. _sut.Validate(context)]; + + RequestFlowValidationProblem problem = problems.ShouldHaveSingleItem(); + problem.Message.ShouldContain("3 times"); + problem.Message.ShouldNotContain("one of the two"); + } + + [Fact] + public void Given_A_Request_With_Two_Handlers_When_Validating_Then_The_Message_Drops_The_Run_Count() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(int), r => r + .AddHandler(typeof(OtherStage), typeof(int)) + .AddHandler(typeof(OpenStage), typeof(int)) + .AddStage(typeof(OpenStage<>), typeof(OpenStage)) + .AddStage(typeof(OpenStage<>), typeof(OpenStage)) + .AddStage(typeof(OpenStage), typeof(OpenStage))) + .BuildContext(); + + List problems = [.. _sut.Validate(context)]; + + RequestFlowValidationProblem problem = problems.ShouldHaveSingleItem(); + problem.Code.ShouldBe("RF0104"); + problem.Message.ShouldContain("more than once"); + problem.Message.ShouldNotContain("3 times"); + } + + // Each closing can belong to a different handler's chain, and the model merges the chains + // into one list, so nothing here says the class runs twice anywhere. + [Fact] + public void Given_Two_Closings_Of_One_Class_On_A_Request_With_Two_Handlers_When_Validating_Then_Reports_Nothing() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(int), r => r + .AddHandler(typeof(FirstHandler), typeof(int)) + .AddHandler(typeof(SecondHandler), typeof(long)) + .AddStage(typeof(OpenStage), typeof(OpenStage)) + .AddStage(typeof(OpenStage), typeof(OpenStage))) + .BuildContext(); + + _sut.Validate(context).ShouldBeEmpty(); + } + + [Fact] + public void Given_Distinct_Stage_Classes_When_Validating_Then_Reports_Nothing() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(int), r => r + .AddStage(typeof(OpenStage<>), typeof(OpenStage)) + .AddStage(typeof(OtherStage), typeof(OtherStage))) + .BuildContext(); + + _sut.Validate(context).ShouldBeEmpty(); + } + + #region Initialization + + private readonly AliasedStageRule _sut = new(); + + #endregion + + #region Helpers + + private sealed class OpenStage + { } + + private sealed class OtherStage + { } + + private sealed class FirstHandler + { } + + private sealed class SecondHandler + { } + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Validation/BuiltInRuleFailureTests.cs b/tests/RequestFlow.Tests.Unit/Validation/BuiltInRuleFailureTests.cs new file mode 100644 index 0000000..898181d --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Validation/BuiltInRuleFailureTests.cs @@ -0,0 +1,37 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; + +namespace RequestFlow.Tests.Unit.Validation; + +public sealed class BuiltInRuleFailureTests +{ + [Fact] + public void Given_A_Built_In_Rule_Throwing_When_Validating_Then_The_Exception_Surfaces_As_Thrown() + { + var registry = new RequestFlowRegistry(); + registry.AllowUnhandledRequests(); + registry.Add([], [new UnloadableInterfacesType(typeof(Probe))], []); + using ServiceProvider provider = new ServiceCollection().BuildServiceProvider(); + + TypeLoadException exception = + Should.Throw(() => registry.BuildDispatchMap(provider)); + + exception.Message.ShouldBe("Could not load type 'Contracts.IAudited'."); + } + + #region Helpers + + // Stands in for a request type; the rule under test reads its interfaces, never its contract. + private sealed class Probe + { } + + // A request type whose interface list lives in an assembly the application did not deploy; + // MultiContractRequestRule reads that list for every request. + private sealed class UnloadableInterfacesType(Type inner) : TypeDelegator(inner) + { + public override Type[] GetInterfaces() + => throw new TypeLoadException("Could not load type 'Contracts.IAudited'."); + } + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Validation/DuplicateHandlerRuleTests.cs b/tests/RequestFlow.Tests.Unit/Validation/DuplicateHandlerRuleTests.cs new file mode 100644 index 0000000..922bfe7 --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Validation/DuplicateHandlerRuleTests.cs @@ -0,0 +1,49 @@ +using RequestFlow; + +namespace RequestFlow.Tests.Unit.Validation; + +public sealed class DuplicateHandlerRuleTests +{ + [Fact] + public void Given_Two_Handlers_For_One_Request_When_Validating_Then_Reports_One_Problem() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(int), r => r + .AddHandler(typeof(string), typeof(bool)) + .AddHandler(typeof(object), typeof(bool))) + .BuildContext(); + + List problems = [.. _sut.Validate(context)]; + + RequestFlowValidationProblem problem = problems.ShouldHaveSingleItem(); + problem.Code.ShouldBe("RF0101"); + problem.Subject.ShouldBe(typeof(int)); + problem.Message.ShouldContain("more than one handler"); + } + + [Fact] + public void Given_One_Handler_Per_Request_When_Validating_Then_Reports_Nothing() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(int), r => r.AddHandler(typeof(string), typeof(bool))) + .BuildContext(); + + _sut.Validate(context).ShouldBeEmpty(); + } + + [Fact] + public void Given_Unhandled_Request_When_Validating_Then_Reports_Nothing() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(int)) + .BuildContext(); + + _sut.Validate(context).ShouldBeEmpty(); + } + + #region Initialization + + private readonly DuplicateHandlerRule _sut = new(); + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Validation/DuplicateStageRuleTests.cs b/tests/RequestFlow.Tests.Unit/Validation/DuplicateStageRuleTests.cs new file mode 100644 index 0000000..1637fc3 --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Validation/DuplicateStageRuleTests.cs @@ -0,0 +1,54 @@ +using RequestFlow; + +namespace RequestFlow.Tests.Unit.Validation; + +public sealed class DuplicateStageRuleTests +{ + [Fact] + public void Given_Same_Stage_Declared_Twice_When_Validating_Then_Reports_One_Problem() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddStageDeclaration(typeof(string)) + .AddStageDeclaration(typeof(string)) + .BuildContext(); + + List problems = [.. _sut.Validate(context)]; + + RequestFlowValidationProblem problem = problems.ShouldHaveSingleItem(); + problem.Code.ShouldBe("RF0103"); + problem.Subject.ShouldBe(typeof(string)); + problem.Message.ShouldContain("registered more than once"); + problem.Message.ShouldContain("whatever each call filtered on"); + } + + [Fact] + public void Given_Same_Stage_Declared_Three_Times_When_Validating_Then_Reports_One_Problem() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddStageDeclaration(typeof(string)) + .AddStageDeclaration(typeof(string)) + .AddStageDeclaration(typeof(string)) + .BuildContext(); + + List problems = [.. _sut.Validate(context)]; + + problems.ShouldHaveSingleItem().Code.ShouldBe("RF0103"); + } + + [Fact] + public void Given_Distinct_Stages_When_Validating_Then_Reports_Nothing() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddStageDeclaration(typeof(string)) + .AddStageDeclaration(typeof(int)) + .BuildContext(); + + _sut.Validate(context).ShouldBeEmpty(); + } + + #region Initialization + + private readonly DuplicateStageRule _sut = new(); + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Validation/MultiContractRequestRuleTests.cs b/tests/RequestFlow.Tests.Unit/Validation/MultiContractRequestRuleTests.cs new file mode 100644 index 0000000..86adaea --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Validation/MultiContractRequestRuleTests.cs @@ -0,0 +1,92 @@ +using Microsoft.Extensions.DependencyInjection; +using RequestFlow; +using RequestFlow.Tests.ValidationFixtures; + +namespace RequestFlow.Tests.Unit.Validation; + +public sealed class MultiContractRequestRuleTests +{ + [Fact] + public void Given_A_Request_With_Two_Response_Contracts_When_Validating_Then_Reports_The_Request() + { + RequestFlowValidationContext context = Context(typeof(TwoContracts)); + + List problems = [.. _sut.Validate(context)]; + + RequestFlowValidationProblem problem = problems.ShouldHaveSingleItem(); + problem.Code.ShouldBe("RF0106"); + problem.Subject.ShouldBe(typeof(TwoContracts)); + problem.Message.ShouldContain("more than one request contract"); + problem.Message.ShouldContain("System.String"); + problem.Message.ShouldContain("System.Int32"); + } + + [Fact] + public void Given_A_Void_Request_With_A_Typed_Contract_When_Validating_Then_Reports_The_Request() + { + List problems = [.. _sut.Validate(Context(typeof(VoidAndTyped)))]; + + problems.ShouldHaveSingleItem().Subject.ShouldBe(typeof(VoidAndTyped)); + } + + [Fact] + public void Given_A_Request_With_One_Contract_When_Validating_Then_Reports_Nothing() + { + _sut.Validate(Context(typeof(SingleContract))).ShouldBeEmpty(); + } + + [Fact] + public void Given_A_Void_Request_When_Validating_Then_Reports_Nothing() + { + _sut.Validate(Context(typeof(VoidOnly))).ShouldBeEmpty(); + } + + [Fact] + public void Given_Two_Marker_Interfaces_Sharing_One_Contract_When_Validating_Then_Reports_Nothing() + { + _sut.Validate(Context(typeof(SharedContract))).ShouldBeEmpty(); + } + + [Fact] + public void Given_A_Scanned_Multi_Contract_Request_When_Resolving_Dispatcher_Then_The_Problem_Is_In_The_Exception() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o.RegisterHandlersFromAssembly(typeof(Forked).Assembly)); + + RequestFlowValidationException exception = Should.Throw(() => + services.BuildServiceProvider().GetRequiredService()); + + exception.Problems.ShouldContain(p => p.Code == "RF0106" && p.Subject == typeof(Forked)); + } + + #region Initialization + + private readonly MultiContractRequestRule _sut = new(); + + #endregion + + #region Helpers + + private static RequestFlowValidationContext Context(Type requestType) + => new RequestFlowModelBuilder().AddRequest(requestType).BuildContext(); + + // Abstract keeps these out of the scanner when other tests scan this assembly; the rule + // reads a type's interfaces only. + private abstract record TwoContracts : IRequest, IRequest; + + private abstract record VoidAndTyped : IRequest, IRequest; + + private abstract record SingleContract : IRequest; + + private abstract record VoidOnly : IRequest; + + private interface IFirstMarker : IRequest + { } + + private interface ISecondMarker : IRequest + { } + + private abstract record SharedContract : IFirstMarker, ISecondMarker; + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Validation/RegistrationSnapshotTests.cs b/tests/RequestFlow.Tests.Unit/Validation/RegistrationSnapshotTests.cs new file mode 100644 index 0000000..2be2808 --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Validation/RegistrationSnapshotTests.cs @@ -0,0 +1,520 @@ +using Microsoft.Extensions.DependencyInjection; +using RequestFlow; + +namespace RequestFlow.Tests.Unit.Validation; + +public sealed class RegistrationSnapshotTests +{ + [Fact] + public void Given_Scanned_Request_Without_Handler_When_Building_Model_Then_Request_Has_No_Handlers() + { + RequestFlowModel model = RegistrationSnapshot.Capture( + handlers: [], requestTypes: [typeof(Ping)], stageDeclarations: [], closings: new StageClosingCache()); + + RequestModel request = model.Requests.ShouldHaveSingleItem(); + request.RequestType.ShouldBe(typeof(Ping)); + request.Handlers.ShouldBeEmpty(); + request.Stages.ShouldBeEmpty(); + } + + [Fact] + public void Given_Handler_For_Unscanned_Request_When_Building_Model_Then_Request_Is_Included() + { + var registration = new HandlerRegistration( + new HandlerDiscovery(typeof(PingHandler), typeof(Ping), typeof(string), isVoid: false), + ServiceLifetime.Transient); + + RequestFlowModel model = RegistrationSnapshot.Capture( + handlers: [registration], requestTypes: [], stageDeclarations: [], closings: new StageClosingCache()); + + RequestModel request = model.Requests.ShouldHaveSingleItem(); + HandlerModel handler = request.Handlers.ShouldHaveSingleItem(); + handler.HandlerType.ShouldBe(typeof(PingHandler)); + handler.ResponseType.ShouldBe(typeof(string)); + } + + [Fact] + public void Given_Duplicate_Handlers_When_Building_Model_Then_Both_Are_Listed() + { + var first = new HandlerRegistration( + new HandlerDiscovery(typeof(PingHandler), typeof(Ping), typeof(string), isVoid: false), + ServiceLifetime.Transient); + var second = new HandlerRegistration( + new HandlerDiscovery(typeof(SecondPingHandler), typeof(Ping), typeof(string), isVoid: false), + ServiceLifetime.Transient); + + RequestFlowModel model = RegistrationSnapshot.Capture( + handlers: [first, second], requestTypes: [typeof(Ping)], stageDeclarations: [], closings: new StageClosingCache()); + + model.Requests.ShouldHaveSingleItem().Handlers.Count.ShouldBe(2); + } + + [Fact] + public void Given_Applicable_Stage_When_Building_Model_Then_Chain_Names_Declaration_And_Closed_Type() + { + var registration = new HandlerRegistration( + new HandlerDiscovery(typeof(PingHandler), typeof(Ping), typeof(string), isVoid: false), + ServiceLifetime.Transient); + var declaration = new StageDeclaration(typeof(WrapStage<,>), handlerFilter: null); + + RequestFlowModel model = RegistrationSnapshot.Capture( + handlers: [registration], requestTypes: [typeof(Ping)], stageDeclarations: [declaration], + closings: new StageClosingCache()); + + ClosedStageModel closing = model.Requests.ShouldHaveSingleItem().Stages.ShouldHaveSingleItem(); + closing.DeclaredType.ShouldBe(typeof(WrapStage<,>)); + closing.ClosedType.ShouldBe(typeof(WrapStage)); + } + + [Fact] + public void Given_Stage_Declarations_When_Building_Model_Then_Stages_Keep_Registration_Order_And_Duplicates() + { + var first = new StageDeclaration(typeof(WrapStage<,>), handlerFilter: null); + var second = new StageDeclaration(typeof(WrapStage<,>), handlerFilter: null); + + RequestFlowModel model = RegistrationSnapshot.Capture( + handlers: [], requestTypes: [], stageDeclarations: [first, second], closings: new StageClosingCache()); + + model.StageDeclarations.Count.ShouldBe(2); + model.StageDeclarations[0].StageType.ShouldBe(typeof(WrapStage<,>)); + } + + [Fact] + public void Given_Scanned_And_Handler_Only_Requests_When_Building_Model_Then_Scanned_Request_Comes_First() + { + var registration = new HandlerRegistration( + new HandlerDiscovery(typeof(PingHandler), typeof(Ping), typeof(string), isVoid: false), + ServiceLifetime.Transient); + + RequestFlowModel model = RegistrationSnapshot.Capture( + handlers: [registration], requestTypes: [typeof(Purge)], stageDeclarations: [], closings: new StageClosingCache()); + + model.Requests.Count.ShouldBe(2); + model.Requests[0].RequestType.ShouldBe(typeof(Purge)); + model.Requests[1].RequestType.ShouldBe(typeof(Ping)); + } + + [Fact] + public void Given_Duplicate_Handlers_And_A_Stage_When_Building_Model_Then_Chain_Holds_One_Closing_Per_Handler() + { + var first = new HandlerRegistration( + new HandlerDiscovery(typeof(MultiPingStringHandler), typeof(MultiPing), typeof(string), isVoid: false), + ServiceLifetime.Transient); + var second = new HandlerRegistration( + new HandlerDiscovery(typeof(MultiPingIntHandler), typeof(MultiPing), typeof(int), isVoid: false), + ServiceLifetime.Transient); + var declaration = new StageDeclaration(typeof(WrapStage<,>), handlerFilter: null); + + RequestFlowModel model = RegistrationSnapshot.Capture( + handlers: [first, second], requestTypes: [typeof(MultiPing)], stageDeclarations: [declaration], + closings: new StageClosingCache()); + + IReadOnlyList chain = model.Requests.ShouldHaveSingleItem().Stages; + chain.Count.ShouldBe(2); + chain[0].ClosedType.ShouldBe(typeof(WrapStage)); + chain[1].ClosedType.ShouldBe(typeof(WrapStage)); + } + + [Fact] + public void Given_Stage_Closing_Only_Over_The_Second_Handler_When_Building_Model_Then_Chain_Includes_It() + { + var first = new HandlerRegistration( + new HandlerDiscovery(typeof(MultiPingStringHandler), typeof(MultiPing), typeof(string), isVoid: false), + ServiceLifetime.Transient); + var second = new HandlerRegistration( + new HandlerDiscovery(typeof(MultiPingIntHandler), typeof(MultiPing), typeof(int), isVoid: false), + ServiceLifetime.Transient); + var declaration = new StageDeclaration(typeof(IntResultStage<>), handlerFilter: null); + + RequestFlowModel model = RegistrationSnapshot.Capture( + handlers: [first, second], requestTypes: [typeof(MultiPing)], stageDeclarations: [declaration], + closings: new StageClosingCache()); + + ClosedStageModel closing = model.Requests.ShouldHaveSingleItem().Stages.ShouldHaveSingleItem(); + closing.ClosedType.ShouldBe(typeof(IntResultStage)); + } + + [Fact] + public void Given_Unhandled_Request_And_A_Stage_Declaration_When_Building_Model_Then_Chain_Is_Empty() + { + var declaration = new StageDeclaration(typeof(WrapStage<,>), handlerFilter: null); + + RequestFlowModel model = RegistrationSnapshot.Capture( + handlers: [], requestTypes: [typeof(Ping)], stageDeclarations: [declaration], closings: new StageClosingCache()); + + model.Requests.ShouldHaveSingleItem().Stages.ShouldBeEmpty(); + } + + [Fact] + public void Given_Distinct_Stage_Declarations_When_Building_Model_Then_Stages_Preserve_Declared_Order() + { + var first = new StageDeclaration(typeof(WrapStage<,>), handlerFilter: null); + var second = new StageDeclaration(typeof(ExtraStage<,>), handlerFilter: null); + + RequestFlowModel model = RegistrationSnapshot.Capture( + handlers: [], requestTypes: [], stageDeclarations: [first, second], closings: new StageClosingCache()); + + model.StageDeclarations.Count.ShouldBe(2); + model.StageDeclarations[0].StageType.ShouldBe(typeof(WrapStage<,>)); + model.StageDeclarations[1].StageType.ShouldBe(typeof(ExtraStage<,>)); + } + + [Fact] + public void Given_Stage_That_Does_Not_Apply_To_The_Handler_When_Building_Model_Then_Chain_Excludes_It() + { + var registration = new HandlerRegistration( + new HandlerDiscovery(typeof(PingHandler), typeof(Ping), typeof(string), isVoid: false), + ServiceLifetime.Transient); + var declaration = new StageDeclaration(typeof(PurgeOnlyStage), handlerFilter: null); + + RequestFlowModel model = RegistrationSnapshot.Capture( + handlers: [registration], requestTypes: [typeof(Ping)], stageDeclarations: [declaration], + closings: new StageClosingCache()); + + model.Requests.ShouldHaveSingleItem().Stages.ShouldBeEmpty(); + } + + [Fact] + public void Given_A_Void_Stage_Declaration_When_Capturing_Then_The_Void_Contract_Is_Recorded() + { + var handler = new HandlerRegistration( + new HandlerDiscovery(typeof(VoidHandler), typeof(VoidRequest), typeof(NoResult), isVoid: true), + ServiceLifetime.Transient); + var declaration = new StageDeclaration(typeof(VoidStage), handlerFilter: null); + + RequestFlowModel model = RegistrationSnapshot.Capture( + [handler], [typeof(VoidRequest)], [declaration], new StageClosingCache()); + + model.StageDeclarations.ShouldHaveSingleItem().ContractType.ShouldBe(typeof(IRequestStage<>)); + model.Requests.ShouldHaveSingleItem() + .Stages.ShouldHaveSingleItem().ContractType.ShouldBe(typeof(IRequestStage<>)); + } + + [Fact] + public void Given_A_Typed_Stage_Declaration_When_Capturing_Then_The_Typed_Contract_Is_Recorded() + { + var handler = new HandlerRegistration( + new HandlerDiscovery(typeof(PingHandler), typeof(Ping), typeof(string), isVoid: false), + ServiceLifetime.Transient); + var declaration = new StageDeclaration(typeof(WrapStage<,>), handlerFilter: null); + + RequestFlowModel model = RegistrationSnapshot.Capture( + [handler], [typeof(Ping)], [declaration], new StageClosingCache()); + + model.StageDeclarations.ShouldHaveSingleItem().ContractType.ShouldBe(typeof(IRequestStage<,>)); + model.Requests.ShouldHaveSingleItem() + .Stages.ShouldHaveSingleItem().ContractType.ShouldBe(typeof(IRequestStage<,>)); + } + + [Fact] + public void Given_A_Handler_Implementing_A_Derived_Contract_When_Capturing_Then_The_Derived_Contract_Is_Recorded() + { + var handler = new HandlerRegistration( + new HandlerDiscovery(typeof(AuditedHandler), typeof(Audited), typeof(string), isVoid: false), + ServiceLifetime.Transient); + + RequestFlowModel model = RegistrationSnapshot.Capture( + [handler], [typeof(Audited)], [], new StageClosingCache()); + + model.Requests.ShouldHaveSingleItem() + .Handlers.ShouldHaveSingleItem().ContractType.ShouldBe(typeof(IAuditedHandler<,>)); + } + + [Fact] + public void Given_A_Void_Handler_Implementing_A_Derived_Contract_When_Capturing_Then_The_Derived_Contract_Is_Recorded() + { + var handler = new HandlerRegistration( + new HandlerDiscovery(typeof(AuditedVoidHandler), typeof(AuditedVoid), typeof(NoResult), isVoid: true), + ServiceLifetime.Transient); + + RequestFlowModel model = RegistrationSnapshot.Capture( + [handler], [typeof(AuditedVoid)], [], new StageClosingCache()); + + model.Requests.ShouldHaveSingleItem() + .Handlers.ShouldHaveSingleItem().ContractType.ShouldBe(typeof(IAuditedHandler<>)); + } + + [Fact] + public void Given_A_Handler_Implementing_Two_Derived_Contracts_When_Capturing_Then_The_Core_Contract_Is_Recorded() + { + var handler = new HandlerRegistration( + new HandlerDiscovery(typeof(TwiceDerivedHandler), typeof(TwiceDerived), typeof(string), isVoid: false), + ServiceLifetime.Transient); + + RequestFlowModel model = RegistrationSnapshot.Capture( + [handler], [typeof(TwiceDerived)], [], new StageClosingCache()); + + model.Requests.ShouldHaveSingleItem() + .Handlers.ShouldHaveSingleItem().ContractType.ShouldBe(typeof(IRequestHandler<,>)); + } + + [Fact] + public void Given_A_Stage_Implementing_A_Derived_Contract_When_Capturing_Then_The_Derived_Contract_Is_Recorded() + { + var handler = new HandlerRegistration( + new HandlerDiscovery(typeof(PingHandler), typeof(Ping), typeof(string), isVoid: false), + ServiceLifetime.Transient); + var declaration = new StageDeclaration(typeof(AuditedStage<,>), handlerFilter: null); + + RequestFlowModel model = RegistrationSnapshot.Capture( + [handler], [typeof(Ping)], [declaration], new StageClosingCache()); + + model.StageDeclarations.ShouldHaveSingleItem().ContractType.ShouldBe(typeof(IAuditedStage<,>)); + model.Requests.ShouldHaveSingleItem() + .Stages.ShouldHaveSingleItem().ContractType.ShouldBe(typeof(IAuditedStage<,>)); + } + + // The closed type is what the container resolves, so the void request's chain names NoResult + // even though the handler reports no response. + [Fact] + public void Given_A_Typed_Stage_Over_A_Void_Request_When_Capturing_Then_The_Closing_Closes_Over_No_Result() + { + var handler = new HandlerRegistration( + new HandlerDiscovery(typeof(PurgeHandler), typeof(Purge), typeof(NoResult), isVoid: true), + ServiceLifetime.Transient); + var declaration = new StageDeclaration(typeof(WrapStage<,>), handlerFilter: null); + + RequestFlowModel model = RegistrationSnapshot.Capture( + [handler], [typeof(Purge)], [declaration], new StageClosingCache()); + + RequestModel request = model.Requests.ShouldHaveSingleItem(); + request.Handlers.ShouldHaveSingleItem().ResponseType.ShouldBeNull(); + request.Stages.ShouldHaveSingleItem().ClosedType.ShouldBe(typeof(WrapStage)); + } + + [Theory] + [InlineData(ServiceLifetime.Transient, RequestFlowLifetime.Transient)] + [InlineData(ServiceLifetime.Scoped, RequestFlowLifetime.Scoped)] + [InlineData(ServiceLifetime.Singleton, RequestFlowLifetime.Singleton)] + public void Given_A_Handler_Registered_With_A_Lifetime_When_Capturing_Then_The_Model_Reports_It( + ServiceLifetime registered, RequestFlowLifetime expected) + { + var handler = new HandlerRegistration( + new HandlerDiscovery(typeof(PingHandler), typeof(Ping), typeof(string), isVoid: false), + registered); + + RequestFlowModel model = RegistrationSnapshot.Capture( + handlers: [handler], requestTypes: [typeof(Ping)], stageDeclarations: [], + closings: new StageClosingCache()); + + model.Requests.ShouldHaveSingleItem().Handlers.ShouldHaveSingleItem().Lifetime.ShouldBe(expected); + } + + // Each AddRequestFlow call decides for the handlers it found, so two handlers can differ. + [Fact] + public void Given_Handlers_Registered_With_Different_Lifetimes_When_Capturing_Then_Each_Reports_Its_Own() + { + var scoped = new HandlerRegistration( + new HandlerDiscovery(typeof(PingHandler), typeof(Ping), typeof(string), isVoid: false), + ServiceLifetime.Scoped); + var transient = new HandlerRegistration( + new HandlerDiscovery(typeof(PurgeHandler), typeof(Purge), typeof(NoResult), isVoid: true), + ServiceLifetime.Transient); + + RequestFlowModel model = RegistrationSnapshot.Capture( + handlers: [scoped, transient], requestTypes: [typeof(Ping), typeof(Purge)], stageDeclarations: [], + closings: new StageClosingCache()); + + model.Requests[0].Handlers.ShouldHaveSingleItem().Lifetime.ShouldBe(RequestFlowLifetime.Scoped); + model.Requests[1].Handlers.ShouldHaveSingleItem().Lifetime.ShouldBe(RequestFlowLifetime.Transient); + } + + [Theory] + [InlineData(ServiceLifetime.Transient, RequestFlowLifetime.Transient)] + [InlineData(ServiceLifetime.Scoped, RequestFlowLifetime.Scoped)] + [InlineData(ServiceLifetime.Singleton, RequestFlowLifetime.Singleton)] + public void Given_A_Stage_Registered_With_A_Lifetime_When_Capturing_Then_The_Model_Reports_It( + ServiceLifetime registered, RequestFlowLifetime expected) + { + var declaration = new StageDeclaration(typeof(WrapStage<,>), handlerFilter: null, registered); + + RequestFlowModel model = RegistrationSnapshot.Capture( + [], [], [declaration], new StageClosingCache()); + + model.StageDeclarations.ShouldHaveSingleItem().Lifetime.ShouldBe(expected); + } + + [Fact] + public void Given_A_Void_Handler_When_Capturing_Then_The_Handler_Reports_Void() + { + var handler = new HandlerRegistration( + new HandlerDiscovery(typeof(PurgeHandler), typeof(Purge), typeof(NoResult), isVoid: true), + ServiceLifetime.Transient); + + RequestFlowModel model = RegistrationSnapshot.Capture( + [handler], [typeof(Purge)], [], new StageClosingCache()); + + HandlerModel captured = model.Requests.ShouldHaveSingleItem().Handlers.ShouldHaveSingleItem(); + captured.IsVoid.ShouldBeTrue(); + captured.ResponseType.ShouldBeNull(); + } + + [Fact] + public void Given_A_Typed_Handler_When_Capturing_Then_The_Handler_Does_Not_Report_Void() + { + var handler = new HandlerRegistration( + new HandlerDiscovery(typeof(PingHandler), typeof(Ping), typeof(string), isVoid: false), + ServiceLifetime.Transient); + + RequestFlowModel model = RegistrationSnapshot.Capture( + [handler], [typeof(Ping)], [], new StageClosingCache()); + + model.Requests.ShouldHaveSingleItem().Handlers.ShouldHaveSingleItem().IsVoid.ShouldBeFalse(); + } + + // A request nothing handles has no chain, so no declaration can claim it. + [Fact] + public void Given_A_Request_With_No_Handler_When_Capturing_Then_The_Declaration_Does_Not_Claim_It() + { + var handler = new HandlerRegistration( + new HandlerDiscovery(typeof(PingHandler), typeof(Ping), typeof(string), isVoid: false), + ServiceLifetime.Transient); + var declaration = new StageDeclaration(typeof(WrapStage<,>), handlerFilter: null); + + RequestFlowModel model = RegistrationSnapshot.Capture( + [handler], [typeof(Ping), typeof(Purge)], [declaration], new StageClosingCache()); + + model.Requests.Count.ShouldBe(2); + model.StageDeclarations.ShouldHaveSingleItem().ReachedRequests.ShouldBe([typeof(Ping)]); + } + + #region Helpers + + public sealed record Ping : IRequest; + + public sealed record Purge : IRequest; + + public sealed class PingHandler : IRequestHandler + { + public Task HandleAsync(Ping request, CancellationToken cancellationToken) + => Task.FromResult("pong"); + } + + // Not a real IRequestHandler implementer: the builder only reads the Type off + // a manually built HandlerRegistration, and a second live implementer would make the whole + // test assembly's scan see a genuine duplicate handler for Ping. + public sealed class SecondPingHandler + { } + + public sealed class PurgeHandler : IRequestHandler + { + public Task HandleAsync(Purge request, CancellationToken cancellationToken) + => Task.CompletedTask; + } + + public sealed class WrapStage : IRequestStage + where TRequest : IRequest + { + public Task HandleAsync( + TRequest request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(); + } + + // A second open generic stage, distinct from WrapStage, so declaration order is + // distinguishable from duplicate retention. + public sealed class ExtraStage : IRequestStage + where TRequest : IRequest + { + public Task HandleAsync( + TRequest request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(); + } + + // Closed stage declared for Purge only, so it never satisfies the contract for a Ping handler. + public sealed class PurgeOnlyStage : IRequestStage + { + public Task HandleAsync(Purge request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(); + } + + // Closes only for a handler returning int, so on MultiPing it reaches the second + // registration and not the first. + public sealed class IntResultStage : IRequestStage + where TRequest : IRequest + { + public Task HandleAsync( + TRequest request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(); + } + + // Implements two closed IRequest<> instantiations so the same request type can carry + // handler registrations with different response types. Abstract, and with no live handler + // below, so whole-assembly scans skip it; a scannable multi-contract request would fail + // every such scan with RF0106. + public abstract record MultiPing : IRequest, IRequest; + + // Not real IRequestHandler implementers: the builder only reads the Type off a manually + // built HandlerRegistration, and a live handler would pull MultiPing into every scan. + public sealed class MultiPingStringHandler + { } + + public sealed class MultiPingIntHandler + { } + + private sealed record VoidRequest : IRequest; + + private sealed class VoidHandler : IRequestHandler + { + public Task HandleAsync(VoidRequest request, CancellationToken cancellationToken) + => Task.CompletedTask; + } + + private sealed class VoidStage : IRequestStage + { + public Task HandleAsync(VoidRequest request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(cancellationToken); + } + + // Contracts of the kind a package adds on top of the core ones. + private interface IAuditedHandler : IRequestHandler + where TRequest : IRequest + { } + + private interface IAuditedHandler : IRequestHandler + where TRequest : IRequest + { } + + private interface IOtherHandler : IRequestHandler + where TRequest : IRequest + { } + + private interface IAuditedStage : IRequestStage + where TRequest : IRequest + { } + + private sealed record Audited : IRequest; + + private sealed class AuditedHandler : IAuditedHandler + { + public Task HandleAsync(Audited request, CancellationToken cancellationToken) + => Task.FromResult(string.Empty); + } + + private sealed record AuditedVoid : IRequest; + + private sealed class AuditedVoidHandler : IAuditedHandler + { + public Task HandleAsync(AuditedVoid request, CancellationToken cancellationToken) + => Task.CompletedTask; + } + + private sealed record TwiceDerived : IRequest; + + // Neither contract is more derived than the other, so the snapshot falls back to the core one. + private sealed class TwiceDerivedHandler + : IAuditedHandler, IOtherHandler + { + public Task HandleAsync(TwiceDerived request, CancellationToken cancellationToken) + => Task.FromResult(string.Empty); + } + + private sealed class AuditedStage : IAuditedStage + where TRequest : IRequest + { + public Task HandleAsync( + TRequest request, Continuation next, CancellationToken cancellationToken) + => next.InvokeAsync(); + } + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Validation/RequestFlowValidationContextTests.cs b/tests/RequestFlow.Tests.Unit/Validation/RequestFlowValidationContextTests.cs new file mode 100644 index 0000000..2d8881e --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Validation/RequestFlowValidationContextTests.cs @@ -0,0 +1,205 @@ +using Microsoft.Extensions.DependencyInjection; +using RequestFlow; + +namespace RequestFlow.Tests.Unit.Validation; + +public sealed class RequestFlowValidationContextTests +{ + // Reaches the internal constructor through the InternalsVisibleTo grant in + // RequestFlow.Abstractions.csproj; an application gets a context from the freeze. + [Fact] + public void Given_A_Model_And_Both_Flags_When_Creating_The_Context_Then_The_Parts_Round_Trip() + { + RequestFlowModel model = new RequestFlowModelBuilder().AddRequest(typeof(int)).Build(); + + var context = new RequestFlowValidationContext( + model, unhandledRequestsAllowed: true, unusedStagesDisallowed: true); + + context.Model.ShouldBeSameAs(model); + context.UnhandledRequestsAllowed.ShouldBeTrue(); + context.UnusedStagesDisallowed.ShouldBeTrue(); + } + + [Fact] + public void Given_A_Null_Model_When_Creating_The_Context_Then_Throws_Argument_Null_Exception() + { + Should.Throw(() => new RequestFlowValidationContext(null!, false, false)); + } + + [Fact] + public void Given_No_Arguments_When_Building_A_Context_Then_Both_Flags_Are_False() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder().BuildContext(); + + context.UnhandledRequestsAllowed.ShouldBeFalse(); + context.UnusedStagesDisallowed.ShouldBeFalse(); + } + + [Fact] + public void Given_Unhandled_Requests_Allowed_When_Building_A_Context_Then_Only_That_Flag_Is_Set() + { + RequestFlowValidationContext context = + new RequestFlowModelBuilder().BuildContext(unhandledRequestsAllowed: true); + + context.UnhandledRequestsAllowed.ShouldBeTrue(); + context.UnusedStagesDisallowed.ShouldBeFalse(); + } + + [Fact] + public void Given_Unused_Stages_Disallowed_When_Building_A_Context_Then_Only_That_Flag_Is_Set() + { + RequestFlowValidationContext context = + new RequestFlowModelBuilder().BuildContext(unusedStagesDisallowed: true); + + context.UnusedStagesDisallowed.ShouldBeTrue(); + context.UnhandledRequestsAllowed.ShouldBeFalse(); + } + + [Fact] + public void Given_A_Built_Model_When_Building_A_Context_Then_Its_Model_Has_The_Same_Shape() + { + RequestFlowModelBuilder builder = new RequestFlowModelBuilder() + .AddRequest(typeof(int), r => r.AddHandler(typeof(object), typeof(string)).AddStage(typeof(Uri), typeof(Uri))) + .AddRequest(typeof(long)) + .AddStageDeclaration(typeof(Uri), RequestFlowLifetime.Singleton); + RequestFlowModel expected = builder.Build(); + + RequestFlowModel model = builder.BuildContext().Model; + + model.Requests.Select(r => r.RequestType).ShouldBe(expected.Requests.Select(r => r.RequestType)); + model.Requests[0].Handlers.Select(h => h.HandlerType) + .ShouldBe(expected.Requests[0].Handlers.Select(h => h.HandlerType)); + model.Requests[0].Stages.Select(s => s.DeclaredType) + .ShouldBe(expected.Requests[0].Stages.Select(s => s.DeclaredType)); + + StageDeclarationModel declaration = model.StageDeclarations.ShouldHaveSingleItem(); + declaration.StageType.ShouldBe(typeof(Uri)); + declaration.Lifetime.ShouldBe(RequestFlowLifetime.Singleton); + declaration.ReachedRequests.ShouldBe([typeof(int)]); + declaration.ReachedRequests.ShouldBe(expected.StageDeclarations[0].ReachedRequests); + } + + [Fact] + public void Given_A_Context_Already_Built_When_Building_Another_Then_It_Wraps_Its_Own_Model() + { + RequestFlowModelBuilder builder = new RequestFlowModelBuilder().AddRequest(typeof(int)); + RequestFlowValidationContext first = builder.BuildContext(); + + RequestFlowValidationContext second = builder.BuildContext(); + + second.Model.ShouldNotBeSameAs(first.Model); + } + + [Fact] + public void Given_A_Hand_Built_Context_When_An_External_Rule_Validates_Then_It_Reads_Both_Flags() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .BuildContext(unhandledRequestsAllowed: true, unusedStagesDisallowed: true); + + RequestFlowValidationProblem problem = + new FlagReportingRule().Validate(context).ShouldHaveSingleItem(); + + problem.Message.ShouldBe("unhandled=True unused=True"); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void Given_Registration_Opt_Ins_When_An_External_Rule_Runs_At_Freeze_Then_It_Reads_Them_Off_The_Context( + bool allowUnhandledRequests, bool disallowUnusedStages) + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => + { + o.RegisterHandlersFromAssemblyContaining(); + if (allowUnhandledRequests) + o.AllowUnhandledRequests(); + if (disallowUnusedStages) + o.DisallowUnusedStages(); + }) + .AddValidationRule(); + using ServiceProvider provider = services.BuildServiceProvider(); + + RequestFlowValidationException exception = + Should.Throw(() => provider.ValidateRequestFlow()); + + exception.Problems.ShouldContain(p => + p.Code == FlagCode + && p.Message == $"unhandled={allowUnhandledRequests} unused={disallowUnusedStages}"); + } + + [Fact] + public void Given_Scoped_Handlers_When_An_External_Rule_Runs_At_Freeze_Then_It_Reads_The_Handler_Lifetime() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o + .RegisterHandlersFromAssemblyContaining() + .WithScopedHandlers()) + .AddValidationRule(); + using ServiceProvider provider = services.BuildServiceProvider(); + + RequestFlowValidationException exception = + Should.Throw(() => provider.ValidateRequestFlow()); + + string message = exception.Problems.Single(p => p.Code == LifetimeCode).Message; + message.ShouldContain("=Scoped"); + message.ShouldNotContain("=Transient"); + } + + // Each call decides for the handlers it found, so a rule reads the lifetime off each handler. + [Fact] + public void Given_Two_Calls_Choosing_Different_Handler_Lifetimes_When_A_Rule_Runs_Then_Each_Handler_Reports_Its_Own() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o + .RegisterHandlersFromAssemblyContaining() + .WithScopedHandlers()) + .AddValidationRule(); + services.AddRequestFlow(o => o + .RegisterHandlersFromAssembly(typeof(RequestFlow.Tests.ValidationFixtures.Lonely).Assembly) + .AllowUnhandledRequests()); + using ServiceProvider provider = services.BuildServiceProvider(); + + RequestFlowValidationException exception = + Should.Throw(() => provider.ValidateRequestFlow()); + + string message = exception.Problems.Single(p => p.Code == LifetimeCode).Message; + message.ShouldContain("=Scoped"); + message.ShouldContain($"{nameof(RequestFlow.Tests.ValidationFixtures.Rooted)}=Transient"); + } + + #region Helpers + + private const string FlagCode = "TEST0200"; + + private const string LifetimeCode = "TEST0201"; + + private sealed class HandlerLifetimeReportingRule : IRequestFlowValidationRule + { + public IEnumerable Validate(RequestFlowValidationContext context) + { + List reported = []; + foreach (RequestModel request in context.Model.Requests) + { + foreach (HandlerModel handler in request.Handlers) + reported.Add($"{request.RequestType.Name}={handler.Lifetime}"); + } + + return [new RequestFlowValidationProblem(LifetimeCode, string.Join(" ", reported))]; + } + } + + // Stands in for a rule an application adds: it reports what only a built-in rule could read + // before the context existed. + private sealed class FlagReportingRule : IRequestFlowValidationRule + { + public IEnumerable Validate(RequestFlowValidationContext context) + => [new RequestFlowValidationProblem( + FlagCode, + $"unhandled={context.UnhandledRequestsAllowed} unused={context.UnusedStagesDisallowed}")]; + } + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Validation/UnhandledRequestRuleTests.cs b/tests/RequestFlow.Tests.Unit/Validation/UnhandledRequestRuleTests.cs new file mode 100644 index 0000000..500f7d4 --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Validation/UnhandledRequestRuleTests.cs @@ -0,0 +1,37 @@ +using RequestFlow; + +namespace RequestFlow.Tests.Unit.Validation; + +public sealed class UnhandledRequestRuleTests +{ + [Fact] + public void Given_Request_Without_Handler_When_Validating_Then_Reports_The_Request() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(int)) + .BuildContext(); + + List problems = [.. _sut.Validate(context)]; + + RequestFlowValidationProblem problem = problems.ShouldHaveSingleItem(); + problem.Code.ShouldBe("RF0102"); + problem.Subject.ShouldBe(typeof(int)); + problem.Message.ShouldContain("has no handler"); + } + + [Fact] + public void Given_Handled_Request_When_Validating_Then_Reports_Nothing() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(int), r => r.AddHandler(typeof(string), typeof(bool))) + .BuildContext(); + + _sut.Validate(context).ShouldBeEmpty(); + } + + #region Initialization + + private readonly UnhandledRequestRule _sut = new(); + + #endregion +} diff --git a/tests/RequestFlow.Tests.Unit/Validation/UnusedStageRuleTests.cs b/tests/RequestFlow.Tests.Unit/Validation/UnusedStageRuleTests.cs new file mode 100644 index 0000000..23b1bdc --- /dev/null +++ b/tests/RequestFlow.Tests.Unit/Validation/UnusedStageRuleTests.cs @@ -0,0 +1,115 @@ +using RequestFlow; + +namespace RequestFlow.Tests.Unit.Validation; + +public sealed class UnusedStageRuleTests +{ + [Fact] + public void Given_Stage_Reaching_No_Request_When_Validating_Then_Reports_The_Stage() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(int)) + .AddStageDeclaration(typeof(string)) + .BuildContext(); + + List problems = [.. _sut.Validate(context)]; + + RequestFlowValidationProblem problem = problems.ShouldHaveSingleItem(); + problem.Code.ShouldBe("RF0105"); + problem.Subject.ShouldBe(typeof(string)); + problem.Message.ShouldContain("applies to no registered request"); + } + + [Fact] + public void Given_Stage_In_A_Chain_When_Validating_Then_Reports_Nothing() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(int), r => r.AddStage(typeof(string), typeof(string))) + .AddStageDeclaration(typeof(string)) + .BuildContext(); + + _sut.Validate(context).ShouldBeEmpty(); + } + + [Fact] + public void Given_Unused_Stage_Declared_Twice_When_Validating_Then_Reports_Once() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddStageDeclaration(typeof(string)) + .AddStageDeclaration(typeof(string)) + .BuildContext(); + + List problems = [.. _sut.Validate(context)]; + + problems.Count.ShouldBe(1); + } + + // The application asked for the unhandled requests, so a handler is not the fix it is + // waiting for. + [Fact] + public void Given_Unhandled_Requests_Allowed_When_Validating_Then_The_Message_Names_The_Opt_In_Word_For_Word() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(int)) + .AddStageDeclaration(typeof(string)) + .BuildContext(unhandledRequestsAllowed: true); + + List problems = [.. _sut.Validate(context)]; + + problems.ShouldHaveSingleItem().Message.ShouldBe( + BaseMessage(typeof(string)) + + " Some registered requests have no handler, which AllowUnhandledRequests permits; a stage " + + "reaching only those still counts as unused."); + } + + [Fact] + public void Given_Unhandled_Requests_Not_Allowed_When_Validating_Then_The_Message_Names_The_Fix_Word_For_Word() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(int)) + .AddStageDeclaration(typeof(string)) + .BuildContext(); + + List problems = [.. _sut.Validate(context)]; + + problems.ShouldHaveSingleItem().Message.ShouldBe( + BaseMessage(typeof(string)) + + " Some registered requests have no handler; a stage reaching only those counts as unused, so " + + "the missing handler may be the fix."); + } + + // Neither flag value changes the message once every request has a handler, since the sentence + // about them is what the flag picks between. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Given_All_Requests_Handled_When_Validating_Then_The_Message_Is_The_Base_One( + bool unhandledRequestsAllowed) + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(int), r => r.AddHandler(typeof(object), typeof(bool))) + .AddStageDeclaration(typeof(string)) + .BuildContext(unhandledRequestsAllowed); + + List problems = [.. _sut.Validate(context)]; + + problems.ShouldHaveSingleItem().Message.ShouldBe(BaseMessage(typeof(string))); + } + + #region Initialization + + private readonly UnusedStageRule _sut = new(); + + #endregion + + #region Helpers + + // The assembly name is part of the message and differs per target framework, so it is read off + // the type rather than written out. + private static string BaseMessage(Type stageType) + => $"Stage '{stageType.FullName}' from assembly '{stageType.Assembly.GetName().Name}' applies to no " + + "registered request; widen its generic constraints, scan the assembly holding the requests it " + + "targets, or drop DisallowUnusedStages."; + + #endregion +} diff --git a/tests/RequestFlow.Tests.ValidationFixtures/Fixtures.cs b/tests/RequestFlow.Tests.ValidationFixtures/Fixtures.cs index becc8ea..7824777 100644 --- a/tests/RequestFlow.Tests.ValidationFixtures/Fixtures.cs +++ b/tests/RequestFlow.Tests.ValidationFixtures/Fixtures.cs @@ -1,4 +1,5 @@ using RequestFlow; +using RequestFlow.Cqrs; namespace RequestFlow.Tests.ValidationFixtures; @@ -24,6 +25,25 @@ public Task HandleAsync(Duplicated request, CancellationToken cancellationT => Task.FromResult(2); } +/// +/// A request carrying two response contracts with a handler for each; startup validation must +/// report the duplicate. A stage declared over the int contract reaches the second handler only, +/// which is what the validation model has to see. +/// +public sealed record Forked : IRequest, IRequest; + +public sealed class ForkedStringHandler : IRequestHandler +{ + public Task HandleAsync(Forked request, CancellationToken cancellationToken) + => Task.FromResult("forked"); +} + +public sealed class ForkedIntHandler : IRequestHandler +{ + public Task HandleAsync(Forked request, CancellationToken cancellationToken) + => Task.FromResult(1); +} + /// /// Base request with a handler of its own; inherits its contract. @@ -41,3 +61,21 @@ public sealed class RootedHandler : IRequestHandler public Task HandleAsync(Rooted request, CancellationToken cancellationToken) => Task.FromResult(0); } + +/// +/// Classified as both a command and a query; the AddCqrs validation rule must report it. +/// Handled so it adds no unhandled-request noise to tests that scan this assembly. +/// +public sealed record Confused : ICommand, IQuery; + +public sealed class ConfusedHandler : IRequestHandler +{ + public Task HandleAsync(Confused request, CancellationToken cancellationToken) + => Task.FromResult(0); +} + +/// +/// A void command also classified as a query; exercises the split rule's void-command path. +/// Unhandled, like . +/// +public sealed record VoidConfused : ICommand, IQuery; diff --git a/tests/RequestFlow.Tests.ValidationFixtures/RequestFlow.Tests.ValidationFixtures.csproj b/tests/RequestFlow.Tests.ValidationFixtures/RequestFlow.Tests.ValidationFixtures.csproj index 11f54b8..887ea36 100644 --- a/tests/RequestFlow.Tests.ValidationFixtures/RequestFlow.Tests.ValidationFixtures.csproj +++ b/tests/RequestFlow.Tests.ValidationFixtures/RequestFlow.Tests.ValidationFixtures.csproj @@ -2,6 +2,7 @@ + From 190f3b365c607b96f2f5ee2169a5de1e4f001bfa Mon Sep 17 00:00:00 2001 From: Illia Filippov Date: Sun, 9 Aug 2026 14:48:01 +0200 Subject: [PATCH 3/5] feat(cqrs): reject a request that is both a command and a query AddCqrs registers CommandQuerySplitRule, so a request implementing both ICommand and IQuery fails the freeze with CQRS0001 instead of resolving through whichever typed dispatcher the caller happened to reach for. The rule checks contract assignability rather than response shapes, so it also catches ICommand next to IQuery. CqrsProblemCodes is public and ships in RequestFlow.Cqrs.Abstractions, so an assembly referencing only the contracts can match the code instead of a literal string. --- .../CqrsProblemCodes.cs | 11 ++ src/RequestFlow.Cqrs/CommandQuerySplitRule.cs | 41 +++++++ .../CqrsRequestFlowBuilderExtensions.cs | 7 ++ .../AddCqrsTests.cs | 64 ++++++++++ .../CommandQuerySplitRuleTests.cs | 116 ++++++++++++++++++ .../CqrsProblemCodesTests.cs | 26 ++++ .../RequestFlow.Cqrs.Tests.Unit.csproj | 1 + 7 files changed, 266 insertions(+) create mode 100644 src/RequestFlow.Cqrs.Abstractions/CqrsProblemCodes.cs create mode 100644 src/RequestFlow.Cqrs/CommandQuerySplitRule.cs create mode 100644 tests/RequestFlow.Cqrs.Tests.Unit/CommandQuerySplitRuleTests.cs create mode 100644 tests/RequestFlow.Cqrs.Tests.Unit/CqrsProblemCodesTests.cs diff --git a/src/RequestFlow.Cqrs.Abstractions/CqrsProblemCodes.cs b/src/RequestFlow.Cqrs.Abstractions/CqrsProblemCodes.cs new file mode 100644 index 0000000..3ff5a30 --- /dev/null +++ b/src/RequestFlow.Cqrs.Abstractions/CqrsProblemCodes.cs @@ -0,0 +1,11 @@ +namespace RequestFlow.Cqrs; + +/// +/// Stable codes for the validation problems the CQRS package reports. Documented in +/// docs/validation-rules.md; never renumbered. Match +/// against these instead of literal strings. +/// +public static class CqrsProblemCodes +{ + public const string CommandQuerySplit = "CQRS0001"; +} diff --git a/src/RequestFlow.Cqrs/CommandQuerySplitRule.cs b/src/RequestFlow.Cqrs/CommandQuerySplitRule.cs new file mode 100644 index 0000000..43abca4 --- /dev/null +++ b/src/RequestFlow.Cqrs/CommandQuerySplitRule.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +namespace RequestFlow.Cqrs; + +/// +/// Reports every request classified as both a command and a query. Checks contract +/// assignability, not response shapes, so it also catches ICommand<A> next to +/// IQuery<B>. +/// +internal sealed class CommandQuerySplitRule : IRequestFlowValidationRule +{ + public IEnumerable Validate(RequestFlowValidationContext context) + { + List problems = []; + foreach (var request in context.Model.Requests) + { + if (ImplementsDefinition(request.RequestType, typeof(ICommand<>)) + && ImplementsDefinition(request.RequestType, typeof(IQuery<>))) + { + problems.Add(new RequestFlowValidationProblem( + CqrsProblemCodes.CommandQuerySplit, + $"Request '{request.RequestType.FullName}' is classified as both a command and a query; pick one side of the split.", + request.RequestType)); + } + } + + return problems; + } + + private static bool ImplementsDefinition(Type requestType, Type definition) + { + foreach (var iface in requestType.GetInterfaces()) + { + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == definition) + return true; + } + + return false; + } +} diff --git a/src/RequestFlow.Cqrs/CqrsRequestFlowBuilderExtensions.cs b/src/RequestFlow.Cqrs/CqrsRequestFlowBuilderExtensions.cs index cafa05e..d1f96e5 100644 --- a/src/RequestFlow.Cqrs/CqrsRequestFlowBuilderExtensions.cs +++ b/src/RequestFlow.Cqrs/CqrsRequestFlowBuilderExtensions.cs @@ -16,6 +16,10 @@ public static class CqrsRequestFlowBuilderExtensions /// Registers the typed dispatchers and /// . Command and query handlers need no extra /// registration; the AddRequestFlow assembly scan discovers them. + /// + /// Also adds a singleton validation rule to the startup pass. A request classified as both a + /// command and a query fails the freeze with CQRS0001. + /// /// /// public static RequestFlowBuilder AddCqrs(this RequestFlowBuilder builder) @@ -26,6 +30,9 @@ public static RequestFlowBuilder AddCqrs(this RequestFlowBuilder builder) builder.Services.TryAddTransient(); builder.Services.TryAddTransient(); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + return builder; } } diff --git a/tests/RequestFlow.Cqrs.Tests.Unit/AddCqrsTests.cs b/tests/RequestFlow.Cqrs.Tests.Unit/AddCqrsTests.cs index 21cc8a2..a5dcaa3 100644 --- a/tests/RequestFlow.Cqrs.Tests.Unit/AddCqrsTests.cs +++ b/tests/RequestFlow.Cqrs.Tests.Unit/AddCqrsTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using RequestFlow; using RequestFlow.Cqrs; @@ -158,10 +159,73 @@ public void Given_Null_Builder_When_Registering_Cqrs_Then_Throws_Argument_Null_E () => ((RequestFlowBuilder)null!).AddCqrs()); } + [Fact] + public void Given_Add_Cqrs_When_Registering_Then_The_Split_Rule_Is_Registered_Once() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => o.RegisterHandlersFromAssemblyContaining()) + .AddCqrs() + .AddCqrs(); + + services.Count(d => + d.ServiceType == typeof(IRequestFlowValidationRule) + && d.ImplementationType == typeof(CommandQuerySplitRule)).ShouldBe(1); + } + + [Fact] + public void Given_A_Confused_Request_When_Validating_With_Cqrs_Then_Throws_With_The_Split_Problem() + { + var services = new ServiceCollection(); + services.AddRequestFlow(o => + { + o.RegisterHandlersFromAssembly(typeof(RequestFlow.Tests.ValidationFixtures.Confused).Assembly); + o.AllowUnhandledRequests(); + }) + .AddCqrs(); + using ServiceProvider provider = services.BuildServiceProvider(); + + RequestFlowValidationException exception = + Should.Throw(() => provider.ValidateRequestFlow()); + + exception.Problems.ShouldContain(p => + p.Code == "CQRS0001" && p.Subject == typeof(RequestFlow.Tests.ValidationFixtures.Confused)); + } + + [Fact] + public void Given_Cqrs_Handlers_When_Validating_Then_A_Rule_Sees_The_Command_Contracts() + { + var rule = new CapturingRule(); + var services = new ServiceCollection(); + services.AddRequestFlow(o => o.RegisterHandlersFromAssemblyContaining()).AddCqrs(); + services.TryAddEnumerable(ServiceDescriptor.Singleton(rule)); + using ServiceProvider provider = services.BuildServiceProvider(); + + provider.ValidateRequestFlow(); + + ContractOf(rule, typeof(AddCqrsTests.CreateOrder)).ShouldBe(typeof(ICommandHandler<,>)); + ContractOf(rule, typeof(AddCqrsTests.CancelOrder)).ShouldBe(typeof(ICommandHandler<>)); + ContractOf(rule, typeof(AddCqrsTests.GetOrder)).ShouldBe(typeof(IQueryHandler<,>)); + } + #region Helpers private static RequestFlowBuilder RegisterRequestFlow(IServiceCollection services) => services.AddRequestFlow(o => o.RegisterHandlersFromAssemblyContaining()); + private static Type ContractOf(CapturingRule rule, Type requestType) + => rule.Model!.Requests.Single(r => r.RequestType == requestType).Handlers.Single().ContractType; + + private sealed class CapturingRule : IRequestFlowValidationRule + { + public RequestFlowModel? Model { get; private set; } + + public IEnumerable Validate(RequestFlowValidationContext context) + { + Model = context.Model; + + return []; + } + } + #endregion } diff --git a/tests/RequestFlow.Cqrs.Tests.Unit/CommandQuerySplitRuleTests.cs b/tests/RequestFlow.Cqrs.Tests.Unit/CommandQuerySplitRuleTests.cs new file mode 100644 index 0000000..243bccd --- /dev/null +++ b/tests/RequestFlow.Cqrs.Tests.Unit/CommandQuerySplitRuleTests.cs @@ -0,0 +1,116 @@ +using RequestFlow.Tests.ValidationFixtures; + +namespace RequestFlow.Cqrs.Tests.Unit; + +public sealed class CommandQuerySplitRuleTests +{ + [Fact] + public void Given_A_Request_That_Is_Command_And_Query_When_Validating_Then_Reports_The_Type() + { + RequestFlowValidationContext context = Context(typeof(Confused)); + + List problems = [.. _sut.Validate(context)]; + + RequestFlowValidationProblem problem = problems.ShouldHaveSingleItem(); + problem.Code.ShouldBe("CQRS0001"); + problem.Subject.ShouldBe(typeof(Confused)); + problem.Message.ShouldContain("both a command and a query"); + problem.Message.ShouldContain("pick one side of the split"); + } + + [Fact] + public void Given_A_Void_Command_That_Is_Also_A_Query_When_Validating_Then_Reports_The_Type() + { + List problems = [.. _sut.Validate(Context(typeof(VoidConfused)))]; + + RequestFlowValidationProblem problem = problems.ShouldHaveSingleItem(); + problem.Code.ShouldBe("CQRS0001"); + problem.Subject.ShouldBe(typeof(VoidConfused)); + } + + [Fact] + public void Given_A_Plain_Command_When_Validating_Then_Reports_Nothing() + { + _sut.Validate(Context(typeof(PlainCommand))).ShouldBeEmpty(); + } + + [Fact] + public void Given_A_Plain_Query_When_Validating_Then_Reports_Nothing() + { + _sut.Validate(Context(typeof(PlainQuery))).ShouldBeEmpty(); + } + + [Fact] + public void Given_A_Plain_Request_When_Validating_Then_Reports_Nothing() + { + _sut.Validate(Context(typeof(PlainRequest))).ShouldBeEmpty(); + } + + [Fact] + public void Given_A_Split_Request_Among_Clean_Ones_When_Validating_Then_Only_The_Split_One_Is_Reported() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(PlainCommand)) + .AddRequest(typeof(Confused)) + .AddRequest(typeof(PlainQuery)) + .BuildContext(); + + List problems = [.. _sut.Validate(context)]; + + problems.ShouldHaveSingleItem().Subject.ShouldBe(typeof(Confused)); + } + + // The rule reads the model off the context and nothing else, so the registration opt-ins leave + // its finding alone. + [Fact] + public void Given_Both_Registration_Opt_Ins_When_Validating_Then_The_Split_Is_Still_Reported() + { + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(Confused)) + .BuildContext(unhandledRequestsAllowed: true, unusedStagesDisallowed: true); + + List problems = [.. _sut.Validate(context)]; + + problems.ShouldHaveSingleItem().Code.ShouldBe("CQRS0001"); + } + + #region Initialization + + private readonly CommandQuerySplitRule _sut = new(); + + #endregion + + #region Helpers + + private static RequestFlowValidationContext Context(Type requestType) + => new RequestFlowModelBuilder().AddRequest(requestType).BuildContext(); + + // Confused and VoidConfused stay in the fixtures assembly: a split request declared here + // would fail AddCqrsTests's freeze of this assembly. + private sealed record PlainCommand : ICommand; + + private sealed record PlainQuery : IQuery; + + private sealed record PlainRequest : IRequest; + + // Handled because AddCqrsTests freezes this assembly without AllowUnhandledRequests. + private sealed class PlainCommandHandler : IRequestHandler + { + public Task HandleAsync(PlainCommand request, CancellationToken cancellationToken) + => Task.FromResult(0); + } + + private sealed class PlainQueryHandler : IRequestHandler + { + public Task HandleAsync(PlainQuery request, CancellationToken cancellationToken) + => Task.FromResult(0); + } + + private sealed class PlainRequestHandler : IRequestHandler + { + public Task HandleAsync(PlainRequest request, CancellationToken cancellationToken) + => Task.FromResult(0); + } + + #endregion +} diff --git a/tests/RequestFlow.Cqrs.Tests.Unit/CqrsProblemCodesTests.cs b/tests/RequestFlow.Cqrs.Tests.Unit/CqrsProblemCodesTests.cs new file mode 100644 index 0000000..4314967 --- /dev/null +++ b/tests/RequestFlow.Cqrs.Tests.Unit/CqrsProblemCodesTests.cs @@ -0,0 +1,26 @@ +namespace RequestFlow.Cqrs.Tests.Unit; + +public sealed class CqrsProblemCodesTests +{ + // Same contract as RequestFlow's ProblemCodes: a caller matches the constant, not the + // literal, so the class has to be public. + [Fact] + public void Given_The_Cqrs_Problem_Codes_Class_When_Reflecting_Then_It_Is_Public() + { + typeof(CqrsProblemCodes).IsPublic.ShouldBeTrue(); + } + + // Also like ProblemCodes: the constant lives in the abstractions package, so a caller that + // never references the runtime one can still match on it. + [Fact] + public void Given_The_Cqrs_Problem_Codes_Class_When_Reflecting_Then_It_Ships_In_The_Abstractions_Package() + { + typeof(CqrsProblemCodes).Assembly.ShouldBe(typeof(ICommand<>).Assembly); + } + + [Fact] + public void Given_The_Split_Code_When_Reading_Then_It_Matches_The_Documented_Value() + { + CqrsProblemCodes.CommandQuerySplit.ShouldBe("CQRS0001"); + } +} diff --git a/tests/RequestFlow.Cqrs.Tests.Unit/RequestFlow.Cqrs.Tests.Unit.csproj b/tests/RequestFlow.Cqrs.Tests.Unit/RequestFlow.Cqrs.Tests.Unit.csproj index 3894aa4..27ad478 100644 --- a/tests/RequestFlow.Cqrs.Tests.Unit/RequestFlow.Cqrs.Tests.Unit.csproj +++ b/tests/RequestFlow.Cqrs.Tests.Unit/RequestFlow.Cqrs.Tests.Unit.csproj @@ -3,6 +3,7 @@ + From 3ae012b507feb09eebe63d0d0d27f886bfdd6221 Mon Sep 17 00:00:00 2001 From: Illia Filippov Date: Sun, 9 Aug 2026 14:48:15 +0200 Subject: [PATCH 4/5] docs(validation): cover writing and registering a validation rule docs/validation-rules.md walks the rule contract, the model a rule reads, registration and lifetime, and how to test a rule without a container. It also lists every built-in code, since a caller matching ProblemCodes needs to know what already fires. The exception, lifetime, registration, and stage pages pick up the code-carrying problems and the narrowed RF0104. --- CHANGELOG.md | 16 +++ README.md | 1 + docs/exceptions.md | 65 +++++++----- docs/lifetimes.md | 5 + docs/registration.md | 11 +++ docs/stages.md | 2 +- docs/validation-rules.md | 207 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 281 insertions(+), 26 deletions(-) create mode 100644 docs/validation-rules.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c7dce7..71b52b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ Pre-1.0: the public API can still change between previews. Releases are cut from this file. The `release` workflow reads the section matching the pushed tag and uses it as the GitHub Release body, so a tag with no matching section fails the build before anything reaches nuget.org. Before tagging, rename `[Unreleased]` to the version you are shipping and give it a date. +## [Unreleased] + +### Added + +- `IRequestFlowValidationRule`: any package or application can add its own checks to the startup validation pass with `AddValidationRule()` and report into the same exception as the built-in checks. A rule reads the whole registration picture, including the `AllowUnhandledRequests` and `DisallowUnusedStages` opt-ins, off `RequestFlowValidationContext`. A rule that throws is reported as `RF0107` and the rules after it still run. [validation-rules.md](docs/validation-rules.md) covers writing, registering, and testing one. +- `RequestFlowModelBuilder` builds a `RequestFlowModel` by hand and `BuildContext` wraps one in the context a rule receives, which is how a rule is unit tested without a container. +- Startup validation rejects a request type that implements more than one `IRequest` contract (`RF0106`). The extra contract used to pass the freeze and fail every dispatch under it with `ResponseTypeMismatchException`. +- `AddCqrs` rejects a request classified as both a command and a query (`CQRS0001`). +- `ProblemCodes` and `CqrsProblemCodes` are public and ship in the abstractions packages, so an assembly referencing only those can match `ProblemCodes.UnhandledRequest` instead of a literal string. +- The model reports lifetimes as `RequestFlowLifetime` rather than the container's `ServiceLifetime`, since `RequestFlow.Abstractions` takes no dependency on the DI package. Handlers and stages also report the contract they implement as `ContractType`, so a kind contributed on top of a core contract, such as `ICommandHandler`, comes through under its own. Every list on the model is read-only. + +### Changed + +- `RequestFlowValidationException.Problems` holds `RequestFlowValidationProblem` values (stable code, message, offending type) instead of strings, and the public constructor takes the same list, so a call site passing strings no longer compiles. Message lines now read `RF0101: ...`, so anything matching on the old text breaks. Repeated registrations collapse into one problem each, where a request with three handlers used to report two identical lines. +- Stage declarations that alias one stage class report a single `RF0104` naming every declaration in the collision, instead of a line per colliding pair. `RF0104` also no longer fires for two closings of one stage class on a request with more than one handler: that request already fails on `RF0101`, and the collision surfaces once the duplicate handler is gone. + ## [1.0.0-preview.5] - 2026-08-07 ### Added diff --git a/README.md b/README.md index fb82c39..7bc5957 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Contracts live in their own packages so your domain layer, and any future add-on - [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 +- [Validation rules](https://github.com/illia1f/RequestFlow/blob/main/docs/validation-rules.md): contributing custom checks to startup validation, the model rules see, built-in problem codes ## Contributing diff --git a/docs/exceptions.md b/docs/exceptions.md index cff2618..16603a5 100644 --- a/docs/exceptions.md +++ b/docs/exceptions.md @@ -6,12 +6,14 @@ Every exception RequestFlow throws, when it surfaces, and how to fix it. | Exception | Thrown from | When | | -------------------------------- | ------------------------ | -------------------------------------------------------------- | -| `RequestFlowValidationException` | Startup validation | Any registration problem; one throw lists all of them | +| `RequestFlowValidationException` | Startup validation | Any registration problem, or a validation rule that threw; 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 | | `HandlerNullTaskException` | `SendAsync` | A handler returned a null task from `HandleAsync` | | `StageNullTaskException` | `SendAsync` | A stage returned a null task from `HandleAsync` | | `InvalidOperationException` | `WhereHandlerImplements` | A second handler filter added to one stage | +| `InvalidOperationException` | Startup validation | A validation rule returned null, or a null problem | +| `InvalidOperationException` (the container's) | Startup validation | A validation rule depends on a RequestFlow dispatcher and the provider validates scopes; without that check nothing throws and startup hangs | | `ArgumentNullException` | All public entry points | A required argument is null | | `ArgumentException` | `RegisterGenericHandler` | `closingTypes` contains a null element | @@ -21,30 +23,36 @@ The RequestFlow types live in the `RequestFlow` namespace in the `RequestFlow.Ab Thrown when RequestFlow validates everything registered: the first time a dispatcher is resolved, or earlier if `ValidateRequestFlow` runs at startup (see [lifetimes.md](lifetimes.md) for validation timing). Problems accumulate across every `AddRequestFlow` call and surface as one exception. The message and the `Problems` property list all of them, so one failed start reports everything at once. Failed validation does not stick: every later dispatcher resolution validates again and throws the same list. -| Problem message starts with | Cause | Fix | -| ---------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -| `Request '...' has no handler.` | A scanned request type no handler covers | Write the handler, scan its assembly, or call `AllowUnhandledRequests` | -| `Request '...' has more than one handler...` | Two handlers cover the same request, via scan or generic closings | Remove one; exactly one handler per request | -| `'...' is not an open generic type definition...` | `RegisterGenericHandler(typeof(AuditHandler), ...)` or a non-generic type | Pass the open definition: `typeof(AuditHandler<>)` | -| `'...' is abstract...` | An abstract class passed to `RegisterGenericHandler` | Register a concrete handler class | -| `'...' has N generic parameters...` | An open generic with more than one type parameter | Only single-parameter generic handlers are supported | -| `'...' does not implement IRequestHandler.` | The type is not a handler | Implement `IRequestHandler` or `IRequestHandler` | -| `Generic handler '...' declares no closing types...` | `RegisterGenericHandler(typeof(AuditHandler<>))` with no closings | Declare at least one closing type | -| `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 | +`Problems` holds `RequestFlowValidationProblem` values: a stable `Code`, a `Message` saying what to fix, and the `Subject` type at fault where the problem has one. Each line of the exception message is one problem, printed as `CODE: message`. A rule of your own reports into the same list, and [validation-rules.md](validation-rules.md) covers writing one. + +| Code | Problem message starts with | Cause | Fix | +| -------- | ---------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `RF0101` | `Request '...' has more than one handler...` | Two handlers cover the same request, via scan or generic closings | Remove one; exactly one handler per request | +| `RF0102` | `Request '...' has no handler.` | A scanned request type no handler covers | Write the handler, scan its assembly, or call `AllowUnhandledRequests` | +| `RF0106` | `Request '...' implements more than one request contract...` | The type implements two `IRequest` contracts, directly or through interfaces; a void request carries `IRequest` | Keep one contract; split the type if both responses are needed | +| `RF0107` | `Validation rule '...' threw ...` | A rule of yours or a package's threw out of `Validate`. That rule's findings were dropped, every other rule still reported, and the message names the exception type and text | Fix the rule, or catch inside it and report a problem so the message can name what was being checked | +| `RF0001` | `'...' is not an open generic type definition...` | `RegisterGenericHandler(typeof(AuditHandler), ...)` or a non-generic type | Pass the open definition: `typeof(AuditHandler<>)` | +| `RF0002` | `'...' is abstract...` | An abstract class passed to `RegisterGenericHandler` | Register a concrete handler class | +| `RF0003` | `'...' has N generic parameters...` | An open generic with more than one type parameter | Only single-parameter generic handlers are supported | +| `RF0004` | `'...' does not implement IRequestHandler.` | The type is not a handler | Implement `IRequestHandler` or `IRequestHandler` | +| `RF0005` | `Generic handler '...' declares no closing types...` | `RegisterGenericHandler(typeof(AuditHandler<>))` with no closings | Declare at least one closing type | +| `RF0006` | `Closing type '...' ... is not a closed type.` | An open generic passed as a closing type | Close it first: `typeof(Audit)`, not `typeof(Audit<>)` | +| `RF0007` | `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 | +| Code | Problem message starts with | Cause | Fix | +| -------- | -------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `RF0008` | `'...' is an interface; only concrete stage classes...` | An interface passed to `AddStage` | Register the implementing class | +| `RF0009` | `'...' is abstract; only concrete stage classes...` | An abstract class passed to `AddStage` | Register a concrete stage class | +| `RF0010` | `'...' is partially closed...` | A stage type with some type parameters bound and some open | Pass the open definition or a fully closed type | +| `RF0011` | `'...' does not implement IRequestStage...` | The registered type is not a stage | Implement `IRequestStage` or `IRequestStage` | +| `RF0012` | `'...' 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 | +| `RF0103` | `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 | +| `RF0104` | `Stages '...' and '...' both resolve to '...'` / `Stages ... are the same stage class...` | An open definition registered next to its own closed form, or several closings of one class reaching the same request; one problem names every declaration in the group. A request with more than one handler has one chain per handler, so only declarations resolving to one closed type collide there | Keep one of the named `AddStage` calls and remove the rest | +| `RF0105` | `Stage '...' from assembly '...' applies to no registered request...` | `DisallowUnusedStages` is on and the stage reached nothing. A request with no handler gets no stage chain, so a stage aimed only at unhandled requests lands here too | Widen its constraints, scan the assembly holding its requests, add the missing handler, or drop the opt-in | + +One more code comes from the CQRS package: `CQRS0001` for a request classified as both a command and a query. `AddCqrs` contributes that check as a rule. Example: a contracts assembly scanned without its handlers fails at startup, not per request. @@ -54,8 +62,8 @@ services.AddRequestFlow(o => o // First dispatcher resolution (or ValidateRequestFlow) throws: // RequestFlowValidationException: RequestFlow registration is invalid: -// Request 'Contracts.CreateOrder' has no handler. -// Request 'Contracts.CancelOrder' has no handler. +// RF0102: Request 'Contracts.CreateOrder' has no handler. +// RF0102: Request 'Contracts.CancelOrder' has no handler. ``` If the assembly intentionally contains only requests, opt out with `AllowUnhandledRequests`: @@ -153,7 +161,11 @@ The base class is abstract with no public constructor, so those two are the only ## Plain InvalidOperationException -One case is left with no type of its own. Adding a second `WhereHandlerImplements` to one stage throws from the `AddStage` configure delegate, with a message starting `This stage already filters on '...'`. A stage takes one handler filter, so give the target handlers one shared contract instead. +Two cases are left with no type of their own. Adding a second `WhereHandlerImplements` to one stage throws from the `AddStage` configure delegate, with a message starting `This stage already filters on '...'`. A stage takes one handler filter, so give the target handlers one shared contract instead. + +The second comes from a broken validation rule: a rule that returns null instead of an empty sequence, or a sequence with a null problem in it, throws at the freeze with a message naming the rule. Those two are the only rule failures that come out this way. An exception the rule throws from its own code is reported as `RF0107` in the validation exception instead, and the rules after it still run (see [validation-rules.md](validation-rules.md)). + +One case that looks like it belongs here throws nothing at all. A rule that takes a dispatcher needs the map the freeze is still building, so the container waits on a result only that freeze can produce and startup hangs. A provider that validates scopes, which is what ASP.NET Core does in Development, rejects the rule earlier with `Cannot consume scoped service 'RequestFlow.IRequestDispatcher' from singleton 'RequestFlow.IRequestFlowValidationRule'`, since the dispatcher is scoped and a rule is a singleton. Both point at the same fix: take `IRequestDispatcher`, `ICommandDispatcher`, and `IQueryDispatcher` out of the rule's constructor. A handler or a stage in there triggers neither, though [validation-rules.md](validation-rules.md) covers why it is still the wrong dependency. ## Argument validation @@ -168,6 +180,9 @@ Argument checks at the public surface throw immediately at the call site: | `RegisterGenericHandler` | `ArgumentException` | `closingTypes` contains a null element | | `AddStage` | `ArgumentNullException` | `stageType` is null | | `ValidateRequestFlow` | `ArgumentNullException` | `provider` is null | +| `new RequestFlowValidationException` | `ArgumentNullException` | `problems` is null | +| `RequestFlowModelBuilder`, `RequestModelBuilder` | `ArgumentNullException` | A required `Type` argument is null | +| `RequestFlowModelBuilder`, `RequestModelBuilder` | `ArgumentException` | `contractType` is not an open generic interface built on the handler or stage contract ([validation-rules.md](validation-rules.md#the-model)) | ## What RequestFlow never wraps diff --git a/docs/lifetimes.md b/docs/lifetimes.md index 1a5aee1..ca70096 100644 --- a/docs/lifetimes.md +++ b/docs/lifetimes.md @@ -9,8 +9,13 @@ What RequestFlow registers, with which lifetime, and what you can change. | Handlers (`IRequestHandler`, `IRequestHandler`) | Transient | Yes, `WithScopedHandlers`, per `AddRequestFlow` call | | Stages (`IRequestStage`, `IRequestStage`) | Transient | Yes, `AsSingleton` or `AsScoped`, per `AddStage` call | | `IRequestDispatcher` | Scoped | Yes, `WithTransientDispatcher` | +| Validation rules (`IRequestFlowValidationRule`) | Singleton, added by `AddValidationRule` and by `AddCqrs` | Not through those calls; register your own descriptor for another lifetime | | Dispatch map (internal handler lookup) | Singleton, built the first time the dispatcher is resolved | No | +The lifetimes in the first two rows are readable at the freeze. A validation rule of your own reads the lifetime of every handler and stage off its model, so a house rule such as "no singleton stages here" can fail the start instead of waiting for a code review ([validation-rules.md](validation-rules.md#the-model)). + +A rule is resolved while the dispatch map is built, so it comes from the root provider, and a provider that validates scopes throws there. A scoped rule throws ``Cannot resolve scoped service 'System.Collections.Generic.IEnumerable`1[RequestFlow.IRequestFlowValidationRule]' from root provider``, which names the enumerable rather than the rule, so look for the descriptor you registered scoped. A rule whose constructor takes a scoped dependency throws `Cannot consume scoped service` instead, naming the dependency and `RequestFlow.IRequestFlowValidationRule`, and `ValidateOnBuild` reports that one at `BuildServiceProvider`. [validation-rules.md](validation-rules.md) covers writing and registering one. + ## Configuring handler lifetime Handlers are transient by default. Every call into the handler gets a fresh instance, so a handler can hold mutable state without leaking it into the next dispatch. The bottom of a stage chain resolves on each entry, so a retry stage that runs the chain twice reaches a second instance rather than the one that failed. Call `WithScopedHandlers` when handlers share per-request dependencies such as a `DbContext`. It chains with the registration methods: diff --git a/docs/registration.md b/docs/registration.md index 114f1aa..17e2f24 100644 --- a/docs/registration.md +++ b/docs/registration.md @@ -101,3 +101,14 @@ IServiceProvider provider = services.BuildServiceProvider().ValidateRequestFlow( ``` All problems are reported in one `RequestFlowValidationException`, not one at a time (see [exceptions.md](exceptions.md)). The container's own `ValidateOnBuild` cannot catch these problems; [lifetimes.md](lifetimes.md) explains why and covers validation timing in detail. + +## Checks of your own + +`AddRequestFlow` returns a builder, and `AddValidationRule` puts a check of yours in the same startup pass: + +```csharp +services.AddRequestFlow(o => o.RegisterHandlersFromAssemblyContaining()) + .AddValidationRule(); +``` + +The rule sees every registered request, handler, and stage, and reports into the same exception as the built-in checks. [validation-rules.md](validation-rules.md) covers writing, registering, and testing one. diff --git a/docs/stages.md b/docs/stages.md index dd8a11c..1e2d8a8 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -299,5 +299,5 @@ Stage problems surface with every other registration problem, in the one `Reques - 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. +- No stage type is registered twice, and no two declarations reach one request as the same stage class. A request with more than one handler has one chain per handler, so only declarations resolving to one closed type count as a collision there. - With `DisallowUnusedStages`, every stage reaches at least one request. diff --git a/docs/validation-rules.md b/docs/validation-rules.md new file mode 100644 index 0000000..e9ba1c5 --- /dev/null +++ b/docs/validation-rules.md @@ -0,0 +1,207 @@ +# Validation rules + +A validation rule is a check of your own that runs inside RequestFlow's startup validation. An application adds one to enforce a convention across its requests. A package adds one to check its own contracts, which is how `AddCqrs` rejects a request classified as both a command and a query. + +Rules run once, when the dispatch map freezes: at the first dispatcher resolution, or at startup under `ValidateRequestFlow` (see [lifetimes.md](lifetimes.md) for the timing). Their problems land in the same `RequestFlowValidationException` as the built-in ones, so one failed start reports everything at once. + +The built-in checks are fixed. A rule adds checks to the pass and cannot remove or replace one. + +## Writing a rule + +Implement `IRequestFlowValidationRule`. `Validate` receives a `RequestFlowValidationContext` and returns every problem it found: + +```csharp +using RequestFlow; + +public sealed class RequestNameRule : IRequestFlowValidationRule +{ + public IEnumerable Validate(RequestFlowValidationContext context) + { + List problems = []; + foreach (RequestModel request in context.Model.Requests) + { + if (request.RequestType.Name.EndsWith("Request", StringComparison.Ordinal)) + continue; + + problems.Add(new RequestFlowValidationProblem( + "ACME0001", + $"Request '{request.RequestType.FullName}' does not end in 'Request'; rename it.", + request.RequestType)); + } + + return problems; + } +} +``` + +The context carries `Model`, the registration as frozen, plus `UnhandledRequestsAllowed` and `UnusedStagesDisallowed`, the two opt-ins registration made. One context is built per pass and handed to every rule, so a rule of yours reads what a built-in one reads. + +A problem carries three things. `Code` is a stable identifier for the kind of problem, which is what a caller matches on. `Message` says what is wrong and how to fix it. `Subject` is the type at fault, and it is optional, since not every problem has one. `ToString` renders `Code: Message`, and that is the line the exception message shows. + +Return an empty sequence when nothing is wrong. Returning null, or a sequence with a null in it, throws an `InvalidOperationException` naming the rule. + +An exception out of a rule becomes a problem of its own. The pass records it as `RF0107`, naming the rule and the exception, drops that rule's findings, and runs the rules after it, so a rule that throws while validating cannot hide what the others found. Startup still fails, because `RF0107` counts like any other problem. What goes missing is the stack trace, so catch inside the rule and report a problem yourself: your message can name the registration you were checking, and `RF0107` cannot. + +Two failures stay outside that net. A rule whose constructor throws fails while the container builds the rule list, before any rule validates, and takes the pass down with it. A rule that resolves a dispatcher inside `Validate` never reaches `RF0107` either, because the resolution never returns: see [Registering a rule](#registering-a-rule) below. + +## Registering a rule + +`AddRequestFlow` returns a builder, and `AddValidationRule` takes the rule type from there: + +```csharp +services.AddRequestFlow(o => o + .RegisterHandlersFromAssemblyContaining()) + .AddValidationRule(); +``` + +The rule resolves from the container, so constructor dependencies work. `AddValidationRule` registers it as a singleton and offers no other lifetime. The dispatch map is a singleton, so a rule is resolved from the root provider, and a scoped rule or a scoped dependency throws there on a provider that validates scopes. + +One singleton instance serves every validation pass, and a failed pass runs again on the next dispatcher resolution ([exceptions.md](exceptions.md#requestflowvalidationexception)). Keep the rule stateless, or register it transient yourself instead of calling `AddValidationRule`: + +```csharp +using Microsoft.Extensions.DependencyInjection.Extensions; + +services.TryAddEnumerable( + ServiceDescriptor.Transient()); +``` + +That trades one problem for another when the rule is or owns an `IDisposable`. The rule resolves from the root provider, which tracks every transient disposable it creates and releases none of them until the provider is disposed, so a start that keeps failing leaves one instance behind per dispatcher resolution. A rule that clears its state at the top of `Validate` stays a singleton and avoids both. + +Do not take `IRequestDispatcher`, `ICommandDispatcher`, or `IQueryDispatcher` in a rule. Resolving one needs the dispatch map the freeze is still building, so the container waits on a result only that freeze can produce. Nothing throws and the stack never overflows. The process never finishes starting. + +Most providers reject the rule before it gets that far. The dispatcher is scoped by default and a rule is a singleton, so a provider that validates scopes fails first: + +``` +Cannot consume scoped service 'RequestFlow.IRequestDispatcher' from singleton +'RequestFlow.IRequestFlowValidationRule'. +``` + +That is what ASP.NET Core shows in Development, and `ValidateOnBuild` reports it at `BuildServiceProvider`. The hang is what you get on a provider that does not validate scopes, or after `WithTransientDispatcher` makes the dispatcher resolvable from the root. If startup produces no output and no error while the process stays alive, this is why. The fix either way is to drop the dependency. + +Handlers and stages are a different case. They resolve without touching the map, so a rule taking one starts fine. The cost is quieter: the rule is a singleton resolved from the root provider, so it pins a transient handler for as long as the provider lives, and once the application calls `WithScopedHandlers` the same rule stops resolving on any provider that validates scopes. Read the model instead; it already names every handler and stage type. + +A package registers its rule on the service collection directly, the way `AddCqrs` does: + +```csharp +using Microsoft.Extensions.DependencyInjection.Extensions; + +services.TryAddEnumerable( + ServiceDescriptor.Singleton()); +``` + +`TryAddEnumerable` keys on the implementation type, so the rule registers once however many times that code runs. `AddValidationRule` does the same, so calling it twice for one rule type costs nothing. + +## Testing a rule + +A rule reads a context and returns problems, so a test needs no container and no dispatcher. Build the part of the model the rule reads and leave the rest empty: + +```csharp +[Fact] +public void Given_A_Request_Without_The_Suffix_When_Validating_Then_The_Rule_Reports_It() +{ + RequestFlowValidationContext context = new RequestFlowModelBuilder() + .AddRequest(typeof(PlaceOrder), r => r.AddHandler(typeof(PlaceOrderHandler), typeof(OrderId))) + .BuildContext(); + + RequestFlowValidationProblem[] problems = [.. new RequestNameRule().Validate(context)]; + + Assert.Equal("ACME0001", Assert.Single(problems).Code); +} +``` + +`BuildContext` wraps a freshly built model, with both flags where registration leaves them when the application opts out of nothing: a request with no handler is a problem, a stage that reached nothing is not. Pass the one your rule reads to cover the other case, as `BuildContext(unhandledRequestsAllowed: true)`. `Build()` is still there for a test that wants the model on its own. + +`RequestFlowValidationContext`, `RequestFlowModel`, `RequestModel`, `HandlerModel`, `StageDeclarationModel`, and `ClosedStageModel` all take internal constructors, so `RequestFlowModelBuilder` is how a test outside the library builds one. Build only the part the rule reads and leave the rest empty: a rule that never looks at stages is tested against requests that have none. Repeated `AddRequest` calls for one request type configure a single entry, the same way the freeze groups handlers by request type. The builder itself only grows by gaining methods, never by widening the ones it has, so a model-building test written against today's builder still compiles once the model gains members. + +Two problems are equal when their code, message, and subject match, so a test can assert the whole value rather than pick it apart. + +## The model + +`context.Model` is the registration as recorded, minus what the shape checks threw out. A declaration reported under `RF0001` to `RF0012` never reaches a rule, so a rule auditing every registered stage type sees only the ones that could run. Past that nothing is cleaned up: a request no handler covers is in the list with an empty `Handlers`, and a stage registered twice appears twice. Every rule reads the same snapshot, and every list on it is read-only, so one rule cannot change what the next one reads. + +| Type | Carries | +| --- | --- | +| `RequestFlowModel` | `Requests`, every known request type; `StageDeclarations`, every stage declaration in registration order | +| `RequestModel` | `RequestType`, the `Handlers` covering it, and `Stages`, its closed stage chain in execution order | +| `HandlerModel` | `HandlerType`; `ResponseType`, null for a void handler, which `IsVoid` reports as a `bool`; `Lifetime`, what this handler is registered with; and `ContractType`, the handler contract it implements | +| `StageDeclarationModel` | `StageType`, the type `AddStage` was given; `ReachedRequests`, the requests that stage type reached; `Lifetime`, what the stage is registered with; and `ContractType`, the stage contract it implements | +| `ClosedStageModel` | `DeclaredType`, the type `AddStage` was given; `ClosedType`, the stage that runs for this request; and `ContractType` | + +`ReachedRequests` is derived from the chains rather than recorded at registration: it holds every request whose `Stages` contains a closing of that `StageType`, in request order. A declaration that closed for nothing has an empty list, and so does one aimed only at requests without a handler, because those get no chain to close into. A model a test builds by hand derives it the same way, from the `AddStage` calls under `AddRequest`, matched on the declared type. Pass `AddStageDeclaration`'s type as `AddStage`'s `declaredType` there; naming the closed type instead leaves `ReachedRequests` empty for a stage the freeze would report as reaching the request. + +`StageDeclarationModel.Lifetime` is a `RequestFlowLifetime`: `Transient`, `Scoped`, or `Singleton`. It comes from the declaration's own `AddStage` call, which is where all three are reachable, `AsSingleton` and `AsScoped` included ([lifetimes.md](lifetimes.md)). A singleton stage is shared across concurrent dispatches, so a rule of your own can hold a convention about which stages may take one. The enum mirrors the container's `ServiceLifetime` rather than naming it, because `RequestFlow.Abstractions` takes no package dependencies, the DI abstractions included, so an assembly referencing only the contracts can still read a lifetime. + +`HandlerModel.Lifetime` is the same enum, transient or scoped. Each `AddRequestFlow` call decides for the handlers it found ([lifetimes.md](lifetimes.md#lifetime-is-per-registration-call)), so two handlers in one registration can differ and each carries its own: + +```csharp +foreach (RequestModel request in context.Model.Requests) +{ + foreach (HandlerModel handler in request.Handlers) + { + if (handler.Lifetime != RequestFlowLifetime.Scoped) + { + problems.Add(new RequestFlowValidationProblem( + "ACME0002", $"Handler '{handler.HandlerType.FullName}' must be scoped.", handler.HandlerType)); + } + } +} +``` + +Naming the handler is the point of reading it there: the rule can say which one to change. What it reports is what `AddRequestFlow` registered. A handler the application registers by hand afterwards wins at resolution without changing this value. A model a test builds by hand names the lifetime on `AddHandler`, and records transient without it. + +`ContractType` is a `Type`, not an enum, so a package can implement its own handler or stage contract and have it recorded there without RequestFlow needing to know that contract exists ahead of time. The freeze records the most derived contract the type implements: a handler written against `ICommandHandler` comes through as `typeof(ICommandHandler<,>)`, and a plain one as `typeof(IRequestHandler<,>)`. When two contracts apply and neither derives from the other, the core contract is recorded rather than one of the two. A rule matching on it compares against the exact contract it declared itself, rather than switching over a fixed set of cases the core would otherwise have to enumerate. The builder takes an open generic interface built on the family the entry belongs to and throws `ArgumentException` on anything else. A handler entry takes `IRequestHandler`, `IRequestHandler`, or an interface deriving from one, and a stage entry does the same for `IRequestStage`. A class, a closed interface, and an unrelated open interface such as `IEquatable<>` are all rejected, since none of them matches a comparison a rule would write. + +Requests come in scan order, followed by request types only a handler brought in. Closing a stage needs a handler, so a request nothing handles has an empty chain whatever stages would otherwise reach it. + +`Stages` is a single chain only when the request has one handler, which is what every registration that starts produces. A request two handlers cover is already an `RF0101` failure, and its `Stages` holds what each of them closes, merged into one list, so a single `AddStage` call can show up there twice. A rule that counts or orders stages should skip any request whose `Handlers.Count` is not one, the same way it skips the unhandled case. + +`ClosedType` names the type the container resolves, so a two-parameter stage over a void request reads `LoggingStage` there while the handler's `ResponseType` is null. The two describe different things: what the handler returns, and what the container constructs. Take the response from `HandlerModel.ResponseType` and leave `ClosedType`'s type arguments alone. + +## The opt-in flags + +The two opt-in flags sit on the context, beside the model. `UnhandledRequestsAllowed` is true once `AllowUnhandledRequests` has been called, `UnusedStagesDisallowed` once `DisallowUnusedStages` has. They decide which built-in rules run, and a rule reads them to word a finding around the choice. `UnusedStageRule` does exactly that: the stage is unused either way, and the flag decides whether its message offers the missing handler as the likely fix or says the missing handler was permitted. + +A rule with its own opinion about a request should still skip the unhandled case and leave it to `RF0102`: + +```csharp +foreach (RequestModel request in context.Model.Requests) +{ + if (request.Handlers.Count == 0) + continue; + + // your check here +} +``` + +Skipping it is right whether or not the application opted in. With the flag off, `RF0102` already reports the request; with it on, the missing handler is deliberate. Read `UnhandledRequestsAllowed` when the two cases deserve different wording, not to decide whether to skip. + +## Built-in codes + +Codes are stable and never renumbered. `RF0001` to `RF0012` are shape checks on a single declaration, recorded by the `AddRequestFlow` call that made it. The `RF01xx` codes are whole-picture checks that run at the freeze, except `RF0107`, which the pass reports about a rule that threw rather than about a registration. [exceptions.md](exceptions.md) has the cause and the fix behind each one. The `RF` constants live on `ProblemCodes` in `RequestFlow.Abstractions`, and the CQRS one on `CqrsProblemCodes` in `RequestFlow.Cqrs.Abstractions`, so a caller matches `ProblemCodes.UnhandledRequest` rather than a literal without referencing the runtime packages. + +| Code | Problem | +| --- | --- | +| `RF0001` | `RegisterGenericHandler` got a type that is not an open generic definition | +| `RF0002` | `RegisterGenericHandler` got an abstract class | +| `RF0003` | A generic handler declares more than one type parameter | +| `RF0004` | A declared handler type does not implement `IRequestHandler` | +| `RF0005` | A generic handler declaration names no closing types | +| `RF0006` | A closing type is itself open | +| `RF0007` | A closing type violates the handler's generic constraints | +| `RF0008` | `AddStage` got an interface | +| `RF0009` | `AddStage` got an abstract class | +| `RF0010` | `AddStage` got a partially closed type | +| `RF0011` | A registered stage type does not implement `IRequestStage` | +| `RF0012` | An open generic stage does not use its own parameters as the request in its contract | +| `RF0101` | A request has more than one handler | +| `RF0102` | A request has no handler; skipped under `AllowUnhandledRequests` | +| `RF0103` | A stage type is registered more than once | +| `RF0104` | Two or more stage declarations reach one request as the same stage class | +| `RF0105` | A stage applies to no registered request; reported only under `DisallowUnusedStages` | +| `RF0106` | A request implements more than one `IRequest` contract | +| `RF0107` | A validation rule threw; reported by the pass, not by a rule | +| `CQRS0001` | A request is classified as both a command and a query; contributed by `AddCqrs` | + +Problems come out in a fixed order: the shape problems first, then the built-in checks `RF0101` to `RF0106` in the order above, then the rules the container holds, in registration order. An `RF0107` takes the place of whatever the rule that threw would have reported, so it lands in that rule's position in the list. + +Give your own codes a prefix that names where they come from, the way `CQRS0001` names the CQRS package. `RF` is reserved for RequestFlow. From 228cb0f2b614860a507876e7e7ef5e2867405ba4 Mon Sep 17 00:00:00 2001 From: Illia Filippov Date: Sun, 9 Aug 2026 18:57:39 +0200 Subject: [PATCH 5/5] docs(samples): add the Orders minimal API sample Orders.Api dispatches commands and queries, filters a validation stage by handler marker, and contributes two validation rules of its own. Orders.Api.Violations holds the types those rules reject, in an assembly scanned only under --break-rules, so the startup failure is reproducible from a checkout. --- README.md | 1 + RequestFlow.slnx | 5 +- docs/validation-rules.md | 2 + samples/.gitkeep | 0 .../Orders.Api.Violations/BrokenRequests.cs | 34 +++++++++ .../Orders.Api.Violations.csproj | 14 ++++ samples/Orders.Api/ExceptionHandler.cs | 27 +++++++ samples/Orders.Api/Orders.Api.csproj | 25 +++++++ .../Orders/CancelOrder/CancelOrderCommand.cs | 5 ++ .../CancelOrder/CancelOrderCommandHandler.cs | 15 ++++ .../Orders/CancelOrder/CancelOrderEndpoint.cs | 17 +++++ .../Orders/CreateOrder/CreateOrderCommand.cs | 16 +++++ .../CreateOrder/CreateOrderCommandHandler.cs | 14 ++++ .../Orders/CreateOrder/CreateOrderEndpoint.cs | 19 +++++ .../Orders/GetOrder/GetOrderEndpoint.cs | 18 +++++ .../Orders/GetOrder/GetOrderQuery.cs | 5 ++ .../Orders/GetOrder/GetOrderQueryHandler.cs | 16 +++++ .../Orders/IOrdersCommandHandler.cs | 12 ++++ samples/Orders.Api/Orders/Order.cs | 3 + samples/Orders.Api/Orders/OrderDto.cs | 3 + .../Orders/OrderNotFoundException.cs | 9 +++ samples/Orders.Api/Orders/OrderStore.cs | 30 ++++++++ samples/Orders.Api/Orders/OrdersEndpoints.cs | 11 +++ samples/Orders.Api/Program.cs | 48 +++++++++++++ .../Orders.Api/Properties/launchSettings.json | 35 ++++++++++ samples/Orders.Api/Rules/CommandNamingRule.cs | 70 +++++++++++++++++++ .../Orders.Api/Rules/CommandValidatedRule.cs | 62 ++++++++++++++++ .../Orders.Api/Rules/OrdersProblemCodes.cs | 12 ++++ samples/Orders.Api/Stages/LoggingStage.cs | 40 +++++++++++ .../Validation/IValidatableRequest.cs | 9 +++ .../Orders.Api/Validation/ValidationErrors.cs | 6 ++ .../Validation/ValidationFailedException.cs | 10 +++ .../Orders.Api/Validation/ValidationStage.cs | 24 +++++++ samples/Orders.Api/appsettings.json | 9 +++ samples/README.md | 53 ++++++++++++++ 35 files changed, 678 insertions(+), 1 deletion(-) delete mode 100644 samples/.gitkeep create mode 100644 samples/Orders.Api.Violations/BrokenRequests.cs create mode 100644 samples/Orders.Api.Violations/Orders.Api.Violations.csproj create mode 100644 samples/Orders.Api/ExceptionHandler.cs create mode 100644 samples/Orders.Api/Orders.Api.csproj create mode 100644 samples/Orders.Api/Orders/CancelOrder/CancelOrderCommand.cs create mode 100644 samples/Orders.Api/Orders/CancelOrder/CancelOrderCommandHandler.cs create mode 100644 samples/Orders.Api/Orders/CancelOrder/CancelOrderEndpoint.cs create mode 100644 samples/Orders.Api/Orders/CreateOrder/CreateOrderCommand.cs create mode 100644 samples/Orders.Api/Orders/CreateOrder/CreateOrderCommandHandler.cs create mode 100644 samples/Orders.Api/Orders/CreateOrder/CreateOrderEndpoint.cs create mode 100644 samples/Orders.Api/Orders/GetOrder/GetOrderEndpoint.cs create mode 100644 samples/Orders.Api/Orders/GetOrder/GetOrderQuery.cs create mode 100644 samples/Orders.Api/Orders/GetOrder/GetOrderQueryHandler.cs create mode 100644 samples/Orders.Api/Orders/IOrdersCommandHandler.cs create mode 100644 samples/Orders.Api/Orders/Order.cs create mode 100644 samples/Orders.Api/Orders/OrderDto.cs create mode 100644 samples/Orders.Api/Orders/OrderNotFoundException.cs create mode 100644 samples/Orders.Api/Orders/OrderStore.cs create mode 100644 samples/Orders.Api/Orders/OrdersEndpoints.cs create mode 100644 samples/Orders.Api/Program.cs create mode 100644 samples/Orders.Api/Properties/launchSettings.json create mode 100644 samples/Orders.Api/Rules/CommandNamingRule.cs create mode 100644 samples/Orders.Api/Rules/CommandValidatedRule.cs create mode 100644 samples/Orders.Api/Rules/OrdersProblemCodes.cs create mode 100644 samples/Orders.Api/Stages/LoggingStage.cs create mode 100644 samples/Orders.Api/Validation/IValidatableRequest.cs create mode 100644 samples/Orders.Api/Validation/ValidationErrors.cs create mode 100644 samples/Orders.Api/Validation/ValidationFailedException.cs create mode 100644 samples/Orders.Api/Validation/ValidationStage.cs create mode 100644 samples/Orders.Api/appsettings.json create mode 100644 samples/README.md diff --git a/README.md b/README.md index 7bc5957..baeb6bf 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Contracts live in their own packages so your domain layer, and any future add-on - [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 - [Validation rules](https://github.com/illia1f/RequestFlow/blob/main/docs/validation-rules.md): contributing custom checks to startup validation, the model rules see, built-in problem codes +- [Sample](https://github.com/illia1f/RequestFlow/blob/main/samples/README.md): a minimal API using commands, queries, stages, and two validation rules of its own ## Contributing diff --git a/RequestFlow.slnx b/RequestFlow.slnx index 0d52d34..0ec2b70 100644 --- a/RequestFlow.slnx +++ b/RequestFlow.slnx @@ -1,5 +1,8 @@ - + + + + diff --git a/docs/validation-rules.md b/docs/validation-rules.md index e9ba1c5..f917707 100644 --- a/docs/validation-rules.md +++ b/docs/validation-rules.md @@ -6,6 +6,8 @@ Rules run once, when the dispatch map freezes: at the first dispatcher resolutio The built-in checks are fixed. A rule adds checks to the pass and cannot remove or replace one. +[`samples/Orders.Api`](../samples/README.md) has two working rules, and a command-line flag that makes them fail alongside a built-in check and the CQRS one. + ## Writing a rule Implement `IRequestFlowValidationRule`. `Validate` receives a `RequestFlowValidationContext` and returns every problem it found: diff --git a/samples/.gitkeep b/samples/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/samples/Orders.Api.Violations/BrokenRequests.cs b/samples/Orders.Api.Violations/BrokenRequests.cs new file mode 100644 index 0000000..052ab8b --- /dev/null +++ b/samples/Orders.Api.Violations/BrokenRequests.cs @@ -0,0 +1,34 @@ +using RequestFlow.Cqrs; + +namespace Orders.Api.Violations; + +// Everything here breaks a convention the Orders.Api rules enforce. The scan finds request types as +// well as handlers, so these need an assembly of their own that only --break-rules registers. + +/// +/// Trips ORDERS0001: a query handler covers it, so the name has to end in "Query". +/// +public sealed record FetchOrderDetails(Guid Id) : IQuery; + +public sealed class FetchOrderDetailsHandler : IQueryHandler +{ + public Task HandleAsync(FetchOrderDetails query, CancellationToken cancellationToken) + => Task.FromResult($"order {query.Id}"); +} + +/// +/// Trips ORDERS0002: the handler leaves off IOrdersCommandHandler, so the chain runs without the +/// validation stage. +/// +public sealed record ArchiveOrderCommand(Guid Id) : ICommand; + +public sealed class ArchiveOrderCommandHandler : ICommandHandler +{ + public Task HandleAsync(ArchiveOrderCommand command, CancellationToken cancellationToken) + => Task.FromResult(command.Id); +} + +/// +/// Trips CQRS0001, which AddCqrs contributes, and RF0102, since nothing handles it. +/// +public sealed record RefundOrderCommand(Guid Id) : ICommand, IQuery; diff --git a/samples/Orders.Api.Violations/Orders.Api.Violations.csproj b/samples/Orders.Api.Violations/Orders.Api.Violations.csproj new file mode 100644 index 0000000..8bb4163 --- /dev/null +++ b/samples/Orders.Api.Violations/Orders.Api.Violations.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + + + + + + + + diff --git a/samples/Orders.Api/ExceptionHandler.cs b/samples/Orders.Api/ExceptionHandler.cs new file mode 100644 index 0000000..e17a5b5 --- /dev/null +++ b/samples/Orders.Api/ExceptionHandler.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Diagnostics; +using Orders.Api.Orders; +using Orders.Api.Validation; + +namespace Orders.Api; + +public sealed class ExceptionHandler : IExceptionHandler +{ + public async ValueTask TryHandleAsync(HttpContext context, Exception exception, CancellationToken cancellationToken) + { + switch (exception) + { + case ValidationFailedException failure: + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync( + new ValidationErrors(failure.Errors), cancellationToken); + return true; + + case OrderNotFoundException: + context.Response.StatusCode = StatusCodes.Status404NotFound; + return true; + + default: + return false; + } + } +} diff --git a/samples/Orders.Api/Orders.Api.csproj b/samples/Orders.Api/Orders.Api.csproj new file mode 100644 index 0000000..bb78266 --- /dev/null +++ b/samples/Orders.Api/Orders.Api.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + diff --git a/samples/Orders.Api/Orders/CancelOrder/CancelOrderCommand.cs b/samples/Orders.Api/Orders/CancelOrder/CancelOrderCommand.cs new file mode 100644 index 0000000..ee59384 --- /dev/null +++ b/samples/Orders.Api/Orders/CancelOrder/CancelOrderCommand.cs @@ -0,0 +1,5 @@ +using RequestFlow.Cqrs; + +namespace Orders.Api.Orders; + +public sealed record CancelOrderCommand(Guid Id) : ICommand; diff --git a/samples/Orders.Api/Orders/CancelOrder/CancelOrderCommandHandler.cs b/samples/Orders.Api/Orders/CancelOrder/CancelOrderCommandHandler.cs new file mode 100644 index 0000000..0696fa2 --- /dev/null +++ b/samples/Orders.Api/Orders/CancelOrder/CancelOrderCommandHandler.cs @@ -0,0 +1,15 @@ +using RequestFlow.Cqrs; + +namespace Orders.Api.Orders; + +public sealed class CancelOrderCommandHandler(OrderStore store) + : ICommandHandler, IOrdersCommandHandler +{ + public Task HandleAsync(CancelOrderCommand command, CancellationToken cancellationToken) + { + if (!store.TryCancel(command.Id)) + throw new OrderNotFoundException(command.Id); + + return Task.CompletedTask; + } +} diff --git a/samples/Orders.Api/Orders/CancelOrder/CancelOrderEndpoint.cs b/samples/Orders.Api/Orders/CancelOrder/CancelOrderEndpoint.cs new file mode 100644 index 0000000..9b9ae80 --- /dev/null +++ b/samples/Orders.Api/Orders/CancelOrder/CancelOrderEndpoint.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Http.HttpResults; +using RequestFlow.Cqrs; + +namespace Orders.Api.Orders; + +internal static class CancelOrderEndpoint +{ + // Produces covers the 404 the exception handler writes for an id the store does not hold. + public static void MapCancelOrder(this IEndpointRouteBuilder routes) + => routes.MapPost("/orders/{id:guid}/cancel", async Task ( + Guid id, ICommandDispatcher commands, CancellationToken cancellationToken) => + { + await commands.SendAsync(new CancelOrderCommand(id), cancellationToken); + return TypedResults.NoContent(); + }) + .Produces(StatusCodes.Status404NotFound); +} diff --git a/samples/Orders.Api/Orders/CreateOrder/CreateOrderCommand.cs b/samples/Orders.Api/Orders/CreateOrder/CreateOrderCommand.cs new file mode 100644 index 0000000..260df09 --- /dev/null +++ b/samples/Orders.Api/Orders/CreateOrder/CreateOrderCommand.cs @@ -0,0 +1,16 @@ +using Orders.Api.Validation; +using RequestFlow.Cqrs; + +namespace Orders.Api.Orders; + +public sealed record CreateOrderCommand(string Customer, decimal Total) : ICommand, IValidatableRequest +{ + public IEnumerable Validate() + { + if (string.IsNullOrWhiteSpace(Customer)) + yield return "Customer is required."; + + if (Total <= 0) + yield return "Total has to be greater than zero."; + } +} diff --git a/samples/Orders.Api/Orders/CreateOrder/CreateOrderCommandHandler.cs b/samples/Orders.Api/Orders/CreateOrder/CreateOrderCommandHandler.cs new file mode 100644 index 0000000..085c14f --- /dev/null +++ b/samples/Orders.Api/Orders/CreateOrder/CreateOrderCommandHandler.cs @@ -0,0 +1,14 @@ +using RequestFlow.Cqrs; + +namespace Orders.Api.Orders; + +public sealed class CreateOrderCommandHandler(OrderStore store) + : ICommandHandler, IOrdersCommandHandler +{ + public Task HandleAsync(CreateOrderCommand command, CancellationToken cancellationToken) + { + Order order = new(Guid.NewGuid(), command.Customer, command.Total, Cancelled: false); + store.Save(order); + return Task.FromResult(order.Id); + } +} diff --git a/samples/Orders.Api/Orders/CreateOrder/CreateOrderEndpoint.cs b/samples/Orders.Api/Orders/CreateOrder/CreateOrderEndpoint.cs new file mode 100644 index 0000000..4e5f236 --- /dev/null +++ b/samples/Orders.Api/Orders/CreateOrder/CreateOrderEndpoint.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Http.HttpResults; +using Orders.Api.Validation; +using RequestFlow.Cqrs; + +namespace Orders.Api.Orders; + +public sealed record OrderCreated(Guid Id); + +internal static class CreateOrderEndpoint +{ + public static void MapCreateOrder(this IEndpointRouteBuilder routes) + => routes.MapPost("/orders", async Task> ( + CreateOrderCommand command, ICommandDispatcher commands, CancellationToken cancellationToken) => + { + Guid id = await commands.SendAsync(command, cancellationToken); + return TypedResults.Created($"/orders/{id}", new OrderCreated(id)); + }) + .Produces(StatusCodes.Status400BadRequest); +} diff --git a/samples/Orders.Api/Orders/GetOrder/GetOrderEndpoint.cs b/samples/Orders.Api/Orders/GetOrder/GetOrderEndpoint.cs new file mode 100644 index 0000000..3594b84 --- /dev/null +++ b/samples/Orders.Api/Orders/GetOrder/GetOrderEndpoint.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Http.HttpResults; +using RequestFlow.Cqrs; + +namespace Orders.Api.Orders; + +internal static class GetOrderEndpoint +{ + public static void MapGetOrder(this IEndpointRouteBuilder routes) + => routes.MapGet("/orders/{id:guid}", async Task, NotFound>> ( + Guid id, IQueryDispatcher queries, CancellationToken cancellationToken) => + { + OrderDto? order = await queries.SendAsync(new GetOrderQuery(id), cancellationToken); + if (order is null) + return TypedResults.NotFound(); + + return TypedResults.Ok(order); + }); +} diff --git a/samples/Orders.Api/Orders/GetOrder/GetOrderQuery.cs b/samples/Orders.Api/Orders/GetOrder/GetOrderQuery.cs new file mode 100644 index 0000000..6f7e48e --- /dev/null +++ b/samples/Orders.Api/Orders/GetOrder/GetOrderQuery.cs @@ -0,0 +1,5 @@ +using RequestFlow.Cqrs; + +namespace Orders.Api.Orders; + +public sealed record GetOrderQuery(Guid Id) : IQuery; diff --git a/samples/Orders.Api/Orders/GetOrder/GetOrderQueryHandler.cs b/samples/Orders.Api/Orders/GetOrder/GetOrderQueryHandler.cs new file mode 100644 index 0000000..117df7d --- /dev/null +++ b/samples/Orders.Api/Orders/GetOrder/GetOrderQueryHandler.cs @@ -0,0 +1,16 @@ +using RequestFlow.Cqrs; + +namespace Orders.Api.Orders; + +public sealed class GetOrderQueryHandler(OrderStore store) : IQueryHandler +{ + public Task HandleAsync(GetOrderQuery query, CancellationToken cancellationToken) + { + Order? order = store.Find(query.Id); + OrderDto? dto = order is null + ? null + : new OrderDto(order.Id, order.Customer, order.Total, order.Cancelled ? "cancelled" : "open"); + + return Task.FromResult(dto); + } +} diff --git a/samples/Orders.Api/Orders/IOrdersCommandHandler.cs b/samples/Orders.Api/Orders/IOrdersCommandHandler.cs new file mode 100644 index 0000000..f8c3864 --- /dev/null +++ b/samples/Orders.Api/Orders/IOrdersCommandHandler.cs @@ -0,0 +1,12 @@ +namespace Orders.Api.Orders; + +/// +/// Every command handler in the application implements this, and the validation stage filters on it. +/// +/// +/// A stage filter takes one contract as a type argument and an open generic definition is not legal +/// there, so a marker is what covers both command handler shapes at once. Forgetting it on a new +/// handler still compiles and still dispatches, which is what CommandValidatedRule catches. +/// +public interface IOrdersCommandHandler +{ } diff --git a/samples/Orders.Api/Orders/Order.cs b/samples/Orders.Api/Orders/Order.cs new file mode 100644 index 0000000..a4d20fc --- /dev/null +++ b/samples/Orders.Api/Orders/Order.cs @@ -0,0 +1,3 @@ +namespace Orders.Api.Orders; + +public sealed record Order(Guid Id, string Customer, decimal Total, bool Cancelled); diff --git a/samples/Orders.Api/Orders/OrderDto.cs b/samples/Orders.Api/Orders/OrderDto.cs new file mode 100644 index 0000000..a49f7fc --- /dev/null +++ b/samples/Orders.Api/Orders/OrderDto.cs @@ -0,0 +1,3 @@ +namespace Orders.Api.Orders; + +public sealed record OrderDto(Guid Id, string Customer, decimal Total, string Status); diff --git a/samples/Orders.Api/Orders/OrderNotFoundException.cs b/samples/Orders.Api/Orders/OrderNotFoundException.cs new file mode 100644 index 0000000..a208a2f --- /dev/null +++ b/samples/Orders.Api/Orders/OrderNotFoundException.cs @@ -0,0 +1,9 @@ +namespace Orders.Api.Orders; + +/// +/// Thrown when a command names an order the store does not hold. +/// +public sealed class OrderNotFoundException(Guid id) : Exception($"Order '{id}' does not exist.") +{ + public Guid Id { get; } = id; +} diff --git a/samples/Orders.Api/Orders/OrderStore.cs b/samples/Orders.Api/Orders/OrderStore.cs new file mode 100644 index 0000000..7fabf38 --- /dev/null +++ b/samples/Orders.Api/Orders/OrderStore.cs @@ -0,0 +1,30 @@ +using System.Collections.Concurrent; + +namespace Orders.Api.Orders; + +/// +/// The sample's whole persistence layer. +/// +public sealed class OrderStore +{ + private readonly ConcurrentDictionary _orders = new(); + + public void Save(Order order) + => _orders[order.Id] = order; + + public Order? Find(Guid id) + => _orders.TryGetValue(id, out Order? order) ? order : null; + + // Find and Save as two calls would drop a concurrent write, so the update only lands while the + // order still matches what this call read. + public bool TryCancel(Guid id) + { + while (_orders.TryGetValue(id, out Order? current)) + { + if (_orders.TryUpdate(id, current with { Cancelled = true }, current)) + return true; + } + + return false; + } +} diff --git a/samples/Orders.Api/Orders/OrdersEndpoints.cs b/samples/Orders.Api/Orders/OrdersEndpoints.cs new file mode 100644 index 0000000..833cb88 --- /dev/null +++ b/samples/Orders.Api/Orders/OrdersEndpoints.cs @@ -0,0 +1,11 @@ +namespace Orders.Api.Orders; + +public static class OrdersEndpoints +{ + public static void MapOrders(this IEndpointRouteBuilder routes) + { + routes.MapCreateOrder(); + routes.MapCancelOrder(); + routes.MapGetOrder(); + } +} diff --git a/samples/Orders.Api/Program.cs b/samples/Orders.Api/Program.cs new file mode 100644 index 0000000..cbae34e --- /dev/null +++ b/samples/Orders.Api/Program.cs @@ -0,0 +1,48 @@ +using Orders.Api; +using Orders.Api.Orders; +using Orders.Api.Rules; +using Orders.Api.Stages; +using Orders.Api.Validation; +using Orders.Api.Violations; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.AddSingleton(); +builder.Services.AddOpenApi(); + +builder.Services.AddProblemDetails(); +builder.Services.AddExceptionHandler(); + +builder.Services + .AddRequestFlow(options => + { + options.RegisterHandlersFromAssemblyContaining(); + options.AddStage(typeof(LoggingStage<,>)); + options.AddStage( + typeof(ValidationStage<,>), stage => stage.WhereHandlerImplements()); + + // The scan finds request types as well as handlers, so the types that break conventions + // only stay out of a normal start by sitting in an assembly of their own. + if (args.Contains("--break-rules")) + options.RegisterHandlersFromAssembly(typeof(RefundOrderCommand).Assembly); + }) + .AddCqrs() + .AddValidationRule() + .AddValidationRule(); + +WebApplication app = builder.Build(); + +// Every problem lands here, at startup, instead of on the first request. +app.Services.ValidateRequestFlow(); + +app.UseExceptionHandler(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); + app.UseSwaggerUI(options => options.SwaggerEndpoint("/openapi/v1.json", "Orders.Api")); +} + +app.MapOrders(); + +app.Run(); diff --git a/samples/Orders.Api/Properties/launchSettings.json b/samples/Orders.Api/Properties/launchSettings.json new file mode 100644 index 0000000..dd021a8 --- /dev/null +++ b/samples/Orders.Api/Properties/launchSettings.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5113", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7249;http://localhost:5113", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "break-rules": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "commandLineArgs": "--break-rules", + "applicationUrl": "http://localhost:5113", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/Orders.Api/Rules/CommandNamingRule.cs b/samples/Orders.Api/Rules/CommandNamingRule.cs new file mode 100644 index 0000000..5764548 --- /dev/null +++ b/samples/Orders.Api/Rules/CommandNamingRule.cs @@ -0,0 +1,70 @@ +using RequestFlow; +using RequestFlow.Cqrs; + +namespace Orders.Api.Rules; + +/// +/// A request a command handler covers has to end in "Command", and one a query handler covers in +/// "Query". +/// +public sealed class CommandNamingRule : IRequestFlowValidationRule +{ + public IEnumerable Validate(RequestFlowValidationContext context) + { + List problems = []; + foreach (RequestModel request in context.Model.Requests) + { + // A request with no handler is RF0102's to report, and one with several is RF0101's. + if (request.Handlers.Count != 1) + continue; + + HandlerModel handler = request.Handlers[0]; + string? suffix = SuffixFor(handler.ContractType); + if (suffix is null || NameOf(request.RequestType).EndsWith(suffix, StringComparison.Ordinal)) + continue; + + problems.Add(new RequestFlowValidationProblem( + OrdersProblemCodes.RequestSuffix, + $"Request '{request.RequestType.FullName}' is handled by " + + $"'{handler.HandlerType.FullName}', so its name has to end in '{suffix}'.", + request.RequestType)); + } + + return problems; + } + + // A closed generic request reads as "Audit`1" here, and the suffix sits in front of the backtick. + private static string NameOf(Type requestType) + { + string name = requestType.Name; + int arity = name.IndexOf('`'); + + return arity < 0 ? name : name[..arity]; + } + + // A void command handler records ICommandHandler<>, not ICommandHandler<,>, so matching only the + // two-parameter shape would wave every void command through. + private static string? SuffixFor(Type contract) + { + if (Implements(contract, typeof(ICommandHandler<,>)) || Implements(contract, typeof(ICommandHandler<>))) + return "Command"; + + return Implements(contract, typeof(IQueryHandler<,>)) ? "Query" : null; + } + + // An application that gives its handlers a contract of their own has that one recorded instead, + // and comparing for equality would drop every command from the check without saying so. + private static bool Implements(Type contract, Type coreContract) + { + if (contract == coreContract) + return true; + + foreach (Type inherited in contract.GetInterfaces()) + { + if (inherited.IsGenericType && inherited.GetGenericTypeDefinition() == coreContract) + return true; + } + + return false; + } +} diff --git a/samples/Orders.Api/Rules/CommandValidatedRule.cs b/samples/Orders.Api/Rules/CommandValidatedRule.cs new file mode 100644 index 0000000..62849b5 --- /dev/null +++ b/samples/Orders.Api/Rules/CommandValidatedRule.cs @@ -0,0 +1,62 @@ +using Orders.Api.Orders; +using Orders.Api.Validation; +using RequestFlow; +using RequestFlow.Cqrs; + +namespace Orders.Api.Rules; + +/// +/// Every command has to run through . A handler +/// that leaves off falls out of the stage's filter. +/// +public sealed class CommandValidatedRule : IRequestFlowValidationRule +{ + public IEnumerable Validate(RequestFlowValidationContext context) + { + List problems = []; + foreach (RequestModel request in context.Model.Requests) + { + if (request.Handlers.Count != 1 || !IsCommand(request.RequestType)) + continue; + + if (Validates(request)) + continue; + + problems.Add(new RequestFlowValidationProblem( + OrdersProblemCodes.CommandNotValidated, + $"Command '{request.RequestType.FullName}' has no validation stage in its chain; " + + $"its handler '{request.Handlers[0].HandlerType.FullName}' has to implement " + + $"{nameof(IOrdersCommandHandler)}.", + request.RequestType)); + } + + return problems; + } + + // Stages is the chain as the freeze closed it, so this reads what will actually run. ClosedType + // is the same either way, while DeclaredType is whatever shape AddStage was handed. + private static bool Validates(RequestModel request) + { + foreach (ClosedStageModel stage in request.Stages) + { + if (stage.ClosedType.IsGenericType + && stage.ClosedType.GetGenericTypeDefinition() == typeof(ValidationStage<,>)) + { + return true; + } + } + + return false; + } + + private static bool IsCommand(Type requestType) + { + foreach (Type contract in requestType.GetInterfaces()) + { + if (contract.IsGenericType && contract.GetGenericTypeDefinition() == typeof(ICommand<>)) + return true; + } + + return false; + } +} diff --git a/samples/Orders.Api/Rules/OrdersProblemCodes.cs b/samples/Orders.Api/Rules/OrdersProblemCodes.cs new file mode 100644 index 0000000..45701ef --- /dev/null +++ b/samples/Orders.Api/Rules/OrdersProblemCodes.cs @@ -0,0 +1,12 @@ +namespace Orders.Api.Rules; + +/// +/// The codes this application's rules report. A prefix of its own keeps them clear of RF, which +/// RequestFlow reserves, and of any package contributing rules of its own. +/// +public static class OrdersProblemCodes +{ + public const string RequestSuffix = "ORDERS0001"; + + public const string CommandNotValidated = "ORDERS0002"; +} diff --git a/samples/Orders.Api/Stages/LoggingStage.cs b/samples/Orders.Api/Stages/LoggingStage.cs new file mode 100644 index 0000000..b69179c --- /dev/null +++ b/samples/Orders.Api/Stages/LoggingStage.cs @@ -0,0 +1,40 @@ +using System.Diagnostics; +using RequestFlow; + +namespace Orders.Api.Stages; + +/// +/// Times every request in the application. Registered without a filter, so it closes over commands +/// and queries alike, void ones included. +/// +public sealed class LoggingStage(ILogger> logger) + : IRequestStage + where TRequest : IRequest +{ + public async Task HandleAsync( + TRequest request, Continuation next, CancellationToken cancellationToken) + { + long start = Stopwatch.GetTimestamp(); + try + { + return await next.InvokeAsync(cancellationToken); + } + finally + { + StageLog.Timed(logger, typeof(TRequest).Name, Stopwatch.GetElapsedTime(start)); + } + } +} + +/// +/// The stage's log messages, generated rather than formatted. +/// +/// +/// A dispatch reaches this on every request, and the params overload of LogInformation would +/// build an array and box the elapsed time there whether or not the level is on. +/// +internal static partial class StageLog +{ + [LoggerMessage(Level = LogLevel.Information, Message = "{Request} took {Elapsed}.")] + public static partial void Timed(ILogger logger, string request, TimeSpan elapsed); +} diff --git a/samples/Orders.Api/Validation/IValidatableRequest.cs b/samples/Orders.Api/Validation/IValidatableRequest.cs new file mode 100644 index 0000000..1f477d6 --- /dev/null +++ b/samples/Orders.Api/Validation/IValidatableRequest.cs @@ -0,0 +1,9 @@ +namespace Orders.Api.Validation; + +/// +/// A request that checks itself before its handler runs. +/// +public interface IValidatableRequest +{ + IEnumerable Validate(); +} diff --git a/samples/Orders.Api/Validation/ValidationErrors.cs b/samples/Orders.Api/Validation/ValidationErrors.cs new file mode 100644 index 0000000..5a8b07d --- /dev/null +++ b/samples/Orders.Api/Validation/ValidationErrors.cs @@ -0,0 +1,6 @@ +namespace Orders.Api.Validation; + +/// +/// The body a request that failed its own checks comes back with. +/// +public sealed record ValidationErrors(IReadOnlyList Errors); diff --git a/samples/Orders.Api/Validation/ValidationFailedException.cs b/samples/Orders.Api/Validation/ValidationFailedException.cs new file mode 100644 index 0000000..5f75387 --- /dev/null +++ b/samples/Orders.Api/Validation/ValidationFailedException.cs @@ -0,0 +1,10 @@ +namespace Orders.Api.Validation; + +/// +/// Thrown by when a request fails its own checks. +/// +public sealed class ValidationFailedException(IReadOnlyList errors) + : Exception($"The request failed validation: {string.Join("; ", errors)}") +{ + public IReadOnlyList Errors { get; } = errors; +} diff --git a/samples/Orders.Api/Validation/ValidationStage.cs b/samples/Orders.Api/Validation/ValidationStage.cs new file mode 100644 index 0000000..976a66f --- /dev/null +++ b/samples/Orders.Api/Validation/ValidationStage.cs @@ -0,0 +1,24 @@ +using RequestFlow; + +namespace Orders.Api.Validation; + +/// +/// Runs a request's own checks and stops the chain when any of them fails. Program.cs registers it +/// behind a handler filter, so queries never enter it. +/// +public sealed class ValidationStage : IRequestStage + where TRequest : IRequest +{ + public Task HandleAsync( + TRequest request, Continuation next, CancellationToken cancellationToken) + { + if (request is IValidatableRequest validatable) + { + List errors = [.. validatable.Validate()]; + if (errors.Count > 0) + throw new ValidationFailedException(errors); + } + + return next.InvokeAsync(cancellationToken); + } +} diff --git a/samples/Orders.Api/appsettings.json b/samples/Orders.Api/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/samples/Orders.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 0000000..2b59248 --- /dev/null +++ b/samples/README.md @@ -0,0 +1,53 @@ +# Samples + +`Orders.Api` is a minimal API over an in-memory store, wired with `AddRequestFlow`, `AddCqrs`, two +stages, and two validation rules of its own. `Orders.Api.Violations` holds the types those rules +reject, in an assembly the application scans only when you ask it to. + +## Run it + +```bash +dotnet run --project samples/Orders.Api +``` + +Swagger is at and the document behind it at `/openapi/v1.json`, +both in Development only. + +```bash +curl -X POST http://localhost:5113/orders -H "Content-Type: application/json" -d '{"customer":"ada","total":42.5}' +curl http://localhost:5113/orders/{id} +curl -X POST http://localhost:5113/orders/{id}/cancel +``` + +An empty customer or a total of zero returns 400, thrown by `ValidationStage` before the handler +runs. Cancelling an id the store does not hold returns 404. + +## Watch the rules fire + +```bash +dotnet run --project samples/Orders.Api -- --break-rules +``` + +The flag scans `Orders.Api.Violations`, and startup fails with every problem at once: + +``` +RequestFlow.RequestFlowValidationException: RequestFlow registration is invalid: +RF0102: Request 'Orders.Api.Violations.RefundOrderCommand' has no handler. +CQRS0001: Request 'Orders.Api.Violations.RefundOrderCommand' is classified as both a command and a query; pick one side of the split. +ORDERS0001: Request 'Orders.Api.Violations.FetchOrderDetails' is handled by 'Orders.Api.Violations.FetchOrderDetailsHandler', so its name has to end in 'Query'. +ORDERS0002: Command 'Orders.Api.Violations.ArchiveOrderCommand' has no validation stage in its chain; its handler 'Orders.Api.Violations.ArchiveOrderCommandHandler' has to implement IOrdersCommandHandler. +``` + +Four problems from three sources: the core reports `RF0102`, `AddCqrs` contributes `CQRS0001`, and +the application's own rules report the other two. + +| Code | Rule | Reads | +| --- | --- | --- | +| `ORDERS0001` | `Rules/CommandNamingRule.cs` | `HandlerModel.ContractType`, to tell a command from a query | +| `ORDERS0002` | `Rules/CommandValidatedRule.cs` | `RequestModel.Stages`, the chain as the freeze closed it | + +`ORDERS0002` catches a real mistake. `ValidationStage` is filtered to handlers implementing +`IOrdersCommandHandler`, so a command handler that leaves the marker off still compiles and still +dispatches, with no validation in its chain. + +[docs/validation-rules.md](../docs/validation-rules.md) covers writing and registering a rule.