diff --git a/CHANGELOG.md b/CHANGELOG.md index c4018f2..3d071e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ To have a consistent experience across all packages, some public interfaces have - **request.validation:** Make `Severity` meaningful — `Validation.IsValid` now only fails on `Error` problems; `Warning`/`Info` no longer invalidate. Added `Validation.IsValidFor(Severity)` to set the failing-severity threshold (see PLAN.md Inconsistencies#B) - **request.dispatcher:** Throw `InvalidOperationException` listing candidates when multiple handlers match a request instead of silently resolving via `.First()` (matches MediatR). Also throws when no handler is registered (see PLAN.md Inconsistencies#C) - **request.dispatcher:** Resolve handlers/behaviors in registration order across closed and open generics instead of the fixed closed-before-open reflection order (see PLAN.md Inconsistencies#D) +- **request.dispatcher:** Add `AddBehavior(order:)` registration-time API so pipeline behavior ordering is explicit and deterministic (lower order runs first, registration order as tie-break) instead of relying on reflection/discovery order (see PLAN.md Tips — Explicit pipeline ordering API) ### Removed diff --git a/src/request.dispatcher.tests/ScalarBehaviourTests.cs b/src/request.dispatcher.tests/ScalarBehaviourTests.cs index 9c10e4b..d2a6dd6 100644 --- a/src/request.dispatcher.tests/ScalarBehaviourTests.cs +++ b/src/request.dispatcher.tests/ScalarBehaviourTests.cs @@ -87,6 +87,26 @@ internal sealed class ScalarBehaviourTests await Assert.That(tracker.Executed).IsTrue(); } + [Test] + public async Task I_can_order_behaviours_explicitly_by_order_property() + { + var sc = new ServiceCollection(); + sc.AddSingleton(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(ScalarTestHandler)) + .AddBehavior(2) + .AddBehavior(1)); + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + var tracker = provider.GetRequiredService(); + + var request = new ScalarTestRequest { Value = "Hello" }; + await dispatcher.DispatchAsync(request); + + // Registered A (order 2) then B (order 1) via AddBehavior; explicit order overrides discovery order. + await Assert.That(tracker.Log).IsEquivalentTo(["OrderedB", "OrderedA"]); + } + [Test] public async Task I_can_maintain_the_ordering_between_open_and_closed_behaviours() { diff --git a/src/request.dispatcher.tests/StreamBehaviourTests.cs b/src/request.dispatcher.tests/StreamBehaviourTests.cs index c96b530..7e9377f 100644 --- a/src/request.dispatcher.tests/StreamBehaviourTests.cs +++ b/src/request.dispatcher.tests/StreamBehaviourTests.cs @@ -66,6 +66,26 @@ internal sealed class StreamBehaviourTests await Assert.That(tracker.Log[1]).IsEquivalentTo("Behaviour2"); } + [Test] + public async Task I_can_order_behaviours_explicitly_by_order_property() + { + var sc = new ServiceCollection(); + sc.AddSingleton(); + sc.AddRequestDispatcher(builder => builder + .Add(typeof(StreamTestHandler)) + .AddBehavior(2) + .AddBehavior(1)); + var provider = sc.BuildServiceProvider(); + var dispatcher = provider.GetRequiredService(); + var tracker = provider.GetRequiredService(); + + var request = new StreamTestRequest { Value = "Hello" }; + await dispatcher.DispatchAsync(request).ToListAsync(); + + // Registered A (order 2) then B (order 1) via AddBehavior; explicit order overrides discovery order. + await Assert.That(tracker.Log).IsEquivalentTo(["OrderedB", "OrderedA"]); + } + [Test] public async Task I_can_work_with_a_generic_wrapper_request_and_the_open_behaviour() { diff --git a/src/request.dispatcher.tests/_fixtures/OrderedScalarBehavior.cs b/src/request.dispatcher.tests/_fixtures/OrderedScalarBehavior.cs new file mode 100644 index 0000000..1eab048 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/OrderedScalarBehavior.cs @@ -0,0 +1,22 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class OrderedScalarBehaviorA(ScalarTestTracker tracker) : IScalarRequestBehavior +{ + public async Task HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("OrderedA"); + return await next(request, cancellationToken); + } +} + +public class OrderedScalarBehaviorB(ScalarTestTracker tracker) : IScalarRequestBehavior +{ + public async Task HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate next, CancellationToken cancellationToken) + { + tracker.Log.Add("OrderedB"); + return await next(request, cancellationToken); + } +} diff --git a/src/request.dispatcher.tests/_fixtures/OrderedStreamBehavior.cs b/src/request.dispatcher.tests/_fixtures/OrderedStreamBehavior.cs new file mode 100644 index 0000000..0439f21 --- /dev/null +++ b/src/request.dispatcher.tests/_fixtures/OrderedStreamBehavior.cs @@ -0,0 +1,28 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Request.Dispatcher.Tests; + +public class OrderedStreamBehaviorA(StreamTestTracker tracker) : IStreamRequestBehavior +{ + public async IAsyncEnumerable HandleAsync(StreamTestRequest request, StreamHandlerDelegate next, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + tracker.Log.Add("OrderedA"); + await foreach (var item in next(request, cancellationToken)) + { + yield return item; + } + } +} + +public class OrderedStreamBehaviorB(StreamTestTracker tracker) : IStreamRequestBehavior +{ + public async IAsyncEnumerable HandleAsync(StreamTestRequest request, StreamHandlerDelegate next, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + tracker.Log.Add("OrderedB"); + await foreach (var item in next(request, cancellationToken)) + { + yield return item; + } + } +} diff --git a/src/request.dispatcher/RequestDispatcherBuilderExtensions.cs b/src/request.dispatcher/RequestDispatcherBuilderExtensions.cs index 8d53e3b..c2b6976 100644 --- a/src/request.dispatcher/RequestDispatcherBuilderExtensions.cs +++ b/src/request.dispatcher/RequestDispatcherBuilderExtensions.cs @@ -70,6 +70,27 @@ public static class RequestDispatcherBuilderExtensions return builder; } + /// + /// Adds the specified pipeline behavior to the request dispatcher configuration, with an explicit + /// pipeline used to determine its position in the pipeline. Lower values run + /// first (outermost); behaviors with equal order run in registration order. + /// + /// The behavior type to add. Must implement a request behavior interface. + /// The to configure. + /// The pipeline order for this behavior. Defaults to 0. + /// The instance for further configuration. + public static IRequestDispatcherBuilder AddBehavior(this IRequestDispatcherBuilder builder, int order = 0) + where TBehavior : class + { + ArgumentNullException.ThrowIfNull(builder); + ValidateNoNestedRequestHandlers([typeof(TBehavior)]); + + builder.Services.AddOptions() + .Configure(options => options.Inspect([typeof(TBehavior)], order)); + + return builder; + } + /// /// Adds the specified type to the request dispatcher configuration for inspection. /// diff --git a/src/request.dispatcher/RequestDispatcherOptions.cs b/src/request.dispatcher/RequestDispatcherOptions.cs index 6560f46..f7f8c68 100644 --- a/src/request.dispatcher/RequestDispatcherOptions.cs +++ b/src/request.dispatcher/RequestDispatcherOptions.cs @@ -10,24 +10,27 @@ namespace Geekeey.Request.Dispatcher; internal sealed class RequestDispatcherOptions { - private readonly List _search = []; + private readonly List<(Type Type, int? Order)> _search = []; private readonly Lazy _behaviorsTypeIndex; private readonly Lazy _handlersTypeIndex; public RequestDispatcherOptions() { - _behaviorsTypeIndex = new Lazy(() => new BehaviorTypeIndex(_search.Distinct())); - _handlersTypeIndex = new Lazy(() => new HandlerTypeIndex(_search.Distinct())); + _behaviorsTypeIndex = new Lazy(() => new BehaviorTypeIndex(_search.DistinctBy(x => x.Type))); + _handlersTypeIndex = new Lazy(() => new HandlerTypeIndex(_search.DistinctBy(x => x.Type))); } - public void Inspect(IEnumerable assembly) + public void Inspect(IEnumerable assembly, int? order = null) { if (_behaviorsTypeIndex.IsValueCreated || _handlersTypeIndex.IsValueCreated) { throw new InvalidOperationException("The type index has already been created. Cannot inspect new assemblies."); } - _search.AddRange(assembly); + foreach (var type in assembly) + { + _search.Add((type, order)); + } } public IEnumerable GetRequestBehaviors(IServiceProvider services) @@ -47,15 +50,21 @@ internal sealed class RequestDispatcherOptions protected readonly Dictionary> _closedTypeInfo = []; protected readonly List _openTypeInfo = []; protected readonly Dictionary _order = []; + protected readonly Dictionary _explicitOrder = []; - protected TypeIndex(IEnumerable collection, Func predicate) + protected TypeIndex(IEnumerable<(Type Type, int? Order)> collection, Func predicate) { var index = 0; - foreach (var type in collection) + foreach (var (type, order) in collection) { _order[type] = index++; + if (order is { } explicitOrder) + { + _explicitOrder[type] = explicitOrder; + } + if (type.IsGenericTypeDefinition) { if (type.GetInterfaces().Any(predicate)) @@ -73,6 +82,11 @@ internal sealed class RequestDispatcherOptions } } + protected int EffectiveOrder(Type type) + { + return _explicitOrder.TryGetValue(type, out var order) ? order : _order[type]; + } + public IEnumerable Resolve(IServiceProvider services) { return (IEnumerable)_cache.GetOrAdd(typeof(T), CreateResolverFactory)(services); @@ -102,7 +116,7 @@ internal sealed class RequestDispatcherOptions type.GetGenericTypeDefinition() == typeof(IStreamRequestHandler<,>)); } - private sealed class HandlerTypeIndex(IEnumerable collection) + private sealed class HandlerTypeIndex(IEnumerable<(Type Type, int? Order)> collection) : TypeIndex(collection, IsRequestHandlerType) { protected override IReadOnlyList IsAssignableTo(Type @interface) @@ -113,7 +127,7 @@ internal sealed class RequestDispatcherOptions { foreach (var type in list) { - result.Add((_order[type], type)); + result.Add((EffectiveOrder(type), type)); } } @@ -129,7 +143,7 @@ internal sealed class RequestDispatcherOptions var impl = type.MakeGenericType(requestType); if (impl.IsAssignableTo(@interface)) { - result.Add((_order[type], impl)); + result.Add((EffectiveOrder(type), impl)); } } catch (ArgumentException) @@ -145,7 +159,7 @@ internal sealed class RequestDispatcherOptions var impl = type.MakeGenericType(requestType.GetGenericArguments()); if (impl.IsAssignableTo(@interface)) { - result.Add((_order[type], impl)); + result.Add((EffectiveOrder(type), impl)); } } } @@ -154,7 +168,7 @@ internal sealed class RequestDispatcherOptions } } - return result.OrderBy(x => x.Order).Select(x => x.Type).ToList(); + return [.. result.OrderBy(static item => item.Order).Select(static item => item.Type)]; } } @@ -165,7 +179,7 @@ internal sealed class RequestDispatcherOptions type.GetGenericTypeDefinition() == typeof(IStreamRequestBehavior<,>)); } - private sealed class BehaviorTypeIndex(IEnumerable collection) + private sealed class BehaviorTypeIndex(IEnumerable<(Type Type, int? Order)> collection) : TypeIndex(collection, IsRequestBehaviorType) { protected override IReadOnlyList IsAssignableTo(Type @interface) @@ -176,7 +190,7 @@ internal sealed class RequestDispatcherOptions { foreach (var type in list) { - result.Add((_order[type], type)); + result.Add((EffectiveOrder(type), type)); } } @@ -191,7 +205,7 @@ internal sealed class RequestDispatcherOptions var impl = behaviour.MakeGenericType(requestType, responseType); if (impl.IsAssignableTo(@interface)) { - result.Add((_order[behaviour], impl)); + result.Add((EffectiveOrder(behaviour), impl)); } } catch (ArgumentException) @@ -204,7 +218,7 @@ internal sealed class RequestDispatcherOptions var impl = behaviour.MakeGenericType(requestType.GetGenericArguments()); if (impl.IsAssignableTo(@interface)) { - result.Add((_order[behaviour], impl)); + result.Add((EffectiveOrder(behaviour), impl)); } } catch (ArgumentException) @@ -212,7 +226,7 @@ internal sealed class RequestDispatcherOptions } } - return result.OrderBy(x => x.Order).Select(x => x.Type).ToList(); + return [.. result.OrderBy(static item => item.Order).Select(static item => item.Type)]; } } }