Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ed8ac913b6 |
27 changed files with 814 additions and 7 deletions
|
|
@ -42,6 +42,7 @@ To have a consistent experience across all packages, some public interfaces have
|
|||
### Added
|
||||
|
||||
- **request.validation:** Support `PropertyPath` JSON converter for string values and dictionary property names
|
||||
- **request.dispatcher:** Notification (pub/sub) support with `INotification`
|
||||
|
||||
### Changed
|
||||
|
||||
|
|
|
|||
74
src/request.dispatcher.tests/NotificationBehaviourTests.cs
Normal file
74
src/request.dispatcher.tests/NotificationBehaviourTests.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
internal sealed class NotificationBehaviourTests
|
||||
{
|
||||
[Test]
|
||||
public async Task I_can_execute_a_single_behaviour_wrapping_the_publisher()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<NotificationTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(TestNotificationHandler))
|
||||
.Add(typeof(TestNotificationBehavior))
|
||||
.UseSequentialNotificationPublisher());
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
var tracker = provider.GetRequiredService<NotificationTestTracker>();
|
||||
|
||||
await dispatcher.PublishAsync(new TestNotification { Value = "Wrapped" });
|
||||
|
||||
await Assert.That(tracker.Log).Count().IsEqualTo(3);
|
||||
await Assert.That(tracker.Log[0]).IsEquivalentTo("Before");
|
||||
await Assert.That(tracker.Log[1]).IsEquivalentTo("Handler:Wrapped");
|
||||
await Assert.That(tracker.Log[2]).IsEquivalentTo("After");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_chain_behaviours_in_registration_order()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<NotificationTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(TestNotificationHandler))
|
||||
.Add(typeof(NotificationChainedBehaviour1))
|
||||
.Add(typeof(NotificationChainedBehaviour2))
|
||||
.UseSequentialNotificationPublisher());
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
var tracker = provider.GetRequiredService<NotificationTestTracker>();
|
||||
|
||||
await dispatcher.PublishAsync(new TestNotification { Value = "Chain" });
|
||||
|
||||
// behaviours.Reverse() wrapping means Behaviour1 wraps Behaviour2 wraps publisher
|
||||
// Execution: Behaviour1 before -> Behaviour2 before -> handler -> Behaviour2 after -> Behaviour1 after
|
||||
await Assert.That(tracker.Log).Count().IsEqualTo(3);
|
||||
await Assert.That(tracker.Log[0]).IsEquivalentTo("Behaviour1");
|
||||
await Assert.That(tracker.Log[1]).IsEquivalentTo("Behaviour2");
|
||||
await Assert.That(tracker.Log[2]).IsEquivalentTo("Handler:Chain");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_see_a_behaviour_that_exits_early_blocks_subsequent_execution()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<NotificationTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(TestNotificationHandler))
|
||||
.Add(typeof(BlockingNotificationBehavior))
|
||||
.UseSequentialNotificationPublisher());
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
var tracker = provider.GetRequiredService<NotificationTestTracker>();
|
||||
|
||||
await dispatcher.PublishAsync(new TestNotification { Value = "Blocked" });
|
||||
|
||||
// BlockingNotificationBehavior does not call next, so the handler should not execute.
|
||||
await Assert.That(tracker.Log).Count().IsEqualTo(1);
|
||||
await Assert.That(tracker.Log[0]).IsEquivalentTo("Blocked");
|
||||
}
|
||||
}
|
||||
143
src/request.dispatcher.tests/NotificationDispatcherTests.cs
Normal file
143
src/request.dispatcher.tests/NotificationDispatcherTests.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
internal sealed class NotificationDispatcherTests
|
||||
{
|
||||
[Test]
|
||||
public async Task I_can_publish_a_notification_to_a_single_handler()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<NotificationTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(TestNotificationHandler))
|
||||
.UseSequentialNotificationPublisher());
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
var tracker = provider.GetRequiredService<NotificationTestTracker>();
|
||||
|
||||
await dispatcher.PublishAsync(new TestNotification { Value = "Hello" });
|
||||
|
||||
await Assert.That(tracker.Log).Count().IsEqualTo(1);
|
||||
await Assert.That(tracker.Log[0]).IsEquivalentTo("Handler:Hello");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_publish_a_notification_to_multiple_handlers()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<NotificationTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(TestNotificationHandler))
|
||||
.Add(typeof(SecondTestNotificationHandler))
|
||||
.UseSequentialNotificationPublisher());
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
var tracker = provider.GetRequiredService<NotificationTestTracker>();
|
||||
|
||||
await dispatcher.PublishAsync(new TestNotification { Value = "Multi" });
|
||||
|
||||
await Assert.That(tracker.Log).Count().IsEqualTo(2);
|
||||
await Assert.That(tracker.Log[0]).IsEquivalentTo("Handler:Multi");
|
||||
await Assert.That(tracker.Log[1]).IsEquivalentTo("SecondHandler:Multi");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_publish_a_notification_with_zero_handlers()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<NotificationTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.UseSequentialNotificationPublisher());
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
|
||||
await dispatcher.PublishAsync(new TestNotification { Value = "NoOp" });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_publish_a_notification_to_an_open_generic_handler()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<NotificationTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(OpenNotificationHandler<>))
|
||||
.UseSequentialNotificationPublisher());
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
var tracker = provider.GetRequiredService<NotificationTestTracker>();
|
||||
|
||||
await dispatcher.PublishAsync(new TestNotification { Value = "Open" });
|
||||
|
||||
await Assert.That(tracker.Log).Count().IsEqualTo(1);
|
||||
await Assert.That(tracker.Log[0]).IsEquivalentTo("OpenHandler:TestNotification");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_see_the_exception_propagate_from_a_failing_handler()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<NotificationTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(FailingNotificationHandler))
|
||||
.Add(typeof(SecondTestNotificationHandler))
|
||||
.UseSequentialNotificationPublisher());
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
dispatcher.PublishAsync(new TestNotification { Value = "Fail" }));
|
||||
|
||||
await Assert.That(ex?.Message).IsEquivalentTo("Handler failed");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_see_subsequent_handlers_are_skipped_after_a_handler_failure()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<NotificationTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(FailingNotificationHandler))
|
||||
.Add(typeof(SecondTestNotificationHandler))
|
||||
.UseSequentialNotificationPublisher());
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
var tracker = provider.GetRequiredService<NotificationTestTracker>();
|
||||
|
||||
try
|
||||
{
|
||||
await dispatcher.PublishAsync(new TestNotification { Value = "Skip" });
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
}
|
||||
|
||||
// Failing handler throws, so the second handler should not be executed
|
||||
await Assert.That(tracker.Log).IsEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_see_inherited_notification_types_are_handled()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<NotificationTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(OpenNotificationHandler<>))
|
||||
.UseSequentialNotificationPublisher());
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
var tracker = provider.GetRequiredService<NotificationTestTracker>();
|
||||
|
||||
await dispatcher.PublishAsync(new DerivedNotification { Value = "Derived" });
|
||||
|
||||
await Assert.That(tracker.Log).Count().IsEqualTo(1);
|
||||
await Assert.That(tracker.Log[0]).IsEquivalentTo("OpenHandler:DerivedNotification");
|
||||
}
|
||||
|
||||
private sealed class DerivedNotification : TestNotification
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
internal sealed class NotificationPublisherStrategyTests
|
||||
{
|
||||
[Test]
|
||||
public async Task I_can_use_a_custom_publisher_via_per_call_parameter()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<NotificationTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(TestNotificationHandler)));
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
var tracker = provider.GetRequiredService<NotificationTestTracker>();
|
||||
|
||||
var customPub = new CustomNotificationPublisher(tracker);
|
||||
|
||||
await dispatcher.PublishAsync(new TestNotification { Value = "PerCall" }, publisher: customPub);
|
||||
|
||||
await Assert.That(tracker.Log).Count().IsEqualTo(2);
|
||||
await Assert.That(tracker.Log[0]).IsEquivalentTo("CustomPublisher");
|
||||
await Assert.That(tracker.Log[1]).IsEquivalentTo("Handler:PerCall");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_use_a_custom_publisher_via_di_registration()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<NotificationTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(TestNotificationHandler))
|
||||
.UseNotificationPublisher<CustomNotificationPublisher>());
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
var tracker = provider.GetRequiredService<NotificationTestTracker>();
|
||||
|
||||
await dispatcher.PublishAsync(new TestNotification { Value = "DI" });
|
||||
|
||||
await Assert.That(tracker.Log).Count().IsEqualTo(2);
|
||||
await Assert.That(tracker.Log[0]).IsEquivalentTo("CustomPublisher");
|
||||
await Assert.That(tracker.Log[1]).IsEquivalentTo("Handler:DI");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_use_the_default_sequential_publisher_when_no_custom_publisher_is_registered()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<NotificationTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(TestNotificationHandler))
|
||||
.UseSequentialNotificationPublisher());
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
var tracker = provider.GetRequiredService<NotificationTestTracker>();
|
||||
|
||||
await dispatcher.PublishAsync(new TestNotification { Value = "Default" });
|
||||
|
||||
await Assert.That(tracker.Log).Count().IsEqualTo(1);
|
||||
await Assert.That(tracker.Log[0]).IsEquivalentTo("Handler:Default");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_see_per_call_publisher_wins_over_di_registered_publisher()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<NotificationTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(TestNotificationHandler))
|
||||
.UseNotificationPublisher<CustomNotificationPublisher>());
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
var tracker = provider.GetRequiredService<NotificationTestTracker>();
|
||||
|
||||
// Use a different publisher per-call that doesn't log "CustomPublisher"
|
||||
var otherPub = new OtherNotificationPublisher(tracker);
|
||||
|
||||
await dispatcher.PublishAsync(new TestNotification { Value = "Override" }, publisher: otherPub);
|
||||
|
||||
await Assert.That(tracker.Log).Count().IsEqualTo(2);
|
||||
await Assert.That(tracker.Log[0]).IsEquivalentTo("OtherPublisher");
|
||||
await Assert.That(tracker.Log[1]).IsEquivalentTo("Handler:Override");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_use_the_parallel_publisher_via_di_registration()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<NotificationTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(TestNotificationHandler))
|
||||
.Add(typeof(SecondTestNotificationHandler))
|
||||
.UseParallelNotificationPublisher());
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
var tracker = provider.GetRequiredService<NotificationTestTracker>();
|
||||
|
||||
await dispatcher.PublishAsync(new TestNotification { Value = "Parallel" });
|
||||
|
||||
await Assert.That(tracker.Log).Count().IsEqualTo(2);
|
||||
await Assert.That(tracker.Log).Contains("Handler:Parallel");
|
||||
await Assert.That(tracker.Log).Contains("SecondHandler:Parallel");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_use_the_parallel_publisher_with_max_degree_of_parallelism()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<NotificationTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(TestNotificationHandler))
|
||||
.Add(typeof(SecondTestNotificationHandler))
|
||||
.UseParallelNotificationPublisher(maxDegreeOfParallelism: 1));
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
var tracker = provider.GetRequiredService<NotificationTestTracker>();
|
||||
|
||||
await dispatcher.PublishAsync(new TestNotification { Value = "SequentialParallel" });
|
||||
|
||||
await Assert.That(tracker.Log).Count().IsEqualTo(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task I_can_see_the_parallel_publisher_collects_all_exceptions()
|
||||
{
|
||||
var sc = new ServiceCollection();
|
||||
sc.AddSingleton<NotificationTestTracker>();
|
||||
sc.AddRequestDispatcher(builder => builder
|
||||
.Add(typeof(FailingNotificationHandler))
|
||||
.Add(typeof(SecondTestNotificationHandler))
|
||||
.UseParallelNotificationPublisher());
|
||||
var provider = sc.BuildServiceProvider();
|
||||
var dispatcher = provider.GetRequiredService<IRequestDispatcher>();
|
||||
var tracker = provider.GetRequiredService<NotificationTestTracker>();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<AggregateException>(async () =>
|
||||
await dispatcher.PublishAsync(new TestNotification { Value = "Fail" }));
|
||||
|
||||
await Assert.That(ex?.InnerExceptions).Count().IsEqualTo(1);
|
||||
await Assert.That(ex?.InnerExceptions[0].Message).IsEquivalentTo("Handler failed");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
public class BlockingNotificationBehavior(NotificationTestTracker tracker) : INotificationBehavior<TestNotification>
|
||||
{
|
||||
public Task HandleAsync(TestNotification notification, NotificationHandlerDelegate<TestNotification> next, CancellationToken cancellationToken)
|
||||
{
|
||||
tracker.Log.Add("Blocked");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
public class CustomNotificationPublisher(NotificationTestTracker tracker) : INotificationPublisher
|
||||
{
|
||||
public async Task PublishAsync<TNotification>(IEnumerable<INotificationHandler<TNotification>> handlers, TNotification notification, CancellationToken cancellationToken) where TNotification : INotification
|
||||
{
|
||||
tracker.Log.Add("CustomPublisher");
|
||||
|
||||
foreach (var handler in handlers)
|
||||
{
|
||||
await handler.HandleAsync(notification, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
public class FailingNotificationHandler : INotificationHandler<TestNotification>
|
||||
{
|
||||
public Task HandleAsync(TestNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new InvalidOperationException("Handler failed");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
public class NotificationChainedBehaviour1(NotificationTestTracker tracker) : INotificationBehavior<TestNotification>
|
||||
{
|
||||
public async Task HandleAsync(TestNotification notification, NotificationHandlerDelegate<TestNotification> next, CancellationToken cancellationToken)
|
||||
{
|
||||
tracker.Log.Add("Behaviour1");
|
||||
await next(notification, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
public class NotificationChainedBehaviour2(NotificationTestTracker tracker) : INotificationBehavior<TestNotification>
|
||||
{
|
||||
public async Task HandleAsync(TestNotification notification, NotificationHandlerDelegate<TestNotification> next, CancellationToken cancellationToken)
|
||||
{
|
||||
tracker.Log.Add("Behaviour2");
|
||||
await next(notification, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
public class NotificationTestTracker
|
||||
{
|
||||
public List<string> Log { get; } = [];
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
public class OpenNotificationHandler<T>(NotificationTestTracker tracker) : INotificationHandler<T>
|
||||
where T : INotification
|
||||
{
|
||||
public Task HandleAsync(T notification, CancellationToken cancellationToken)
|
||||
{
|
||||
tracker.Log.Add($"OpenHandler:{typeof(T).Name}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
public class OtherNotificationPublisher(NotificationTestTracker tracker) : INotificationPublisher
|
||||
{
|
||||
public async Task PublishAsync<TNotification>(IEnumerable<INotificationHandler<TNotification>> handlers, TNotification notification, CancellationToken cancellationToken) where TNotification : INotification
|
||||
{
|
||||
tracker.Log.Add("OtherPublisher");
|
||||
|
||||
foreach (var handler in handlers)
|
||||
{
|
||||
await handler.HandleAsync(notification, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
public class SecondTestNotificationHandler(NotificationTestTracker tracker) : INotificationHandler<TestNotification>
|
||||
{
|
||||
public Task HandleAsync(TestNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
tracker.Log.Add($"SecondHandler:{notification.Value}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
public class TestNotification : INotification
|
||||
{
|
||||
public string? Value { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
public class TestNotificationBehavior(NotificationTestTracker tracker) : INotificationBehavior<TestNotification>
|
||||
{
|
||||
public async Task HandleAsync(TestNotification notification, NotificationHandlerDelegate<TestNotification> next, CancellationToken cancellationToken)
|
||||
{
|
||||
tracker.Log.Add("Before");
|
||||
await next(notification, cancellationToken);
|
||||
tracker.Log.Add("After");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher.Tests;
|
||||
|
||||
public class TestNotificationHandler(NotificationTestTracker tracker) : INotificationHandler<TestNotification>
|
||||
{
|
||||
public Task HandleAsync(TestNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
tracker.Log.Add($"Handler:{notification.Value}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
13
src/request.dispatcher/INotification.cs
Normal file
13
src/request.dispatcher/INotification.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a notification message that can be published to zero or more
|
||||
/// <see cref="INotificationHandler{TNotification}"/> handlers.
|
||||
/// This interface serves as a marker for distinguishing notification types.
|
||||
/// </summary>
|
||||
public interface INotification
|
||||
{
|
||||
}
|
||||
34
src/request.dispatcher/INotificationBehavior.cs
Normal file
34
src/request.dispatcher/INotificationBehavior.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
#pragma warning disable CA1711
|
||||
#pragma warning disable CA1716
|
||||
|
||||
namespace Geekeey.Request.Dispatcher;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a behavior in the notification pipeline, allowing interception, modification,
|
||||
/// or chaining of notification processing.
|
||||
/// </summary>
|
||||
/// <typeparam name="TNotification">The type of notification being processed. Must implement <see cref="INotification"/>.</typeparam>
|
||||
public interface INotificationBehavior<TNotification> where TNotification : INotification
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles the asynchronous processing of a notification, allowing behavior customization
|
||||
/// such as interception, modification, or chaining of the notification pipeline.
|
||||
/// </summary>
|
||||
/// <param name="notification">The notification instance being processed.</param>
|
||||
/// <param name="next">The next delegate in the pipeline to execute after the custom behavior.</param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task HandleAsync(TNotification notification, NotificationHandlerDelegate<TNotification> next, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the delegate responsible for handling notifications in a pipeline.
|
||||
/// </summary>
|
||||
/// <typeparam name="TNotification">The type of notification being processed. Must implement <see cref="INotification"/>.</typeparam>
|
||||
/// <param name="notification">The notification instance being processed.</param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public delegate Task NotificationHandlerDelegate<TNotification>(TNotification notification, CancellationToken cancellationToken) where TNotification : INotification;
|
||||
19
src/request.dispatcher/INotificationHandler.cs
Normal file
19
src/request.dispatcher/INotificationHandler.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a handler for processing notifications of a specific type.
|
||||
/// </summary>
|
||||
/// <typeparam name="TNotification">The type of notification to handle. Must implement <see cref="INotification"/>.</typeparam>
|
||||
public interface INotificationHandler<in TNotification> where TNotification : INotification
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles the specified notification asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="notification">The notification object to process.</param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task HandleAsync(TNotification notification, CancellationToken cancellationToken);
|
||||
}
|
||||
20
src/request.dispatcher/INotificationPublisher.cs
Normal file
20
src/request.dispatcher/INotificationPublisher.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a strategy for publishing notifications to registered handlers.
|
||||
/// </summary>
|
||||
public interface INotificationPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Publishes the specified notification to the given collection of handlers.
|
||||
/// </summary>
|
||||
/// <typeparam name="TNotification">The type of notification being published. Must implement <see cref="INotification"/>.</typeparam>
|
||||
/// <param name="handlers">The list of handlers to invoke.</param>
|
||||
/// <param name="notification">The notification instance to publish.</param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous publish operation.</returns>
|
||||
Task PublishAsync<TNotification>(IEnumerable<INotificationHandler<TNotification>> handlers, TNotification notification, CancellationToken cancellationToken) where TNotification : INotification;
|
||||
}
|
||||
|
|
@ -25,4 +25,13 @@ public interface IRequestDispatcher
|
|||
/// <typeparam name="TResponse">Response type</typeparam>
|
||||
/// <returns>The created async enumerable, representing the stream of responses.</returns>
|
||||
IAsyncEnumerable<TResponse> DispatchAsync<TResponse>(IStreamRequest<TResponse> request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously publish a notification to all registered handlers.
|
||||
/// </summary>
|
||||
/// <param name="notification">Notification object</param>
|
||||
/// <param name="publisher">Optional custom notification publisher</param>
|
||||
/// <param name="cancellationToken">Optional cancellation token</param>
|
||||
/// <returns>A task that represents the publish operation.</returns>
|
||||
Task PublishAsync(INotification notification, INotificationPublisher? publisher = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
|
|||
50
src/request.dispatcher/NotificationInvoker.cs
Normal file
50
src/request.dispatcher/NotificationInvoker.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
using System.Diagnostics;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Geekeey.Request.Dispatcher;
|
||||
|
||||
internal abstract class NotificationInvoker
|
||||
{
|
||||
public abstract Task HandleAsync(object notification, IServiceProvider serviceProvider, INotificationPublisher? publisher, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class NotificationInvoker<TNotification> : NotificationInvoker
|
||||
where TNotification : INotification
|
||||
{
|
||||
public override Task HandleAsync(object notification, IServiceProvider serviceProvider, INotificationPublisher? publisher, CancellationToken cancellationToken)
|
||||
{
|
||||
return HandleAsync((TNotification)notification, serviceProvider, publisher, cancellationToken);
|
||||
}
|
||||
|
||||
public Task HandleAsync(TNotification notification, IServiceProvider serviceProvider, INotificationPublisher? publisher, CancellationToken cancellationToken)
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<IOptions<RequestDispatcherOptions>>().Value;
|
||||
|
||||
var handlers = options.GetRequestHandlers<INotificationHandler<TNotification>>(serviceProvider).ToList();
|
||||
|
||||
publisher ??= serviceProvider.GetRequiredService<INotificationPublisher>();
|
||||
|
||||
var behaviors = options.GetRequestBehaviors<INotificationBehavior<TNotification>>(serviceProvider);
|
||||
|
||||
var pipeline = behaviors
|
||||
.Reverse()
|
||||
.Aggregate((NotificationHandlerDelegate<TNotification>)Terminal, Chain);
|
||||
|
||||
return pipeline(notification, cancellationToken);
|
||||
|
||||
static NotificationHandlerDelegate<TNotification> Chain(NotificationHandlerDelegate<TNotification> next, INotificationBehavior<TNotification> behavior)
|
||||
{
|
||||
return [StackTraceHidden](n, ct) => behavior.HandleAsync(n, next, ct);
|
||||
}
|
||||
|
||||
Task Terminal(TNotification n, CancellationToken ct)
|
||||
{
|
||||
return publisher.PublishAsync(handlers, n, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
47
src/request.dispatcher/ParallelNotificationPublisher.cs
Normal file
47
src/request.dispatcher/ParallelNotificationPublisher.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher;
|
||||
|
||||
internal sealed class ParallelNotificationPublisher : INotificationPublisher
|
||||
{
|
||||
private readonly int _maxDegreeOfParallelism;
|
||||
|
||||
public ParallelNotificationPublisher(int? maxDegreeOfParallelism = null)
|
||||
{
|
||||
_maxDegreeOfParallelism = maxDegreeOfParallelism ?? Environment.ProcessorCount;
|
||||
}
|
||||
|
||||
public async Task PublishAsync<TNotification>(IEnumerable<INotificationHandler<TNotification>> handlers, TNotification notification, CancellationToken cancellationToken)
|
||||
where TNotification : INotification
|
||||
{
|
||||
var exceptions = new List<Exception>();
|
||||
var @lock = new object();
|
||||
|
||||
await Parallel.ForEachAsync(handlers,
|
||||
new ParallelOptions
|
||||
{
|
||||
CancellationToken = cancellationToken,
|
||||
MaxDegreeOfParallelism = _maxDegreeOfParallelism,
|
||||
},
|
||||
async (handler, ct) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await handler.HandleAsync(notification, ct);
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
{
|
||||
lock (@lock)
|
||||
{
|
||||
exceptions.Add(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (exceptions.Count > 0)
|
||||
{
|
||||
throw new AggregateException(exceptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ internal sealed class RequestDispatcher : IRequestDispatcher
|
|||
{
|
||||
private static readonly ConcurrentDictionary<(Type, Type), ScalarRequestInvoker> ScalarRequestHandlers = new();
|
||||
private static readonly ConcurrentDictionary<(Type, Type), StreamRequestInvoker> StreamRequestHandlers = new();
|
||||
private static readonly ConcurrentDictionary<Type, NotificationInvoker> NotificationHandlers = new();
|
||||
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
|
|
@ -24,6 +25,7 @@ internal sealed class RequestDispatcher : IRequestDispatcher
|
|||
{
|
||||
ScalarRequestHandlers.Clear();
|
||||
StreamRequestHandlers.Clear();
|
||||
NotificationHandlers.Clear();
|
||||
}
|
||||
|
||||
public Task<TResponse> DispatchAsync<TResponse>(IScalarRequest<TResponse> request, CancellationToken cancellationToken = default)
|
||||
|
|
@ -51,4 +53,17 @@ internal sealed class RequestDispatcher : IRequestDispatcher
|
|||
|
||||
return ((StreamRequestInvoker<TResponse>)handler).HandleAsync(request, _serviceProvider, cancellationToken);
|
||||
}
|
||||
|
||||
public Task PublishAsync(INotification notification, INotificationPublisher? publisher = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(notification);
|
||||
|
||||
var handler = NotificationHandlers.GetOrAdd(notification.GetType(), static key =>
|
||||
{
|
||||
var type = typeof(NotificationInvoker<>).MakeGenericType(key);
|
||||
return (NotificationInvoker)Activator.CreateInstance(type)!;
|
||||
});
|
||||
|
||||
return handler.HandleAsync(notification, _serviceProvider, publisher, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,6 +150,70 @@ public static class RequestDispatcherBuilderExtensions
|
|||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers <typeparamref name="T"/> as the default <see cref="INotificationPublisher"/>
|
||||
/// in the dependency injection container.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type implementing <see cref="INotificationPublisher"/> to register.</typeparam>
|
||||
/// <param name="builder">The <see cref="IRequestDispatcherBuilder"/> to configure.</param>
|
||||
/// <param name="lifetime">The lifetime with which the publisher is registered (default <see cref="ServiceLifetime.Transient"/>).</param>
|
||||
/// <returns>The <see cref="IRequestDispatcherBuilder"/> instance for further configuration.</returns>
|
||||
public static IRequestDispatcherBuilder UseNotificationPublisher<T>(this IRequestDispatcherBuilder builder, ServiceLifetime lifetime = ServiceLifetime.Transient)
|
||||
where T : class, INotificationPublisher
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.Services.Replace(new ServiceDescriptor(typeof(INotificationPublisher), typeof(T), lifetime));
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the <see cref="SequentialNotificationPublisher"/> as the default
|
||||
/// <see cref="INotificationPublisher"/>.
|
||||
/// </summary>
|
||||
/// <param name="builder">The <see cref="IRequestDispatcherBuilder"/> to configure.</param>
|
||||
/// <returns>The <see cref="IRequestDispatcherBuilder"/> instance for further configuration.</returns>
|
||||
public static IRequestDispatcherBuilder UseSequentialNotificationPublisher(this IRequestDispatcherBuilder builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.Services.Replace(new ServiceDescriptor(typeof(INotificationPublisher), new SequentialNotificationPublisher()));
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the <see cref="ParallelNotificationPublisher"/> as the default
|
||||
/// <see cref="INotificationPublisher"/> using the specified maximum degree of parallelism.
|
||||
/// </summary>
|
||||
/// <param name="builder">The <see cref="IRequestDispatcherBuilder"/> to configure.</param>
|
||||
/// <returns>The <see cref="IRequestDispatcherBuilder"/> instance for further configuration.</returns>
|
||||
public static IRequestDispatcherBuilder UseParallelNotificationPublisher(this IRequestDispatcherBuilder builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.Services.Replace(new ServiceDescriptor(typeof(INotificationPublisher), new ParallelNotificationPublisher()));
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the <see cref="ParallelNotificationPublisher"/> as the default
|
||||
/// <see cref="INotificationPublisher"/> using the specified maximum degree of parallelism.
|
||||
/// </summary>
|
||||
/// <param name="builder">The <see cref="IRequestDispatcherBuilder"/> to configure.</param>
|
||||
/// <param name="maxDegreeOfParallelism">The maximum number of handlers to execute concurrently.</param>
|
||||
/// <returns>The <see cref="IRequestDispatcherBuilder"/> instance for further configuration.</returns>
|
||||
public static IRequestDispatcherBuilder UseParallelNotificationPublisher(this IRequestDispatcherBuilder builder, int maxDegreeOfParallelism)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.Services.Replace(new ServiceDescriptor(typeof(INotificationPublisher), new ParallelNotificationPublisher(maxDegreeOfParallelism)));
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<Type> ValidateNoNestedRequestHandlers(IReadOnlyCollection<Type> types, [CallerMemberName] string? invoker = null)
|
||||
{
|
||||
var nestedHandlers = types
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@ internal sealed class RequestDispatcherOptions
|
|||
{
|
||||
return type.IsGenericType &&
|
||||
(type.GetGenericTypeDefinition() == typeof(IScalarRequestHandler<,>) ||
|
||||
type.GetGenericTypeDefinition() == typeof(IStreamRequestHandler<,>));
|
||||
type.GetGenericTypeDefinition() == typeof(IStreamRequestHandler<,>) ||
|
||||
type.GetGenericTypeDefinition() == typeof(INotificationHandler<>));
|
||||
}
|
||||
|
||||
private sealed class HandlerTypeIndex(IEnumerable<Type> collection)
|
||||
|
|
@ -110,7 +111,6 @@ internal sealed class RequestDispatcherOptions
|
|||
}
|
||||
|
||||
var requestType = @interface.GetGenericArguments()[0];
|
||||
_ = @interface.GetGenericArguments()[1];
|
||||
|
||||
foreach (var type in _openTypeInfo)
|
||||
{
|
||||
|
|
@ -154,7 +154,8 @@ internal sealed class RequestDispatcherOptions
|
|||
{
|
||||
return type.IsGenericType &&
|
||||
(type.GetGenericTypeDefinition() == typeof(IScalarRequestBehavior<,>) ||
|
||||
type.GetGenericTypeDefinition() == typeof(IStreamRequestBehavior<,>));
|
||||
type.GetGenericTypeDefinition() == typeof(IStreamRequestBehavior<,>) ||
|
||||
type.GetGenericTypeDefinition() == typeof(INotificationBehavior<>));
|
||||
}
|
||||
|
||||
private sealed class BehaviorTypeIndex(IEnumerable<Type> collection)
|
||||
|
|
@ -169,15 +170,12 @@ internal sealed class RequestDispatcherOptions
|
|||
result.AddRange(list);
|
||||
}
|
||||
|
||||
var requestType = @interface.GetGenericArguments()[0];
|
||||
var responseType = @interface.GetGenericArguments()[1];
|
||||
|
||||
foreach (var behaviour in _openTypeInfo)
|
||||
{
|
||||
try
|
||||
{
|
||||
// open type case one: Behaviour<TRequest, TResponse> : IRequestBehaviour<TRequest, TResponse>
|
||||
var impl = behaviour.MakeGenericType(requestType, responseType);
|
||||
var impl = behaviour.MakeGenericType(@interface.GetGenericArguments());
|
||||
if (impl.IsAssignableTo(@interface))
|
||||
{
|
||||
result.Add(impl);
|
||||
|
|
@ -190,6 +188,7 @@ internal sealed class RequestDispatcherOptions
|
|||
try
|
||||
{
|
||||
// open type case two: Behaviour<T> : IRequestBehaviour<WrapperRequest<T>, TResponse>
|
||||
var requestType = @interface.GetGenericArguments()[0];
|
||||
var impl = behaviour.MakeGenericType(requestType.GetGenericArguments());
|
||||
if (impl.IsAssignableTo(@interface))
|
||||
{
|
||||
|
|
|
|||
16
src/request.dispatcher/SequentialNotificationPublisher.cs
Normal file
16
src/request.dispatcher/SequentialNotificationPublisher.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Copyright (c) The Geekeey Authors
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
namespace Geekeey.Request.Dispatcher;
|
||||
|
||||
internal sealed class SequentialNotificationPublisher : INotificationPublisher
|
||||
{
|
||||
public async Task PublishAsync<TNotification>(IEnumerable<INotificationHandler<TNotification>> handlers, TNotification notification, CancellationToken cancellationToken)
|
||||
where TNotification : INotification
|
||||
{
|
||||
foreach (var handler in handlers)
|
||||
{
|
||||
await handler.HandleAsync(notification, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue