Skip to content

Commit a061c3f

Browse files
committed
Add support for generic message types
1 parent e321002 commit a061c3f

10 files changed

Lines changed: 209 additions & 10 deletions

File tree

.vscode/launch.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
55
"version": "0.2.0",
66
"configurations": [
7+
{
8+
"name": "Modular Monolith",
9+
"type": "dotnet",
10+
"request": "launch",
11+
"projectPath": "${workspaceFolder}/samples/ModularMonolithSample/src/WebApp/WebApp.csproj",
12+
},
713
{
814
"name": "ConsoleSample",
915
"type": "dotnet",

samples/ModularMonolithSample/src/Orders.Module/Api/OrdersApi.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Microsoft.AspNetCore.Http;
44
using Microsoft.AspNetCore.Routing;
55
using Orders.Module.Extensions;
6+
using Orders.Module.Handlers;
67
using Orders.Module.Messages;
78

89
namespace Orders.Module.Api;
@@ -54,5 +55,13 @@ public static void MapOrdersEndpoints(this IEndpointRouteBuilder endpoints)
5455
})
5556
.WithName("DeleteOrder")
5657
.WithSummary("Delete an order");
58+
59+
group.MapPost("/action", async (EntityAction<Order> command, IMediator mediator) =>
60+
{
61+
var result = await mediator.InvokeAsync<Result<Order>>(command);
62+
return result.ToCreatedResult($"/api/orders/{result.Value?.Id}");
63+
})
64+
.WithName("EntityAction")
65+
.WithSummary("Perform an action on an order");
5766
}
5867
}

samples/ModularMonolithSample/src/Orders.Module/Handlers/OrderHandler.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Foundatio.Mediator;
2+
using Microsoft.Extensions.Logging;
23
using Orders.Module.Messages;
34

45
namespace Orders.Module.Handlers;
@@ -68,4 +69,25 @@ public async Task<Result<List<Order>>> HandleAsync(GetOrders query)
6869

6970
return (Result.Success(), new OrderDeleted(command.OrderId, DateTime.UtcNow));
7071
}
72+
73+
public async Task<Result> HandleAsync(EntityAction<Order> command, ILogger<OrderHandler> logger)
74+
{
75+
logger.LogInformation("Handling entity action {Action} for order {OrderId}: {TypeName}", command.Action, command.Entity.Id, MessageTypeKey.Get(typeof(Orders.Module.Handlers.EntityAction<Orders.Module.Messages.Order>)));
76+
await Task.CompletedTask; // Simulate async work
77+
78+
return Result.Success();
79+
}
80+
}
81+
82+
public class EntityAction<T>
83+
{
84+
public T Entity { get; init; } = default!;
85+
public EntityActionType Action { get; init; } = default!;
86+
}
87+
88+
public enum EntityActionType
89+
{
90+
Create,
91+
Update,
92+
Delete
7193
}

src/Foundatio.Mediator.Abstractions/Mediator.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,10 @@ private PublishAsyncDelegate[] GetAllApplicableHandlers(object message)
173173
[DebuggerStepThrough]
174174
private IEnumerable<HandlerRegistration> GetHandlersForType(Type type)
175175
{
176-
return _serviceProvider.GetKeyedServices<HandlerRegistration>(type.FullName);
176+
var handlers = _serviceProvider.GetKeyedServices<HandlerRegistration>(MessageTypeKey.Get(type));
177+
if (handlers != null && handlers.Any()) return handlers;
178+
179+
return Array.Empty<HandlerRegistration>();
177180
}
178181

179182
private static readonly ConcurrentDictionary<Type, object> _middlewareCache = new();
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using System.Text;
2+
3+
namespace Foundatio.Mediator;
4+
5+
public static class MessageTypeKey
6+
{
7+
public static string Get(Type type)
8+
{
9+
if (type == null) throw new ArgumentNullException(nameof(type));
10+
11+
if (!type.IsGenericType || type.ContainsGenericParameters)
12+
return type.FullName!;
13+
14+
return Build(type);
15+
}
16+
17+
private static string Build(Type type)
18+
{
19+
if (!type.IsGenericType)
20+
return type.FullName!;
21+
22+
var sb = new StringBuilder();
23+
sb.Append(type.Namespace);
24+
sb.Append('.');
25+
var backtickIndex = type.Name.IndexOf('`');
26+
var simpleName = backtickIndex > 0 ? type.Name.Substring(0, backtickIndex) : type.Name;
27+
sb.Append(simpleName);
28+
sb.Append('<');
29+
var args = type.GetGenericArguments();
30+
for (int i = 0; i < args.Length; i++)
31+
{
32+
if (i > 0) sb.Append(',');
33+
sb.Append(Get(args[i]));
34+
}
35+
sb.Append('>');
36+
return sb.ToString();
37+
}
38+
}

src/Foundatio.Mediator/DIRegistrationGenerator.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ public static void Execute(SourceProductionContext context, List<HandlerInfo> ha
5959
}
6060

6161
// Use reflection FullName so nested types resolve with '+' and match runtime Type.FullName keys
62-
source.AppendLine($"services.AddKeyedSingleton<HandlerRegistration>(typeof({handler.MessageType.FullName}).FullName!,");
62+
source.AppendLine($"services.AddKeyedSingleton<HandlerRegistration>(MessageTypeKey.Get(typeof({handler.MessageType.FullName})),");
6363
source.AppendLine($" new HandlerRegistration(");
64-
source.AppendLine($" typeof({handler.MessageType.FullName}).FullName!,");
64+
source.AppendLine($" MessageTypeKey.Get(typeof({handler.MessageType.FullName})),");
6565

6666
if (handler.IsAsync)
6767
{

src/Foundatio.Mediator/Models/TypeSymbolInfo.cs

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ internal readonly record struct TypeSymbolInfo
6262
/// </summary>
6363
public bool IsTypeParameter { get; init; }
6464
/// <summary>
65+
/// Indicates if the type is a constructed generic type.
66+
/// </summary>
67+
public bool IsGeneric { get; init; }
68+
/// <summary>
6569
/// Contains information about the items in a tuple type, if applicable.
6670
/// </summary>
6771
public EquatableArray<TupleItemInfo> TupleItems { get; init; }
@@ -84,7 +88,8 @@ public static TypeSymbolInfo Void()
8488
IsCancellationToken = false,
8589
IsTuple = false,
8690
IsTypeParameter = false,
87-
TupleItems = EquatableArray<TupleItemInfo>.Empty
91+
TupleItems = EquatableArray<TupleItemInfo>.Empty,
92+
IsGeneric = false
8893
};
8994
}
9095

@@ -114,9 +119,32 @@ public static TypeSymbolInfo From(ITypeSymbol typeSymbol, Compilation compilatio
114119
var tupleItems = unwrappedNullableType.GetTupleItems(compilation);
115120
bool isTypeParameter = typeSymbol.TypeKind == TypeKind.TypeParameter;
116121

122+
string identifier;
123+
if (typeSymbol is INamedTypeSymbol named && named.IsGenericType && !named.IsUnboundGenericType)
124+
{
125+
static string GetTypeArgIdentifier(ITypeSymbol ts)
126+
{
127+
if (ts is INamedTypeSymbol nts && nts.IsGenericType && !nts.IsUnboundGenericType)
128+
{
129+
var inner = string.Join("_", nts.TypeArguments.Select(GetTypeArgIdentifier));
130+
return ($"{nts.Name.ToIdentifier()}_{inner}");
131+
}
132+
return ts.Name.ToIdentifier();
133+
}
134+
135+
var typeArgs = string.Join("_", named.TypeArguments.Select(GetTypeArgIdentifier));
136+
identifier = ($"{named.Name.ToIdentifier()}_{typeArgs}");
137+
}
138+
else
139+
{
140+
identifier = typeSymbol.Name.ToIdentifier();
141+
}
142+
143+
bool isGeneric = typeSymbol is INamedTypeSymbol { IsGenericType: true } nts && !nts.IsUnboundGenericType;
144+
117145
return new TypeSymbolInfo
118146
{
119-
Identifier = typeSymbol.Name.ToIdentifier(),
147+
Identifier = identifier,
120148
FullName = typeSymbol.ToDisplayString(),
121149
UnwrappedFullName = unwrappedTypeFullName,
122150
IsNullable = isNullable,
@@ -130,7 +158,8 @@ public static TypeSymbolInfo From(ITypeSymbol typeSymbol, Compilation compilatio
130158
IsCancellationToken = isCancellationToken,
131159
IsTuple = isTuple,
132160
IsTypeParameter = isTypeParameter,
133-
TupleItems = tupleItems
161+
TupleItems = tupleItems,
162+
IsGeneric = isGeneric
134163
};
135164
}
136165
}

tests/Foundatio.Mediator.Tests/BasicHandlerGenerationTests.GeneratesWrapperForSimpleHandler.verified.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,9 @@ public static class Tests_MediatorHandlers
164164
// Register HandlerRegistration instances keyed by message type name
165165
// Optionally register handler classes into DI based on MediatorHandlerLifetime setting
166166

167-
services.AddKeyedSingleton<HandlerRegistration>(typeof(Ping).FullName!,
167+
services.AddKeyedSingleton<HandlerRegistration>(MessageTypeKey.Get(typeof(Ping)),
168168
new HandlerRegistration(
169-
typeof(Ping).FullName!,
169+
MessageTypeKey.Get(typeof(Ping)),
170170
PingHandler_Ping_Handler.UntypedHandleAsync,
171171
null,
172172
true));

tests/Foundatio.Mediator.Tests/DIRegistrationTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ public class BHandler { public void Handle(B m) { } }
1717

1818
var (_, _, trees) = RunGenerator(src, [ new MediatorGenerator() ]);
1919
var di = trees.First(t => t.HintName.EndsWith("_MediatorHandlers.g.cs"));
20-
Assert.Contains("AddKeyedSingleton<HandlerRegistration>(typeof(A).FullName!", di.Source);
21-
Assert.Contains("AddKeyedSingleton<HandlerRegistration>(typeof(B).FullName!", di.Source);
20+
Assert.Contains("AddKeyedSingleton<HandlerRegistration>(MessageTypeKey.Get(typeof(A))", di.Source);
21+
Assert.Contains("AddKeyedSingleton<HandlerRegistration>(MessageTypeKey.Get(typeof(B))", di.Source);
2222
Assert.Contains("UntypedHandleAsync", di.Source);
2323
Assert.Contains("UntypedHandle(", di.Source);
2424
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
3+
namespace Foundatio.Mediator.Tests;
4+
5+
public class GenericMessageTests : GeneratorTestBase
6+
{
7+
[Fact]
8+
public void GeneratesDistinctWrappersForClosedGenericMessages()
9+
{
10+
var source = """
11+
using System.Threading;
12+
using System.Threading.Tasks;
13+
using Foundatio.Mediator;
14+
15+
public record MyMessage<T>(T Value) : IQuery;
16+
17+
public class IntHandler {
18+
public Task<int> HandleAsync(MyMessage<int> message, CancellationToken ct) => Task.FromResult(message.Value + 1);
19+
}
20+
21+
public class StringHandler {
22+
public Task<string> HandleAsync(MyMessage<string> message, CancellationToken ct) => Task.FromResult(message.Value + "!");
23+
}
24+
""";
25+
26+
var (diagnostics, generatorDiagnostics, generatedTrees) = RunGenerator(source, new[] { new MediatorGenerator() });
27+
28+
Assert.Empty(diagnostics);
29+
Assert.Empty(generatorDiagnostics);
30+
31+
// Expect two handler wrapper files with unique message identifiers including generic argument
32+
var intWrapper = generatedTrees.FirstOrDefault(t => t.HintName.Contains("IntHandler") && t.HintName.Contains("MyMessage"));
33+
var stringWrapper = generatedTrees.FirstOrDefault(t => t.HintName.Contains("StringHandler") && t.HintName.Contains("MyMessage"));
34+
35+
Assert.True(intWrapper != default, "No handler wrapper generated for IntHandler");
36+
Assert.True(stringWrapper != default, "No handler wrapper generated for StringHandler");
37+
38+
Assert.NotEqual(intWrapper.HintName, stringWrapper.HintName); // should differ due to generic argument identifiers
39+
40+
// Ensure the identifiers include the generic argument name to avoid collisions
41+
Assert.Contains("MyMessage_Int32", intWrapper.HintName);
42+
Assert.Contains("MyMessage_String", stringWrapper.HintName);
43+
}
44+
45+
[Fact]
46+
public async Task CanInvokeClosedGenericMessageHandlersAtRuntime()
47+
{
48+
// Define messages and handlers in-line for runtime integration
49+
var services = new ServiceCollection();
50+
services.AddMediator(b => b.AddAssembly<IntHandler>()); // will scan assembly for both handlers
51+
52+
using var provider = services.BuildServiceProvider();
53+
var mediator = provider.GetRequiredService<IMediator>();
54+
55+
var intResult = await mediator.InvokeAsync<int>(new MyMessage<int>(40));
56+
Assert.Equal(42, intResult);
57+
58+
var stringResult = await mediator.InvokeAsync<string>(new MyMessage<string>("Hello"));
59+
Assert.Equal("Hello?", stringResult);
60+
}
61+
62+
[Fact]
63+
public void RegistersFriendlyGenericKey()
64+
{
65+
var source = """
66+
using System.Threading;
67+
using System.Threading.Tasks;
68+
using Foundatio.Mediator;
69+
70+
public record Order(string Id);
71+
public record EntityAction<T>(T Entity) : IQuery;
72+
73+
public class OrderActionHandler {
74+
public Task<Result> HandleAsync(EntityAction<Order> action, CancellationToken ct) => Task.FromResult(Result.Success());
75+
}
76+
""";
77+
78+
var (diagnostics, generatorDiagnostics, trees) = RunGenerator(source, [ new MediatorGenerator() ]);
79+
Assert.Empty(diagnostics);
80+
Assert.Empty(generatorDiagnostics);
81+
82+
var di = trees.First(t => t.HintName.EndsWith("_MediatorHandlers.g.cs"));
83+
// Should use helper call and not raw backtick notation
84+
Assert.Contains("MessageTypeKey.Get(typeof(EntityAction<Order>))", di.Source);
85+
Assert.DoesNotContain("EntityAction`1[[", di.Source);
86+
}
87+
88+
// Test types
89+
public record MyMessage<T>(T Value) : IQuery;
90+
public class IntHandler { public Task<int> HandleAsync(MyMessage<int> message, CancellationToken ct) => Task.FromResult(message.Value + 2); }
91+
public class StringHandler { public Task<string> HandleAsync(MyMessage<string> message, CancellationToken ct) => Task.FromResult(message.Value + "?"); }
92+
}

0 commit comments

Comments
 (0)