Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
49eafc2181
chore(ci): add manual workflow dispatch
All checks were successful
default / dotnet-default-workflow (push) Successful in 1m52s
2026-07-12 22:02:05 +02:00
f9de3a99de
feat(dispatcher): add registration-time pipeline behavior ordering via AddBehavior<T>(order:)
Pipeline behavior ordering previously depended on discovery/registration
order. Add an AddBehavior<T>(order:) API that captures the order at
registration time; the BehaviorTypeIndex now orders matching behaviors by
that explicit order (lower runs first), falling back to registration order
for ties. Removed the runtime Order property approach.
2026-07-12 21:58:49 +02:00
8099898f3d
fix(dispatcher): resolve handlers in registration order across closed and open generics
Handler/behavior resolution previously forced closed generics before open
generics regardless of registration order, which was surprising and fragile.
TypeIndex now tracks each registered type's index and orders matching
candidates by registration order, so closed/open ordering is deterministic
and follows registration.
2026-07-12 21:58:47 +02:00
a7d7e9fbaa
fix(dispatcher): throw on ambiguous handlers instead of silent .First()
Multiple handlers for the same request were previously resolved via .First(),
with ordering depending on Type.GetInterfaces() order (non-deterministic).
Now ScalarRequestInvoker/StreamRequestInvoker throw InvalidOperationException
listing the candidate handlers when more than one matches.
2026-07-12 21:58:25 +02:00
15bf9a8e56
fix(validation): make Severity meaningful in Validation.IsValid
Validation.IsValid previously counted every problem regardless of severity,
leaving Severity inert. Now IsValid only fails on Error problems, so Warning
and Info no longer invalidate the result, and IsValidFor(Severity) lets
callers set the failing-severity threshold.
2026-07-12 21:54:45 +02:00
581b0a1ced
fix(validation): return no problems for null reference instance in Rule.Validate
Rule<T,TProperty>.Validate cast a null instance to T and validated it, so a
reference T whose accessor dereferences the instance threw
NullReferenceException on a direct validator.Validate(context) with a null
instance. Guard the null-instance path and return no problems instead.
2026-07-12 21:53:26 +02:00
fd6ed64755
fix(validation): guard null comparison bounds in comparison builders
Passing a null bound to GreaterThan/LessThan/Between etc. called
value.CompareTo(null), throwing NullReferenceException for non-null
reference-type values. Short-circuit when the bound is null so the rule is
satisfied instead of crashing.
2026-07-12 21:53:26 +02:00
5b368602fe
docs(result): document that failure equality ignores error content
Result/Result<T> equality treats any two failures as equal regardless of their
Error, which was undocumented and surprising for failure-specific branching.
2026-07-12 21:53:26 +02:00
fbbe94bfa8
fix(result): Result<T> hash collision
A success result wrapping a value whose GetHashCode is 0 (e.g. Result<int>
with 0) collides with a failure result, both hashing to 0.
2026-07-12 21:25:21 +02:00
06a40184ee
fix(result): correct wrong namespace in Prelude doc comment
The doc comment referenced `Geekeey.Extensions.Result.Prelude` but the actual
namespace is `Geekeey.Request.Result`, so the suggested using directive would
not compile.
2026-07-12 18:33:32 +02:00
24 changed files with 614 additions and 44 deletions

View file

@ -1,6 +1,7 @@
name: default
on:
workflow_dispatch:
push:
branches: [ "main", "develop" ]
paths-ignore:

View file

@ -45,6 +45,19 @@ To have a consistent experience across all packages, some public interfaces have
### Changed
- **request.result:** Document that `Result`/`Result<T>` equality treats any two failures as equal, ignoring error content; branch on the `Error` directly for failure-specific logic
### Fixed
- **request.result:** Correct the namespace in the `Prelude` doc comment (`Geekeey.Extensions.Result``Geekeey.Request.Result`)
- **request.result:** Fold `IsSuccess` into `Result<T>.GetHashCode` to avoid collisions between a failure and a success value whose hash is `0`
- **request.validation:** Guard null comparison bounds in `GreaterThan`/`LessThan`/`Between` etc. so a `null` bound no longer throws (`NullReferenceException`)
- **request.validation:** Return no problems when validating a `null` instance of a reference type instead of dereferencing it (`NullReferenceException`)
- **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
- **request.dispatcher:** Throw `InvalidOperationException` listing candidates when multiple handlers match a request instead of silently resolving via `.First()`. Also throws when no handler is registered
- **request.dispatcher:** Resolve handlers/behaviors in registration order across closed and open generics instead of the fixed closed-before-open reflection order
- **request.dispatcher:** Add `AddBehavior<T>(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
### Removed
[1.0.0]: https://code.geekeey.de/geekeey/request/releases/tag/1.0.0

View file

@ -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<ScalarTestTracker>();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(ScalarTestHandler))
.AddBehavior<OrderedScalarBehaviorA>(2)
.AddBehavior<OrderedScalarBehaviorB>(1));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var tracker = provider.GetRequiredService<ScalarTestTracker>();
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()
{

View file

@ -116,7 +116,7 @@ internal sealed class ScalarDispatcherTests
}
[Test]
public async Task I_can_dispatch_a_request_async_with_an_interface_constrained_handler()
public async Task I_can_see_it_throw_on_ambiguous_concrete_and_constrained_scalar_handlers()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
@ -126,13 +126,18 @@ internal sealed class ScalarDispatcherTests
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new InterfaceInheritedScalarRequest { Name = "Constrained" };
var result = await dispatcher.DispatchAsync(request);
// Both InterfaceInheritedScalarHandler and InterfaceConstrainedScalarHandler could match.
// InterfaceInheritedScalarHandler is a concrete match for InterfaceInheritedScalarRequest.
// InterfaceConstrainedScalarHandler is an open generic match.
// Currently Dispatcher.SendAsync checks concrete handlers first.
await Assert.That(result).IsEquivalentTo("Constrained-InterfaceHandled");
// Ambiguity is no longer resolved silently with .First(); it throws instead.
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws<InvalidOperationException>();
using (Assert.Multiple())
{
await Assert.That(ex?.Message).Contains(nameof(InterfaceInheritedScalarHandler));
await Assert.That(ex?.Message).Contains("InterfaceConstrainedScalarHandler");
}
}
[Test]
@ -234,6 +239,49 @@ internal sealed class ScalarDispatcherTests
}
}
[Test]
public async Task I_can_see_it_throw_on_ambiguous_scalar_handlers()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(DuplicateScalarHandlerA))
.Add(typeof(DuplicateScalarHandlerB)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new DuplicateScalarRequest();
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws<InvalidOperationException>();
using (Assert.Multiple())
{
await Assert.That(ex?.Message).Contains(nameof(DuplicateScalarHandlerA));
await Assert.That(ex?.Message).Contains(nameof(DuplicateScalarHandlerB));
}
}
[Test]
public async Task I_can_see_candidates_ordered_by_registration_not_closed_first()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(InterfaceConstrainedScalarHandler<>))
.Add(typeof(InterfaceInheritedScalarHandler)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new InterfaceInheritedScalarRequest { Name = "Constrained" };
// The open generic handler is registered before the closed handler, so candidates must be
// listed in registration order (open first), not the old fixed closed-before-open order.
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request)).Throws<InvalidOperationException>();
var message = ex?.Message ?? string.Empty;
var openIndex = message.IndexOf("InterfaceConstrainedScalarHandler", StringComparison.Ordinal);
var closedIndex = message.IndexOf("InterfaceInheritedScalarHandler", StringComparison.Ordinal);
await Assert.That(openIndex >= 0 && closedIndex >= 0 && openIndex < closedIndex).IsTrue();
}
[Test]
public async Task I_can_see_it_throw_if_dispatcher_options_are_modified_after_build()
{

View file

@ -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<StreamTestTracker>();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(StreamTestHandler))
.AddBehavior<OrderedStreamBehaviorA>(2)
.AddBehavior<OrderedStreamBehaviorB>(1));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var tracker = provider.GetRequiredService<StreamTestTracker>();
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()
{

View file

@ -7,6 +7,26 @@ namespace Geekeey.Request.Dispatcher.Tests;
public class StreamDispatcherTests
{
[Test]
public async Task I_can_see_it_throw_on_ambiguous_stream_handlers()
{
var sc = new ServiceCollection();
sc.AddRequestDispatcher(builder => builder
.Add(typeof(DuplicateStreamHandlerA))
.Add(typeof(DuplicateStreamHandlerB)));
var provider = sc.BuildServiceProvider();
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new DuplicateStreamRequest();
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request).ToListAsync()).Throws<InvalidOperationException>();
using (Assert.Multiple())
{
await Assert.That(ex?.Message).Contains(nameof(DuplicateStreamHandlerA));
await Assert.That(ex?.Message).Contains(nameof(DuplicateStreamHandlerB));
}
}
[Test]
public async Task I_can_dispatch_a_request_async_with_an_open_generic_handler()
{
@ -121,13 +141,18 @@ public class StreamDispatcherTests
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
var request = new InterfaceInheritedStreamRequest { Name = "Constrained" };
var results = await dispatcher.DispatchAsync(request).ToListAsync();
// Both InterfaceInheritedStreamHandler and InterfaceConstrainedStreamHandler could match.
// InterfaceInheritedStreamHandler is a concrete match for InterfaceInheritedStreamRequest.
// InterfaceConstrainedStreamHandler is an open generic match.
// Currently Dispatcher checks concrete handlers first.
await Assert.That(results).IsEquivalentTo(["Constrained-InterfaceHandled-0", "Constrained-InterfaceHandled-1"]);
// Ambiguity is no longer resolved silently with .First(); it throws instead.
var ex = await Assert.That(async () => await dispatcher.DispatchAsync(request).ToListAsync()).Throws<InvalidOperationException>();
using (Assert.Multiple())
{
await Assert.That(ex?.Message).Contains(nameof(InterfaceInheritedStreamHandler));
await Assert.That(ex?.Message).Contains("InterfaceConstrainedStreamHandler");
}
}
[Test]

View file

@ -0,0 +1,24 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Dispatcher.Tests;
public sealed class DuplicateScalarRequest : IScalarRequest<string>
{
}
public sealed class DuplicateScalarHandlerA : IScalarRequestHandler<DuplicateScalarRequest, string>
{
public Task<string> HandleAsync(DuplicateScalarRequest request, CancellationToken cancellationToken)
{
return Task.FromResult("A");
}
}
public sealed class DuplicateScalarHandlerB : IScalarRequestHandler<DuplicateScalarRequest, string>
{
public Task<string> HandleAsync(DuplicateScalarRequest request, CancellationToken cancellationToken)
{
return Task.FromResult("B");
}
}

View file

@ -0,0 +1,24 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Dispatcher.Tests;
public sealed class DuplicateStreamRequest : IStreamRequest<string>
{
}
public sealed class DuplicateStreamHandlerA : IStreamRequestHandler<DuplicateStreamRequest, string>
{
public async IAsyncEnumerable<string> HandleAsync(DuplicateStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return "A";
}
}
public sealed class DuplicateStreamHandlerB : IStreamRequestHandler<DuplicateStreamRequest, string>
{
public async IAsyncEnumerable<string> HandleAsync(DuplicateStreamRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return "B";
}
}

View file

@ -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<ScalarTestRequest, string>
{
public async Task<string> HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken)
{
tracker.Log.Add("OrderedA");
return await next(request, cancellationToken);
}
}
public class OrderedScalarBehaviorB(ScalarTestTracker tracker) : IScalarRequestBehavior<ScalarTestRequest, string>
{
public async Task<string> HandleAsync(ScalarTestRequest request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken)
{
tracker.Log.Add("OrderedB");
return await next(request, cancellationToken);
}
}

View file

@ -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<StreamTestRequest, string>
{
public async IAsyncEnumerable<string> HandleAsync(StreamTestRequest request, StreamHandlerDelegate<string> 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<StreamTestRequest, string>
{
public async IAsyncEnumerable<string> HandleAsync(StreamTestRequest request, StreamHandlerDelegate<string> next, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
tracker.Log.Add("OrderedB");
await foreach (var item in next(request, cancellationToken))
{
yield return item;
}
}
}

View file

@ -70,6 +70,27 @@ public static class RequestDispatcherBuilderExtensions
return builder;
}
/// <summary>
/// Adds the specified pipeline behavior to the request dispatcher configuration, with an explicit
/// pipeline <paramref name="order"/> used to determine its position in the pipeline. Lower values run
/// first (outermost); behaviors with equal order run in registration order.
/// </summary>
/// <typeparam name="TBehavior">The behavior type to add. Must implement a request behavior interface.</typeparam>
/// <param name="builder">The <see cref="IRequestDispatcherBuilder"/> to configure.</param>
/// <param name="order">The pipeline order for this behavior. Defaults to <c>0</c>.</param>
/// <returns>The <see cref="IRequestDispatcherBuilder"/> instance for further configuration.</returns>
public static IRequestDispatcherBuilder AddBehavior<TBehavior>(this IRequestDispatcherBuilder builder, int order = 0)
where TBehavior : class
{
ArgumentNullException.ThrowIfNull(builder);
ValidateNoNestedRequestHandlers([typeof(TBehavior)]);
builder.Services.AddOptions<RequestDispatcherOptions>()
.Configure(options => options.Inspect([typeof(TBehavior)], order));
return builder;
}
/// <summary>
/// Adds the specified type to the request dispatcher configuration for inspection.
/// </summary>

View file

@ -10,24 +10,27 @@ namespace Geekeey.Request.Dispatcher;
internal sealed class RequestDispatcherOptions
{
private readonly List<Type> _search = [];
private readonly List<(Type Type, int? Order)> _search = [];
private readonly Lazy<TypeIndex> _behaviorsTypeIndex;
private readonly Lazy<TypeIndex> _handlersTypeIndex;
public RequestDispatcherOptions()
{
_behaviorsTypeIndex = new Lazy<TypeIndex>(() => new BehaviorTypeIndex(_search.Distinct()));
_handlersTypeIndex = new Lazy<TypeIndex>(() => new HandlerTypeIndex(_search.Distinct()));
_behaviorsTypeIndex = new Lazy<TypeIndex>(() => new BehaviorTypeIndex(_search.DistinctBy(x => x.Type)));
_handlersTypeIndex = new Lazy<TypeIndex>(() => new HandlerTypeIndex(_search.DistinctBy(x => x.Type)));
}
public void Inspect(IEnumerable<Type> assembly)
public void Inspect(IEnumerable<Type> 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<T> GetRequestBehaviors<T>(IServiceProvider services)
@ -46,11 +49,22 @@ internal sealed class RequestDispatcherOptions
protected readonly Dictionary<Type, List<Type>> _closedTypeInfo = [];
protected readonly List<Type> _openTypeInfo = [];
protected readonly Dictionary<Type, int> _order = [];
protected readonly Dictionary<Type, int> _explicitOrder = [];
protected TypeIndex(IEnumerable<Type> collection, Func<Type, bool> predicate)
protected TypeIndex(IEnumerable<(Type Type, int? Order)> collection, Func<Type, bool> predicate)
{
foreach (var type in collection)
var index = 0;
foreach (var (type, order) in collection)
{
_order[type] = index++;
if (order is { } explicitOrder)
{
_explicitOrder[type] = explicitOrder;
}
if (type.IsGenericTypeDefinition)
{
if (type.GetInterfaces().Any(predicate))
@ -68,6 +82,11 @@ internal sealed class RequestDispatcherOptions
}
}
protected int EffectiveOrder(Type type)
{
return _explicitOrder.TryGetValue(type, out var order) ? order : _order[type];
}
public IEnumerable<T> Resolve<T>(IServiceProvider services)
{
return (IEnumerable<T>)_cache.GetOrAdd(typeof(T), CreateResolverFactory<T>)(services);
@ -97,16 +116,19 @@ internal sealed class RequestDispatcherOptions
type.GetGenericTypeDefinition() == typeof(IStreamRequestHandler<,>));
}
private sealed class HandlerTypeIndex(IEnumerable<Type> collection)
private sealed class HandlerTypeIndex(IEnumerable<(Type Type, int? Order)> collection)
: TypeIndex(collection, IsRequestHandlerType)
{
protected override IReadOnlyList<Type> IsAssignableTo(Type @interface)
{
var result = new List<Type>();
var result = new List<(int Order, Type Type)>();
if (_closedTypeInfo.TryGetValue(@interface, out var list))
{
result.AddRange(list);
foreach (var type in list)
{
result.Add((EffectiveOrder(type), type));
}
}
var requestType = @interface.GetGenericArguments()[0];
@ -121,7 +143,7 @@ internal sealed class RequestDispatcherOptions
var impl = type.MakeGenericType(requestType);
if (impl.IsAssignableTo(@interface))
{
result.Add(impl);
result.Add((EffectiveOrder(type), impl));
}
}
catch (ArgumentException)
@ -137,7 +159,7 @@ internal sealed class RequestDispatcherOptions
var impl = type.MakeGenericType(requestType.GetGenericArguments());
if (impl.IsAssignableTo(@interface))
{
result.Add(impl);
result.Add((EffectiveOrder(type), impl));
}
}
}
@ -146,7 +168,7 @@ internal sealed class RequestDispatcherOptions
}
}
return result;
return [.. result.OrderBy(static item => item.Order).Select(static item => item.Type)];
}
}
@ -157,16 +179,19 @@ internal sealed class RequestDispatcherOptions
type.GetGenericTypeDefinition() == typeof(IStreamRequestBehavior<,>));
}
private sealed class BehaviorTypeIndex(IEnumerable<Type> collection)
private sealed class BehaviorTypeIndex(IEnumerable<(Type Type, int? Order)> collection)
: TypeIndex(collection, IsRequestBehaviorType)
{
protected override IReadOnlyList<Type> IsAssignableTo(Type @interface)
{
var result = new List<Type>();
var result = new List<(int Order, Type Type)>();
if (_closedTypeInfo.TryGetValue(@interface, out var list))
{
result.AddRange(list);
foreach (var type in list)
{
result.Add((EffectiveOrder(type), type));
}
}
var requestType = @interface.GetGenericArguments()[0];
@ -180,7 +205,7 @@ internal sealed class RequestDispatcherOptions
var impl = behaviour.MakeGenericType(requestType, responseType);
if (impl.IsAssignableTo(@interface))
{
result.Add(impl);
result.Add((EffectiveOrder(behaviour), impl));
}
}
catch (ArgumentException)
@ -193,7 +218,7 @@ internal sealed class RequestDispatcherOptions
var impl = behaviour.MakeGenericType(requestType.GetGenericArguments());
if (impl.IsAssignableTo(@interface))
{
result.Add(impl);
result.Add((EffectiveOrder(behaviour), impl));
}
}
catch (ArgumentException)
@ -201,7 +226,7 @@ internal sealed class RequestDispatcherOptions
}
}
return result;
return [.. result.OrderBy(static item => item.Order).Select(static item => item.Type)];
}
}
}

View file

@ -43,7 +43,20 @@ internal sealed class ScalarRequestInvoker<TRequest, TResponse> : ScalarRequestI
Task<TResponse> Head(IScalarRequest<TResponse> r, CancellationToken ct)
{
return options.GetRequestHandlers<IScalarRequestHandler<TRequest, TResponse>>(serviceProvider).First().HandleAsync((TRequest)r, ct);
var handlers = options.GetRequestHandlers<IScalarRequestHandler<TRequest, TResponse>>(serviceProvider).ToArray();
if (handlers.Length is 0)
{
throw new InvalidOperationException($"No handler is registered for request '{typeof(TRequest).FullName}'.");
}
if (handlers.Length > 1)
{
var candidates = string.Join(", ", handlers.Select(handler => handler.GetType().FullName));
throw new InvalidOperationException($"Multiple handlers are registered for request '{typeof(TRequest).FullName}'. Ambiguous handlers: {candidates}");
}
return handlers[0].HandleAsync((TRequest)r, ct);
}
}
}

View file

@ -47,7 +47,20 @@ internal sealed class StreamRequestInvoker<TRequest, TResponse> : StreamRequestI
IAsyncEnumerable<TResponse> Head(IStreamRequest<TResponse> r, CancellationToken ct)
{
return options.GetRequestHandlers<IStreamRequestHandler<TRequest, TResponse>>(serviceProvider).First().HandleAsync((TRequest)r, ct);
var handlers = options.GetRequestHandlers<IStreamRequestHandler<TRequest, TResponse>>(serviceProvider).ToArray();
if (handlers.Length is 0)
{
throw new InvalidOperationException($"No handler is registered for request '{typeof(TRequest).FullName}'.");
}
if (handlers.Length > 1)
{
var candidates = string.Join(", ", handlers.Select(handler => handler.GetType().FullName));
throw new InvalidOperationException($"Multiple handlers are registered for request '{typeof(TRequest).FullName}'. Ambiguous handlers: {candidates}");
}
return handlers[0].HandleAsync((TRequest)r, ct);
}
}
}

View file

@ -166,11 +166,48 @@ internal sealed class ResultEqualityTests
}
[Test]
public async Task I_can_get_hashcode_and_get_zero_for_failure()
public async Task I_can_get_hashcode_and_get_non_zero_for_failure()
{
var result = Prelude.Failure<int>("error");
await Assert.That(result.GetHashCode()).IsZero();
await Assert.That(result.GetHashCode()).IsNotEqualTo(0);
}
[Test]
public async Task I_can_get_hashcode_and_not_collide_non_generic_success_and_failure()
{
await Assert.That(Prelude.Success().GetHashCode())
.IsNotEqualTo(Prelude.Failure("error").GetHashCode());
}
[Test]
public async Task I_can_get_hashcode_and_not_collide_success_value_zero_with_failure()
{
var success = Prelude.Success(0);
var failure = Prelude.Failure<int>("error");
await Assert.That(success.Equals(failure)).IsFalse();
await Assert.That(success.GetHashCode()).IsNotEqualTo(failure.GetHashCode());
}
[Test]
public async Task I_can_equal_failure_and_failure_ignoring_error_content()
{
var a = Prelude.Failure<int>("error 1");
var b = Prelude.Failure<int>("error 2");
await Assert.That(a.Equals(b)).IsTrue();
await Assert.That(a.Error).IsNotEqualTo(b.Error);
}
[Test]
public async Task I_can_equal_non_generic_failure_and_failure_ignoring_error_content()
{
var a = Prelude.Failure("error 1");
var b = Prelude.Failure("error 2");
await Assert.That(a.Equals(b)).IsTrue();
await Assert.That(a.Error).IsNotEqualTo(b.Error);
}
[Test]
@ -199,4 +236,53 @@ internal sealed class ResultEqualityTests
await Assert.That(a.Equals(b)).IsFalse();
}
[Test]
public async Task I_can_use_equality_operator_and_get_false_for_success_and_failure()
{
var success = Prelude.Success(2);
var failure = Prelude.Failure<int>("error");
await Assert.That(success == failure).IsFalse();
await Assert.That(success != failure).IsTrue();
}
[Test]
public async Task I_can_use_equality_operator_and_get_true_for_successes_with_equal_value()
{
await Assert.That(Prelude.Success(2) == Prelude.Success(2)).IsTrue();
await Assert.That(Prelude.Success(2) != Prelude.Success(3)).IsTrue();
}
[Test]
public async Task I_can_use_equality_operator_and_compare_result_to_value()
{
await Assert.That(Prelude.Success(2) == 2).IsTrue();
await Assert.That(Prelude.Success(2) != 3).IsTrue();
await Assert.That(Prelude.Failure<int>("x") == 2).IsFalse();
}
[Test]
public async Task I_can_equal_object_and_get_false_for_null()
{
object result = Prelude.Success(2);
await Assert.That(result.Equals(null)).IsFalse();
}
[Test]
public async Task I_can_equal_object_and_get_false_for_wrong_type()
{
object result = Prelude.Success(2);
await Assert.That(result.Equals("not a result")).IsFalse();
}
[Test]
public async Task I_can_equal_object_and_get_true_for_boxed_equal_result()
{
object result = Prelude.Success(2);
await Assert.That(result.Equals(Prelude.Success(2))).IsTrue();
}
}

View file

@ -9,7 +9,7 @@ namespace Geekeey.Request.Result;
/// A class containing various utility methods, a 'prelude' to the rest of the library.
/// </summary>
/// <remarks>
/// This class is meant to be imported statically, e.g. <c>using static Geekeey.Extensions.Result.Prelude;</c>.
/// This class is meant to be imported statically, e.g. <c>using static Geekeey.Request.Result.Prelude;</c>.
/// Recommended to be imported globally via a global using statement.
/// </remarks>
public static class Prelude

View file

@ -9,7 +9,15 @@ namespace Geekeey.Request.Result;
public partial class Result : IEquatable<Result>
{
/// <inheritdoc/>
/// <summary>
/// Checks whether two results are equal. Results are equal if both are success or both are failure.
/// </summary>
/// <remarks>
/// Two failures are considered equal regardless of their <see cref="Error"/> content. Equality therefore
/// ignores the error; if you need to branch on a specific failure, compare the <see cref="Error"/> values
/// directly instead of relying on equality.
/// </remarks>
/// <param name="other">The result to check for equality with the current result.</param>
[Pure]
public bool Equals(Result? other)
{
@ -49,7 +57,8 @@ public partial class Result : IEquatable<Result>
public partial class Result : IEqualityOperators<Result, Result, bool>
{
/// <summary>
/// Checks whether two results are equal. Results are equal if they are both success or both failure.
/// Checks whether two results are equal. Results are equal if they are both success or both failure. Two
/// failures are equal regardless of their error content.
/// </summary>
/// <param name="a">The first result to compare.</param>
/// <param name="b">The second result to compare.</param>
@ -79,6 +88,11 @@ public partial class Result<T> : IEquatable<Result<T>>, IEquatable<T>
/// Checks whether the result is equal to another result. Results are equal if both results are success values and
/// the success values are equal, or if both results are failures.
/// </summary>
/// <remarks>
/// Two failures are considered equal regardless of their <see cref="Error"/> content. Equality therefore
/// ignores the error; if you need to branch on a specific failure, compare the <see cref="Error"/> values
/// directly instead of relying on equality.
/// </remarks>
/// <param name="other">The result to check for equality with the current result.</param>
[Pure]
public bool Equals(Result<T>? other)
@ -181,7 +195,12 @@ public partial class Result<T> : IEquatable<Result<T>>, IEquatable<T>
return comparer.GetHashCode(result.Value);
}
return 0;
if (result is { IsSuccess: true, Value: null })
{
return 0;
}
return result.Error?.GetHashCode() ?? 0;
}
}
@ -189,7 +208,7 @@ public partial class Result<T> : IEqualityOperators<Result<T>, Result<T>, bool>,
{
/// <summary>
/// Checks whether two results are equal. Results are equal if both results are success values and the success
/// values are equal, or if both results are failures.
/// values are equal, or if both results are failures. Two failures are equal regardless of their error content.
/// </summary>
/// <param name="a">The first result to compare.</param>
/// <param name="b">The second result to compare.</param>

View file

@ -314,6 +314,71 @@ internal sealed class RuleBuilderExtensionsTests
.Throws<ArgumentOutOfRangeException>();
}
[Test]
public async Task I_can_validate_greater_than_with_null_bound_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.GreaterThan(null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_greater_than_or_equal_to_with_null_bound_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.GreaterThanOrEqualTo(null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_less_than_with_null_bound_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.LessThan(null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_less_than_or_equal_to_with_null_bound_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.LessThanOrEqualTo(null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_between_with_null_bounds_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.Between(null, null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_see_it_throw_for_invalid_between_configuration()
{

View file

@ -0,0 +1,49 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class ValidationSeverityTests
{
[Test]
public async Task I_can_have_warning_problems_not_invalidate_by_default()
{
var validator = new PropertyValidator<Person, string?>(person
=> person.Name, rule => rule.Must(value => !string.IsNullOrWhiteSpace(value), "Name is required.")
.WithSeverity(Severity.Warning));
var result = validator.Validate(new Person { Name = "" });
await Assert.That(result.Problems).Count().IsEqualTo(1);
await Assert.That(result.IsValid).IsTrue();
await Assert.That(result.IsValidFor(Severity.Warning)).IsFalse();
await Assert.That(result.IsValidFor(Severity.Info)).IsFalse();
}
[Test]
public async Task I_can_have_info_problems_only_invalidate_at_info_threshold()
{
var validator = new PropertyValidator<Person, string?>(person
=> person.Name, rule => rule.Must(value => !string.IsNullOrWhiteSpace(value), "Name is required.")
.WithSeverity(Severity.Info));
var result = validator.Validate(new Person { Name = "" });
await Assert.That(result.Problems).Count().IsEqualTo(1);
await Assert.That(result.IsValid).IsTrue();
await Assert.That(result.IsValidFor(Severity.Warning)).IsTrue();
await Assert.That(result.IsValidFor(Severity.Info)).IsFalse();
}
[Test]
public async Task I_can_have_error_problems_invalidate_by_default()
{
var validator = new PropertyValidator<Person, string?>(person
=> person.Name, rule => rule.Must(value => !string.IsNullOrWhiteSpace(value), "Name is required."));
var result = validator.Validate(new Person { Name = "" });
await Assert.That(result.IsValid).IsFalse();
await Assert.That(result.IsValidFor(Severity.Error)).IsFalse();
}
}

View file

@ -14,7 +14,8 @@ internal sealed class ValidatorTests
var result = validator.Validate(new Person { Name = "" });
await Assert.That(result.IsValid).IsFalse();
await Assert.That(result.IsValid).IsTrue();
await Assert.That(result.IsValidFor(Severity.Warning)).IsFalse();
await Assert.That(result.Problems).Count().IsEqualTo(1);
var problem = result.Problems.Single();
@ -39,12 +40,24 @@ internal sealed class ValidatorTests
using (Assert.Multiple())
{
await Assert.That(invalid.IsValid).IsFalse();
await Assert.That(invalid.IsValid).IsTrue();
await Assert.That(invalid.IsValidFor(Severity.Warning)).IsFalse();
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(valid.Problems).IsEmpty();
}
}
[Test]
public async Task I_can_validate_with_a_null_instance_without_throwing()
{
var validator = new PropertyValidator<Person?, string?>(person
=> person!.Name, rule => rule.Must(value => value is not null, "Name is required."));
var result = validator.Validate((Person?)null);
await Assert.That(result.IsValid).IsTrue();
}
[Test]
public async Task I_can_compose_nested_validators_and_aggregate_property_paths()
{

View file

@ -0,0 +1,19 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Request.Validation.Tests;
internal sealed class ComparableModel
{
public ComparableValue? Value { get; init; }
}
internal sealed class ComparableValue : IComparable<ComparableValue?>
{
public int Number { get; init; }
public int CompareTo(ComparableValue? other)
{
return Number.CompareTo(other!.Number);
}
}

View file

@ -46,7 +46,7 @@ internal abstract record Rule<T, TProperty> : Rule
if (context.Instance is null && default(T) is null)
{
return Validate((T)context.Instance!, context);
return [];
}
var actualType = context.Instance?.GetType().FullName ?? "null";

View file

@ -170,7 +170,7 @@ public static class RuleBuilderExtensions
{
ArgumentNullException.ThrowIfNull(rule);
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) > 0,
return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) > 0,
$"Value must be greater than {comparisonValue}.");
}
@ -189,7 +189,7 @@ public static class RuleBuilderExtensions
{
ArgumentNullException.ThrowIfNull(rule);
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) >= 0,
return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) >= 0,
$"Value must be greater than or equal to {comparisonValue}.");
}
@ -208,7 +208,7 @@ public static class RuleBuilderExtensions
{
ArgumentNullException.ThrowIfNull(rule);
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) < 0,
return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) < 0,
$"Value must be less than {comparisonValue}.");
}
@ -227,7 +227,7 @@ public static class RuleBuilderExtensions
{
ArgumentNullException.ThrowIfNull(rule);
return rule.Must(value => IsNull(value) || value.CompareTo(comparisonValue) <= 0,
return rule.Must(value => IsNull(value) || IsNull(comparisonValue) || value.CompareTo(comparisonValue) <= 0,
$"Value must be less than or equal to {comparisonValue}.");
}
@ -254,7 +254,9 @@ public static class RuleBuilderExtensions
"Maximum value must be greater than or equal to minimum value.");
}
return rule.Must(value => IsNull(value) || (value.CompareTo(minValue) >= 0 && value.CompareTo(maxValue) <= 0),
return rule.Must(value => IsNull(value)
|| ((minValue is null || value.CompareTo(minValue) >= 0)
&& (maxValue is null || value.CompareTo(maxValue) <= 0)),
$"Value must be between {minValue} and {maxValue}.");
}

View file

@ -23,7 +23,27 @@ public sealed class Validation
/// <summary>
/// Whether the validation was successful.
/// </summary>
public bool IsValid => Problems.Count is 0;
/// <remarks>
/// A validation is considered successful unless it contains at least one problem with
/// <see cref="Severity.Error"/>. Problems of lower severity (<see cref="Severity.Warning"/> or
/// <see cref="Severity.Info"/>) do not invalidate the result. Use <see cref="IsValidFor"/>
/// to control which severities are treated as failures.
/// </remarks>
public bool IsValid => IsValidFor(Severity.Error);
/// <summary>
/// Whether the validation was successful, treating problems at or above the given severity as failures.
/// </summary>
/// <param name="minimum">The minimum severity that should be considered a failure.</param>
/// <remarks>
/// A problem invalidates the validation when its <see cref="Severity"/> is at least as severe as
/// <paramref name="minimum"/>. For example, <c>IsValidFor(Severity.Warning)</c> fails on errors and
/// warnings but not on informational problems.
/// </remarks>
public bool IsValidFor(Severity minimum)
{
return !Problems.Any(problem => problem.Severity <= minimum);
}
/// <summary>
/// The problems that were found during validation.