Skip to content

Commit 258c99f

Browse files
Copilothasanxdev
andauthored
Support open-generic notification handlers with full scenario tests (#48)
* feat: support open generic notification registration and add tests Agent-Logs-Url: https://github.com/hasanxdev/DispatchR/sessions/595ce1db-3d15-4029-9de2-c03cfde0e56d Co-authored-by: hasanxdev <30981174+hasanxdev@users.noreply.github.com> * test: cover open-generic notifications across publish scenarios Agent-Logs-Url: https://github.com/hasanxdev/DispatchR/sessions/595ce1db-3d15-4029-9de2-c03cfde0e56d Co-authored-by: hasanxdev <30981174+hasanxdev@users.noreply.github.com> * fix: simplify open-generic registration to eliminate dead branches and reach 100% coverage Agent-Logs-Url: https://github.com/hasanxdev/DispatchR/sessions/90e47ff8-87c9-4142-88cf-83c7eec466cc Co-authored-by: hasanxdev <30981174+hasanxdev@users.noreply.github.com> * feat(sample): add open-generic INotificationHandler<TNotification> demo Agent-Logs-Url: https://github.com/hasanxdev/DispatchR/sessions/9ef271ed-cd29-415a-a675-f6a2be488540 Co-authored-by: hasanxdev <30981174+hasanxdev@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: hasanxdev <30981174+hasanxdev@users.noreply.github.com>
1 parent d9c6ca6 commit 258c99f

11 files changed

Lines changed: 227 additions & 16 deletions

File tree

src/DispatchR/Configuration/ServiceRegistrator.cs

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -170,20 +170,28 @@ public static void RegisterHandlers(IServiceCollection services, List<Type> allT
170170
}
171171
}
172172

173-
public static void RegisterNotification(IServiceCollection services, List<Type> allTypes,
174-
Type syncNotificationHandlerType)
175-
{
176-
var allNotifications = allTypes
177-
.SelectMany(handlerType => handlerType.GetInterfaces()
178-
.Where(i => i.IsGenericType && syncNotificationHandlerType == i.GetGenericTypeDefinition())
179-
.Select(i => new { HandlerType = handlerType, Interface = i }))
180-
.ToList();
181-
182-
foreach (var notification in allNotifications)
183-
{
184-
services.AddScoped(notification.Interface, notification.HandlerType);
185-
}
186-
}
173+
public static void RegisterNotification(IServiceCollection services, List<Type> allTypes,
174+
Type syncNotificationHandlerType)
175+
{
176+
var allNotifications = allTypes
177+
.SelectMany(handlerType => handlerType.GetInterfaces()
178+
.Where(i => i.IsGenericType && syncNotificationHandlerType == i.GetGenericTypeDefinition())
179+
.Select(i => new { HandlerType = handlerType, Interface = i }))
180+
.ToList();
181+
182+
foreach (var notification in allNotifications)
183+
{
184+
var serviceType = notification.Interface;
185+
var implementationType = notification.HandlerType;
186+
187+
if (serviceType.ContainsGenericParameters)
188+
{
189+
serviceType = serviceType.GetGenericTypeDefinition();
190+
}
191+
192+
services.AddScoped(serviceType, implementationType);
193+
}
194+
}
187195

188196
private static bool IsAwaitable(Type type)
189197
{
@@ -200,4 +208,4 @@ private static bool IsAwaitable(Type type)
200208
return false;
201209
}
202210
}
203-
}
211+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using DispatchR.Abstractions.Notification;
2+
3+
namespace Sample.DispatchR.Notification;
4+
5+
/// <summary>
6+
/// Open-generic notification handler — handles every INotification published through DispatchR.
7+
/// Useful for cross-cutting concerns such as logging, auditing or telemetry.
8+
/// </summary>
9+
public sealed class AllNotificationsLogger<TNotification>(ILogger<AllNotificationsLogger<TNotification>> logger)
10+
: INotificationHandler<TNotification>
11+
where TNotification : INotification
12+
{
13+
public ValueTask Handle(TNotification notification, CancellationToken cancellationToken)
14+
{
15+
logger.LogInformation("[Generic] Received notification of type {NotificationType}: {@Notification}",
16+
typeof(TNotification).Name, notification);
17+
return ValueTask.CompletedTask;
18+
}
19+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
using DispatchR.Abstractions.Notification;
2+
3+
namespace Sample.DispatchR.Notification;
4+
5+
public sealed record GenericAwareNotification(Guid Id, string Message) : INotification;

src/Sample/Program.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,16 @@
124124
return "It works";
125125
});
126126

127+
// Demonstrates open-generic INotificationHandler<TNotification>:
128+
// AllNotificationsLogger<TNotification> is registered once and handles every INotification.
129+
// GenericAwareNotification is handled by both its own specific handler (if any) and the generic one.
130+
app.MapGet("/Notification/DispatchR/Generic", async (DispatchR.IMediator mediator, ILogger<Program> logger) =>
131+
{
132+
var notification = new DispatchRNotificationSample.GenericAwareNotification(Guid.NewGuid(), "Hello from open-generic handler!");
133+
await mediator.Publish(notification, CancellationToken.None);
134+
return "It works";
135+
});
136+
127137
app.Run();
128138

129139
namespace Sample

tests/DispatchR.IntegrationTest/NotificationTests.cs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,77 @@ public void RegisterNotification_SingleClassWithMultipleNotificationInterfaces_R
103103
Assert.Contains(handlers1, h => h is MultiNotificationHandler);
104104
Assert.Contains(handlers2, h => h is MultiNotificationHandler);
105105
}
106+
107+
[Fact]
108+
public async Task Publish_CallsOpenGenericAndSpecificHandlers_WhenBothAreRegistered()
109+
{
110+
// Arrange
111+
var services = new ServiceCollection();
112+
services.AddSingleton<OpenGenericNotificationExecutionStore>();
113+
services.AddDispatchR(cfg =>
114+
{
115+
cfg.Assemblies.Add(typeof(Fixture).Assembly);
116+
cfg.RegisterPipelines = false;
117+
cfg.RegisterNotifications = true;
118+
});
119+
var serviceProvider = services.BuildServiceProvider();
120+
var mediator = serviceProvider.GetRequiredService<IMediator>();
121+
var executionStore = serviceProvider.GetRequiredService<OpenGenericNotificationExecutionStore>();
122+
123+
// Act
124+
await mediator.Publish(new OpenGenericTargetNotification(Guid.NewGuid()), CancellationToken.None);
125+
126+
// Assert
127+
Assert.Equal(1, executionStore.Count($"generic:{nameof(OpenGenericTargetNotification)}"));
128+
Assert.Equal(1, executionStore.Count($"specific:{nameof(OpenGenericTargetNotification)}"));
129+
}
130+
131+
[Fact]
132+
public async Task PublishObject_CallsOpenGenericAndSpecificHandlers_WhenBothAreRegistered()
133+
{
134+
// Arrange
135+
var services = new ServiceCollection();
136+
services.AddSingleton<OpenGenericNotificationExecutionStore>();
137+
services.AddDispatchR(cfg =>
138+
{
139+
cfg.Assemblies.Add(typeof(Fixture).Assembly);
140+
cfg.RegisterPipelines = false;
141+
cfg.RegisterNotifications = true;
142+
});
143+
var serviceProvider = services.BuildServiceProvider();
144+
var mediator = serviceProvider.GetRequiredService<IMediator>();
145+
var executionStore = serviceProvider.GetRequiredService<OpenGenericNotificationExecutionStore>();
146+
147+
// Act
148+
object notificationObject = new OpenGenericTargetNotification(Guid.NewGuid());
149+
await mediator.Publish(notificationObject, CancellationToken.None);
150+
151+
// Assert
152+
Assert.Equal(1, executionStore.Count($"generic:{nameof(OpenGenericTargetNotification)}"));
153+
Assert.Equal(1, executionStore.Count($"specific:{nameof(OpenGenericTargetNotification)}"));
154+
}
155+
156+
[Fact]
157+
public async Task Publish_CallsOpenGenericHandler_WhenNoSpecificHandlerExists()
158+
{
159+
// Arrange
160+
var services = new ServiceCollection();
161+
services.AddSingleton<OpenGenericNotificationExecutionStore>();
162+
services.AddDispatchR(cfg =>
163+
{
164+
cfg.Assemblies.Add(typeof(Fixture).Assembly);
165+
cfg.RegisterPipelines = false;
166+
cfg.RegisterNotifications = true;
167+
});
168+
var serviceProvider = services.BuildServiceProvider();
169+
var mediator = serviceProvider.GetRequiredService<IMediator>();
170+
var executionStore = serviceProvider.GetRequiredService<OpenGenericNotificationExecutionStore>();
171+
172+
// Act
173+
await mediator.Publish(new OpenGenericOnlyNotification(Guid.NewGuid()), CancellationToken.None);
174+
175+
// Assert
176+
Assert.Equal(1, executionStore.Count($"generic:{nameof(OpenGenericOnlyNotification)}"));
177+
Assert.Equal(0, executionStore.Count($"specific:{nameof(OpenGenericOnlyNotification)}"));
178+
}
106179
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using System.Collections.Concurrent;
2+
3+
namespace DispatchR.TestCommon.Fixtures.Notification;
4+
5+
public sealed class OpenGenericNotificationExecutionStore
6+
{
7+
private readonly ConcurrentDictionary<string, int> _counters = new();
8+
9+
public void Increment(string key)
10+
{
11+
_counters.AddOrUpdate(key, 1, (_, current) => current + 1);
12+
}
13+
14+
public int Count(string key)
15+
{
16+
return _counters.TryGetValue(key, out var count) ? count : 0;
17+
}
18+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using DispatchR.Abstractions.Notification;
2+
3+
namespace DispatchR.TestCommon.Fixtures.Notification;
4+
5+
public sealed class OpenGenericNotificationHandler<TNotification> : INotificationHandler<TNotification>
6+
where TNotification : INotification
7+
{
8+
private static readonly OpenGenericNotificationExecutionStore FallbackStore = new();
9+
private readonly OpenGenericNotificationExecutionStore _store;
10+
11+
public OpenGenericNotificationHandler(OpenGenericNotificationExecutionStore? store = null)
12+
{
13+
_store = store ?? FallbackStore;
14+
}
15+
16+
public ValueTask Handle(TNotification request, CancellationToken cancellationToken)
17+
{
18+
_store.Increment($"generic:{typeof(TNotification).Name}");
19+
return ValueTask.CompletedTask;
20+
}
21+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
using DispatchR.Abstractions.Notification;
2+
3+
namespace DispatchR.TestCommon.Fixtures.Notification;
4+
5+
public sealed record OpenGenericOnlyNotification(Guid Id) : INotification;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
using DispatchR.Abstractions.Notification;
2+
3+
namespace DispatchR.TestCommon.Fixtures.Notification;
4+
5+
public sealed record OpenGenericTargetNotification(Guid Id) : INotification;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using DispatchR.Abstractions.Notification;
2+
3+
namespace DispatchR.TestCommon.Fixtures.Notification;
4+
5+
public sealed class OpenGenericTargetNotificationHandler : INotificationHandler<OpenGenericTargetNotification>
6+
{
7+
private static readonly OpenGenericNotificationExecutionStore FallbackStore = new();
8+
private readonly OpenGenericNotificationExecutionStore _store;
9+
10+
public OpenGenericTargetNotificationHandler(OpenGenericNotificationExecutionStore? store = null)
11+
{
12+
_store = store ?? FallbackStore;
13+
}
14+
15+
public ValueTask Handle(OpenGenericTargetNotification request, CancellationToken cancellationToken)
16+
{
17+
_store.Increment($"specific:{nameof(OpenGenericTargetNotification)}");
18+
return ValueTask.CompletedTask;
19+
}
20+
}

0 commit comments

Comments
 (0)