diff --git a/.codex b/.codex new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Mocha/examples/AotExample/AotExample.Contracts/AotExample.Contracts.csproj b/src/Mocha/examples/AotExample/AotExample.Contracts/AotExample.Contracts.csproj new file mode 100644 index 00000000000..fb76bbd8c8e --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.Contracts/AotExample.Contracts.csproj @@ -0,0 +1,13 @@ + + + AotExample.Contracts + + + + + + diff --git a/src/Mocha/examples/AotExample/AotExample.Contracts/AotExampleJsonContext.cs b/src/Mocha/examples/AotExample/AotExample.Contracts/AotExampleJsonContext.cs new file mode 100644 index 00000000000..2bf9141cfab --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.Contracts/AotExampleJsonContext.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using AotExample.Contracts.Events; +using AotExample.Contracts.Requests; + +namespace AotExample.Contracts; + +[JsonSerializable(typeof(OrderPlacedEvent))] +[JsonSerializable(typeof(OrderShippedEvent))] +[JsonSerializable(typeof(CheckInventoryRequest))] +[JsonSerializable(typeof(CheckInventoryResponse))] +public partial class AotExampleJsonContext : JsonSerializerContext; diff --git a/src/Mocha/examples/AotExample/AotExample.Contracts/AssemblyInfo.cs b/src/Mocha/examples/AotExample/AotExample.Contracts/AssemblyInfo.cs new file mode 100644 index 00000000000..eee01dc673c --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.Contracts/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using AotExample.Contracts; +using Mocha; + +[assembly: MessagingModule( + "AotExampleContracts", + JsonContext = typeof(AotExampleJsonContext))] diff --git a/src/Mocha/examples/AotExample/AotExample.Contracts/Events/OrderPlacedEvent.cs b/src/Mocha/examples/AotExample/AotExample.Contracts/Events/OrderPlacedEvent.cs new file mode 100644 index 00000000000..7c10e381f99 --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.Contracts/Events/OrderPlacedEvent.cs @@ -0,0 +1,14 @@ +using Mocha.Sagas; + +namespace AotExample.Contracts.Events; + +public sealed class OrderPlacedEvent : ICorrelatable +{ + public required string OrderId { get; init; } + + public required string ProductName { get; init; } + + public required int Quantity { get; init; } + + public Guid? CorrelationId { get; init; } +} diff --git a/src/Mocha/examples/AotExample/AotExample.Contracts/Events/OrderShippedEvent.cs b/src/Mocha/examples/AotExample/AotExample.Contracts/Events/OrderShippedEvent.cs new file mode 100644 index 00000000000..750e3b916c0 --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.Contracts/Events/OrderShippedEvent.cs @@ -0,0 +1,12 @@ +using Mocha.Sagas; + +namespace AotExample.Contracts.Events; + +public sealed class OrderShippedEvent : ICorrelatable +{ + public required string OrderId { get; init; } + + public required string TrackingNumber { get; init; } + + public Guid? CorrelationId { get; init; } +} diff --git a/src/Mocha/examples/AotExample/AotExample.Contracts/Requests/CheckInventoryRequest.cs b/src/Mocha/examples/AotExample/AotExample.Contracts/Requests/CheckInventoryRequest.cs new file mode 100644 index 00000000000..d3efc770127 --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.Contracts/Requests/CheckInventoryRequest.cs @@ -0,0 +1,9 @@ +using Mocha; + +namespace AotExample.Contracts.Requests; + +public sealed class CheckInventoryRequest : IEventRequest +{ + public required string ProductName { get; init; } + public required int Quantity { get; init; } +} diff --git a/src/Mocha/examples/AotExample/AotExample.Contracts/Requests/CheckInventoryResponse.cs b/src/Mocha/examples/AotExample/AotExample.Contracts/Requests/CheckInventoryResponse.cs new file mode 100644 index 00000000000..b60d5bc6de3 --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.Contracts/Requests/CheckInventoryResponse.cs @@ -0,0 +1,7 @@ +namespace AotExample.Contracts.Requests; + +public sealed class CheckInventoryResponse +{ + public required bool IsAvailable { get; init; } + public required int QuantityOnHand { get; init; } +} diff --git a/src/Mocha/examples/AotExample/AotExample.FulfillmentService/AotExample.FulfillmentService.csproj b/src/Mocha/examples/AotExample/AotExample.FulfillmentService/AotExample.FulfillmentService.csproj new file mode 100644 index 00000000000..ad309fa182d --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.FulfillmentService/AotExample.FulfillmentService.csproj @@ -0,0 +1,17 @@ + + + AotExample.FulfillmentService + true + true + + + + + + + + diff --git a/src/Mocha/examples/AotExample/AotExample.FulfillmentService/Handlers/OrderPlacedHandler.cs b/src/Mocha/examples/AotExample/AotExample.FulfillmentService/Handlers/OrderPlacedHandler.cs new file mode 100644 index 00000000000..2c8a9712421 --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.FulfillmentService/Handlers/OrderPlacedHandler.cs @@ -0,0 +1,50 @@ +using AotExample.Contracts.Events; +using AotExample.Contracts.Requests; +using Mocha; + +namespace AotExample.FulfillmentService.Handlers; + +public sealed class OrderPlacedHandler(IMessageBus messageBus, ILogger logger) + : IEventHandler +{ + public async ValueTask HandleAsync(OrderPlacedEvent message, CancellationToken cancellationToken) + { + logger.LogOrderReceived(message.OrderId, message.Quantity, message.ProductName); + + // Check inventory with OrderService via bus request/response + var inventory = await messageBus.RequestAsync( + new CheckInventoryRequest { ProductName = message.ProductName, Quantity = message.Quantity }, + cancellationToken); + + if (!inventory.IsAvailable) + { + logger.LogCannotFulfillOrder(message.OrderId, inventory.QuantityOnHand, message.ProductName); + return; + } + + var trackingNumber = $"TRK-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}"; + + await messageBus.PublishAsync( + new OrderShippedEvent + { + OrderId = message.OrderId, + TrackingNumber = trackingNumber, + CorrelationId = message.CorrelationId + }, + cancellationToken); + + logger.LogOrderFulfilled(message.OrderId, trackingNumber); + } +} + +internal static partial class Logs +{ + [LoggerMessage(Level = LogLevel.Information, Message = "Received order {OrderId}: {Quantity}x {ProductName}")] + public static partial void LogOrderReceived(this ILogger logger, string orderId, int quantity, string productName); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Cannot fulfill order {OrderId}: only {QuantityOnHand} of {ProductName} on hand")] + public static partial void LogCannotFulfillOrder(this ILogger logger, string orderId, int quantityOnHand, string productName); + + [LoggerMessage(Level = LogLevel.Information, Message = "Order {OrderId} fulfilled - tracking {TrackingNumber}")] + public static partial void LogOrderFulfilled(this ILogger logger, string orderId, string trackingNumber); +} diff --git a/src/Mocha/examples/AotExample/AotExample.FulfillmentService/Program.cs b/src/Mocha/examples/AotExample/AotExample.FulfillmentService/Program.cs new file mode 100644 index 00000000000..bfade8c8f1e --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.FulfillmentService/Program.cs @@ -0,0 +1,20 @@ +using Mocha; +using Mocha.Transport.RabbitMQ; +using RabbitMQ.Client; + +[assembly: MessagingModule("FulfillmentService")] + +var builder = WebApplication.CreateBuilder(args); + +builder.WebHost.UseUrls("http://localhost:52310"); + +builder.Services.AddSingleton( + new ConnectionFactory { HostName = "localhost", Port = 5673 }); + +builder.Services.AddMessageBus().AddAotExampleContracts().AddFulfillmentService().AddRabbitMQ(); + +var app = builder.Build(); + +app.MapGet("/", () => "Fulfillment Service (AOT Example)"); + +app.Run(); diff --git a/src/Mocha/examples/AotExample/AotExample.FulfillmentService/Properties/launchSettings.json b/src/Mocha/examples/AotExample/AotExample.FulfillmentService/Properties/launchSettings.json new file mode 100644 index 00000000000..2c61fbf750f --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.FulfillmentService/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "AotExample.FulfillmentService": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5247", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Mocha/examples/AotExample/AotExample.OrderService/AotExample.OrderService.csproj b/src/Mocha/examples/AotExample/AotExample.OrderService/AotExample.OrderService.csproj new file mode 100644 index 00000000000..9843f1ac432 --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.OrderService/AotExample.OrderService.csproj @@ -0,0 +1,19 @@ + + + AotExample.OrderService + true + true + + + + + + + + + + diff --git a/src/Mocha/examples/AotExample/AotExample.OrderService/Commands/PlaceOrderCommand.cs b/src/Mocha/examples/AotExample/AotExample.OrderService/Commands/PlaceOrderCommand.cs new file mode 100644 index 00000000000..01ce0ec7180 --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.OrderService/Commands/PlaceOrderCommand.cs @@ -0,0 +1,14 @@ +using Mocha.Mediator; + +namespace AotExample.OrderService.Commands; + +public sealed class PlaceOrderCommand : ICommand +{ + public required string ProductName { get; init; } + public required int Quantity { get; init; } +} + +public sealed class PlaceOrderResult +{ + public required string OrderId { get; init; } +} diff --git a/src/Mocha/examples/AotExample/AotExample.OrderService/Handlers/CheckInventoryRequestHandler.cs b/src/Mocha/examples/AotExample/AotExample.OrderService/Handlers/CheckInventoryRequestHandler.cs new file mode 100644 index 00000000000..ee2a12c362f --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.OrderService/Handlers/CheckInventoryRequestHandler.cs @@ -0,0 +1,38 @@ +using AotExample.Contracts.Requests; +using Mocha; + +namespace AotExample.OrderService.Handlers; + +public sealed class CheckInventoryRequestHandler(ILogger logger) + : IEventRequestHandler +{ + public ValueTask HandleAsync( + CheckInventoryRequest request, + CancellationToken cancellationToken) + { + var quantityOnHand = Random.Shared.Next(0, 20); + var isAvailable = quantityOnHand >= request.Quantity; + + logger.LogInventoryCheck( + request.ProductName, + quantityOnHand, + request.Quantity, + isAvailable ? "available" : "insufficient"); + + return new ValueTask( + new CheckInventoryResponse { IsAvailable = isAvailable, QuantityOnHand = quantityOnHand }); + } +} + +internal static partial class Logs +{ + [LoggerMessage( + Level = LogLevel.Information, + Message = "Inventory check for {ProductName}: {QuantityOnHand} on hand, requested {Quantity} - {Result}")] + public static partial void LogInventoryCheck( + this ILogger logger, + string productName, + int quantityOnHand, + int quantity, + string result); +} diff --git a/src/Mocha/examples/AotExample/AotExample.OrderService/Handlers/GetOrderStatusQueryHandler.cs b/src/Mocha/examples/AotExample/AotExample.OrderService/Handlers/GetOrderStatusQueryHandler.cs new file mode 100644 index 00000000000..16410feceb4 --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.OrderService/Handlers/GetOrderStatusQueryHandler.cs @@ -0,0 +1,20 @@ +using AotExample.OrderService.Queries; +using Mocha.Mediator; + +namespace AotExample.OrderService.Handlers; + +public sealed class GetOrderStatusQueryHandler + : IQueryHandler +{ + public ValueTask HandleAsync( + GetOrderStatusQuery query, + CancellationToken cancellationToken) + { + return new ValueTask( + new GetOrderStatusResponse + { + OrderId = query.OrderId, + Status = "Processing" + }); + } +} diff --git a/src/Mocha/examples/AotExample/AotExample.OrderService/Handlers/OrderShippedHandler.cs b/src/Mocha/examples/AotExample/AotExample.OrderService/Handlers/OrderShippedHandler.cs new file mode 100644 index 00000000000..0dad1898e97 --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.OrderService/Handlers/OrderShippedHandler.cs @@ -0,0 +1,25 @@ +using AotExample.Contracts.Events; +using AotExample.OrderService.Notifications; +using Mocha; +using Mocha.Mediator; + +namespace AotExample.OrderService.Handlers; + +public sealed class OrderShippedHandler(IPublisher publisher, ILogger logger) + : IEventHandler +{ + public async ValueTask HandleAsync(OrderShippedEvent message, CancellationToken cancellationToken) + { + logger.LogOrderShipped(message.OrderId, message.TrackingNumber); + + await publisher.PublishAsync( + new OrderStatusChangedNotification { OrderId = message.OrderId, Status = "Shipped" }, + cancellationToken); + } +} + +internal static partial class Logs +{ + [LoggerMessage(Level = LogLevel.Information, Message = "Order {OrderId} shipped with tracking {TrackingNumber}")] + public static partial void LogOrderShipped(this ILogger logger, string orderId, string trackingNumber); +} diff --git a/src/Mocha/examples/AotExample/AotExample.OrderService/Handlers/OrderStatusChangedHandler.cs b/src/Mocha/examples/AotExample/AotExample.OrderService/Handlers/OrderStatusChangedHandler.cs new file mode 100644 index 00000000000..f85aea563b3 --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.OrderService/Handlers/OrderStatusChangedHandler.cs @@ -0,0 +1,21 @@ +using AotExample.OrderService.Notifications; +using Mocha.Mediator; + +namespace AotExample.OrderService.Handlers; + +public sealed class OrderStatusChangedHandler(ILogger logger) + : INotificationHandler +{ + public ValueTask HandleAsync(OrderStatusChangedNotification notification, CancellationToken cancellationToken) + { + logger.LogOrderStatusChanged(notification.OrderId, notification.Status); + + return ValueTask.CompletedTask; + } +} + +internal static partial class Logs +{ + [LoggerMessage(Level = LogLevel.Information, Message = "Order {OrderId} status changed to {Status}")] + public static partial void LogOrderStatusChanged(this ILogger logger, string orderId, string status); +} diff --git a/src/Mocha/examples/AotExample/AotExample.OrderService/Handlers/PlaceOrderCommandHandler.cs b/src/Mocha/examples/AotExample/AotExample.OrderService/Handlers/PlaceOrderCommandHandler.cs new file mode 100644 index 00000000000..7a5a13926a0 --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.OrderService/Handlers/PlaceOrderCommandHandler.cs @@ -0,0 +1,23 @@ +using AotExample.OrderService.Commands; +using Mocha.Mediator; + +namespace AotExample.OrderService.Handlers; + +public sealed class PlaceOrderCommandHandler(ILogger logger) + : ICommandHandler +{ + public ValueTask HandleAsync(PlaceOrderCommand command, CancellationToken cancellationToken) + { + var orderId = Guid.NewGuid().ToString("N")[..8]; + + logger.LogOrderPlaced(orderId, command.Quantity, command.ProductName); + + return new ValueTask(new PlaceOrderResult { OrderId = orderId }); + } +} + +internal static partial class Logs +{ + [LoggerMessage(Level = LogLevel.Information, Message = "Order {OrderId} placed: {Quantity}x {ProductName}")] + public static partial void LogOrderPlaced(this ILogger logger, string orderId, int quantity, string productName); +} diff --git a/src/Mocha/examples/AotExample/AotExample.OrderService/Notifications/OrderStatusChangedNotification.cs b/src/Mocha/examples/AotExample/AotExample.OrderService/Notifications/OrderStatusChangedNotification.cs new file mode 100644 index 00000000000..23ca8b6f6b6 --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.OrderService/Notifications/OrderStatusChangedNotification.cs @@ -0,0 +1,10 @@ +using Mocha.Mediator; + +namespace AotExample.OrderService.Notifications; + +public sealed class OrderStatusChangedNotification : INotification +{ + public required string OrderId { get; init; } + + public required string Status { get; init; } +} diff --git a/src/Mocha/examples/AotExample/AotExample.OrderService/OrderServiceJsonContext.cs b/src/Mocha/examples/AotExample/AotExample.OrderService/OrderServiceJsonContext.cs new file mode 100644 index 00000000000..ada8c441f88 --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.OrderService/OrderServiceJsonContext.cs @@ -0,0 +1,7 @@ +using System.Text.Json.Serialization; +using AotExample.OrderService.Sagas; + +namespace AotExample.OrderService; + +[JsonSerializable(typeof(OrderSagaState))] +public partial class OrderServiceJsonContext : JsonSerializerContext; diff --git a/src/Mocha/examples/AotExample/AotExample.OrderService/OrderSimulatorWorker.cs b/src/Mocha/examples/AotExample/AotExample.OrderService/OrderSimulatorWorker.cs new file mode 100644 index 00000000000..28b2bb33da7 --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.OrderService/OrderSimulatorWorker.cs @@ -0,0 +1,98 @@ +using AotExample.Contracts.Events; +using AotExample.OrderService.Commands; +using AotExample.OrderService.Queries; +using Mocha; +using Mocha.Mediator; + +namespace AotExample.OrderService; + +public sealed class OrderSimulatorWorker( + IServiceScopeFactory scopeFactory, + ILogger logger +) : BackgroundService +{ + private static readonly string[] s_products = + [ + "Mechanical Keyboard", + "Wireless Mouse", + "USB-C Hub", + "Monitor Stand", + "Webcam HD", + "Noise-Cancelling Headphones" + ]; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Delay(3000, stoppingToken); + + logger.LogSimulatorStarted(); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var sender = scope.ServiceProvider.GetRequiredService(); + var messageBus = scope.ServiceProvider.GetRequiredService(); + + var product = s_products[Random.Shared.Next(s_products.Length)]; + var quantity = Random.Shared.Next(1, 6); + + // 1. Place order via mediator command + var result = await sender.SendAsync( + new PlaceOrderCommand { ProductName = product, Quantity = quantity }, + stoppingToken); + + // 2. Query order status via mediator query + var status = await sender.QueryAsync( + new GetOrderStatusQuery { OrderId = result.OrderId }, + stoppingToken); + + logger.LogOrderStatus(result.OrderId, status.Status); + + var correlationId = Guid.NewGuid(); + + // 3. Publish to bus - FulfillmentService will pick this up, saga will track it + + await messageBus.PublishAsync( + new OrderShippedEvent + { + OrderId = result.OrderId, + TrackingNumber = $"TRACK-{Random.Shared.Next(1000, 9999)}" + }, + stoppingToken); + await messageBus.PublishAsync( + new OrderPlacedEvent + { + OrderId = result.OrderId, + ProductName = product, + Quantity = quantity, + CorrelationId = correlationId + }, + stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + logger.LogSimulationError(ex); + } + + await Task.Delay(5000, stoppingToken); + } + } +} + +internal static partial class Logs +{ + [LoggerMessage(Level = LogLevel.Information, Message = "Order simulator started")] + public static partial void LogSimulatorStarted(this ILogger logger); + + [LoggerMessage(Level = LogLevel.Information, Message = "Order {OrderId} status: {Status}")] + public static partial void LogOrderStatus(this ILogger logger, string orderId, string status); + + [LoggerMessage(Level = LogLevel.Error, Message = "Error in order simulation")] + public static partial void LogSimulationError(this ILogger logger, Exception ex); +} diff --git a/src/Mocha/examples/AotExample/AotExample.OrderService/Program.cs b/src/Mocha/examples/AotExample/AotExample.OrderService/Program.cs new file mode 100644 index 00000000000..8303f3b5419 --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.OrderService/Program.cs @@ -0,0 +1,28 @@ +using AotExample.OrderService; +using Mocha; +using Mocha.Mediator; +using Mocha.Transport.RabbitMQ; +using RabbitMQ.Client; + +[assembly: MessagingModule("OrderService", JsonContext = typeof(OrderServiceJsonContext))] +[assembly: MediatorModule("OrderService")] + +var builder = WebApplication.CreateBuilder(args); + +builder.WebHost.UseUrls("http://localhost:52300"); + +builder.Services.AddSingleton( + new ConnectionFactory { HostName = "localhost", Port = 5673 } +); + +builder.Services.AddHostedService(); + +builder.Services.AddMessageBus().AddAotExampleContracts().AddOrderService().AddRabbitMQ(); + +builder.Services.AddMediator().AddOrderService(); + +var app = builder.Build(); + +app.MapGet("/", () => "Order Service (AOT Example)"); + +app.Run(); diff --git a/src/Mocha/examples/AotExample/AotExample.OrderService/Properties/launchSettings.json b/src/Mocha/examples/AotExample/AotExample.OrderService/Properties/launchSettings.json new file mode 100644 index 00000000000..66553d0b7a1 --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.OrderService/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5098", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Mocha/examples/AotExample/AotExample.OrderService/Queries/GetOrderStatusQuery.cs b/src/Mocha/examples/AotExample/AotExample.OrderService/Queries/GetOrderStatusQuery.cs new file mode 100644 index 00000000000..f4acae2726e --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.OrderService/Queries/GetOrderStatusQuery.cs @@ -0,0 +1,8 @@ +using Mocha.Mediator; + +namespace AotExample.OrderService.Queries; + +public sealed class GetOrderStatusQuery : IQuery +{ + public required string OrderId { get; init; } +} diff --git a/src/Mocha/examples/AotExample/AotExample.OrderService/Queries/GetOrderStatusResponse.cs b/src/Mocha/examples/AotExample/AotExample.OrderService/Queries/GetOrderStatusResponse.cs new file mode 100644 index 00000000000..7e2340cbe4e --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.OrderService/Queries/GetOrderStatusResponse.cs @@ -0,0 +1,8 @@ +namespace AotExample.OrderService.Queries; + +public sealed class GetOrderStatusResponse +{ + public required string OrderId { get; init; } + + public required string Status { get; init; } +} diff --git a/src/Mocha/examples/AotExample/AotExample.OrderService/Sagas/OrderSaga.cs b/src/Mocha/examples/AotExample/AotExample.OrderService/Sagas/OrderSaga.cs new file mode 100644 index 00000000000..8eee1e78de2 --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.OrderService/Sagas/OrderSaga.cs @@ -0,0 +1,35 @@ +using AotExample.Contracts.Events; +using Mocha.Sagas; + +namespace AotExample.OrderService.Sagas; + +public sealed class OrderSaga : Saga +{ + private const string AwaitingShipment = nameof(AwaitingShipment); + private const string Shipped = nameof(Shipped); + + protected override void Configure(ISagaDescriptor descriptor) + { + descriptor.Timeout(TimeSpan.FromSeconds(30)); + + descriptor + .Initially() + .OnEvent() + .StateFactory(e => new OrderSagaState + { + OrderId = e.OrderId, + ProductName = e.ProductName, + Quantity = e.Quantity + }) + .TransitionTo(AwaitingShipment); + + descriptor + .During(AwaitingShipment) + .OnEvent() + .Then((state, e) => state.TrackingNumber = e.TrackingNumber) + .TransitionTo(Shipped); + + descriptor + .Finally(Shipped); + } +} diff --git a/src/Mocha/examples/AotExample/AotExample.OrderService/Sagas/OrderSagaState.cs b/src/Mocha/examples/AotExample/AotExample.OrderService/Sagas/OrderSagaState.cs new file mode 100644 index 00000000000..3664116c69f --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.OrderService/Sagas/OrderSagaState.cs @@ -0,0 +1,14 @@ +using Mocha.Sagas; + +namespace AotExample.OrderService.Sagas; + +public class OrderSagaState : SagaStateBase +{ + public string OrderId { get; set; } = string.Empty; + + public string ProductName { get; set; } = string.Empty; + + public int Quantity { get; set; } + + public string? TrackingNumber { get; set; } +} diff --git a/src/Mocha/examples/AotExample/AotExample.slnx b/src/Mocha/examples/AotExample/AotExample.slnx new file mode 100644 index 00000000000..e96d4733e5c --- /dev/null +++ b/src/Mocha/examples/AotExample/AotExample.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/Mocha/examples/AotExample/Directory.Build.props b/src/Mocha/examples/AotExample/Directory.Build.props new file mode 100644 index 00000000000..813350dfb1e --- /dev/null +++ b/src/Mocha/examples/AotExample/Directory.Build.props @@ -0,0 +1,11 @@ + + + + net10.0 + net10.0 + + + + + + diff --git a/src/Mocha/examples/AotExample/docker-compose.yml b/src/Mocha/examples/AotExample/docker-compose.yml new file mode 100644 index 00000000000..4765fea30da --- /dev/null +++ b/src/Mocha/examples/AotExample/docker-compose.yml @@ -0,0 +1,9 @@ +services: + rabbitmq: + image: rabbitmq:4-management + ports: + - "5673:5672" + - "15673:15672" + environment: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest diff --git a/src/Mocha/src/.gitignore b/src/Mocha/src/.gitignore index 0e2b7f4aa32..1f4674a85bd 100644 --- a/src/Mocha/src/.gitignore +++ b/src/Mocha/src/.gitignore @@ -3,3 +3,5 @@ mocha-visualizer/* !mocha-visualizer/package.json !mocha-visualizer/README.md !mocha-visualizer/dist/ + +Generated diff --git a/src/Mocha/src/Examples/Observability/OpenTelemetry/OpenTelemetryExample.cs b/src/Mocha/src/Examples/Observability/OpenTelemetry/OpenTelemetryExample.cs index fda91d42cbb..b44b5686ff0 100644 --- a/src/Mocha/src/Examples/Observability/OpenTelemetry/OpenTelemetryExample.cs +++ b/src/Mocha/src/Examples/Observability/OpenTelemetry/OpenTelemetryExample.cs @@ -39,7 +39,7 @@ // Spans are emitted to the "Mocha" activity source - subscribe via AddSource("Mocha"). .AddInstrumentation() // Register a custom listener alongside the built-in one for application-level telemetry. - // Multiple listeners compose automatically — no manual aggregation needed. + // Multiple listeners compose automatically - no manual aggregation needed. .AddDiagnosticEventListener() .AddEventHandler() .AddInMemory(); @@ -91,7 +91,7 @@ public ValueTask HandleAsync( // --- Custom diagnostic listener --- // Extend MessagingDiagnosticEventListener to collect telemetry or integrate with a -// non-OpenTelemetry backend. Override only the methods you care about — the base +// non-OpenTelemetry backend. Override only the methods you care about - the base // class provides no-op defaults for the rest. Each scope method returns an // IDisposable whose disposal marks the end of the observed scope. public sealed class ConsoleDiagnosticObserver : MessagingDiagnosticEventListener diff --git a/src/Mocha/src/Mocha.Abstractions/MessagingModuleAttribute.cs b/src/Mocha/src/Mocha.Abstractions/MessagingModuleAttribute.cs index d594591e9b3..09721fa9a73 100644 --- a/src/Mocha/src/Mocha.Abstractions/MessagingModuleAttribute.cs +++ b/src/Mocha/src/Mocha.Abstractions/MessagingModuleAttribute.cs @@ -23,4 +23,10 @@ public MessagingModuleAttribute(string name) /// Gets the module name. /// public string Name { get; } + + /// + /// Gets or sets the + /// type to use for AOT-compatible JSON serialization of messages and saga state. + /// + public Type? JsonContext { get; set; } } diff --git a/src/Mocha/src/Mocha.Abstractions/MessagingModuleInfoAttribute.cs b/src/Mocha/src/Mocha.Abstractions/MessagingModuleInfoAttribute.cs new file mode 100644 index 00000000000..dd67a406534 --- /dev/null +++ b/src/Mocha/src/Mocha.Abstractions/MessagingModuleInfoAttribute.cs @@ -0,0 +1,25 @@ +namespace Mocha; + +/// +/// Annotates a generated messaging registration method with metadata about the message types +/// it registers. This enables cross-project analyzers to discover types registered by +/// referenced modules without scanning assemblies. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class MessagingModuleInfoAttribute : Attribute +{ + /// + /// Gets or sets the message types registered by this module. + /// + public Type[] MessageTypes { get; set; } = []; + + /// + /// Gets or sets the saga types registered by this module. + /// + public Type[] SagaTypes { get; set; } = []; + + /// + /// Gets or sets the handler types registered by this module. + /// + public Type[] HandlerTypes { get; set; } = []; +} diff --git a/src/Mocha/src/Mocha.Abstractions/Mocha.Abstractions.csproj b/src/Mocha/src/Mocha.Abstractions/Mocha.Abstractions.csproj index ad7f4fa0fdf..53d56ad1d15 100644 --- a/src/Mocha/src/Mocha.Abstractions/Mocha.Abstractions.csproj +++ b/src/Mocha/src/Mocha.Abstractions/Mocha.Abstractions.csproj @@ -4,6 +4,7 @@ Mocha.Abstractions enable enable + true diff --git a/src/Mocha/src/Mocha.Analyzers/AnalyzerReleases.Unshipped.md b/src/Mocha/src/Mocha.Analyzers/AnalyzerReleases.Unshipped.md index 7415218a67e..d34419fbe27 100644 --- a/src/Mocha/src/Mocha.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/Mocha/src/Mocha.Analyzers/AnalyzerReleases.Unshipped.md @@ -7,7 +7,12 @@ MO0002 | Mediator | Error | Message type has multiple handlers MO0003 | Mediator | Warning | Handler is abstract and will not be registered MO0004 | Mediator | Info | Open generic message type cannot be dispatched MO0005 | Mediator | Error | Handler implements multiple mediator handler interfaces +MO0006 | Mediator | Info | Open generic handler cannot be auto-registered MO0011 | Messaging | Error | Request type has multiple handlers MO0012 | Messaging | Info | Open generic messaging handler cannot be auto-registered MO0013 | Messaging | Warning | Messaging handler is abstract MO0014 | Messaging | Error | Saga must have a public parameterless constructor +MO0015 | Messaging | Error | Missing JsonSerializerContext for AOT +MO0016 | Messaging | Error | Missing JsonSerializable attribute +MO0018 | Messaging | Warning | Type not in JsonSerializerContext +MO0020 | Mediator | Warning | Command/query sent but no handler found diff --git a/src/Mocha/src/Mocha.Analyzers/Errors.cs b/src/Mocha/src/Mocha.Analyzers/Errors.cs index 1e25bb72393..980a38f5933 100644 --- a/src/Mocha/src/Mocha.Analyzers/Errors.cs +++ b/src/Mocha/src/Mocha.Analyzers/Errors.cs @@ -84,6 +84,24 @@ public static class Errors defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); + /// + /// Gets the descriptor for MO0006: a mediator handler is declared with unbound type parameters. + /// + /// + /// Reported as an info on the handler declaration when it has unbound type parameters. + /// Source-generated registration is skipped for open generic handlers because there is no + /// closed type to register. Manual registration of a closed form (for example + /// AddHandler<ConcreteHandler<MyType>>()) remains supported; suppress the + /// diagnostic on the declaration if the handler is registered that way intentionally. + /// + public static readonly DiagnosticDescriptor OpenGenericHandler = new( + id: "MO0006", + title: "Open generic handler cannot be auto-registered", + messageFormat: "Handler '{0}' has unbound type parameters; source-generated registration is skipped. Register the closed form manually (e.g. AddHandler>()) if intentional.", + category: "Mediator", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true); + /// /// Gets the descriptor for MO0011: a request type has more than one handler. /// @@ -143,4 +161,67 @@ public static class Errors category: "Messaging", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); + + /// + /// Gets the descriptor for MO0015: a messaging module must specify a JsonSerializerContext + /// when publishing for AOT. + /// + /// + /// Reported as an error when PublishAot is true but the + /// [assembly: MessagingModule] attribute does not include a JsonContext property. + /// + public static readonly DiagnosticDescriptor MissingJsonSerializerContext = new( + id: "MO0015", + title: "Missing JsonSerializerContext for AOT", + messageFormat: "MessagingModule '{0}' must specify JsonContext when publishing for AOT. Add JsonContext = typeof(YourJsonContext) to the MessagingModule attribute.", + category: "Messaging", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// Gets the descriptor for MO0016: a message type is not included in the JsonSerializerContext. + /// + /// + /// Reported as an error when a type is used as a message, request, response, or saga state + /// but is not declared via [JsonSerializable(typeof(...))] on the specified + /// JsonSerializerContext. + /// + public static readonly DiagnosticDescriptor MissingJsonSerializable = new( + id: "MO0016", + title: "Missing JsonSerializable attribute", + messageFormat: "Type '{0}' is used as a message type but is not included in JsonSerializerContext '{1}'. Add [JsonSerializable(typeof({0}))] to the context.", + category: "Messaging", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// Gets the descriptor for MO0018: a type used at a call site is not in the JsonSerializerContext. + /// + /// + /// Reported as a warning when AOT publishing is enabled and a message type used in a + /// dispatch call is not declared via [JsonSerializable(typeof(...))] on the specified + /// JsonSerializerContext. + /// + public static readonly DiagnosticDescriptor CallSiteTypeNotInJsonContext = new( + id: "MO0018", + title: "Type not in JsonSerializerContext", + messageFormat: "Type '{0}' is used in a {1} call but is not included in JsonSerializerContext '{2}'. Add [JsonSerializable(typeof({0}))] to the context.", + category: "Messaging", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + /// + /// Gets the descriptor for MO0020: a command or query is sent but no handler was found. + /// + /// + /// Reported as a warning when a command or query type is dispatched via ISender + /// but no corresponding handler implementation is found in the compilation. + /// + public static readonly DiagnosticDescriptor CallSiteNoHandler = new( + id: "MO0020", + title: "Command/query sent but no handler found", + messageFormat: "Type '{0}' is sent via {1} but no handler was found in this assembly. Ensure a handler is registered.", + category: "Mediator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); } diff --git a/src/Mocha/src/Mocha.Analyzers/FileBuilders/DependencyInjectionFileBuilder.cs b/src/Mocha/src/Mocha.Analyzers/FileBuilders/DependencyInjectionFileBuilder.cs index 4b4f6244605..f7758d3aea1 100644 --- a/src/Mocha/src/Mocha.Analyzers/FileBuilders/DependencyInjectionFileBuilder.cs +++ b/src/Mocha/src/Mocha.Analyzers/FileBuilders/DependencyInjectionFileBuilder.cs @@ -32,8 +32,62 @@ public override void WriteBeginClass() Writer.IncreaseIndent(); } - public void WriteBeginRegistrationMethod() + /// + /// Writes the opening of the registration extension method, including the + /// [MediatorModuleInfo] attribute with the message and handler types arrays. + /// + /// + /// The fully qualified message type names to include in the attribute, or to omit. + /// + /// + /// The fully qualified handler type names to include in the attribute, or to omit. + /// + public void WriteBeginRegistrationMethod( + IReadOnlyList? messageTypeNames = null, + IReadOnlyList? handlerTypeNames = null) { + var hasMessages = messageTypeNames is { Count: > 0 }; + var hasHandlers = handlerTypeNames is { Count: > 0 }; + + if (hasMessages || hasHandlers) + { + // Build the list of property assignments to emit, so we can handle + // comma placement correctly (no trailing comma on the last property). + var properties = new List<(string Name, IReadOnlyList Types)>(); + + if (hasMessages) + { + properties.Add(("MessageTypes", messageTypeNames!)); + } + + if (hasHandlers) + { + properties.Add(("HandlerTypes", handlerTypeNames!)); + } + + Writer.WriteIndentedLine("[global::Mocha.Mediator.MediatorModuleInfo("); + Writer.IncreaseIndent(); + + for (var i = 0; i < properties.Count; i++) + { + var (name, types) = properties[i]; + var isLast = i == properties.Count - 1; + + Writer.WriteIndentedLine("{0} = new global::System.Type[]", name); + Writer.WriteIndentedLine("{"); + Writer.IncreaseIndent(); + foreach (var typeName in types) + { + Writer.WriteIndentedLine("typeof({0}),", typeName); + } + Writer.DecreaseIndent(); + Writer.WriteIndentedLine(isLast ? "}" : "},"); + } + + Writer.DecreaseIndent(); + Writer.WriteIndentedLine(")]"); + } + Writer.WriteIndentedLine("public static global::Mocha.Mediator.IMediatorHostBuilder {0}(", _methodName); Writer.IncreaseIndent(); Writer.WriteIndentedLine("this global::Mocha.Mediator.IMediatorHostBuilder builder)"); @@ -72,9 +126,7 @@ public void WriteHandlerConfiguration(HandlerInfo handler) /// /// Writes an AddHandlerConfiguration call for a notification handler. /// - public void WriteNotificationHandlerConfiguration( - string notificationType, - NotificationHandlerInfo handler) + public void WriteNotificationHandlerConfiguration(string notificationType, NotificationHandlerInfo handler) { WriteAddHandlerConfiguration( handler.HandlerTypeName, diff --git a/src/Mocha/src/Mocha.Analyzers/FileBuilders/MessagingDependencyInjectionFileBuilder.cs b/src/Mocha/src/Mocha.Analyzers/FileBuilders/MessagingDependencyInjectionFileBuilder.cs index f5d32a07fb7..e4b4ec385ab 100644 --- a/src/Mocha/src/Mocha.Analyzers/FileBuilders/MessagingDependencyInjectionFileBuilder.cs +++ b/src/Mocha/src/Mocha.Analyzers/FileBuilders/MessagingDependencyInjectionFileBuilder.cs @@ -41,10 +41,71 @@ public override void WriteBeginClass() } /// - /// Writes the opening of the registration extension method. + /// Writes the opening of the registration extension method, including the + /// [MessagingModuleInfo] attribute with the message, saga, and handler types arrays. /// - public void WriteBeginRegistrationMethod() + /// + /// The fully qualified message type names to include in the attribute, or to omit. + /// + /// + /// The fully qualified saga type names to include in the attribute, or to omit. + /// + /// + /// The fully qualified handler type names to include in the attribute, or to omit. + /// + public void WriteBeginRegistrationMethod( + IReadOnlyList? messageTypeNames = null, + IReadOnlyList? sagaTypeNames = null, + IReadOnlyList? handlerTypeNames = null) { + var hasMessages = messageTypeNames is { Count: > 0 }; + var hasSagas = sagaTypeNames is { Count: > 0 }; + var hasHandlers = handlerTypeNames is { Count: > 0 }; + + if (hasMessages || hasSagas || hasHandlers) + { + // Build the list of property assignments to emit, so we can handle + // comma placement correctly (no trailing comma on the last property). + var properties = new List<(string Name, IReadOnlyList Types)>(); + + if (hasMessages) + { + properties.Add(("MessageTypes", messageTypeNames!)); + } + + if (hasSagas) + { + properties.Add(("SagaTypes", sagaTypeNames!)); + } + + if (hasHandlers) + { + properties.Add(("HandlerTypes", handlerTypeNames!)); + } + + Writer.WriteIndentedLine("[global::Mocha.MessagingModuleInfo("); + Writer.IncreaseIndent(); + + for (var i = 0; i < properties.Count; i++) + { + var (name, types) = properties[i]; + var isLast = i == properties.Count - 1; + + Writer.WriteIndentedLine("{0} = new global::System.Type[]", name); + Writer.WriteIndentedLine("{"); + Writer.IncreaseIndent(); + foreach (var typeName in types) + { + Writer.WriteIndentedLine("typeof({0}),", typeName); + } + Writer.DecreaseIndent(); + Writer.WriteIndentedLine(isLast ? "}" : "},"); + } + + Writer.DecreaseIndent(); + Writer.WriteIndentedLine(")]"); + } + Writer.WriteIndentedLine("public static global::Mocha.IMessageBusHostBuilder {0}(", _methodName); Writer.IncreaseIndent(); Writer.WriteIndentedLine("this global::Mocha.IMessageBusHostBuilder builder)"); @@ -53,6 +114,115 @@ public void WriteBeginRegistrationMethod() Writer.IncreaseIndent(); } + /// + /// Writes the strict mode configuration that requires explicit message type registration. + /// + public void WriteStrictModeConfiguration() + { + Writer.WriteIndentedLine("global::Mocha.MessageBusHostBuilderExtensions.ModifyOptions("); + Writer.IncreaseIndent(); + Writer.WriteIndentedLine("builder,"); + Writer.WriteIndentedLine("static o => o.IsAotCompatible = true);"); + Writer.DecreaseIndent(); + } + + /// + /// Writes the registration of a JsonSerializerContext as a type info resolver. + /// + /// The fully qualified type name of the JsonSerializerContext. + public void WriteJsonTypeInfoResolverRegistration(string jsonContextTypeName) + { + Writer.WriteIndentedLine("global::Mocha.MessageBusHostBuilderExtensions.AddJsonTypeInfoResolver("); + Writer.IncreaseIndent(); + Writer.WriteIndentedLine("builder,"); + Writer.WriteIndentedLine("{0}.Default);", jsonContextTypeName); + Writer.DecreaseIndent(); + } + + /// + /// Writes a message configuration registration with a pre-built JSON serializer. + /// + /// The fully qualified message type name. + /// The fully qualified type name of the JsonSerializerContext. + /// + /// The pre-computed enclosed types sorted by specificity (including framework base types + /// such as IEventRequest and closed IEventRequest<T>), or + /// to omit. + /// + public void WriteMessageConfiguration( + string messageTypeName, + string jsonContextTypeName, + IReadOnlyList? enclosedTypes = null) + { + Writer.WriteIndentedLine("global::Mocha.MessageBusHostBuilderExtensions.AddMessageConfiguration("); + Writer.IncreaseIndent(); + Writer.WriteIndentedLine("builder,"); + Writer.WriteIndentedLine("new global::Mocha.MessagingMessageConfiguration"); + Writer.WriteIndentedLine("{"); + Writer.IncreaseIndent(); + Writer.WriteIndentedLine("MessageType = typeof({0}),", messageTypeName); + Writer.WriteIndentedLine("Serializer = new global::Mocha.JsonMessageSerializer("); + Writer.IncreaseIndent(); + Writer.WriteIndentedLine("{0}.Default.GetTypeInfo(typeof({1}))!),", jsonContextTypeName, messageTypeName); + Writer.DecreaseIndent(); + + if (enclosedTypes is { Count: > 0 }) + { + Writer.WriteIndentedLine( + "EnclosedTypes = global::System.Collections.Immutable.ImmutableArray.Create("); + Writer.IncreaseIndent(); + for (var i = 0; i < enclosedTypes.Count; i++) + { + var typeName = enclosedTypes[i]; + var isLast = i == enclosedTypes.Count - 1; + Writer.WriteIndentedLine(isLast ? "typeof({0})" : "typeof({0}),", typeName); + } + Writer.DecreaseIndent(); + Writer.WriteIndentedLine("),"); + } + + Writer.DecreaseIndent(); + Writer.WriteIndentedLine("});"); + Writer.DecreaseIndent(); + } + + /// + /// Writes a saga configuration registration. When a + /// is provided, includes a pre-built JSON state serializer; otherwise emits a configuration + /// with only the saga type. + /// + /// The fully qualified saga type name. + /// The fully qualified saga state type name. + /// + /// The fully qualified type name of the JsonSerializerContext, or + /// to omit the state serializer. + /// + public void WriteSagaConfiguration(string sagaTypeName, string stateTypeName, string? jsonContextTypeName) + { + Writer.WriteIndentedLine("global::Mocha.MessageBusHostBuilderExtensions.AddSagaConfiguration<"); + Writer.IncreaseIndent(); + Writer.WriteIndentedLine("{0}>(", sagaTypeName); + Writer.DecreaseIndent(); + Writer.IncreaseIndent(); + Writer.WriteIndentedLine("builder,"); + Writer.WriteIndentedLine("new global::Mocha.MessagingSagaConfiguration"); + Writer.WriteIndentedLine("{"); + Writer.IncreaseIndent(); + Writer.WriteIndentedLine("SagaType = typeof({0}),", sagaTypeName); + + if (jsonContextTypeName is not null) + { + Writer.WriteIndentedLine("StateSerializer = new global::Mocha.Sagas.JsonSagaStateSerializer("); + Writer.IncreaseIndent(); + Writer.WriteIndentedLine("{0}.Default.GetTypeInfo(typeof({1}))!),", jsonContextTypeName, stateTypeName); + Writer.DecreaseIndent(); + } + + Writer.DecreaseIndent(); + Writer.WriteIndentedLine("});"); + Writer.DecreaseIndent(); + } + /// /// Writes a handler registration call for the specified messaging handler. /// @@ -61,16 +231,12 @@ public void WriteHandlerRegistration(MessagingHandlerInfo handler) { var factoryCall = handler.Kind switch { - MessagingHandlerKind.Event => - $"Subscribe<{handler.HandlerTypeName}, {handler.MessageTypeName}>()", - MessagingHandlerKind.Send => - $"Send<{handler.HandlerTypeName}, {handler.MessageTypeName}>()", + MessagingHandlerKind.Event => $"Subscribe<{handler.HandlerTypeName}, {handler.MessageTypeName}>()", + MessagingHandlerKind.Send => $"Send<{handler.HandlerTypeName}, {handler.MessageTypeName}>()", MessagingHandlerKind.RequestResponse => $"Request<{handler.HandlerTypeName}, {handler.MessageTypeName}, {handler.ResponseTypeName}>()", - MessagingHandlerKind.Consumer => - $"Consume<{handler.HandlerTypeName}, {handler.MessageTypeName}>()", - MessagingHandlerKind.Batch => - $"Batch<{handler.HandlerTypeName}, {handler.MessageTypeName}>()", + MessagingHandlerKind.Consumer => $"Consume<{handler.HandlerTypeName}, {handler.MessageTypeName}>()", + MessagingHandlerKind.Batch => $"Batch<{handler.HandlerTypeName}, {handler.MessageTypeName}>()", _ => throw new ArgumentOutOfRangeException() }; @@ -88,19 +254,6 @@ public void WriteHandlerRegistration(MessagingHandlerInfo handler) Writer.DecreaseIndent(); } - /// - /// Writes a saga registration call for the specified saga. - /// - /// The saga info to register. - public void WriteSagaRegistration(SagaInfo saga) - { - Writer.WriteIndentedLine( - "global::Mocha.MessageBusHostBuilderExtensions.AddSaga<"); - Writer.IncreaseIndent(); - Writer.WriteIndentedLine("{0}>(builder);", saga.SagaTypeName); - Writer.DecreaseIndent(); - } - /// /// Writes a section comment to visually separate groups of registrations. /// diff --git a/src/Mocha/src/Mocha.Analyzers/Filters/InvocationCallSiteFilter.cs b/src/Mocha/src/Mocha.Analyzers/Filters/InvocationCallSiteFilter.cs new file mode 100644 index 00000000000..1f62ac16bed --- /dev/null +++ b/src/Mocha/src/Mocha.Analyzers/Filters/InvocationCallSiteFilter.cs @@ -0,0 +1,41 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Mocha.Analyzers.Filters; + +/// +/// Provides a singleton that matches invocation expressions +/// where the method name is one of the known message dispatch methods. This is a cheap +/// syntactic check - no semantic analysis is performed. +/// +public sealed class InvocationCallSiteFilter : ISyntaxFilter +{ + private InvocationCallSiteFilter() { } + + /// + public bool IsMatch(SyntaxNode node) + => node is InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax memberAccess } + && IsDispatchMethodName(GetMethodName(memberAccess)); + + /// + /// Gets the singleton instance of . + /// + public static InvocationCallSiteFilter Instance { get; } = new(); + + private static string? GetMethodName(MemberAccessExpressionSyntax memberAccess) + => memberAccess.Name switch + { + GenericNameSyntax generic => generic.Identifier.Text, + IdentifierNameSyntax identifier => identifier.Identifier.Text, + _ => null + }; + + private static bool IsDispatchMethodName(string? name) + => name + is "SendAsync" + or "PublishAsync" + or "QueryAsync" + or "ScheduleSendAsync" + or "SchedulePublishAsync" + or "RequestAsync"; +} diff --git a/src/Mocha/src/Mocha.Analyzers/Filters/InvocationModuleFilter.cs b/src/Mocha/src/Mocha.Analyzers/Filters/InvocationModuleFilter.cs new file mode 100644 index 00000000000..896cc1abcc9 --- /dev/null +++ b/src/Mocha/src/Mocha.Analyzers/Filters/InvocationModuleFilter.cs @@ -0,0 +1,29 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Mocha.Analyzers.Filters; + +/// +/// Provides a singleton that matches invocation expressions +/// where the non-generic method name starts with "Add" and the invocation has no arguments. +/// This is a cheap syntactic pre-screen for module registration methods like +/// builder.AddOrderService(). +/// +public sealed class InvocationModuleFilter : ISyntaxFilter +{ + private InvocationModuleFilter() { } + + /// + public bool IsMatch(SyntaxNode node) + => node is InvocationExpressionSyntax + { + ArgumentList.Arguments.Count: 0, + Expression: MemberAccessExpressionSyntax { Name: IdentifierNameSyntax identifier } + } + && identifier.Identifier.Text.StartsWith("Add", StringComparison.Ordinal); + + /// + /// Gets the singleton instance of . + /// + public static InvocationModuleFilter Instance { get; } = new(); +} diff --git a/src/Mocha/src/Mocha.Analyzers/Generators/DependencyInjectionGenerator.cs b/src/Mocha/src/Mocha.Analyzers/Generators/DependencyInjectionGenerator.cs index 197cff1c0c9..70c15829f21 100644 --- a/src/Mocha/src/Mocha.Analyzers/Generators/DependencyInjectionGenerator.cs +++ b/src/Mocha/src/Mocha.Analyzers/Generators/DependencyInjectionGenerator.cs @@ -35,6 +35,30 @@ public void Generate( return; } + // Collect all unique message types for the [MediatorModuleInfo] attribute. + var allMessageTypeNames = new HashSet(StringComparer.Ordinal); + + foreach (var handler in handlers) + { + allMessageTypeNames.Add(handler.MessageTypeName); + } + + foreach (var handler in notificationHandlers) + { + allMessageTypeNames.Add(handler.NotificationTypeName); + } + + var sortedMessageTypeNames = allMessageTypeNames + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + var sortedHandlerTypeNames = handlers + .Select(h => h.HandlerTypeName) + .Concat(notificationHandlers.Select(h => h.HandlerTypeName)) + .Distinct(StringComparer.Ordinal) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + using var builder = new DependencyInjectionFileBuilder(moduleName, assemblyName); var notificationGroups = notificationHandlers @@ -45,7 +69,7 @@ public void Generate( builder.WriteHeader(); builder.WriteBeginNamespace(); builder.WriteBeginClass(); - builder.WriteBeginRegistrationMethod(); + builder.WriteBeginRegistrationMethod(sortedMessageTypeNames, sortedHandlerTypeNames); // Register all handler configurations if (handlers.Count > 0 || notificationGroups.Count > 0) diff --git a/src/Mocha/src/Mocha.Analyzers/Generators/MessagingDependencyInjectionGenerator.cs b/src/Mocha/src/Mocha.Analyzers/Generators/MessagingDependencyInjectionGenerator.cs index 5b3ef33825b..cd4e6c050bb 100644 --- a/src/Mocha/src/Mocha.Analyzers/Generators/MessagingDependencyInjectionGenerator.cs +++ b/src/Mocha/src/Mocha.Analyzers/Generators/MessagingDependencyInjectionGenerator.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Mocha.Analyzers.FileBuilders; +using Mocha.Analyzers.Utils; namespace Mocha.Analyzers.Generators; @@ -31,17 +32,235 @@ public void Generate( .OrderBy(s => s.OrderByKey) .ToList(); - if (handlers.Count == 0 && sagas.Count == 0) + var contextOnlyTypes = syntaxInfos + .OfType() + .OrderBy(c => c.OrderByKey) + .ToList(); + + if (handlers.Count == 0 && sagas.Count == 0 && contextOnlyTypes.Count == 0) { return; } + // Find the module info to check for JsonContext. + string? jsonContextTypeName = null; + var isAotPublish = false; + + foreach (var info in syntaxInfos) + { + if (jsonContextTypeName is null && info is MessagingModuleInfo moduleInfo) + { + jsonContextTypeName = moduleInfo.JsonContextTypeName; + } + + if (info is AotPublishInfo aotPublishInfo) + { + isAotPublish = aotPublishInfo.IsAotPublish; + } + } + + // Collect type names imported from module methods invoked in this compilation. + // Those module methods have already registered their own message, saga, and handler metadata. + var importedTypeNames = new HashSet(StringComparer.Ordinal); + var importedSagaTypeNames = new HashSet(StringComparer.Ordinal); + var importedHandlerTypeNames = new HashSet(StringComparer.Ordinal); + + // Collect type names that are actually declared in the local JsonSerializerContext. + var jsonContextTypeNames = new HashSet(StringComparer.Ordinal); + + foreach (var info in syntaxInfos) + { + if (info is ImportedModuleTypesInfo imported) + { + foreach (var typeName in imported.ImportedTypeNames) + { + importedTypeNames.Add(typeName); + } + + foreach (var typeName in imported.ImportedSagaTypeNames) + { + importedSagaTypeNames.Add(typeName); + } + + foreach (var typeName in imported.ImportedHandlerTypeNames) + { + importedHandlerTypeNames.Add(typeName); + } + } + else if (info is JsonContextSerializableTypesInfo jsonContextTypes) + { + foreach (var typeName in jsonContextTypes.TypeNames) + { + jsonContextTypeNames.Add(typeName); + } + } + } + + // Collect all unique message types that this module actually registers + // serializers for via AddMessageConfiguration. Only types in the local + // JsonSerializerContext and not already registered by invoked modules qualify. + var messageTypes = new HashSet(StringComparer.Ordinal); + + if (jsonContextTypeName is not null) + { + foreach (var handler in handlers) + { + if (!importedTypeNames.Contains(handler.MessageTypeName) + && jsonContextTypeNames.Contains(handler.MessageTypeName)) + { + messageTypes.Add(handler.MessageTypeName); + } + + if (handler.ResponseTypeName is not null + && !importedTypeNames.Contains(handler.ResponseTypeName) + && jsonContextTypeNames.Contains(handler.ResponseTypeName)) + { + messageTypes.Add(handler.ResponseTypeName); + } + } + + foreach (var contextOnly in contextOnlyTypes) + { + if (!importedTypeNames.Contains(contextOnly.MessageTypeName)) + { + messageTypes.Add(contextOnly.MessageTypeName); + } + } + } + + var sortedMessageTypeNames = messageTypes + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + var sortedSagaTypeNames = sagas + .Select(s => s.SagaTypeName) + .Distinct(StringComparer.Ordinal) + .Where(t => !importedSagaTypeNames.Contains(t)) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + + var sortedHandlerTypeNames = handlers + .Select(h => h.HandlerTypeName) + .Distinct(StringComparer.Ordinal) + .Where(t => !importedHandlerTypeNames.Contains(t)) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + using var builder = new MessagingDependencyInjectionFileBuilder(moduleName, assemblyName); builder.WriteHeader(); builder.WriteBeginNamespace(); builder.WriteBeginClass(); - builder.WriteBeginRegistrationMethod(); + builder.WriteBeginRegistrationMethod(sortedMessageTypeNames, sortedSagaTypeNames, sortedHandlerTypeNames); + + // When JsonContext is specified, emit AOT registrations at the top of the method. + if (jsonContextTypeName is not null) + { + builder.WriteSectionComment("AOT Configuration"); + if (isAotPublish) + { + builder.WriteStrictModeConfiguration(); + } + + builder.WriteJsonTypeInfoResolverRegistration(jsonContextTypeName); + + // Framework base types are emitted as typeof(...) so MessageType.Complete + // can resolve their URN through the runtime naming convention. + var enclosedTypesMap = new Dictionary>(StringComparer.Ordinal); + + // Build a lookup from message type name to its full hierarchy. + var hierarchyLookup = new Dictionary>(StringComparer.Ordinal); + foreach (var handler in handlers) + { + if (!hierarchyLookup.ContainsKey(handler.MessageTypeName)) + { + hierarchyLookup[handler.MessageTypeName] = handler.MessageTypeHierarchy; + } + } + + foreach (var contextOnly in contextOnlyTypes) + { + if (!hierarchyLookup.ContainsKey(contextOnly.MessageTypeName)) + { + hierarchyLookup[contextOnly.MessageTypeName] = contextOnly.MessageTypeHierarchy; + } + } + + foreach (var messageTypeName in messageTypes) + { + // Start with the type itself. + var enclosed = new List { messageTypeName }; + + // Walk the hierarchy; include registered user types and framework base types. + if (hierarchyLookup.TryGetValue(messageTypeName, out var hierarchy)) + { + foreach (var typeName in hierarchy) + { + if (enclosed.Contains(typeName)) + { + continue; + } + + if (messageTypes.Contains(typeName) || IsFrameworkBaseType(typeName)) + { + enclosed.Add(typeName); + } + } + } + + // Precompute specificity scores; List.Sort reorders during comparison, + // so scoring inside the comparator produces non-deterministic results. + var scores = new Dictionary(enclosed.Count, StringComparer.Ordinal); + + foreach (var typeName in enclosed) + { + if (!hierarchyLookup.TryGetValue(typeName, out var h)) + { + scores[typeName] = 0; + continue; + } + + var score = 0; + foreach (var other in enclosed) + { + if (other != typeName && h.Contains(other)) + { + score++; + } + } + scores[typeName] = score; + } + + enclosed.Sort((a, b) => scores[b].CompareTo(scores[a])); + + enclosedTypesMap[messageTypeName] = enclosed; + } + + builder.WriteSectionComment("Message Type Serializers"); + + foreach (var messageType in messageTypes.OrderBy(t => t, StringComparer.Ordinal)) + { + enclosedTypesMap.TryGetValue(messageType, out var enclosedTypes); + builder.WriteMessageConfiguration( + messageType, + jsonContextTypeName, + enclosedTypes); + } + } + + if (sagas.Count > 0) + { + builder.WriteSectionComment("Saga Configuration"); + + foreach (var saga in sagas) + { + var sagaJsonContextTypeName = jsonContextTypeNames.Contains(saga.StateTypeName) + ? jsonContextTypeName + : null; + + builder.WriteSagaConfiguration(saga.SagaTypeName, saga.StateTypeName, sagaJsonContextTypeName); + } + } var batchHandlers = handlers .Where(h => h.Kind == MessagingHandlerKind.Batch) @@ -103,20 +322,26 @@ public void Generate( } } - if (sagas.Count > 0) - { - builder.WriteSectionComment("Sagas"); - - foreach (var saga in sagas) - { - builder.WriteSagaRegistration(saga); - } - } - builder.WriteEndRegistrationMethod(); builder.WriteEndClass(); builder.WriteEndNamespace(); addSource(builder.HintName + ".g.cs", builder.ToSourceText()); } + + /// + /// Determines whether the fully qualified display string refers to a framework base type + /// (Mocha.IEventRequest or closed Mocha.IEventRequest<T>) that must + /// flow into EnclosedTypes without being registered as a standalone message type. + /// + private static bool IsFrameworkBaseType(string fullyQualifiedName) + { + if (fullyQualifiedName == SyntaxConstants.IEventRequestDisplay) + { + return true; + } + + return fullyQualifiedName.StartsWith(SyntaxConstants.IEventRequestOfTDisplayPrefix, StringComparison.Ordinal) + && fullyQualifiedName.EndsWith(">", StringComparison.Ordinal); + } } diff --git a/src/Mocha/src/Mocha.Analyzers/Inspectors/CallSiteMessageTypeInspector.cs b/src/Mocha/src/Mocha.Analyzers/Inspectors/CallSiteMessageTypeInspector.cs new file mode 100644 index 00000000000..202de48e19f --- /dev/null +++ b/src/Mocha/src/Mocha.Analyzers/Inspectors/CallSiteMessageTypeInspector.cs @@ -0,0 +1,287 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Mocha.Analyzers.Filters; +using Mocha.Analyzers.Utils; + +namespace Mocha.Analyzers.Inspectors; + +/// +/// Inspects dispatch call sites (SendAsync, PublishAsync, RequestAsync, +/// QueryAsync, etc.) on IMessageBus, ISender, and IPublisher +/// to extract the compile-time message and response types being dispatched. +/// +/// +/// +/// The extracted types feed downstream generators and analyzers that verify serializer +/// registrations (MO0018) and handler existence (MO0020). +/// +/// +/// Type extraction varies by method shape: for most generic methods the message type comes +/// from the type argument T, but for RequestAsync<TResponse> the type +/// argument is the response type - the message type is inferred from the first +/// argument's compile-time type instead. Non-generic overloads (e.g. +/// RequestAsync(object, …)) also fall back to argument-expression analysis. +/// +/// +public sealed class CallSiteMessageTypeInspector : ISyntaxInspector +{ + /// + public ImmutableArray Filters { get; } = [InvocationCallSiteFilter.Instance]; + + /// + public IImmutableSet SupportedKinds { get; } = ImmutableHashSet.Create(SyntaxKind.InvocationExpression); + + /// + public bool TryHandle( + KnownTypeSymbols knownSymbols, + SyntaxNode node, + SemanticModel semanticModel, + CancellationToken cancellationToken, + out SyntaxInfo? syntaxInfo) + { + syntaxInfo = null; + + if (node is not InvocationExpressionSyntax invocation) + { + return false; + } + + cancellationToken.ThrowIfCancellationRequested(); + + var symbolInfo = semanticModel.GetSymbolInfo(invocation, cancellationToken); + if (symbolInfo.Symbol is not IMethodSymbol methodSymbol) + { + return false; + } + + var receiverType = methodSymbol.ContainingType?.OriginalDefinition; + if (receiverType is null) + { + return false; + } + + // Try IMessageBus + if (knownSymbols.IMessageBus is not null + && SymbolEqualityComparer.Default.Equals(receiverType, knownSymbols.IMessageBus)) + { + return TryHandleMessageBus(methodSymbol, invocation, semanticModel, cancellationToken, out syntaxInfo); + } + + // Try ISender (Mediator) + if (knownSymbols.ISender is not null + && SymbolEqualityComparer.Default.Equals(receiverType, knownSymbols.ISender)) + { + return TryHandleSender(methodSymbol, invocation, semanticModel, cancellationToken, out syntaxInfo); + } + + // Try IPublisher (Mediator) + if (knownSymbols.IPublisher is not null + && SymbolEqualityComparer.Default.Equals(receiverType, knownSymbols.IPublisher)) + { + return TryHandlePublisher(methodSymbol, invocation, out syntaxInfo); + } + + return false; + } + + private static bool TryHandleMessageBus( + IMethodSymbol methodSymbol, + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + CancellationToken cancellationToken, + out SyntaxInfo? syntaxInfo) + { + syntaxInfo = null; + + var methodName = methodSymbol.Name; + CallSiteKind kind; + + switch (methodName) + { + case "PublishAsync": + kind = CallSiteKind.Publish; + break; + case "SendAsync": + kind = CallSiteKind.Send; + break; + case "SchedulePublishAsync": + kind = CallSiteKind.SchedulePublish; + break; + case "ScheduleSendAsync": + kind = CallSiteKind.ScheduleSend; + break; + case "RequestAsync": + kind = CallSiteKind.Request; + break; + default: + return false; + } + + // Non-generic RequestAsync(object, CT) - ack-only version. + // Fall back to argument-expression analysis to get the compile-time type. + if (kind == CallSiteKind.Request && methodSymbol.TypeArguments.Length == 0) + { + var argType = GetFirstArgumentType(invocation, semanticModel, cancellationToken); + if (argType is not null + && argType.SpecialType != SpecialType.System_Object + && !IsOpenTypeParameter(argType)) + { + var argTypeName = argType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var argLocation = invocation.GetLocation().ToLocationInfo(); + syntaxInfo = new CallSiteMessageTypeInfo(argTypeName, kind, argLocation); + return true; + } + + return false; + } + + // All other IMessageBus methods are generic - extract T from type arguments. + if (methodSymbol.TypeArguments.Length == 0) + { + return false; + } + + var messageType = methodSymbol.TypeArguments[0]; + + // For RequestAsync(IEventRequest, ...), the type argument is the response. + // We want the message type (the first parameter's compile-time type) AND the response type. + if (kind == CallSiteKind.Request + && methodSymbol.Parameters.Length > 0 + && methodSymbol.Parameters[0].Type is INamedTypeSymbol) + { + // The first parameter is IEventRequest or a concrete type implementing it. + // Get the compile-time type of the first argument expression. + var firstArgType = GetFirstArgumentType(invocation, semanticModel, cancellationToken); + if (firstArgType is not null && !IsOpenTypeParameter(firstArgType)) + { + var requestTypeName = firstArgType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var location = invocation.GetLocation().ToLocationInfo(); + + // The response type is the type argument TResponse from RequestAsync. + string? responseTypeName = null; + if (methodSymbol.TypeArguments.Length > 0 + && !IsOpenTypeParameter(methodSymbol.TypeArguments[0])) + { + responseTypeName = methodSymbol + .TypeArguments[0] + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + + syntaxInfo = new CallSiteMessageTypeInfo(requestTypeName, kind, location, responseTypeName); + return true; + } + + return false; + } + + if (IsOpenTypeParameter(messageType)) + { + return false; + } + + var typeName = messageType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var locationInfo = invocation.GetLocation().ToLocationInfo(); + syntaxInfo = new CallSiteMessageTypeInfo(typeName, kind, locationInfo); + return true; + } + + private static bool TryHandleSender( + IMethodSymbol methodSymbol, + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + CancellationToken cancellationToken, + out SyntaxInfo? syntaxInfo) + { + syntaxInfo = null; + + var methodName = methodSymbol.Name; + CallSiteKind kind; + + switch (methodName) + { + case "SendAsync": + kind = CallSiteKind.MediatorSend; + break; + case "QueryAsync": + kind = CallSiteKind.MediatorQuery; + break; + default: + return false; + } + + // ISender methods take the message as the first parameter. + // For SendAsync(ICommand, CT) and SendAsync(ICommand, CT) + // and QueryAsync(IQuery, CT), get the first argument's type. + // Skip SendAsync(object, CT) - runtime dispatch, no static type info. + if (methodSymbol.Parameters.Length == 0) + { + return false; + } + + var firstParamType = methodSymbol.Parameters[0].Type; + if (firstParamType.SpecialType == SpecialType.System_Object) + { + return false; + } + + var messageType = GetFirstArgumentType(invocation, semanticModel, cancellationToken); + if (messageType is null || IsOpenTypeParameter(messageType)) + { + return false; + } + + var typeName = messageType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var locationInfo = invocation.GetLocation().ToLocationInfo(); + syntaxInfo = new CallSiteMessageTypeInfo(typeName, kind, locationInfo); + return true; + } + + private static bool TryHandlePublisher( + IMethodSymbol methodSymbol, + InvocationExpressionSyntax invocation, + out SyntaxInfo? syntaxInfo) + { + syntaxInfo = null; + + if (methodSymbol.Name != "PublishAsync") + { + return false; + } + + // Skip PublishAsync(object, CT) - runtime dispatch. + if (methodSymbol.TypeArguments.Length == 0) + { + return false; + } + + var messageType = methodSymbol.TypeArguments[0]; + if (IsOpenTypeParameter(messageType)) + { + return false; + } + + var typeName = messageType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var locationInfo = invocation.GetLocation().ToLocationInfo(); + syntaxInfo = new CallSiteMessageTypeInfo(typeName, CallSiteKind.MediatorPublish, locationInfo); + return true; + } + + private static ITypeSymbol? GetFirstArgumentType( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + CancellationToken cancellationToken) + { + if (invocation.ArgumentList.Arguments.Count == 0) + { + return null; + } + + var firstArgExpression = invocation.ArgumentList.Arguments[0].Expression; + var typeInfo = semanticModel.GetTypeInfo(firstArgExpression, cancellationToken); + return typeInfo.Type; + } + + private static bool IsOpenTypeParameter(ITypeSymbol type) => type.TypeKind == TypeKind.TypeParameter; +} diff --git a/src/Mocha/src/Mocha.Analyzers/Inspectors/HandlerInspector.cs b/src/Mocha/src/Mocha.Analyzers/Inspectors/HandlerInspector.cs index 39d055af115..734814a226d 100644 --- a/src/Mocha/src/Mocha.Analyzers/Inspectors/HandlerInspector.cs +++ b/src/Mocha/src/Mocha.Analyzers/Inspectors/HandlerInspector.cs @@ -53,12 +53,29 @@ public bool TryHandle( return false; } + // Check for open generics (MO0006) + if (namedTypeSymbol is { IsGenericType: true, TypeParameters.Length: > 0 }) + { + if (ImplementsAnyHandlerInterface(knownSymbols, namedTypeSymbol)) + { + var handlerName = namedTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var locationInfo = typeDeclaration.Identifier.GetLocation().ToLocationInfo(); + syntaxInfo = new OpenGenericHandlerDiagnosticInfo(handlerName) + { + Diagnostics = new([ + new DiagnosticInfo(Errors.OpenGenericHandler.Id, locationInfo, new([handlerName])) + ]) + }; + return true; + } + + return false; + } + foreach (var descriptor in s_handlerKinds) { var target = descriptor.GetTarget(knownSymbols); - var implemented = target is not null - ? namedTypeSymbol.FindImplementedInterface(target) - : null; + var implemented = target is not null ? namedTypeSymbol.FindImplementedInterface(target) : null; if (implemented is null) { @@ -82,8 +99,34 @@ public bool TryHandle( return false; } + private static bool ImplementsAnyHandlerInterface(KnownTypeSymbols knownSymbols, INamedTypeSymbol namedTypeSymbol) + { + return ( + knownSymbols.ICommandHandlerVoid is not null + && namedTypeSymbol.FindImplementedInterface(knownSymbols.ICommandHandlerVoid) is not null) + || ( + knownSymbols.ICommandHandlerResponse is not null + && namedTypeSymbol.FindImplementedInterface(knownSymbols.ICommandHandlerResponse) is not null) + || ( + knownSymbols.IQueryHandler is not null + && namedTypeSymbol.FindImplementedInterface(knownSymbols.IQueryHandler) is not null) + || ( + knownSymbols.INotificationHandler is not null + && namedTypeSymbol.FindImplementedInterface(knownSymbols.INotificationHandler) is not null); + } + private sealed record HandlerKindDescriptor( Func GetTarget, HandlerKind Kind, bool HasResponse); } + +/// +/// A diagnostic-only SyntaxInfo for MO0006 (open generic mediator handler). +/// This is not used by code generators. +/// +internal sealed record OpenGenericHandlerDiagnosticInfo(string HandlerTypeName) : SyntaxInfo +{ + /// + public override string OrderByKey => $"OpenGenericDiag:{HandlerTypeName}"; +} diff --git a/src/Mocha/src/Mocha.Analyzers/Inspectors/ImportedMediatorModuleTypeInspector.cs b/src/Mocha/src/Mocha.Analyzers/Inspectors/ImportedMediatorModuleTypeInspector.cs new file mode 100644 index 00000000000..632d15d1ac2 --- /dev/null +++ b/src/Mocha/src/Mocha.Analyzers/Inspectors/ImportedMediatorModuleTypeInspector.cs @@ -0,0 +1,115 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Mocha.Analyzers.Filters; +using Mocha.Analyzers.Utils; + +namespace Mocha.Analyzers.Inspectors; + +/// +/// Inspects invocation expressions to discover calls to source-generated mediator module +/// registration methods (e.g. builder.AddOrderService()) and extracts their declared +/// message and handler types. +/// +/// +/// +/// The source generator decorates each generated Add* extension method with a +/// [MediatorModuleInfo(MessageTypes = new[] { typeof(A) }, HandlerTypes = new[] { typeof(B) })] +/// attribute that lists every message and handler type the module registers. This inspector +/// resolves the called method symbol, reads that attribute, and emits an +/// containing the type names. +/// +/// +/// Downstream validators use this to avoid reporting false-positive MO0001 and MO0020 +/// diagnostics for types whose handlers exist in a referenced module. +/// +/// +/// The syntactic pre-filter () broadly matches any +/// method starting with Add; this inspector then narrows to only those carrying +/// the [MediatorModuleInfo] attribute. +/// +/// +public sealed class ImportedMediatorModuleTypeInspector : ISyntaxInspector +{ + /// + public ImmutableArray Filters { get; } = [InvocationModuleFilter.Instance]; + + /// + public IImmutableSet SupportedKinds { get; } = ImmutableHashSet.Create(SyntaxKind.InvocationExpression); + + /// + public bool TryHandle( + KnownTypeSymbols knownSymbols, + SyntaxNode node, + SemanticModel semanticModel, + CancellationToken cancellationToken, + out SyntaxInfo? syntaxInfo) + { + syntaxInfo = null; + + if (node is not InvocationExpressionSyntax invocation) + { + return false; + } + + cancellationToken.ThrowIfCancellationRequested(); + + var symbolInfo = semanticModel.GetSymbolInfo(invocation, cancellationToken); + if (symbolInfo.Symbol is not IMethodSymbol methodSymbol) + { + return false; + } + + // Look for [MediatorModuleInfo] on the resolved method. + foreach (var attr in methodSymbol.GetAttributes()) + { + if (attr.AttributeClass?.ToDisplayString() != SyntaxConstants.MediatorModuleInfoAttribute) + { + continue; + } + + var messageTypeNames = new List(); + var handlerTypeNames = new List(); + + foreach (var namedArg in attr.NamedArguments) + { + if (namedArg.Value.Kind != TypedConstantKind.Array) + { + continue; + } + + var targetList = namedArg.Key switch + { + SyntaxConstants.MessageTypesProperty => messageTypeNames, + SyntaxConstants.HandlerTypesProperty => handlerTypeNames, + _ => null + }; + + if (targetList is null) + { + continue; + } + + foreach (var element in namedArg.Value.Values) + { + if (element.Value is INamedTypeSymbol typeSymbol) + { + targetList.Add(typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + } + } + + if (messageTypeNames.Count > 0 || handlerTypeNames.Count > 0) + { + syntaxInfo = new ImportedMediatorModuleTypesInfo( + methodSymbol.Name, + new ImmutableEquatableArray(messageTypeNames), + new ImmutableEquatableArray(handlerTypeNames)); + return true; + } + } + + return false; + } +} diff --git a/src/Mocha/src/Mocha.Analyzers/Inspectors/ImportedModuleTypeInspector.cs b/src/Mocha/src/Mocha.Analyzers/Inspectors/ImportedModuleTypeInspector.cs new file mode 100644 index 00000000000..dcc1bd83bbb --- /dev/null +++ b/src/Mocha/src/Mocha.Analyzers/Inspectors/ImportedModuleTypeInspector.cs @@ -0,0 +1,117 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Mocha.Analyzers.Filters; +using Mocha.Analyzers.Utils; + +namespace Mocha.Analyzers.Inspectors; + +/// +/// Inspects invocation expressions to discover calls to source-generated module registration +/// methods (e.g. builder.AddOrderService()) and extracts their declared message types. +/// +/// +/// +/// The source generator decorates each generated Add* extension method with a +/// [MessagingModuleInfo(MessageTypes = new[] { typeof(A), typeof(B) })] attribute +/// that lists every message type the module handles. This inspector resolves the called +/// method symbol, reads that attribute, and emits an +/// containing the type names. +/// +/// +/// Downstream diagnostics and generators use this to treat types registered by invoked +/// module methods as already covered. +/// +/// +/// The syntactic pre-filter () broadly matches any +/// method starting with Add; this inspector then narrows to only those carrying +/// the [MessagingModuleInfo] attribute. +/// +/// +public sealed class ImportedModuleTypeInspector : ISyntaxInspector +{ + /// + public ImmutableArray Filters { get; } = [InvocationModuleFilter.Instance]; + + /// + public IImmutableSet SupportedKinds { get; } = ImmutableHashSet.Create(SyntaxKind.InvocationExpression); + + /// + public bool TryHandle( + KnownTypeSymbols knownSymbols, + SyntaxNode node, + SemanticModel semanticModel, + CancellationToken cancellationToken, + out SyntaxInfo? syntaxInfo) + { + syntaxInfo = null; + + if (node is not InvocationExpressionSyntax invocation) + { + return false; + } + + cancellationToken.ThrowIfCancellationRequested(); + + var symbolInfo = semanticModel.GetSymbolInfo(invocation, cancellationToken); + if (symbolInfo.Symbol is not IMethodSymbol methodSymbol) + { + return false; + } + + // Look for [MessagingModuleInfo] on the resolved method. + foreach (var attr in methodSymbol.GetAttributes()) + { + if (attr.AttributeClass?.ToDisplayString() != SyntaxConstants.MessagingModuleInfoAttribute) + { + continue; + } + + var messageTypeNames = new List(); + var sagaTypeNames = new List(); + var handlerTypeNames = new List(); + + foreach (var namedArg in attr.NamedArguments) + { + if (namedArg.Value.Kind != TypedConstantKind.Array) + { + continue; + } + + var targetList = namedArg.Key switch + { + SyntaxConstants.MessageTypesProperty => messageTypeNames, + SyntaxConstants.SagaTypesProperty => sagaTypeNames, + SyntaxConstants.HandlerTypesProperty => handlerTypeNames, + _ => null + }; + + if (targetList is null) + { + continue; + } + + foreach (var element in namedArg.Value.Values) + { + if (element.Value is INamedTypeSymbol typeSymbol) + { + targetList.Add(typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + } + } + + if (messageTypeNames.Count > 0 || sagaTypeNames.Count > 0 || handlerTypeNames.Count > 0) + { + syntaxInfo = new ImportedModuleTypesInfo( + methodSymbol.Name, + new ImmutableEquatableArray(messageTypeNames), + new ImmutableEquatableArray(sagaTypeNames), + new ImmutableEquatableArray(handlerTypeNames)); + return true; + } + } + + return false; + } +} diff --git a/src/Mocha/src/Mocha.Analyzers/Inspectors/MessagingHandlerInspector.cs b/src/Mocha/src/Mocha.Analyzers/Inspectors/MessagingHandlerInspector.cs index e0f6ceed5e9..54a756bee9c 100644 --- a/src/Mocha/src/Mocha.Analyzers/Inspectors/MessagingHandlerInspector.cs +++ b/src/Mocha/src/Mocha.Analyzers/Inspectors/MessagingHandlerInspector.cs @@ -88,10 +88,23 @@ public bool TryHandle( var handlerFullName = namedTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var handlerNamespace = namedTypeSymbol.ContainingNamespace?.ToDisplayString() ?? string.Empty; - var messageTypeName = implemented.TypeArguments[descriptor.MessageTypeArgIndex] - .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var messageSymbol = (INamedTypeSymbol)implemented.TypeArguments[descriptor.MessageTypeArgIndex]; + var messageTypeName = messageSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var locationInfo = typeDeclaration.Identifier.GetLocation().ToLocationInfo(); + // Walk the full type hierarchy (base types + interfaces) for AOT enclosed type computation. + var hierarchy = new List(); + var currentBase = messageSymbol.BaseType; + while (currentBase is not null && currentBase.SpecialType != SpecialType.System_Object) + { + hierarchy.Add(currentBase.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + currentBase = currentBase.BaseType; + } + foreach (var iface in messageSymbol.AllInterfaces) + { + hierarchy.Add(iface.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + syntaxInfo = new MessagingHandlerInfo( handlerFullName, handlerNamespace, @@ -100,6 +113,7 @@ public bool TryHandle( ? implemented.TypeArguments[1].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) : null, descriptor.Kind, + new ImmutableEquatableArray(hierarchy), locationInfo); return true; } diff --git a/src/Mocha/src/Mocha.Analyzers/Inspectors/MessagingModuleInspector.cs b/src/Mocha/src/Mocha.Analyzers/Inspectors/MessagingModuleInspector.cs index d02a56ab860..ef2f4b58253 100644 --- a/src/Mocha/src/Mocha.Analyzers/Inspectors/MessagingModuleInspector.cs +++ b/src/Mocha/src/Mocha.Analyzers/Inspectors/MessagingModuleInspector.cs @@ -53,7 +53,25 @@ public bool TryHandle( continue; } - syntaxInfo = new MessagingModuleInfo(name); + string? jsonContextTypeName = null; + + // Look for the JsonContext named argument: + // [assembly: MessagingModule("MyApp", JsonContext = typeof(MyJsonContext))] + foreach (var arg in attributeSyntax.ArgumentList.Arguments) + { + if (arg.NameEquals is { Name.Identifier.Text: SyntaxConstants.JsonContextProperty } + && arg.Expression is TypeOfExpressionSyntax typeOfExpr) + { + var typeInfo = semanticModel.GetTypeInfo(typeOfExpr.Type, cancellationToken); + if (typeInfo.Type is not null) + { + jsonContextTypeName = typeInfo.Type.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat); + } + } + } + + syntaxInfo = new MessagingModuleInfo(name, jsonContextTypeName); return true; } } diff --git a/src/Mocha/src/Mocha.Analyzers/Inspectors/SagaInspector.cs b/src/Mocha/src/Mocha.Analyzers/Inspectors/SagaInspector.cs index 173f776967d..1a3496d17ba 100644 --- a/src/Mocha/src/Mocha.Analyzers/Inspectors/SagaInspector.cs +++ b/src/Mocha/src/Mocha.Analyzers/Inspectors/SagaInspector.cs @@ -52,19 +52,20 @@ public bool TryHandle( } // Walk the base type chain looking for Saga - if (!DerivesFromSaga(namedTypeSymbol, knownSymbols.Saga)) + if (!TryGetSagaStateType(namedTypeSymbol, knownSymbols.Saga, out var stateType)) { return false; } var sagaFullName = namedTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var sagaNamespace = namedTypeSymbol.ContainingNamespace?.ToDisplayString() ?? string.Empty; + var stateTypeName = stateType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); // Check for public parameterless constructor (MO0014) if (!HasPublicParameterlessConstructor(namedTypeSymbol)) { var locationInfo = typeDeclaration.Identifier.GetLocation().ToLocationInfo(); - syntaxInfo = new SagaInfo(sagaFullName, sagaNamespace) + syntaxInfo = new SagaInfo(sagaFullName, sagaNamespace, stateTypeName) { Diagnostics = new([ new DiagnosticInfo( @@ -76,11 +77,14 @@ public bool TryHandle( return true; } - syntaxInfo = new SagaInfo(sagaFullName, sagaNamespace); + syntaxInfo = new SagaInfo(sagaFullName, sagaNamespace, stateTypeName); return true; } - private static bool DerivesFromSaga(INamedTypeSymbol type, INamedTypeSymbol sagaSymbol) + private static bool TryGetSagaStateType( + INamedTypeSymbol type, + INamedTypeSymbol sagaSymbol, + out ITypeSymbol stateType) { var current = type.BaseType; while (current is not null) @@ -88,12 +92,14 @@ private static bool DerivesFromSaga(INamedTypeSymbol type, INamedTypeSymbol saga if (current.IsGenericType && SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, sagaSymbol)) { + stateType = current.TypeArguments[0]; return true; } current = current.BaseType; } + stateType = null!; return false; } diff --git a/src/Mocha/src/Mocha.Analyzers/KnownTypeSymbols.cs b/src/Mocha/src/Mocha.Analyzers/KnownTypeSymbols.cs index b42a2b84fa9..479372af83e 100644 --- a/src/Mocha/src/Mocha.Analyzers/KnownTypeSymbols.cs +++ b/src/Mocha/src/Mocha.Analyzers/KnownTypeSymbols.cs @@ -30,6 +30,9 @@ public sealed class KnownTypeSymbols private Resolved? _saga; private Resolved? _eventRequest; private Resolved? _eventRequestOfT; + private Resolved? _messageBus; + private Resolved? _sender; + private Resolved? _publisher; private KnownTypeSymbols(Compilation compilation) { @@ -133,6 +136,24 @@ public INamedTypeSymbol? IEventRequest public INamedTypeSymbol? IEventRequestOfT => Resolve(SyntaxConstants.IEventRequestOfT, ref _eventRequestOfT); + /// + /// Gets the symbol for the IMessageBus interface. + /// + public INamedTypeSymbol? IMessageBus + => Resolve(SyntaxConstants.IMessageBus, ref _messageBus); + + /// + /// Gets the symbol for the ISender interface (Mediator). + /// + public INamedTypeSymbol? ISender + => Resolve(SyntaxConstants.ISender, ref _sender); + + /// + /// Gets the symbol for the IPublisher interface (Mediator). + /// + public INamedTypeSymbol? IPublisher + => Resolve(SyntaxConstants.IPublisher, ref _publisher); + private INamedTypeSymbol? Resolve(string metadataName, ref Resolved? field) { var snapshot = Interlocked.CompareExchange(ref field, null, null); diff --git a/src/Mocha/src/Mocha.Analyzers/MediatorGenerator.cs b/src/Mocha/src/Mocha.Analyzers/MediatorGenerator.cs index 51735b99681..5f45c78feb1 100644 --- a/src/Mocha/src/Mocha.Analyzers/MediatorGenerator.cs +++ b/src/Mocha/src/Mocha.Analyzers/MediatorGenerator.cs @@ -28,14 +28,22 @@ public sealed class MediatorGenerator : IIncrementalGenerator new MediatorModuleInspector() ]; + private static readonly ISyntaxInspector[] s_callSiteInspectors = + [ + new CallSiteMessageTypeInspector(), + new ImportedMediatorModuleTypeInspector() + ]; + private static readonly ISyntaxGenerator[] s_generators = [ new DependencyInjectionGenerator() ]; private static readonly Dictionary> s_inspectorLookup; + private static readonly Dictionary> s_callSiteInspectorLookup; private static readonly Func s_predicate; + private static readonly Func s_callSitePredicate; static MediatorGenerator() { @@ -65,6 +73,34 @@ static MediatorGenerator() { s_inspectorLookup[kvp.Key] = kvp.Value.ToImmutableArray(); } + + // Build call-site filter + inspector lookup. + var callSiteFilterBuilder = new SyntaxFilterBuilder(); + var callSiteInspectorLookup = new Dictionary>(); + + foreach (var inspector in s_callSiteInspectors) + { + callSiteFilterBuilder.AddRange(inspector.Filters); + + foreach (var supportedKind in inspector.SupportedKinds) + { + if (!callSiteInspectorLookup.TryGetValue(supportedKind, out var inspectors)) + { + inspectors = []; + callSiteInspectorLookup[supportedKind] = inspectors; + } + + inspectors.Add(inspector); + } + } + + s_callSitePredicate = callSiteFilterBuilder.Build(); + s_callSiteInspectorLookup = []; + + foreach (var kvp in callSiteInspectorLookup) + { + s_callSiteInspectorLookup[kvp.Key] = kvp.Value.ToImmutableArray(); + } } /// @@ -80,11 +116,21 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Collect() .WithTrackingName("MochaCollectedInfos"); + var callSiteInfos = context + .SyntaxProvider.CreateSyntaxProvider( + predicate: static (s, _) => s_callSitePredicate(s), + transform: static (ctx, ct) => TransformCallSite(ctx.Node, ctx.SemanticModel, ct)) + .WhereNotNull() + .WithComparer(EqualityComparer.Default) + .WithTrackingName("MochaMediatorCallSiteInfos") + .Collect() + .WithTrackingName("MochaMediatorCollectedCallSiteInfos"); + var assemblyName = context.CompilationProvider.Select(static (c, _) => c.AssemblyName ?? "Unknown"); context.RegisterSourceOutput( - assemblyName.Combine(syntaxInfos), - static (context, source) => Execute(context, source.Left, source.Right)); + assemblyName.Combine(syntaxInfos).Combine(callSiteInfos), + static (context, source) => Execute(context, source.Left.Left, source.Left.Right, source.Right)); } private static SyntaxInfo? Transform( @@ -110,10 +156,34 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return null; } + private static SyntaxInfo? TransformCallSite( + SyntaxNode node, + SemanticModel semanticModel, + CancellationToken cancellationToken) + { + if (!s_callSiteInspectorLookup.TryGetValue(node.Kind(), out var inspectors)) + { + return null; + } + + var knownTypeSymbols = KnownTypeSymbols.GetOrCreate(semanticModel.Compilation); + + foreach (var inspector in inspectors) + { + if (inspector.TryHandle(knownTypeSymbols, node, semanticModel, cancellationToken, out var syntaxInfo)) + { + return syntaxInfo; + } + } + + return null; + } + private static void Execute( SourceProductionContext context, string assemblyName, - ImmutableArray syntaxInfos) + ImmutableArray syntaxInfos, + ImmutableArray callSiteInfos) { var sourceFiles = PooledObjects.GetStringDictionary(); var moduleInfo = GetModuleInfo(syntaxInfos, ModuleNameHelper.CreateModuleName(assemblyName)); @@ -134,7 +204,10 @@ private static void Execute( } // Validate message types vs handlers (MO0001, MO0002) - ValidateMessageHandlerPairing(context, syntaxInfos); + ValidateMessageHandlerPairing(context, syntaxInfos, callSiteInfos); + + // Validate call-site types vs handlers (MO0020) + ValidateCallSiteNoHandler(context, syntaxInfos, callSiteInfos); foreach (var generator in s_generators) { @@ -177,7 +250,8 @@ private static MediatorModuleInfo GetModuleInfo(ImmutableArray synta private static void ValidateMessageHandlerPairing( SourceProductionContext context, - ImmutableArray syntaxInfos) + ImmutableArray syntaxInfos, + ImmutableArray callSiteInfos) { var messageTypes = new List(); var handlers = new List(); @@ -199,6 +273,20 @@ private static void ValidateMessageHandlerPairing( return; } + // Collect handler message type names from imported mediator modules. + var importedHandlerMessageTypes = new HashSet(StringComparer.Ordinal); + + foreach (var info in callSiteInfos) + { + if (info is ImportedMediatorModuleTypesInfo imported) + { + foreach (var typeName in imported.ImportedTypeNames) + { + importedHandlerMessageTypes.Add(typeName); + } + } + } + // Build a lookup of handlers by message type name var handlersByMessageType = new Dictionary>(); foreach (var handler in handlers) @@ -219,6 +307,12 @@ private static void ValidateMessageHandlerPairing( if (!handlersByMessageType.TryGetValue(messageType.MessageTypeName, out var matchingHandlers) || matchingHandlers.Count == 0) { + // If the handler exists in an imported module, skip MO0001. + if (importedHandlerMessageTypes.Contains(messageType.MessageTypeName)) + { + continue; + } + // MO0001: Missing handler context.ReportDiagnostic( Diagnostic.Create(Errors.MissingHandler, location, messageType.MessageTypeName)); @@ -239,13 +333,68 @@ private static void ValidateMessageHandlerPairing( } } + private static void ValidateCallSiteNoHandler( + SourceProductionContext context, + ImmutableArray syntaxInfos, + ImmutableArray callSiteInfos) + { + if (callSiteInfos.Length == 0) + { + return; + } + + // Build a set of handler message type names from discovered handlers. + var handlerMessageTypes = new HashSet(StringComparer.Ordinal); + + foreach (var info in syntaxInfos) + { + if (info is HandlerInfo { Diagnostics.Count: 0 } handler) + { + handlerMessageTypes.Add(handler.MessageTypeName); + } + } + + // Include handler message types from imported mediator modules. + foreach (var info in callSiteInfos) + { + if (info is ImportedMediatorModuleTypesInfo imported) + { + foreach (var typeName in imported.ImportedTypeNames) + { + handlerMessageTypes.Add(typeName); + } + } + } + + // Check each call-site for MediatorSend or MediatorQuery - not MediatorPublish. + foreach (var info in callSiteInfos) + { + if (info is CallSiteMessageTypeInfo + { + Kind: CallSiteKind.MediatorSend or CallSiteKind.MediatorQuery + } callSite + && !handlerMessageTypes.Contains(callSite.MessageTypeName)) + { + var location = ReconstructLocation(callSite.Location); + context.ReportDiagnostic( + Diagnostic.Create( + Errors.CallSiteNoHandler, + location, + callSite.MessageTypeName, + callSite.Kind.ToString())); + } + } + } + private static readonly Dictionary s_descriptorLookup = new() { [Errors.MissingHandler.Id] = Errors.MissingHandler, [Errors.DuplicateHandler.Id] = Errors.DuplicateHandler, [Errors.AbstractHandler.Id] = Errors.AbstractHandler, [Errors.OpenGenericMessageType.Id] = Errors.OpenGenericMessageType, - [Errors.MultipleHandlerInterfaces.Id] = Errors.MultipleHandlerInterfaces + [Errors.MultipleHandlerInterfaces.Id] = Errors.MultipleHandlerInterfaces, + [Errors.OpenGenericHandler.Id] = Errors.OpenGenericHandler, + [Errors.CallSiteNoHandler.Id] = Errors.CallSiteNoHandler }; private static Diagnostic ReconstructDiagnostic(DiagnosticInfo info) diff --git a/src/Mocha/src/Mocha.Analyzers/MessagingGenerator.cs b/src/Mocha/src/Mocha.Analyzers/MessagingGenerator.cs index e789c3e0c5a..468a0c7d889 100644 --- a/src/Mocha/src/Mocha.Analyzers/MessagingGenerator.cs +++ b/src/Mocha/src/Mocha.Analyzers/MessagingGenerator.cs @@ -2,6 +2,7 @@ using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; using Mocha.Analyzers.Filters; using Mocha.Analyzers.Generators; @@ -25,14 +26,22 @@ public sealed class MessagingGenerator : IIncrementalGenerator new SagaInspector() ]; + private static readonly ISyntaxInspector[] s_callSiteInspectors = + [ + new CallSiteMessageTypeInspector(), + new ImportedModuleTypeInspector() + ]; + private static readonly ISyntaxGenerator[] s_generators = [ new MessagingDependencyInjectionGenerator() ]; private static readonly Dictionary> s_inspectorLookup; + private static readonly Dictionary> s_callSiteInspectorLookup; private static readonly Func s_predicate; + private static readonly Func s_callSitePredicate; static MessagingGenerator() { @@ -62,6 +71,34 @@ static MessagingGenerator() { s_inspectorLookup[kvp.Key] = kvp.Value.ToImmutableArray(); } + + // Build call-site filter + inspector lookup. + var callSiteFilterBuilder = new SyntaxFilterBuilder(); + var callSiteInspectorLookup = new Dictionary>(); + + foreach (var inspector in s_callSiteInspectors) + { + callSiteFilterBuilder.AddRange(inspector.Filters); + + foreach (var supportedKind in inspector.SupportedKinds) + { + if (!callSiteInspectorLookup.TryGetValue(supportedKind, out var inspectors)) + { + inspectors = []; + callSiteInspectorLookup[supportedKind] = inspectors; + } + + inspectors.Add(inspector); + } + } + + s_callSitePredicate = callSiteFilterBuilder.Build(); + s_callSiteInspectorLookup = new Dictionary>(); + + foreach (var kvp in callSiteInspectorLookup) + { + s_callSiteInspectorLookup[kvp.Key] = kvp.Value.ToImmutableArray(); + } } /// @@ -77,11 +114,51 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Collect() .WithTrackingName("MochaMessagingCollectedInfos"); + var callSiteInfos = context + .SyntaxProvider.CreateSyntaxProvider( + predicate: static (s, _) => s_callSitePredicate(s), + transform: static (ctx, ct) => TransformCallSite(ctx.Node, ctx.SemanticModel, ct)) + .WhereNotNull() + .WithComparer(EqualityComparer.Default) + .WithTrackingName("MochaCallSiteInfos") + .Collect() + .WithTrackingName("MochaCollectedCallSiteInfos"); + var assemblyName = context.CompilationProvider.Select(static (c, _) => c.AssemblyName ?? "Unknown"); + var isAotPublish = context.AnalyzerConfigOptionsProvider.Select(static (options, _) => + { + options.GlobalOptions.TryGetValue("build_property.PublishAot", out var publishAot); + return string.Equals(publishAot, "true", StringComparison.OrdinalIgnoreCase); + }); + + var jsonSerializableInfos = context + .SyntaxProvider.CreateSyntaxProvider( + predicate: static (s, _) => IsJsonSerializableAttributeCandidate(s), + transform: static (ctx, ct) => ExtractJsonSerializableInfo(ctx.Node, ctx.SemanticModel, ct)) + .WhereNotNull() + .WithComparer(EqualityComparer.Default) + .Collect() + .WithTrackingName("MochaJsonSerializableInfos"); + + // Keep JsonContext data syntax-driven instead of flowing CompilationProvider into this path. + var jsonContextInfo = syntaxInfos + .Combine(jsonSerializableInfos) + .Select(static (source, _) => BuildJsonContextInfo(source.Left, source.Right)); + context.RegisterSourceOutput( - assemblyName.Combine(syntaxInfos), - static (context, source) => Execute(context, source.Left, source.Right)); + assemblyName + .Combine(syntaxInfos) + .Combine(callSiteInfos) + .Combine(isAotPublish) + .Combine(jsonContextInfo), + static (context, source) => Execute( + context, + source.Left.Left.Left.Left, + source.Left.Left.Left.Right, + source.Left.Left.Right, + source.Left.Right, + source.Right)); } private static SyntaxInfo? Transform( @@ -107,10 +184,36 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return null; } + private static SyntaxInfo? TransformCallSite( + SyntaxNode node, + SemanticModel semanticModel, + CancellationToken cancellationToken) + { + if (!s_callSiteInspectorLookup.TryGetValue(node.Kind(), out var inspectors)) + { + return null; + } + + var knownTypeSymbols = KnownTypeSymbols.GetOrCreate(semanticModel.Compilation); + + foreach (var inspector in inspectors) + { + if (inspector.TryHandle(knownTypeSymbols, node, semanticModel, cancellationToken, out var syntaxInfo)) + { + return syntaxInfo; + } + } + + return null; + } + private static void Execute( SourceProductionContext context, string assemblyName, - ImmutableArray syntaxInfos) + ImmutableArray syntaxInfos, + ImmutableArray callSiteInfos, + bool isAotPublish, + JsonContextInfo jsonContextInfo) { var sourceFiles = PooledObjects.GetStringDictionary(); var moduleInfo = GetModuleInfo(syntaxInfos, ModuleNameHelper.CreateModuleName(assemblyName)); @@ -132,13 +235,41 @@ private static void Execute( // Validate request handler pairing (MO0011) ValidateRequestHandlerPairing(context, syntaxInfos); + // Validate AOT JsonSerializerContext coverage (MO0015, MO0016) + ValidateAotJsonContext(context, syntaxInfos, callSiteInfos, moduleInfo, isAotPublish, jsonContextInfo); + + // Validate call-site types against JsonSerializerContext (MO0018) + ValidateCallSiteJsonContext(context, callSiteInfos, isAotPublish, jsonContextInfo); + + // Extract context-only message types from JsonSerializerContext (types without handlers). + var augmentedInfos = ExtractContextOnlyTypes(syntaxInfos, jsonContextInfo); + augmentedInfos = augmentedInfos.Add(new AotPublishInfo(isAotPublish)); + + // Pass the full set of JsonContext-serializable type names so the DI generator + // can restrict serializer registrations to types that are actually in the context. + if (jsonContextInfo.SerializableTypes.Count > 0) + { + var serializableTypeNames = new ImmutableEquatableArray( + jsonContextInfo.SerializableTypes.Select(t => t.TypeName)); + augmentedInfos = augmentedInfos.Add(new JsonContextSerializableTypesInfo(serializableTypeNames)); + } + + // Include ImportedModuleTypesInfo entries so the DI generator can skip + // registrations for types already covered by module methods invoked in this compilation. + var importedModuleInfos = callSiteInfos.OfType().ToImmutableArray(); + + if (importedModuleInfos.Length > 0) + { + augmentedInfos = augmentedInfos.AddRange(importedModuleInfos.CastArray()); + } + foreach (var generator in s_generators) { generator.Generate( context, assemblyName, moduleInfo.ModuleName, - syntaxInfos, + augmentedInfos, AddSource); } @@ -164,13 +295,131 @@ private static MessagingModuleInfo GetModuleInfo(ImmutableArray synt { if (syntaxInfo is MessagingModuleInfo module) { - return new MessagingModuleInfo(ModuleNameHelper.SanitizeIdentifier(module.ModuleName)); + return new MessagingModuleInfo( + ModuleNameHelper.SanitizeIdentifier(module.ModuleName), + module.JsonContextTypeName); } } return new MessagingModuleInfo(defaultModuleName); } + private static bool IsJsonSerializableAttributeCandidate(SyntaxNode node) + => node is AttributeSyntax attribute + && GetUnqualifiedName(attribute.Name) is "JsonSerializable" or "JsonSerializableAttribute"; + + private static string? GetUnqualifiedName(NameSyntax name) + => name switch + { + IdentifierNameSyntax identifier => identifier.Identifier.Text, + GenericNameSyntax generic => generic.Identifier.Text, + QualifiedNameSyntax qualified => GetUnqualifiedName(qualified.Right), + AliasQualifiedNameSyntax aliasQualified => aliasQualified.Name.Identifier.Text, + _ => null + }; + + private static JsonSerializableAttributeInfo? ExtractJsonSerializableInfo( + SyntaxNode node, + SemanticModel semanticModel, + CancellationToken cancellationToken) + { + if (node is not AttributeSyntax attribute + || attribute.ArgumentList?.Arguments.FirstOrDefault()?.Expression is not TypeOfExpressionSyntax typeOf) + { + return null; + } + + cancellationToken.ThrowIfCancellationRequested(); + + var contextDeclaration = attribute.FirstAncestorOrSelf(); + if (contextDeclaration is null) + { + return null; + } + + var contextSymbol = semanticModel.GetDeclaredSymbol(contextDeclaration, cancellationToken); + if (contextSymbol is null) + { + return null; + } + + if (semanticModel.GetTypeInfo(typeOf.Type, cancellationToken).Type is not INamedTypeSymbol serializableType) + { + return null; + } + + return new JsonSerializableAttributeInfo( + contextSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + CreateJsonSerializableTypeInfo(serializableType, attribute.GetLocation().ToLocationInfo())); + } + + private static JsonContextInfo BuildJsonContextInfo( + ImmutableArray syntaxInfos, + ImmutableArray jsonSerializableInfos) + { + // Find the module info to get the JsonContext type name. + string? jsonContextTypeName = null; + + foreach (var syntaxInfo in syntaxInfos) + { + if (syntaxInfo is MessagingModuleInfo module) + { + jsonContextTypeName = module.JsonContextTypeName; + break; + } + } + + if (jsonContextTypeName is null) + { + return new JsonContextInfo(null, ImmutableEquatableArray.Empty); + } + + var serializableTypes = new List(); + + foreach (var info in jsonSerializableInfos) + { + if (info.JsonContextTypeName == jsonContextTypeName) + { + serializableTypes.Add(info.SerializableType); + } + } + + return new JsonContextInfo( + jsonContextTypeName, + new ImmutableEquatableArray(serializableTypes)); + } + + private static JsonSerializableTypeInfo CreateJsonSerializableTypeInfo( + INamedTypeSymbol serializableType, + LocationInfo? location) + { + var typeName = serializableType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + // Walk the full type hierarchy (base types + interfaces), same as MessagingHandlerInspector. + var hierarchy = new List(); + var currentBase = serializableType.BaseType; + + while (currentBase is not null && currentBase.SpecialType != SpecialType.System_Object) + { + hierarchy.Add(currentBase.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + currentBase = currentBase.BaseType; + } + + foreach (var iface in serializableType.AllInterfaces) + { + hierarchy.Add(iface.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + + return new JsonSerializableTypeInfo( + typeName, + new ImmutableEquatableArray(hierarchy), + location); + } + + private sealed record JsonSerializableAttributeInfo( + string JsonContextTypeName, + JsonSerializableTypeInfo SerializableType); + private static void ValidateRequestHandlerPairing( SourceProductionContext context, ImmutableArray syntaxInfos) @@ -226,12 +475,286 @@ private static void ValidateRequestHandlerPairing( } } + private static void ValidateAotJsonContext( + SourceProductionContext context, + ImmutableArray syntaxInfos, + ImmutableArray callSiteInfos, + MessagingModuleInfo moduleInfo, + bool isAotPublish, + JsonContextInfo jsonContextInfo) + { + if (!isAotPublish) + { + return; + } + + // Collect type names imported from referenced modules via [MessagingModuleInfo]. + var importedTypes = new HashSet(StringComparer.Ordinal); + + foreach (var info in callSiteInfos) + { + if (info is ImportedModuleTypesInfo imported) + { + foreach (var typeName in imported.ImportedTypeNames) + { + importedTypes.Add(typeName); + } + } + } + + if (jsonContextInfo.JsonContextTypeName is null) + { + // Only fire MO0015 if this module has handlers or sagas that need serialization coverage + // AND those types are not already covered by imported modules. + var requiredTypes = new HashSet(StringComparer.Ordinal); + + foreach (var syntaxInfo in syntaxInfos) + { + if (syntaxInfo is MessagingHandlerInfo { Diagnostics.Count: 0 } handler) + { + requiredTypes.Add(handler.MessageTypeName); + + if (handler.ResponseTypeName is not null) + { + requiredTypes.Add(handler.ResponseTypeName); + } + } + else if (syntaxInfo is SagaInfo { Diagnostics.Count: 0 } saga) + { + requiredTypes.Add(saga.StateTypeName); + } + } + + AddRequiredCallSiteTypes(callSiteInfos, requiredTypes); + + // If all required types are covered by imported modules, no local JsonContext is needed. + if (requiredTypes.Count == 0 || requiredTypes.IsSubsetOf(importedTypes)) + { + return; + } + + context.ReportDiagnostic( + Diagnostic.Create( + Errors.MissingJsonSerializerContext, + Location.None, + moduleInfo.ModuleName)); + + return; + } + + // Build the set of covered types from the local JsonContext + // plus types imported from referenced modules. + var coveredTypes = new HashSet(importedTypes, StringComparer.Ordinal); + + foreach (var serializableType in jsonContextInfo.SerializableTypes) + { + coveredTypes.Add(serializableType.TypeName); + } + + // Collect all required message types. + var requiredMessageTypes = new HashSet(StringComparer.Ordinal); + + foreach (var syntaxInfo in syntaxInfos) + { + if (syntaxInfo is MessagingHandlerInfo { Diagnostics.Count: 0 } handler) + { + requiredMessageTypes.Add(handler.MessageTypeName); + + if (handler.ResponseTypeName is not null) + { + requiredMessageTypes.Add(handler.ResponseTypeName); + } + } + else if (syntaxInfo is SagaInfo { Diagnostics.Count: 0 } saga) + { + requiredMessageTypes.Add(saga.StateTypeName); + } + } + + // Emit MO0016 for each required type not covered by the JsonSerializerContext + // or imported modules. + foreach (var requiredType in requiredMessageTypes.OrderBy(t => t, StringComparer.Ordinal)) + { + if (!coveredTypes.Contains(requiredType)) + { + context.ReportDiagnostic( + Diagnostic.Create( + Errors.MissingJsonSerializable, + Location.None, + requiredType, + jsonContextInfo.JsonContextTypeName)); + } + } + } + + private static void AddRequiredCallSiteTypes( + ImmutableArray callSiteInfos, + HashSet requiredTypes) + { + foreach (var info in callSiteInfos) + { + if (info is CallSiteMessageTypeInfo callSite + && callSite.Kind is not (CallSiteKind.MediatorSend or CallSiteKind.MediatorQuery or CallSiteKind.MediatorPublish)) + { + requiredTypes.Add(callSite.MessageTypeName); + + if (callSite.ResponseTypeName is not null) + { + requiredTypes.Add(callSite.ResponseTypeName); + } + } + } + } + + private static void ValidateCallSiteJsonContext( + SourceProductionContext context, + ImmutableArray callSiteInfos, + bool isAotPublish, + JsonContextInfo jsonContextInfo) + { + if (!isAotPublish || callSiteInfos.Length == 0) + { + return; + } + + // Build the set of covered types from the local JsonContext + // plus types imported from referenced modules. + var coveredTypes = new HashSet(StringComparer.Ordinal); + + foreach (var serializableType in jsonContextInfo.SerializableTypes) + { + coveredTypes.Add(serializableType.TypeName); + } + + foreach (var info in callSiteInfos) + { + if (info is ImportedModuleTypesInfo imported) + { + foreach (var typeName in imported.ImportedTypeNames) + { + coveredTypes.Add(typeName); + } + } + } + + // If there are no covered types and no local context, MO0015 handles the missing context case. + if (coveredTypes.Count == 0 && jsonContextInfo.JsonContextTypeName is null) + { + return; + } + + // Determine the context name for the diagnostic message. + // If no local context, use a generic description. + var contextDisplayName = jsonContextInfo.JsonContextTypeName ?? "any registered module"; + + // Emit MO0018 for each call-site type not covered by the JsonSerializerContext + // or imported module registrations. Only check messaging call sites - mediator + // dispatch is in-process and does not require JSON serialization. + foreach (var info in callSiteInfos) + { + if (info is CallSiteMessageTypeInfo callSite + && callSite.Kind is not (CallSiteKind.MediatorSend or CallSiteKind.MediatorQuery or CallSiteKind.MediatorPublish)) + { + if (!coveredTypes.Contains(callSite.MessageTypeName)) + { + var location = ReconstructLocation(callSite.Location); + context.ReportDiagnostic( + Diagnostic.Create( + Errors.CallSiteTypeNotInJsonContext, + location, + callSite.MessageTypeName, + callSite.Kind.ToString(), + contextDisplayName)); + } + + if (callSite.ResponseTypeName is not null + && !coveredTypes.Contains(callSite.ResponseTypeName)) + { + var location = ReconstructLocation(callSite.Location); + context.ReportDiagnostic( + Diagnostic.Create( + Errors.CallSiteTypeNotInJsonContext, + location, + callSite.ResponseTypeName, + callSite.Kind.ToString(), + contextDisplayName)); + } + } + } + } + + private static ImmutableArray ExtractContextOnlyTypes( + ImmutableArray syntaxInfos, + JsonContextInfo jsonContextInfo) + { + if (jsonContextInfo.JsonContextTypeName is null || jsonContextInfo.SerializableTypes.Count == 0) + { + return syntaxInfos; + } + + // Collect handler message type names and response type names. + var handlerTypeNames = new HashSet(StringComparer.Ordinal); + + foreach (var info in syntaxInfos) + { + if (info is MessagingHandlerInfo { Diagnostics.Count: 0 } handler) + { + handlerTypeNames.Add(handler.MessageTypeName); + + if (handler.ResponseTypeName is not null) + { + handlerTypeNames.Add(handler.ResponseTypeName); + } + } + } + + // Collect saga state type names. + var sagaStateTypeNames = new HashSet(StringComparer.Ordinal); + + foreach (var info in syntaxInfos) + { + if (info is SagaInfo { Diagnostics.Count: 0 } saga) + { + sagaStateTypeNames.Add(saga.StateTypeName); + } + } + + // Find context-only types from the pre-extracted JsonContext data. + var contextOnlyInfos = new List(); + + foreach (var serializableType in jsonContextInfo.SerializableTypes) + { + // Skip types already covered by handlers or sagas. + if (handlerTypeNames.Contains(serializableType.TypeName) + || sagaStateTypeNames.Contains(serializableType.TypeName)) + { + continue; + } + + contextOnlyInfos.Add(new ContextOnlyMessageInfo( + serializableType.TypeName, + serializableType.TypeHierarchy, + serializableType.AttributeLocation)); + } + + if (contextOnlyInfos.Count == 0) + { + return syntaxInfos; + } + + return syntaxInfos.AddRange(contextOnlyInfos); + } + private static readonly Dictionary s_descriptorLookup = new() { [Errors.DuplicateRequestHandler.Id] = Errors.DuplicateRequestHandler, [Errors.OpenGenericMessagingHandler.Id] = Errors.OpenGenericMessagingHandler, [Errors.AbstractMessagingHandler.Id] = Errors.AbstractMessagingHandler, - [Errors.SagaMissingParameterlessConstructor.Id] = Errors.SagaMissingParameterlessConstructor + [Errors.SagaMissingParameterlessConstructor.Id] = Errors.SagaMissingParameterlessConstructor, + [Errors.MissingJsonSerializerContext.Id] = Errors.MissingJsonSerializerContext, + [Errors.MissingJsonSerializable.Id] = Errors.MissingJsonSerializable, + [Errors.CallSiteTypeNotInJsonContext.Id] = Errors.CallSiteTypeNotInJsonContext, + [Errors.CallSiteNoHandler.Id] = Errors.CallSiteNoHandler }; private static Diagnostic ReconstructDiagnostic(DiagnosticInfo info) @@ -265,6 +788,7 @@ private static Location ReconstructLocation(LocationInfo? locationInfo) file static class Extensions { - public static IncrementalValuesProvider WhereNotNull(this IncrementalValuesProvider source) + public static IncrementalValuesProvider WhereNotNull(this IncrementalValuesProvider source) + where T : class => source.Where(static t => t is not null)!; } diff --git a/src/Mocha/src/Mocha.Analyzers/Mocha.Analyzers.csproj b/src/Mocha/src/Mocha.Analyzers/Mocha.Analyzers.csproj index 6812203aab7..33faa94450b 100644 --- a/src/Mocha/src/Mocha.Analyzers/Mocha.Analyzers.csproj +++ b/src/Mocha/src/Mocha.Analyzers/Mocha.Analyzers.csproj @@ -29,4 +29,8 @@ + + + + diff --git a/src/Mocha/src/Mocha.Analyzers/Models/AotPublishInfo.cs b/src/Mocha/src/Mocha.Analyzers/Models/AotPublishInfo.cs new file mode 100644 index 00000000000..d6f83b2e9d7 --- /dev/null +++ b/src/Mocha/src/Mocha.Analyzers/Models/AotPublishInfo.cs @@ -0,0 +1,13 @@ +namespace Mocha.Analyzers; + +/// +/// Carries the evaluated PublishAot MSBuild property to source generators. +/// +/// +/// A value indicating whether the current compilation is being published for AOT. +/// +public sealed record AotPublishInfo(bool IsAotPublish) : SyntaxInfo +{ + /// + public override string OrderByKey => "AotPublish"; +} diff --git a/src/Mocha/src/Mocha.Analyzers/Models/CallSiteKind.cs b/src/Mocha/src/Mocha.Analyzers/Models/CallSiteKind.cs new file mode 100644 index 00000000000..c6ae51cc476 --- /dev/null +++ b/src/Mocha/src/Mocha.Analyzers/Models/CallSiteKind.cs @@ -0,0 +1,47 @@ +namespace Mocha.Analyzers; + +/// +/// Represents the kind of call site detected for a message dispatch invocation. +/// +public enum CallSiteKind +{ + /// + /// A PublishAsync<T> call on IMessageBus. + /// + Publish, + + /// + /// A SendAsync<T> call on IMessageBus. + /// + Send, + + /// + /// A SchedulePublishAsync<T> call on IMessageBus. + /// + SchedulePublish, + + /// + /// A ScheduleSendAsync<T> call on IMessageBus. + /// + ScheduleSend, + + /// + /// A RequestAsync<T> call on IMessageBus. + /// + Request, + + /// + /// A SendAsync call on Mocha.Mediator.ISender. + /// + MediatorSend, + + /// + /// A QueryAsync call on Mocha.Mediator.ISender. + /// + MediatorQuery, + + /// + /// A PublishAsync<T> call on Mocha.Mediator.IPublisher. + /// + MediatorPublish +} diff --git a/src/Mocha/src/Mocha.Analyzers/Models/CallSiteMessageTypeInfo.cs b/src/Mocha/src/Mocha.Analyzers/Models/CallSiteMessageTypeInfo.cs new file mode 100644 index 00000000000..777087d05fe --- /dev/null +++ b/src/Mocha/src/Mocha.Analyzers/Models/CallSiteMessageTypeInfo.cs @@ -0,0 +1,24 @@ +namespace Mocha.Analyzers; + +/// +/// Represents a message type discovered at a call site (method invocation) during source generation. +/// Call-site infos produce diagnostics only and do not generate code. +/// +/// The fully qualified type name of the discovered message type. +/// The kind of call site where the message type was discovered. +/// +/// The equatable source location of the invocation expression, or if unavailable. +/// +/// +/// The fully qualified type name of the response type for request-reply call sites, or +/// for fire-and-forget calls. +/// +public sealed record CallSiteMessageTypeInfo( + string MessageTypeName, + CallSiteKind Kind, + LocationInfo? Location, + string? ResponseTypeName = null) : SyntaxInfo +{ + /// + public override string OrderByKey => $"CallSite:{Kind}:{MessageTypeName}"; +} diff --git a/src/Mocha/src/Mocha.Analyzers/Models/ContextOnlyMessageInfo.cs b/src/Mocha/src/Mocha.Analyzers/Models/ContextOnlyMessageInfo.cs new file mode 100644 index 00000000000..835f93a1aa7 --- /dev/null +++ b/src/Mocha/src/Mocha.Analyzers/Models/ContextOnlyMessageInfo.cs @@ -0,0 +1,25 @@ +using Mocha.Analyzers.Utils; + +namespace Mocha.Analyzers; + +/// +/// Represents a message type discovered from the JsonSerializerContext that does not +/// have a corresponding handler in the current assembly. These types still need serializer +/// registrations for AOT compatibility. +/// +/// The fully qualified type name of the message. +/// +/// The unfiltered type hierarchy of the message type (base types excluding object, plus all interfaces), +/// as fully qualified display strings. Filtering to registered types happens in the generator phase. +/// +/// +/// The equatable source location from the [JsonSerializable] attribute, or if unavailable. +/// +public sealed record ContextOnlyMessageInfo( + string MessageTypeName, + ImmutableEquatableArray MessageTypeHierarchy, + LocationInfo? Location) : SyntaxInfo +{ + /// + public override string OrderByKey => $"MsgContextOnly:{MessageTypeName}"; +} diff --git a/src/Mocha/src/Mocha.Analyzers/Models/ImportedMediatorModuleTypesInfo.cs b/src/Mocha/src/Mocha.Analyzers/Models/ImportedMediatorModuleTypesInfo.cs new file mode 100644 index 00000000000..7de312239c1 --- /dev/null +++ b/src/Mocha/src/Mocha.Analyzers/Models/ImportedMediatorModuleTypesInfo.cs @@ -0,0 +1,24 @@ +using Mocha.Analyzers.Utils; + +namespace Mocha.Analyzers; + +/// +/// Represents the set of types imported from a referenced module's +/// [MediatorModuleInfo] attribute. Each instance corresponds to a single +/// invocation of a module registration method (e.g. builder.AddOrderService()). +/// +/// The name of the invoked module registration method. +/// +/// The fully qualified type names listed in the MessageTypes property of the attribute. +/// +/// +/// The fully qualified type names listed in the HandlerTypes property of the attribute. +/// +public sealed record ImportedMediatorModuleTypesInfo( + string MethodName, + ImmutableEquatableArray ImportedTypeNames, + ImmutableEquatableArray ImportedHandlerTypeNames) : SyntaxInfo +{ + /// + public override string OrderByKey => $"ImportedMediatorModule:{MethodName}"; +} diff --git a/src/Mocha/src/Mocha.Analyzers/Models/ImportedModuleTypesInfo.cs b/src/Mocha/src/Mocha.Analyzers/Models/ImportedModuleTypesInfo.cs new file mode 100644 index 00000000000..78aec3f4d60 --- /dev/null +++ b/src/Mocha/src/Mocha.Analyzers/Models/ImportedModuleTypesInfo.cs @@ -0,0 +1,28 @@ +using Mocha.Analyzers.Utils; + +namespace Mocha.Analyzers; + +/// +/// Represents the set of types imported from a referenced module's +/// [MessagingModuleInfo] attribute. Each instance corresponds to a single +/// invocation of a module registration method (e.g. builder.AddOrderService()). +/// +/// The name of the invoked module registration method. +/// +/// The fully qualified type names listed in the MessageTypes property of the attribute. +/// +/// +/// The fully qualified type names listed in the SagaTypes property of the attribute. +/// +/// +/// The fully qualified type names listed in the HandlerTypes property of the attribute. +/// +public sealed record ImportedModuleTypesInfo( + string MethodName, + ImmutableEquatableArray ImportedTypeNames, + ImmutableEquatableArray ImportedSagaTypeNames, + ImmutableEquatableArray ImportedHandlerTypeNames) : SyntaxInfo +{ + /// + public override string OrderByKey => $"ImportedModule:{MethodName}"; +} diff --git a/src/Mocha/src/Mocha.Analyzers/Models/JsonContextInfo.cs b/src/Mocha/src/Mocha.Analyzers/Models/JsonContextInfo.cs new file mode 100644 index 00000000000..57e19bd7f75 --- /dev/null +++ b/src/Mocha/src/Mocha.Analyzers/Models/JsonContextInfo.cs @@ -0,0 +1,20 @@ +using Mocha.Analyzers.Utils; + +namespace Mocha.Analyzers; + +/// +/// Pre-extracted, equatable information about the JsonSerializerContext referenced +/// from a [MessagingModule] attribute. This model flows through the incremental +/// pipeline in place of to avoid +/// re-execution on every keystroke. +/// +/// +/// The fully qualified type name of the JsonSerializerContext, or if not specified. +/// +/// +/// The types declared via [JsonSerializable(typeof(T))] on the context, including their +/// type hierarchies for context-only message resolution. +/// +public sealed record JsonContextInfo( + string? JsonContextTypeName, + ImmutableEquatableArray SerializableTypes); diff --git a/src/Mocha/src/Mocha.Analyzers/Models/JsonContextSerializableTypesInfo.cs b/src/Mocha/src/Mocha.Analyzers/Models/JsonContextSerializableTypesInfo.cs new file mode 100644 index 00000000000..cd6b9c150c6 --- /dev/null +++ b/src/Mocha/src/Mocha.Analyzers/Models/JsonContextSerializableTypesInfo.cs @@ -0,0 +1,20 @@ +using Mocha.Analyzers.Utils; + +namespace Mocha.Analyzers; + +/// +/// Carries the complete set of type names declared as [JsonSerializable] on the +/// module's JsonSerializerContext. Used by downstream generators to determine which +/// types have local serializer support and should receive AddMessageConfiguration +/// registrations with pre-built serializers. +/// +/// +/// The fully qualified type names of all types declared via [JsonSerializable(typeof(T))] +/// on the module's JsonSerializerContext. +/// +public sealed record JsonContextSerializableTypesInfo( + ImmutableEquatableArray TypeNames) : SyntaxInfo +{ + /// + public override string OrderByKey => "JsonContextTypes"; +} diff --git a/src/Mocha/src/Mocha.Analyzers/Models/JsonSerializableTypeInfo.cs b/src/Mocha/src/Mocha.Analyzers/Models/JsonSerializableTypeInfo.cs new file mode 100644 index 00000000000..bf696fd0cd8 --- /dev/null +++ b/src/Mocha/src/Mocha.Analyzers/Models/JsonSerializableTypeInfo.cs @@ -0,0 +1,20 @@ +using Mocha.Analyzers.Utils; + +namespace Mocha.Analyzers; + +/// +/// Represents a type discovered from a [JsonSerializable(typeof(T))] attribute on a +/// JsonSerializerContext, including its type hierarchy for context-only message resolution. +/// +/// The fully qualified type name of the serializable type. +/// +/// The unfiltered type hierarchy (base types excluding object, plus all interfaces), +/// as fully qualified display strings. +/// +/// +/// The equatable source location of the [JsonSerializable] attribute, or if unavailable. +/// +public sealed record JsonSerializableTypeInfo( + string TypeName, + ImmutableEquatableArray TypeHierarchy, + LocationInfo? AttributeLocation) : IEquatable; diff --git a/src/Mocha/src/Mocha.Analyzers/Models/MessagingHandlerInfo.cs b/src/Mocha/src/Mocha.Analyzers/Models/MessagingHandlerInfo.cs index 5cccda6c3a6..534b59a0a82 100644 --- a/src/Mocha/src/Mocha.Analyzers/Models/MessagingHandlerInfo.cs +++ b/src/Mocha/src/Mocha.Analyzers/Models/MessagingHandlerInfo.cs @@ -1,3 +1,5 @@ +using Mocha.Analyzers.Utils; + namespace Mocha.Analyzers; /// @@ -10,6 +12,10 @@ namespace Mocha.Analyzers; /// The fully qualified type name of the response, or if the handler returns no response. /// /// The kind of messaging handler. +/// +/// The unfiltered type hierarchy of the message type (base types excluding object, plus all interfaces), +/// as fully qualified display strings. Filtering to registered types happens in the generator phase. +/// /// /// The equatable source location of the handler type declaration, or if unavailable. /// @@ -19,6 +25,7 @@ public sealed record MessagingHandlerInfo( string MessageTypeName, string? ResponseTypeName, MessagingHandlerKind Kind, + ImmutableEquatableArray MessageTypeHierarchy, LocationInfo? Location) : SyntaxInfo { /// diff --git a/src/Mocha/src/Mocha.Analyzers/Models/MessagingModuleInfo.cs b/src/Mocha/src/Mocha.Analyzers/Models/MessagingModuleInfo.cs index 3eeaea2029d..dea7126555c 100644 --- a/src/Mocha/src/Mocha.Analyzers/Models/MessagingModuleInfo.cs +++ b/src/Mocha/src/Mocha.Analyzers/Models/MessagingModuleInfo.cs @@ -5,7 +5,11 @@ namespace Mocha.Analyzers; /// discovered during source generation. /// /// The module name specified in the attribute. -public sealed record MessagingModuleInfo(string ModuleName) : SyntaxInfo +/// +/// The fully qualified type name of the JsonSerializerContext specified via +/// the JsonContext named property, or null if not specified. +/// +public sealed record MessagingModuleInfo(string ModuleName, string? JsonContextTypeName = null) : SyntaxInfo { /// public override string OrderByKey => $"MsgModule:{ModuleName}"; diff --git a/src/Mocha/src/Mocha.Analyzers/Models/SagaInfo.cs b/src/Mocha/src/Mocha.Analyzers/Models/SagaInfo.cs index 8e776c96a4b..3b82e9e9a84 100644 --- a/src/Mocha/src/Mocha.Analyzers/Models/SagaInfo.cs +++ b/src/Mocha/src/Mocha.Analyzers/Models/SagaInfo.cs @@ -5,9 +5,11 @@ namespace Mocha.Analyzers; /// /// The fully qualified type name of the saga class. /// The namespace containing the saga class. +/// The fully qualified type name of the saga state type (TState). public sealed record SagaInfo( string SagaTypeName, - string SagaNamespace) : SyntaxInfo + string SagaNamespace, + string StateTypeName) : SyntaxInfo { /// public override string OrderByKey => $"Saga:{SagaTypeName}"; diff --git a/src/Mocha/src/Mocha.Analyzers/README.md b/src/Mocha/src/Mocha.Analyzers/README.md new file mode 100644 index 00000000000..890cbb72330 --- /dev/null +++ b/src/Mocha/src/Mocha.Analyzers/README.md @@ -0,0 +1,308 @@ +# Mocha Messaging Source Generator + +Incremental Roslyn source generator that discovers messaging handlers, sagas, and call-sites from a compilation and emits a dependency injection registration method (`Add{Module}()`) for the message bus. + +## Table of Contents + +- [Module Declaration](#module-declaration) +- [Discovery Pipeline](#discovery-pipeline) +- [Handler Discovery](#handler-discovery) +- [Saga Discovery](#saga-discovery) +- [Call-Site Discovery](#call-site-discovery) +- [Imported Module Discovery](#imported-module-discovery) +- [Code Generation](#code-generation) +- [AOT Mode](#aot-mode) +- [Validation & Diagnostics](#validation--diagnostics) +- [Cross-Module System](#cross-module-system) + +--- + +## Module Declaration + +A module is declared via an assembly-level attribute: + +```csharp +[assembly: MessagingModule("OrderService")] +``` + +With AOT support: + +```csharp +[assembly: MessagingModule("OrderService", JsonContext = typeof(OrderServiceJsonContext))] +``` + +- **Module name** is required and determines the generated method name (`Add{ModuleName}`). +- **JsonContext** is optional. When set, the generator enters AOT mode and emits pre-built serializer registrations. +- Only one `[MessagingModule]` per assembly is supported. + +--- + +## Discovery Pipeline + +The generator runs two parallel incremental pipelines: + +### Pipeline 1: Handler & Saga Discovery + +Syntax predicate filters classes/records with Mocha base types in their base list, then inspectors run in priority order: + +1. **MessagingHandlerInspector** - concrete handlers implementing messaging interfaces +2. **AbstractMessagingHandlerInspector** - abstract handlers (diagnostic-only, MO0013) +3. **MessagingModuleInspector** - `[assembly: MessagingModule(...)]` attributes +4. **SagaInspector** - `Saga` subclasses + +### Pipeline 2: Call-Site & Import Discovery + +Syntax predicate filters invocation expressions, then: + +1. **CallSiteMessageTypeInspector** - dispatch calls on `IMessageBus`, `ISender`, `IPublisher` +2. **ImportedModuleTypeInspector** - calls to methods annotated with `[MessagingModuleInfo]` + +Both pipelines feed into the `Execute` method which validates, augments, and generates code. + +--- + +## Handler Discovery + +Concrete (non-abstract, non-generic) classes or records implementing messaging interfaces are discovered. The inspector checks interfaces in a **priority cascade** - first match wins: + +| Priority | Interface | Kind | Response | +|----------|-----------|------|----------| +| 1 | `IBatchEventHandler` | Batch | No | +| 2 | `IConsumer` | Consumer | No | +| 3 | `IEventRequestHandler` | RequestResponse | Yes | +| 4 | `IEventRequestHandler` | Send | No | +| 5 | `IEventHandler` | Event | No | + +For each discovered handler, the full **type hierarchy** of the message type is captured (all base types excluding `object`, plus all interfaces). This hierarchy is used for enclosed type computation in AOT mode. + +--- + +## Saga Discovery + +Classes (not records) that inherit from `Saga` are discovered. The inspector: + +1. Walks the base type chain to find `Saga` and extracts `TState`. +2. Validates that the saga has a **public parameterless constructor** (required for instantiation). Reports MO0014 if missing. + +--- + +## Call-Site Discovery + +Invocations on `IMessageBus`, `ISender`, and `IPublisher` are inspected to discover message types used at call sites. These are used for validation only (no code generation). + +### IMessageBus Methods + +| Method | CallSiteKind | Type Extraction | +|--------|-------------|-----------------| +| `PublishAsync` | Publish | Type argument | +| `SendAsync` | Send | Type argument | +| `SchedulePublishAsync` | SchedulePublish | Type argument | +| `ScheduleSendAsync` | ScheduleSend | Type argument | +| `RequestAsync` | Request | First argument type + type argument for response | +| `RequestAsync` (non-generic) | Request | First argument type (fallback) | + +### ISender Methods (Mediator) + +| Method | CallSiteKind | Type Extraction | +|--------|-------------|-----------------| +| `SendAsync` | MediatorSend | First argument type | +| `QueryAsync` | MediatorQuery | First argument type | + +### IPublisher Methods (Mediator) + +| Method | CallSiteKind | Type Extraction | +|--------|-------------|-----------------| +| `PublishAsync` | MediatorPublish | Type argument | + +**Mediator call sites are excluded from JSON validation** because mediator dispatch is in-process and does not require serialization. + +--- + +## Imported Module Discovery + +When code calls a method annotated with `[MessagingModuleInfo]` (e.g., `builder.AddOrders()`), the inspector reads the `MessageTypes` array from the attribute. These types are treated as "already registered" and: + +- Excluded from local serializer registration (no duplicate `AddMessageConfiguration`) +- Counted as "covered" in AOT validation (MO0015, MO0016, MO0018) + +--- + +## Code Generation + +The generator emits a single extension method on `IMessageBusHostBuilder`: + +```csharp +namespace Microsoft.Extensions.DependencyInjection +{ + public static class {Module}MessageBusBuilderExtensions + { + [MessagingModuleInfo(MessageTypes = new Type[] { ... })] + public static IMessageBusHostBuilder Add{Module}( + this IMessageBusHostBuilder builder) + { + // registrations + return builder; + } + } +} +``` + +### Registration Order + +Registrations are emitted in this order: + +1. **AOT Configuration** (if JsonContext specified) + - `ModifyOptions(builder, o => o.IsAotCompatible = true)` + - `AddJsonTypeInfoResolver(builder, {JsonContext}.Default)` +2. **Message Type Serializers** - `AddMessageConfiguration` per type +3. **Saga Configuration** - `AddSagaConfiguration` with state serializer +4. **Batch Handlers** - sorted by handler type name +5. **Consumers** - sorted by handler type name +6. **Request Handlers** (RequestResponse + Send) - sorted by handler type name +7. **Event Handlers** - sorted by handler type name +8. **Saga Registrations** - `AddSaga` + +### Handler Registration + +Each handler emits `AddHandlerConfiguration` with a factory: + +| Kind | Factory | +|------|---------| +| Event | `ConsumerFactory.Subscribe()` | +| Send | `ConsumerFactory.Send()` | +| RequestResponse | `ConsumerFactory.Request()` | +| Consumer | `ConsumerFactory.Consume()` | +| Batch | `ConsumerFactory.Batch()` | + +### `[MessagingModuleInfo]` Attribute Population + +The `MessageTypes` array on the generated method contains **only types that receive `AddMessageConfiguration` calls** in the method body. This means: + +- Only types present in the **local** `JsonSerializerContext` (not from referenced assemblies) +- Excluding types already covered by imported modules +- Including context-only types (types in the JsonContext without handlers) +- **Empty** when no JsonContext is specified (non-AOT mode) + +This ensures importing modules know exactly which types have serializer registrations from this module. + +--- + +## AOT Mode + +AOT mode has two independent aspects controlled by different settings: + +- **`JsonContext` on `[MessagingModule]`** - controls **code generation**: serializer registrations and resolver registration are emitted when a `JsonContext` is specified. +- **`PublishAot` MSBuild property** - controls **strict mode and validation strictness**: when `true`, the generated module enables AOT-compatible runtime mode and diagnostics MO0015/MO0016/MO0018 fire. + +Validation diagnostics fire when **`PublishAot` is true**. Serializer code generation requires `JsonContext`. + +### What changes when JsonContext is specified + +1. **Serializer registrations are emitted** - `AddMessageConfiguration` with pre-built `JsonMessageSerializer` for each message type in the local JsonContext. + +2. **JsonTypeInfoResolver is registered** - the specified `JsonSerializerContext` is added as a resolver. + +3. **AOT strict mode is not enabled by JsonContext alone** - `IsAotCompatible = true` is emitted only when `PublishAot` is true. + +4. **Validation diagnostics do not fire by JsonContext alone** - MO0015, MO0016, and MO0018 are checked only when `PublishAot` is true. + +### Which types get serializer registrations + +A type gets an `AddMessageConfiguration` call if **all** of these are true: +- It is declared as `[JsonSerializable(typeof(T))]` on the **local** `JsonSerializerContext` +- It is NOT already imported from a referenced module +- It is either a handler message/response type OR a context-only type + +### Context-Only Types + +Types declared in the `JsonSerializerContext` that have no corresponding handler or saga in the current assembly still receive `AddMessageConfiguration` registrations. These are types the module needs to serialize but doesn't consume. + +### Enclosed Types + +For each message type registration, the generator computes an "enclosed types" array from the type hierarchy. If multiple registered types share a hierarchy (e.g., `OrderUpdated : Order`), enclosed types are sorted by specificity - most specific types first. This supports polymorphic serialization. + +--- + +## Validation & Diagnostics + +### MO0011 - Duplicate Request Handler (Error) + +**Fires when:** Multiple handlers exist for the same message type with `Send` or `RequestResponse` kind. + +**Example:** Two handlers both implement `IEventRequestHandler`. + +### MO0012 - Open Generic Handler (Info) + +**Fires when:** A handler class has unbound type parameters (e.g., `class MyHandler : IEventHandler`). + +**Reason:** The generator cannot register open generic handlers - concrete types are required. + +### MO0013 - Abstract Handler (Warning) + +**Fires when:** An abstract class implements a messaging interface. + +**Reason:** Abstract classes cannot be instantiated and thus cannot be registered as handlers. + +### MO0014 - Saga Missing Parameterless Constructor (Error) + +**Fires when:** A `Saga` subclass does not have a `public` parameterless constructor. + +**Reason:** The saga runtime requires `new()` to instantiate saga instances. + +### MO0015 - Missing JsonSerializerContext (Error) + +**Fires when (AOT mode):** The module has handlers or sagas with message types not fully covered by imported modules, but no `JsonContext` is specified on `[MessagingModule]`. + +**Fix:** Add `JsonContext = typeof(MyJsonContext)` to the attribute. + +### MO0016 - Missing JsonSerializable (Error) + +**Fires when (AOT mode):** A handler message type, response type, or saga state type is not declared as `[JsonSerializable]` on the local `JsonSerializerContext` and not covered by imported modules. + +**Fix:** Add `[JsonSerializable(typeof(MissingType))]` to the JsonContext class. + +### MO0018 - Call-Site Type Not in JsonContext (Warning) + +**Fires when (AOT mode):** A message type used in a dispatch call (`PublishAsync`, `SendAsync`, etc.) is not found in the local `JsonSerializerContext` or imported module types. + +**Scope:** Only messaging dispatch calls - mediator dispatch (`ISender.SendAsync`, `ISender.QueryAsync`, `IPublisher.PublishAsync`) is excluded because it's in-process. + +> **Note:** MO0017 is reserved and not currently used. + +### When diagnostics fire + +| Diagnostic | Condition | +|------------|-----------| +| MO0011 | Always (not AOT-gated) | +| MO0012 | Always (not AOT-gated) | +| MO0013 | Always (not AOT-gated) | +| MO0014 | Always (not AOT-gated) | +| MO0015 | `PublishAot == true` | +| MO0016 | `PublishAot == true` | +| MO0018 | `PublishAot == true` | + +Handlers or sagas that carry a diagnostic (e.g., MO0012, MO0013, MO0014) are **excluded from code generation** - no `AddHandlerConfiguration` or `AddSagaConfiguration` is emitted for them. Only entries with zero diagnostics flow to the generator. + +--- + +## Cross-Module System + +The module system enables multiple assemblies to register their handlers independently while avoiding duplicate serializer registrations. + +### How it works + +1. **Module A** declares `[assembly: MessagingModule("Orders", JsonContext = typeof(OrdersJsonContext))]` +2. The generator emits `AddOrders()` with `[MessagingModuleInfo(MessageTypes = new[] { typeof(OrderCreated), ... })]` +3. **Module B** calls `builder.AddOrders()` in its code +4. The `ImportedModuleTypeInspector` reads the `[MessagingModuleInfo]` attribute and extracts the type names +5. Module B's generator skips serializer registration for imported types and excludes them from its own `[MessagingModuleInfo]` + +### Validation with imports + +- **MO0015:** If all handler types are covered by imports, no local JsonContext is needed +- **MO0016:** Imported types are considered "covered" - no local `[JsonSerializable]` needed +- **MO0018:** Imported types are considered "covered" at call sites + +### Key constraint + +The `[MessagingModuleInfo]` attribute only advertises types for which the module emits `AddMessageConfiguration` calls. Types that are handled but don't have local serializer support (not in the local JsonContext) are **not** included in the attribute. This prevents downstream modules from incorrectly assuming serialization is covered. diff --git a/src/Mocha/src/Mocha.Analyzers/SyntaxConstants.cs b/src/Mocha/src/Mocha.Analyzers/SyntaxConstants.cs index 0b9355f01c3..c80bc2c9dc0 100644 --- a/src/Mocha/src/Mocha.Analyzers/SyntaxConstants.cs +++ b/src/Mocha/src/Mocha.Analyzers/SyntaxConstants.cs @@ -91,8 +91,69 @@ public static class SyntaxConstants /// public const string IEventRequestOfT = "Mocha.IEventRequest`1"; + /// + /// Gets the fully qualified display string for the IEventRequest marker interface, + /// used when comparing emitted typeof(...) expressions. + /// + public const string IEventRequestDisplay = "global::Mocha.IEventRequest"; + + /// + /// Gets the fully qualified display prefix for a closed IEventRequest<TResponse>, + /// used to detect framework base types in emitted typeof(...) expressions. + /// + public const string IEventRequestOfTDisplayPrefix = "global::Mocha.IEventRequest<"; + /// /// Gets the metadata name for the MessagingModuleAttribute class. /// public const string MessagingModuleAttribute = "Mocha.MessagingModuleAttribute"; + + /// + /// Gets the named property name for the JsonContext property + /// on MessagingModuleAttribute. + /// + public const string JsonContextProperty = "JsonContext"; + + /// + /// Gets the metadata name for the MessagingModuleInfoAttribute class. + /// + public const string MessagingModuleInfoAttribute = "Mocha.MessagingModuleInfoAttribute"; + + /// + /// Gets the metadata name for the MediatorModuleInfoAttribute class. + /// + public const string MediatorModuleInfoAttribute = "Mocha.Mediator.MediatorModuleInfoAttribute"; + + /// + /// Gets the named property name for the MessageTypes property + /// on MessagingModuleInfoAttribute and MediatorModuleInfoAttribute. + /// + public const string MessageTypesProperty = "MessageTypes"; + + /// + /// Gets the named property name for the SagaTypes property + /// on MessagingModuleInfoAttribute. + /// + public const string SagaTypesProperty = "SagaTypes"; + + /// + /// Gets the named property name for the HandlerTypes property + /// on MessagingModuleInfoAttribute and MediatorModuleInfoAttribute. + /// + public const string HandlerTypesProperty = "HandlerTypes"; + + /// + /// Gets the metadata name for the IMessageBus interface. + /// + public const string IMessageBus = "Mocha.IMessageBus"; + + /// + /// Gets the metadata name for the ISender interface (Mediator). + /// + public const string ISender = "Mocha.Mediator.ISender"; + + /// + /// Gets the metadata name for the IPublisher interface (Mediator). + /// + public const string IPublisher = "Mocha.Mediator.IPublisher"; } diff --git a/src/Mocha/src/Mocha.Analyzers/buildTransitive/Mocha.Analyzers.props b/src/Mocha/src/Mocha.Analyzers/buildTransitive/Mocha.Analyzers.props new file mode 100644 index 00000000000..05c7c3b4610 --- /dev/null +++ b/src/Mocha/src/Mocha.Analyzers/buildTransitive/Mocha.Analyzers.props @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Mocha/src/Mocha.Mediator.Abstractions/MediatorModuleInfoAttribute.cs b/src/Mocha/src/Mocha.Mediator.Abstractions/MediatorModuleInfoAttribute.cs new file mode 100644 index 00000000000..81f3b6c9103 --- /dev/null +++ b/src/Mocha/src/Mocha.Mediator.Abstractions/MediatorModuleInfoAttribute.cs @@ -0,0 +1,20 @@ +namespace Mocha.Mediator; + +/// +/// Annotates a generated mediator registration method with metadata about the message types +/// it registers. This enables cross-project analyzers to discover types registered by +/// referenced modules without scanning assemblies. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class MediatorModuleInfoAttribute : Attribute +{ + /// + /// Gets or sets the message types registered by this module. + /// + public Type[] MessageTypes { get; set; } = []; + + /// + /// Gets or sets the handler types registered by this module. + /// + public Type[] HandlerTypes { get; set; } = []; +} diff --git a/src/Mocha/src/Mocha.Mediator.Abstractions/Mocha.Mediator.Abstractions.csproj b/src/Mocha/src/Mocha.Mediator.Abstractions/Mocha.Mediator.Abstractions.csproj index 351ef6467a7..48f0b994904 100644 --- a/src/Mocha/src/Mocha.Mediator.Abstractions/Mocha.Mediator.Abstractions.csproj +++ b/src/Mocha/src/Mocha.Mediator.Abstractions/Mocha.Mediator.Abstractions.csproj @@ -4,5 +4,6 @@ Mocha.Mediator enable enable + true diff --git a/src/Mocha/src/Mocha.Mediator/Configuration/MediatorHandlerConfiguration.cs b/src/Mocha/src/Mocha.Mediator/Configuration/MediatorHandlerConfiguration.cs index 430e626060c..96ac517e83b 100644 --- a/src/Mocha/src/Mocha.Mediator/Configuration/MediatorHandlerConfiguration.cs +++ b/src/Mocha/src/Mocha.Mediator/Configuration/MediatorHandlerConfiguration.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace Mocha.Mediator; /// @@ -14,6 +16,7 @@ public class MediatorHandlerConfiguration : MediatorConfiguration /// /// Gets or sets the message type (command, query, or notification type). /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] public Type? MessageType { get; set; } /// diff --git a/src/Mocha/src/Mocha.Mediator/DependencyInjection/MediatorBuilder.cs b/src/Mocha/src/Mocha.Mediator/DependencyInjection/MediatorBuilder.cs index 59144942e17..00cd1c14d82 100644 --- a/src/Mocha/src/Mocha.Mediator/DependencyInjection/MediatorBuilder.cs +++ b/src/Mocha/src/Mocha.Mediator/DependencyInjection/MediatorBuilder.cs @@ -1,6 +1,7 @@ using System.Collections.Frozen; using System.Collections.Immutable; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -15,6 +16,14 @@ namespace Mocha.Mediator; /// public sealed class MediatorBuilder : IMediatorBuilder { + private readonly List _middlewares = [MediatorDiagnosticMiddleware.Create()]; + + private readonly List>> _pipelineModifiers = []; + private readonly Dictionary> _handlerDescriptors = []; + private readonly List> _configureServices = []; + private readonly List> _configureFeatures = []; + private readonly MediatorOptions _options = new(); + private static readonly MethodInfo s_buildCommandPipeline = typeof(PipelineBuilder).GetMethod(nameof(PipelineBuilder.BuildCommandPipeline))!; @@ -27,17 +36,6 @@ public sealed class MediatorBuilder : IMediatorBuilder private static readonly MethodInfo s_buildNotificationPipeline = typeof(PipelineBuilder).GetMethod(nameof(PipelineBuilder.BuildNotificationPipeline))!; - private readonly List _middlewares = - [ - MediatorDiagnosticMiddleware.Create() - ]; - - private readonly List>> _pipelineModifiers = []; - private readonly Dictionary> _handlerDescriptors = []; - private readonly List> _configureServices = []; - private readonly List> _configureFeatures = []; - private readonly MediatorOptions _options = new(); - /// public IMediatorBuilder ConfigureOptions(Action configure) { @@ -106,8 +104,7 @@ public IMediatorBuilder ConfigureServices(Action - public void AddHandler(Action? configure = null) - where THandler : class + public void AddHandler(Action? configure = null) where THandler : class { var handlerType = typeof(THandler); var existing = _handlerDescriptors.GetValueOrDefault(handlerType); @@ -172,6 +169,14 @@ void ApplyConfig(MediatorHandlerDescriptor d) /// /// The application-level service provider used to resolve shared services. /// + [UnconditionalSuppressMessage( + "Trimming", + "IL2026:RequiresUnreferencedCode", + Justification = "Reflection fallback only used for manual (non-generated) handler registration.")] + [UnconditionalSuppressMessage( + "AOT", + "IL3050:RequiresDynamicCode", + Justification = "Reflection fallback only used for manual (non-generated) handler registration.")] public MediatorRuntime Build(IServiceProvider applicationServices) { // Create the mediator's own internal service collection. @@ -202,16 +207,16 @@ public MediatorRuntime Build(IServiceProvider applicationServices) // Compile pipelines using the internal provider for middleware factory context. var factoryCtx = new MediatorMiddlewareFactoryContext { Services = internalProvider, Features = features }; - var middlewareConfigs = _middlewares.Count > 0 - ? new IReadOnlyList[] { _middlewares } - : []; + var middlewareConfigs = + _middlewares.Count > 0 ? new IReadOnlyList[] { _middlewares } : []; - var modifiers = _pipelineModifiers.Count > 0 - ? new IReadOnlyList>>[] { _pipelineModifiers } - : []; + var modifiers = + _pipelineModifiers.Count > 0 + ? new IReadOnlyList>>[] { _pipelineModifiers } + : []; var pipelines = new Dictionary(); - var notificationTerminals = new Dictionary>(); + var notificationTerminals = new Dictionary(); foreach (var (handlerType, configureDelegate) in _handlerDescriptors) { @@ -223,21 +228,24 @@ public MediatorRuntime Build(IServiceProvider applicationServices) if (config.Kind == MediatorHandlerKind.Notification) { - if (!notificationTerminals.TryGetValue(config.MessageType!, out var list)) + if (!notificationTerminals.TryGetValue(config.MessageType!, out var notification)) { - list = []; - notificationTerminals[config.MessageType!] = list; + notification = new NotificationTerminals(config.MessageType!); + notificationTerminals[config.MessageType!] = notification; } - list.Add(terminal); + notification.Terminals.Add(terminal); } else { factoryCtx.MessageType = config.MessageType!; factoryCtx.ResponseType = config.ResponseType; - pipelines[config.MessageType!] = - MediatorMiddlewareCompiler.Compile(factoryCtx, terminal, middlewareConfigs, modifiers); + pipelines[config.MessageType!] = MediatorMiddlewareCompiler.Compile( + factoryCtx, + terminal, + middlewareConfigs, + modifiers); } } @@ -245,19 +253,20 @@ public MediatorRuntime Build(IServiceProvider applicationServices) // wrapped in middleware, producing a MediatorDelegate[] per notification type. var notificationPipelines = new Dictionary>(notificationTerminals.Count); - foreach (var (notificationType, terminals) in notificationTerminals) + foreach (var notification in notificationTerminals.Values) { - factoryCtx.MessageType = notificationType; + factoryCtx.MessageType = notification.MessageType; factoryCtx.ResponseType = null; + var terminals = notification.Terminals; var compiled = ImmutableArray.CreateBuilder(terminals.Count); for (var i = 0; i < terminals.Count; i++) { - compiled.Add(MediatorMiddlewareCompiler.Compile( - factoryCtx, terminals[i], middlewareConfigs, modifiers)); + compiled.Add( + MediatorMiddlewareCompiler.Compile(factoryCtx, terminals[i], middlewareConfigs, modifiers)); } - notificationPipelines[notificationType] = compiled.ToImmutable(); + notificationPipelines[notification.MessageType] = compiled.ToImmutable(); } var pools = applicationServices.GetRequiredService(); @@ -270,27 +279,27 @@ public MediatorRuntime Build(IServiceProvider applicationServices) _options.NotificationPublishMode); } + [RequiresDynamicCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] + [RequiresUnreferencedCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] private static MediatorDelegate BuildPipelineViaReflection(MediatorHandlerConfiguration config) { return config.Kind switch { - MediatorHandlerKind.Command => - (MediatorDelegate)s_buildCommandPipeline - .MakeGenericMethod(config.HandlerType!, config.MessageType!) - .Invoke(null, null)!, + MediatorHandlerKind.Command => (MediatorDelegate) + s_buildCommandPipeline.MakeGenericMethod(config.HandlerType!, config.MessageType!).Invoke(null, null)!, - MediatorHandlerKind.CommandResponse => - (MediatorDelegate)s_buildCommandResponsePipeline + MediatorHandlerKind.CommandResponse => (MediatorDelegate) + s_buildCommandResponsePipeline .MakeGenericMethod(config.HandlerType!, config.MessageType!, config.ResponseType!) .Invoke(null, null)!, - MediatorHandlerKind.Query => - (MediatorDelegate)s_buildQueryPipeline + MediatorHandlerKind.Query => (MediatorDelegate) + s_buildQueryPipeline .MakeGenericMethod(config.HandlerType!, config.MessageType!, config.ResponseType!) .Invoke(null, null)!, - MediatorHandlerKind.Notification => - (MediatorDelegate)s_buildNotificationPipeline + MediatorHandlerKind.Notification => (MediatorDelegate) + s_buildNotificationPipeline .MakeGenericMethod(config.HandlerType!, config.MessageType!) .Invoke(null, null)!, @@ -317,4 +326,13 @@ private static void AddDiagnosticEvents(IServiceCollection services) }; }); } + + private sealed class NotificationTerminals( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type messageType) + { + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] + public Type MessageType { get; } = messageType; + + public List Terminals { get; } = []; + } } diff --git a/src/Mocha/src/Mocha.Mediator/DependencyInjection/MediatorBuilderInstrumentationExtensions.cs b/src/Mocha/src/Mocha.Mediator/DependencyInjection/MediatorBuilderInstrumentationExtensions.cs index 153e63820c0..a8491180e26 100644 --- a/src/Mocha/src/Mocha.Mediator/DependencyInjection/MediatorBuilderInstrumentationExtensions.cs +++ b/src/Mocha/src/Mocha.Mediator/DependencyInjection/MediatorBuilderInstrumentationExtensions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -24,7 +25,9 @@ public static IMediatorBuilder AddInstrumentation(this IMediatorBuilder builder) /// /// Registers a custom implementation. /// - public static IMediatorBuilder AddDiagnosticEventListener(this IMediatorBuilder builder) + public static IMediatorBuilder AddDiagnosticEventListener< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>( + this IMediatorBuilder builder) where T : class, IMediatorDiagnosticEventListener { ArgumentNullException.ThrowIfNull(builder); diff --git a/src/Mocha/src/Mocha.Mediator/DependencyInjection/MediatorHostBuilderExtensions.cs b/src/Mocha/src/Mocha.Mediator/DependencyInjection/MediatorHostBuilderExtensions.cs index 46037ccd531..5ab56f2371f 100644 --- a/src/Mocha/src/Mocha.Mediator/DependencyInjection/MediatorHostBuilderExtensions.cs +++ b/src/Mocha/src/Mocha.Mediator/DependencyInjection/MediatorHostBuilderExtensions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; namespace Mocha.Mediator; @@ -53,7 +54,9 @@ public static IMediatorHostBuilder AddInstrumentation(this IMediatorHostBuilder /// Registers a custom implementation /// into the mediator's internal services. /// - public static IMediatorHostBuilder AddDiagnosticEventListener(this IMediatorHostBuilder builder) + public static IMediatorHostBuilder AddDiagnosticEventListener< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>( + this IMediatorHostBuilder builder) where T : class, IMediatorDiagnosticEventListener { ArgumentNullException.ThrowIfNull(builder); diff --git a/src/Mocha/src/Mocha.Mediator/DependencyInjection/MediatorHostBuilderHandlerExtensions.cs b/src/Mocha/src/Mocha.Mediator/DependencyInjection/MediatorHostBuilderHandlerExtensions.cs index 5fea8ccd6b9..994e952f1f8 100644 --- a/src/Mocha/src/Mocha.Mediator/DependencyInjection/MediatorHostBuilderHandlerExtensions.cs +++ b/src/Mocha/src/Mocha.Mediator/DependencyInjection/MediatorHostBuilderHandlerExtensions.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -20,6 +21,8 @@ public static class MediatorHostBuilderHandlerExtensions /// The handler implementation type. /// The mediator host builder. /// An optional action to configure the handler descriptor. + [RequiresDynamicCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] + [RequiresUnreferencedCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] public static IMediatorHostBuilder AddHandler( this IMediatorHostBuilder builder, Action? configure = null) @@ -43,7 +46,8 @@ public static IMediatorHostBuilder AddHandler( /// The mediator host builder. /// The pre-built handler configuration. [EditorBrowsable(EditorBrowsableState.Never)] - public static IMediatorHostBuilder AddHandlerConfiguration( + public static IMediatorHostBuilder AddHandlerConfiguration< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>( this IMediatorHostBuilder builder, MediatorHandlerConfiguration configuration) where THandler : class diff --git a/src/Mocha/src/Mocha.Mediator/Descriptors/MediatorHandlerDescriptor.cs b/src/Mocha/src/Mocha.Mediator/Descriptors/MediatorHandlerDescriptor.cs index 1706e688ff4..25f7dc83e76 100644 --- a/src/Mocha/src/Mocha.Mediator/Descriptors/MediatorHandlerDescriptor.cs +++ b/src/Mocha/src/Mocha.Mediator/Descriptors/MediatorHandlerDescriptor.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace Mocha.Mediator; /// @@ -14,8 +16,7 @@ public class MediatorHandlerDescriptor /// /// The mediator configuration context. /// The concrete handler implementation type. - public MediatorHandlerDescriptor(IMediatorConfigurationContext context, Type handlerType) - : base(context) + public MediatorHandlerDescriptor(IMediatorConfigurationContext context, Type handlerType) : base(context) { ArgumentNullException.ThrowIfNull(handlerType); @@ -31,6 +32,10 @@ public MediatorHandlerDescriptor(IMediatorConfigurationContext context, Type han /// , auto-detects handler metadata /// from the handler type's interfaces. /// + [UnconditionalSuppressMessage( + "Trimming", + "IL2026:RequiresUnreferencedCode", + Justification = "Reflection fallback is only used when source-generated handler configuration is absent.")] public MediatorHandlerConfiguration CreateConfiguration() { if (Configuration.MessageType is null) @@ -41,6 +46,8 @@ public MediatorHandlerConfiguration CreateConfiguration() return Configuration; } + [RequiresUnreferencedCode( + "Handler detection uses reflection. Use source-generated AddHandlerConfiguration for AOT compatibility.")] private void DetectHandler(Type handlerType) { var found = false; diff --git a/src/Mocha/src/Mocha.Mediator/MediatorMiddlewareFactoryContext.cs b/src/Mocha/src/Mocha.Mediator/MediatorMiddlewareFactoryContext.cs index 53b47c6439d..346527792bd 100644 --- a/src/Mocha/src/Mocha.Mediator/MediatorMiddlewareFactoryContext.cs +++ b/src/Mocha/src/Mocha.Mediator/MediatorMiddlewareFactoryContext.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace Mocha.Mediator; /// @@ -23,6 +25,7 @@ public sealed class MediatorMiddlewareFactoryContext /// /// Gets the message type of the pipeline being compiled. /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] public Type MessageType { get; internal set; } = null!; /// diff --git a/src/Mocha/src/Mocha.Mediator/MediatorMiddlewareFactoryContextExtensions.cs b/src/Mocha/src/Mocha.Mediator/MediatorMiddlewareFactoryContextExtensions.cs index 0605707f264..95512b41094 100644 --- a/src/Mocha/src/Mocha.Mediator/MediatorMiddlewareFactoryContextExtensions.cs +++ b/src/Mocha/src/Mocha.Mediator/MediatorMiddlewareFactoryContextExtensions.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace Mocha.Mediator; /// @@ -58,7 +60,9 @@ public static bool IsResponseAssignableTo(this MediatorMiddlewareFactoryConte public static bool IsResponseAssignableTo(this MediatorMiddlewareFactoryContext context, Type type) => context.ResponseType is not null && type.IsAssignableFrom(context.ResponseType); - private static bool HasGenericInterface(Type type, Type openGeneric) + private static bool HasGenericInterface( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type type, + Type openGeneric) { foreach (var @interface in type.GetInterfaces()) { diff --git a/src/Mocha/src/Mocha.Mediator/Mocha.Mediator.csproj b/src/Mocha/src/Mocha.Mediator/Mocha.Mediator.csproj index 04eda7088ef..bde0988c8a5 100644 --- a/src/Mocha/src/Mocha.Mediator/Mocha.Mediator.csproj +++ b/src/Mocha/src/Mocha.Mediator/Mocha.Mediator.csproj @@ -4,6 +4,7 @@ Mocha.Mediator enable enable + true diff --git a/src/Mocha/src/Mocha/Abstractions/IMessageBusBuilder.cs b/src/Mocha/src/Mocha/Abstractions/IMessageBusBuilder.cs index d6ef05eb24a..06c551b34bc 100644 --- a/src/Mocha/src/Mocha/Abstractions/IMessageBusBuilder.cs +++ b/src/Mocha/src/Mocha/Abstractions/IMessageBusBuilder.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; using Mocha.Middlewares; using Mocha.Sagas; @@ -17,6 +18,8 @@ public interface IMessageBusBuilder /// The handler type implementing . /// Optional action to configure the consumer descriptor. /// The builder instance for method chaining. + [RequiresDynamicCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] + [RequiresUnreferencedCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] IMessageBusBuilder AddHandler(Action? configure = null) where THandler : class, IHandler; @@ -34,6 +37,8 @@ IMessageBusBuilder AddHandler(Action? configure = /// The batch handler type. /// Optional action to configure batch options. /// The builder instance for method chaining. + [RequiresDynamicCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] + [RequiresUnreferencedCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] IMessageBusBuilder AddBatchHandler(Action? configure = null) where THandler : class, IBatchEventHandler; @@ -94,6 +99,23 @@ IMessageBusBuilder AddBatchHandler(Action? configure = n /// The builder instance for method chaining. IMessageBusBuilder Host(Action configure); + /// + /// Registers a message type using pre-built configuration from the source generator. + /// + /// The pre-built message configuration. + /// The builder instance for method chaining. + [EditorBrowsable(EditorBrowsableState.Never)] + IMessageBusBuilder AddMessageConfiguration(MessagingMessageConfiguration configuration); + + /// + /// Registers a saga using pre-built configuration from the source generator. + /// + /// The saga type. + /// The pre-built saga configuration. + /// The builder instance for method chaining. + [EditorBrowsable(EditorBrowsableState.Never)] + IMessageBusBuilder AddSagaConfiguration(MessagingSagaConfiguration configuration) where TSaga : Saga, new(); + /// /// Registers a message type with the bus and configures its serialization, routing, and metadata. /// diff --git a/src/Mocha/src/Mocha/Builder/MessageBusBuilder.cs b/src/Mocha/src/Mocha/Builder/MessageBusBuilder.cs index 2c190c14e9b..7fc4dc14225 100644 --- a/src/Mocha/src/Mocha/Builder/MessageBusBuilder.cs +++ b/src/Mocha/src/Mocha/Builder/MessageBusBuilder.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -66,6 +67,8 @@ public IMessageBusBuilder ConfigureFeature(Action configure) } /// + [RequiresDynamicCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] + [RequiresUnreferencedCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] public IMessageBusBuilder AddHandler(Action? configure = null) where THandler : class, IHandler { @@ -83,9 +86,7 @@ public IMessageBusBuilder AddHandler(Action? conf inner(d); configure(d); } -#pragma warning disable format - : configure; -#pragma warning restore format + : configure; } return this; @@ -143,12 +144,13 @@ public IMessageBusBuilder AddHandler(Action? conf throw ThrowHelper.InvalidHandlerType(); } - _consumerRegistrations.Add(new ConsumerRegistration - { - HandlerType = handlerType, - Configure = configure, - Factory = factory - }); + _consumerRegistrations.Add( + new ConsumerRegistration + { + HandlerType = handlerType, + Configure = configure, + Factory = factory + }); return this; } @@ -166,12 +168,13 @@ public IMessageBusBuilder AddHandlerConfiguration(MessagingHandlerConfiguration return this; } - _consumerRegistrations.Add(new ConsumerRegistration - { - HandlerType = handlerType, - Configure = null, - Factory = configuration.Factory - }); + _consumerRegistrations.Add( + new ConsumerRegistration + { + HandlerType = handlerType, + Configure = null, + Factory = configuration.Factory + }); return this; } @@ -182,6 +185,8 @@ public IMessageBusBuilder AddHandlerConfiguration(MessagingHandlerConfiguration /// The batch handler type. /// Optional action to configure batch options. /// The builder instance for method chaining. + [RequiresDynamicCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] + [RequiresUnreferencedCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] public IMessageBusBuilder AddBatchHandler(Action? configure = null) where THandler : class, IBatchEventHandler { @@ -198,12 +203,66 @@ public IMessageBusBuilder AddBatchHandler(Action? config { var sagaType = typeof(TSaga); - if (_sagaRegistrations.Exists(r => r.SagaType == sagaType)) + _sagaRegistrations.RemoveAll(r => r.SagaType == sagaType); + + _sagaRegistrations.Add(new SagaRegistration { SagaType = sagaType, Factory = static () => new TSaga() }); + + return this; + } + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public IMessageBusBuilder AddMessageConfiguration(MessagingMessageConfiguration configuration) + { + var messageType = configuration.MessageType; + var serializer = configuration.Serializer; + var enclosedTypes = configuration.EnclosedTypes; + + var existingDelegate = _messageDescriptors.GetValueOrDefault(messageType); + + if (existingDelegate is not null) { - return this; + var innerDelegate = existingDelegate; + _messageDescriptors[messageType] = descriptor => + { + innerDelegate(descriptor); + Configure(descriptor); + }; + } + else + { + _messageDescriptors[messageType] = Configure; } - _sagaRegistrations.Add(new SagaRegistration { SagaType = sagaType, Factory = static () => new TSaga() }); + return this; + + void Configure(IMessageTypeDescriptor descriptor) + { + descriptor.AddSerializer(serializer); + + if (!enclosedTypes.IsDefaultOrEmpty) + { + descriptor.Extend().Configuration.EnclosedTypes = enclosedTypes; + } + } + } + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public IMessageBusBuilder AddSagaConfiguration(MessagingSagaConfiguration configuration) + where TSaga : Saga, new() + { + var sagaType = typeof(TSaga); + + _sagaRegistrations.RemoveAll(r => r.SagaType == sagaType); + + _sagaRegistrations.Add( + new SagaRegistration + { + SagaType = sagaType, + Factory = static () => new TSaga(), + StateSerializer = configuration.StateSerializer + }); return this; } @@ -370,6 +429,11 @@ public MessagingRuntime Build(IServiceProvider applicationServices) var servicesCollection = new ServiceCollection(); AddCoreServices(servicesCollection, applicationServices); + foreach (var configure in _configureServices) + { + configure(applicationServices, servicesCollection); + } + var responseManager = applicationServices.GetRequiredService(); // Materialize consumers from registrations @@ -390,6 +454,12 @@ public MessagingRuntime Build(IServiceProvider applicationServices) foreach (var reg in _sagaRegistrations) { var saga = reg.Factory(); + + if (reg.StateSerializer is not null) + { + saga.StateSerializer = reg.StateSerializer; + } + sagas.Add(saga); consumerList.Add(saga.Consumer); } @@ -454,6 +524,7 @@ public MessagingRuntime Build(IServiceProvider applicationServices) configureDelegate(descriptor); var configuration = descriptor.CreateConfiguration(); + var messageType = new MessageType(); messageType.Initialize(setupContext, configuration); messageRegistry.AddMessageType(messageType); @@ -572,22 +643,4 @@ private static void ValidateDefaultTransports(ImmutableArray throw ThrowHelper.MultipleDefaultTransports(defaultTransportNames); } } - - private void PrepareHandlers() - { - foreach (var modifier in _handlerModifiers) - { - modifier(_handlerMiddlewares); - } - - foreach (var modifier in _receiveModifiers) - { - modifier(_receiveMiddlewares); - } - - foreach (var modifier in _dispatchModifiers) - { - modifier(_dispatchMiddlewares); - } - } } diff --git a/src/Mocha/src/Mocha/Builder/MessageBusBuilderExtensions.cs b/src/Mocha/src/Mocha/Builder/MessageBusBuilderExtensions.cs index 3fe7171fa2d..882eb2ce175 100644 --- a/src/Mocha/src/Mocha/Builder/MessageBusBuilderExtensions.cs +++ b/src/Mocha/src/Mocha/Builder/MessageBusBuilderExtensions.cs @@ -52,12 +52,14 @@ internal static void AddDefaults(this MessageBusBuilder builder) builder.AddMessage(x => { x.AddSerializer(new JsonMessageSerializer(AcknowledgementJsonContext.Default.NotAcknowledgedEvent)); + x.Extend().Configuration.EnclosedTypes = [typeof(NotAcknowledgedEvent)]; x.Extend().Configuration.IsInternal = true; }); builder.AddMessage(x => { x.AddSerializer(new JsonMessageSerializer(AcknowledgementJsonContext.Default.AcknowledgedEvent)); + x.Extend().Configuration.EnclosedTypes = [typeof(AcknowledgedEvent)]; x.Extend().Configuration.IsInternal = true; }); } diff --git a/src/Mocha/src/Mocha/Builder/Options/IReadOnlyMessagingOptions.cs b/src/Mocha/src/Mocha/Builder/Options/IReadOnlyMessagingOptions.cs index 13b32f06a7f..f705856903f 100644 --- a/src/Mocha/src/Mocha/Builder/Options/IReadOnlyMessagingOptions.cs +++ b/src/Mocha/src/Mocha/Builder/Options/IReadOnlyMessagingOptions.cs @@ -10,4 +10,10 @@ public interface IReadOnlyMessagingOptions /// is specified on a message type. /// MessageContentType DefaultContentType { get; } + + /// + /// Gets a value indicating whether all message types must be explicitly registered at startup. + /// When true, runtime auto-registration of unknown types is disabled. + /// + bool IsAotCompatible { get; } } diff --git a/src/Mocha/src/Mocha/Builder/Options/MessagingOptions.cs b/src/Mocha/src/Mocha/Builder/Options/MessagingOptions.cs index 8a129064b25..acb39d452d9 100644 --- a/src/Mocha/src/Mocha/Builder/Options/MessagingOptions.cs +++ b/src/Mocha/src/Mocha/Builder/Options/MessagingOptions.cs @@ -10,4 +10,11 @@ public class MessagingOptions : IReadOnlyMessagingOptions /// . /// public MessageContentType DefaultContentType { get; set; } = MessageContentType.Json; + + /// + /// Gets or sets a value indicating whether all message types must be explicitly registered at + /// startup. When true, runtime auto-registration of unknown types throws instead of + /// falling back to reflection-based serialization. Defaults to false. + /// + public bool IsAotCompatible { get; set; } } diff --git a/src/Mocha/src/Mocha/Builder/SagaRegistration.cs b/src/Mocha/src/Mocha/Builder/SagaRegistration.cs index 71e420fb5d2..80ea7090a06 100644 --- a/src/Mocha/src/Mocha/Builder/SagaRegistration.cs +++ b/src/Mocha/src/Mocha/Builder/SagaRegistration.cs @@ -16,4 +16,10 @@ internal sealed class SagaRegistration /// Factory that creates the saga instance. /// public required Func Factory { get; init; } + + /// + /// Optional pre-built state serializer provided by the source generator. + /// When set, the saga uses this serializer instead of resolving one from the factory. + /// + public ISagaStateSerializer? StateSerializer { get; init; } } diff --git a/src/Mocha/src/Mocha/Configuration/MessagingMessageConfiguration.cs b/src/Mocha/src/Mocha/Configuration/MessagingMessageConfiguration.cs new file mode 100644 index 00000000000..0732e59e21a --- /dev/null +++ b/src/Mocha/src/Mocha/Configuration/MessagingMessageConfiguration.cs @@ -0,0 +1,32 @@ +using System.Collections.Immutable; +using System.ComponentModel; + +namespace Mocha; + +/// +/// Represents a pre-built message type configuration emitted by the source generator. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class MessagingMessageConfiguration +{ + /// + /// Gets the CLR type of the message. + /// + public required Type MessageType { get; init; } + + /// + /// Gets the pre-built serializer for this message type. + /// + public required IMessageSerializer Serializer { get; init; } + + /// + /// Gets the pre-computed type hierarchy sorted by specificity (most specific first). Includes both + /// registered user types and framework base types (, + /// ); branches on whether + /// each entry is a framework base type to decide registration versus identity-only recording. + /// When is , + /// falls back to reflection-based hierarchy discovery + /// (non-AOT only). + /// + public ImmutableArray EnclosedTypes { get; init; } +} diff --git a/src/Mocha/src/Mocha/Configuration/MessagingSagaConfiguration.cs b/src/Mocha/src/Mocha/Configuration/MessagingSagaConfiguration.cs new file mode 100644 index 00000000000..2a16e4a9c6f --- /dev/null +++ b/src/Mocha/src/Mocha/Configuration/MessagingSagaConfiguration.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; +using Mocha.Sagas; + +namespace Mocha; + +/// +/// Pre-built saga configuration emitted by the source generator. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class MessagingSagaConfiguration +{ + /// + /// The CLR type of the saga. + /// + public required Type SagaType { get; init; } + + /// + /// The pre-built state serializer for this saga, or to use the runtime fallback. + /// + public ISagaStateSerializer? StateSerializer { get; init; } +} diff --git a/src/Mocha/src/Mocha/Descriptions/MessagingVisitor.cs b/src/Mocha/src/Mocha/Descriptions/MessagingVisitor.cs index e2c3240c5c3..bff9dbb3ca4 100644 --- a/src/Mocha/src/Mocha/Descriptions/MessagingVisitor.cs +++ b/src/Mocha/src/Mocha/Descriptions/MessagingVisitor.cs @@ -157,11 +157,7 @@ protected virtual VisitorAction VisitTransport(MessagingTransport transport, TCo protected virtual VisitorAction VisitSaga(SagaConsumer consumer, TContext context) { - var saga = GetSagaFromConsumer(consumer); - if (saga is null) - { - return VisitorAction.Continue; - } + var saga = consumer.Saga; var action = Enter(saga, context); if (action == VisitorAction.Break) @@ -178,22 +174,6 @@ protected virtual VisitorAction VisitSaga(SagaConsumer consumer, TContext contex return VisitorAction.Continue; } - private static Saga? GetSagaFromConsumer(SagaConsumer consumer) - { - var fields = typeof(SagaConsumer).GetFields( - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - foreach (var field in fields) - { - if (typeof(Saga).IsAssignableFrom(field.FieldType)) - { - return field.GetValue(consumer) as Saga; - } - } - - return null; - } - protected virtual VisitorAction Enter(IMessagingRuntime runtime, TContext context) => runtime is MessagingRuntime messagingRuntime ? Enter(messagingRuntime, context) : VisitorAction.Continue; diff --git a/src/Mocha/src/Mocha/Extensions/IMessageBusHostBuilderExtensions.cs b/src/Mocha/src/Mocha/Extensions/IMessageBusHostBuilderExtensions.cs index e282d6ba9e9..3818f7d6d69 100644 --- a/src/Mocha/src/Mocha/Extensions/IMessageBusHostBuilderExtensions.cs +++ b/src/Mocha/src/Mocha/Extensions/IMessageBusHostBuilderExtensions.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Mocha.Middlewares; @@ -18,6 +19,8 @@ public static class MessageBusHostBuilderExtensions /// The event handler type. /// The host builder. /// The builder for method chaining. + [RequiresDynamicCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] + [RequiresUnreferencedCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] public static IMessageBusHostBuilder AddEventHandler< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>( this IMessageBusHostBuilder builder) @@ -37,6 +40,8 @@ public static IMessageBusHostBuilder AddEventHandler< /// The host builder. /// The action to configure the consumer descriptor. /// The builder for method chaining. + [RequiresDynamicCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] + [RequiresUnreferencedCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] public static IMessageBusHostBuilder AddEventHandler< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>( this IMessageBusHostBuilder builder, @@ -68,6 +73,53 @@ public static IMessageBusHostBuilder AddHandlerConfiguration< return builder; } + /// + /// Registers a message type using pre-built configuration from the source generator. + /// + /// The host builder. + /// The pre-built message configuration. + /// The builder for method chaining. + [EditorBrowsable(EditorBrowsableState.Never)] + public static IMessageBusHostBuilder AddMessageConfiguration( + this IMessageBusHostBuilder builder, + MessagingMessageConfiguration configuration) + { + builder.ConfigureMessageBus(h => h.AddMessageConfiguration(configuration)); + return builder; + } + + /// + /// Registers a saga using pre-built configuration from the source generator. + /// + /// The saga type. + /// The host builder. + /// The pre-built saga configuration. + /// The builder for method chaining. + [EditorBrowsable(EditorBrowsableState.Never)] + public static IMessageBusHostBuilder AddSagaConfiguration( + this IMessageBusHostBuilder builder, + MessagingSagaConfiguration configuration) + where TSaga : Saga, new() + { + builder.ConfigureMessageBus(h => h.AddSagaConfiguration(configuration)); + return builder; + } + + /// + /// Registers a JSON type info resolver for AOT-compatible serialization. + /// + /// The host builder. + /// The JSON type info resolver to register. + /// The builder for method chaining. + [EditorBrowsable(EditorBrowsableState.Never)] + public static IMessageBusHostBuilder AddJsonTypeInfoResolver( + this IMessageBusHostBuilder builder, + IJsonTypeInfoResolver resolver) + { + builder.ConfigureMessageBus(h => h.ConfigureServices(services => services.AddSingleton(resolver))); + return builder; + } + /// /// Registers a batch event handler with the message bus and adds it to the service collection. /// @@ -75,6 +127,8 @@ public static IMessageBusHostBuilder AddHandlerConfiguration< /// The host builder. /// Optional action to configure batch options. /// The builder for method chaining. + [RequiresDynamicCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] + [RequiresUnreferencedCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] public static IMessageBusHostBuilder AddBatchHandler< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>( this IMessageBusHostBuilder builder, @@ -93,7 +147,10 @@ public static IMessageBusHostBuilder AddBatchHandler< /// The saga type. /// The host builder. /// The builder for method chaining. - public static IMessageBusHostBuilder AddSaga(this IMessageBusHostBuilder builder) where TSaga : Saga, new() + public static IMessageBusHostBuilder AddSaga< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TSaga>( + this IMessageBusHostBuilder builder) + where TSaga : Saga, new() { builder.ConfigureMessageBus(static h => h.AddSaga()); return builder; @@ -105,7 +162,11 @@ public static IMessageBusHostBuilder AddBatchHandler< /// The request handler type. /// The host builder. /// The builder for method chaining. - public static IMessageBusHostBuilder AddRequestHandler(this IMessageBusHostBuilder builder) + [RequiresDynamicCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] + [RequiresUnreferencedCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] + public static IMessageBusHostBuilder AddRequestHandler< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>( + this IMessageBusHostBuilder builder) where THandler : class, IEventRequestHandler { builder.Services.TryAddScoped(); @@ -122,7 +183,10 @@ public static IMessageBusHostBuilder AddRequestHandler(this IMessageBu /// The host builder. /// The action to configure the consumer descriptor. /// The builder for method chaining. - public static IMessageBusHostBuilder AddRequestHandler( + [RequiresDynamicCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] + [RequiresUnreferencedCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] + public static IMessageBusHostBuilder AddRequestHandler< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>( this IMessageBusHostBuilder builder, Action configure) where THandler : class, IEventRequestHandler @@ -139,6 +203,8 @@ public static IMessageBusHostBuilder AddRequestHandler( /// The consumer type implementing . /// The host builder. /// The builder for method chaining. + [RequiresDynamicCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] + [RequiresUnreferencedCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] public static IMessageBusHostBuilder AddConsumer< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TConsumer>( this IMessageBusHostBuilder builder) @@ -158,6 +224,8 @@ public static IMessageBusHostBuilder AddConsumer< /// The host builder. /// The action to configure the consumer descriptor. /// The builder for method chaining. + [RequiresDynamicCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] + [RequiresUnreferencedCode("Use source-generated AddHandlerConfiguration for AOT compatibility.")] public static IMessageBusHostBuilder AddConsumer< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TConsumer>( this IMessageBusHostBuilder builder, diff --git a/src/Mocha/src/Mocha/Extensions/TypesExtensions.cs b/src/Mocha/src/Mocha/Extensions/TypesExtensions.cs index 473828d6916..cd7dbfbe90c 100644 --- a/src/Mocha/src/Mocha/Extensions/TypesExtensions.cs +++ b/src/Mocha/src/Mocha/Extensions/TypesExtensions.cs @@ -3,5 +3,5 @@ namespace Mocha; internal static class TypesExtensions { public static bool IsEventRequest(this Type type) - => type.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEventRequest<>)); + => typeof(IEventRequest).IsAssignableFrom(type); } diff --git a/src/Mocha/src/Mocha/IMessageBus.cs b/src/Mocha/src/Mocha/IMessageBus.cs index c74207a6611..2f3fab33a40 100644 --- a/src/Mocha/src/Mocha/IMessageBus.cs +++ b/src/Mocha/src/Mocha/IMessageBus.cs @@ -12,7 +12,7 @@ public interface IMessageBus /// The message instance to publish. /// A token to cancel the publish operation. /// A task that completes when the message has been handed off to the transport. - ValueTask PublishAsync(T message, CancellationToken cancellationToken); + ValueTask PublishAsync(T message, CancellationToken cancellationToken) where T : notnull; /// /// Publishes a message to all subscribers of the specified message type with additional publish options. @@ -22,24 +22,26 @@ public interface IMessageBus /// Options controlling publish behavior such as headers and expiration. /// A token to cancel the publish operation. /// A task that completes when the message has been handed off to the transport. - ValueTask PublishAsync(T message, PublishOptions options, CancellationToken cancellationToken); + ValueTask PublishAsync(T message, PublishOptions options, CancellationToken cancellationToken) where T : notnull; /// /// Sends a message to a single receiver determined by the message type's routing configuration. /// + /// The type of the message to send. /// The message instance to send. /// A token to cancel the send operation. /// A task that completes when the message has been handed off to the transport. - ValueTask SendAsync(object message, CancellationToken cancellationToken); + ValueTask SendAsync(T message, CancellationToken cancellationToken) where T : notnull; /// /// Sends a message to a single receiver with additional send options. /// + /// The type of the message to send. /// The message instance to send. /// Options controlling send behavior such as headers and expiration. /// A token to cancel the send operation. /// A task that completes when the message has been handed off to the transport. - ValueTask SendAsync(object message, SendOptions options, CancellationToken cancellationToken); + ValueTask SendAsync(T message, SendOptions options, CancellationToken cancellationToken) where T : notnull; /// /// Sends a request message and waits for a typed response from the handler. @@ -106,7 +108,8 @@ ValueTask ReplyAsync(TResponse response, ReplyOptions options, Cancel ValueTask SchedulePublishAsync( T message, DateTimeOffset scheduledTime, - CancellationToken cancellationToken) where T : notnull; + CancellationToken cancellationToken) + where T : notnull; /// /// Publishes a message scheduled for delivery at the specified time with additional options. @@ -121,33 +124,38 @@ ValueTask SchedulePublishAsync( T message, DateTimeOffset scheduledTime, PublishOptions options, - CancellationToken cancellationToken) where T : notnull; + CancellationToken cancellationToken) + where T : notnull; /// /// Sends a message scheduled for delivery at the specified time. /// + /// The type of the message to send. /// The message instance to send. /// The absolute time at which the message should be delivered. /// A token to cancel the send operation. /// A scheduling result containing the cancellation token and metadata. - ValueTask ScheduleSendAsync( - object message, + ValueTask ScheduleSendAsync( + T message, DateTimeOffset scheduledTime, - CancellationToken cancellationToken); + CancellationToken cancellationToken) + where T : notnull; /// /// Sends a message scheduled for delivery at the specified time with additional options. /// + /// The type of the message to send. /// The message instance to send. /// The absolute time at which the message should be delivered. /// Options controlling send behavior such as headers and expiration. /// A token to cancel the send operation. /// A scheduling result containing the cancellation token and metadata. - ValueTask ScheduleSendAsync( - object message, + ValueTask ScheduleSendAsync( + T message, DateTimeOffset scheduledTime, SendOptions options, - CancellationToken cancellationToken); + CancellationToken cancellationToken) + where T : notnull; /// /// Cancels a previously scheduled message. Returns true if the message was cancelled, @@ -156,7 +164,5 @@ ValueTask ScheduleSendAsync( /// The opaque scheduling token returned by a prior schedule operation. /// A token to cancel the cancellation operation. /// true if the scheduled message was cancelled; otherwise false. - ValueTask CancelScheduledMessageAsync( - string token, - CancellationToken cancellationToken); + ValueTask CancelScheduledMessageAsync(string token, CancellationToken cancellationToken); } diff --git a/src/Mocha/src/Mocha/MessageTypes/Configurations/MessageTypeConfiguration.cs b/src/Mocha/src/Mocha/MessageTypes/Configurations/MessageTypeConfiguration.cs index 197e5ce4237..6e056f6aea8 100644 --- a/src/Mocha/src/Mocha/MessageTypes/Configurations/MessageTypeConfiguration.cs +++ b/src/Mocha/src/Mocha/MessageTypes/Configurations/MessageTypeConfiguration.cs @@ -1,3 +1,5 @@ +using System.Collections.Immutable; + namespace Mocha; /// @@ -34,4 +36,12 @@ public class MessageTypeConfiguration : MessagingConfiguration /// Gets or sets a value indicating whether this message type is internal (not exposed for external routing). /// public bool IsInternal { get; set; } + + /// + /// Gets or sets the pre-computed enclosed types from the source generator. Contains both + /// concrete registered types and framework base types (for example, + /// and ). + /// indicates the source generator did not supply this information. + /// + public ImmutableArray EnclosedTypes { get; set; } } diff --git a/src/Mocha/src/Mocha/MessageTypes/Descriptors/MessageTypeDescriptor.cs b/src/Mocha/src/Mocha/MessageTypes/Descriptors/MessageTypeDescriptor.cs index 0cd04f9b6e1..aa93868567a 100644 --- a/src/Mocha/src/Mocha/MessageTypes/Descriptors/MessageTypeDescriptor.cs +++ b/src/Mocha/src/Mocha/MessageTypes/Descriptors/MessageTypeDescriptor.cs @@ -25,15 +25,14 @@ public MessageTypeDescriptor(IMessagingConfigurationContext context, Type messag /// The configured . public MessageTypeConfiguration CreateConfiguration() { - var routes = _routes.Select(r => r.CreateConfiguration()).ToList(); - Configuration.Routes = routes; + Configuration.Routes = _routes.ConvertAll(r => r.CreateConfiguration()); return Configuration; } /// public IMessageTypeDescriptor AddSerializer(IMessageSerializer messageSerializer) { - Configuration.MessageSerializer.Add(messageSerializer.ContentType, messageSerializer); + Configuration.MessageSerializer[messageSerializer.ContentType] = messageSerializer; return this; } diff --git a/src/Mocha/src/Mocha/MessageTypes/MessageType.cs b/src/Mocha/src/Mocha/MessageTypes/MessageType.cs index ad470f2f5b6..bc56beaa969 100644 --- a/src/Mocha/src/Mocha/MessageTypes/MessageType.cs +++ b/src/Mocha/src/Mocha/MessageTypes/MessageType.cs @@ -1,4 +1,6 @@ using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; using Mocha.Features; namespace Mocha; @@ -13,10 +15,14 @@ public sealed class MessageType /// public bool IsCompleted { get; private set; } + private ImmutableArray _enclosedTypes; + private IMessageSerializerRegistry _serializerRegistry = null!; - private ImmutableDictionary _serializer - = ImmutableDictionary.Empty; + private ImmutableDictionary _serializer = ImmutableDictionary< + MessageContentType, + IMessageSerializer + >.Empty; /// /// Gets the URN-based identity string that uniquely identifies this message type on the wire. @@ -84,6 +90,8 @@ public void Initialize(IMessagingConfigurationContext context, MessageTypeConfig _serializer = configuration.MessageSerializer.ToImmutableDictionary(k => k.Key, v => v.Value); + _enclosedTypes = configuration.EnclosedTypes; + foreach (var routeConfiguration in configuration.Routes) { var outboundRoute = new OutboundRoute(); @@ -118,48 +126,71 @@ public void Initialize(IMessagingConfigurationContext context, MessageTypeConfig /// Completes initialization by resolving the full type hierarchy and registering enclosed message types. /// /// The messaging configuration context. + [UnconditionalSuppressMessage( + "Trimming", + "IL2026:RequiresUnreferencedCode", + Justification = "Reflection fallback is guarded by IsAotCompatible. AOT uses source-generated enclosed types.")] public void Complete(IMessagingConfigurationContext context) { - var allTypes = GetAllTypesInHierarchy(RuntimeType, context); - - // Sort by specificity (most specific first) - var sortedTypes = allTypes - .OrderByDescending(t => allTypes.Count(other => t != other && t.IsAssignableTo(other))) - .ToList(); - var enclosedMessageTypes = ImmutableArray.CreateBuilder(); var enclosedMessageIdentities = ImmutableArray.CreateBuilder(); - foreach (var type in sortedTypes) + if (!_enclosedTypes.IsDefaultOrEmpty) { - if (IsFrameworkBaseType(type)) + foreach (var type in _enclosedTypes) { - // Don't register framework base types as standalone message types. - // Only include their identity string for wire-level message matching. - enclosedMessageIdentities.Add(context.Naming.GetMessageIdentity(type)); + DispatchEnclosedType(context, type, enclosedMessageTypes, enclosedMessageIdentities); } - else + } + else + { + var options = context.Services.GetRequiredService(); + + if (options.IsAotCompatible) { - var mt = context.Messages.GetOrAdd(context, type); - enclosedMessageTypes.Add(mt); - enclosedMessageIdentities.Add(mt.Identity); + throw new InvalidOperationException( + $"No enclosed types provided for message type '{Identity}'. " + + "Register enclosed types via the source generator. " + + "Set IsAotCompatible = false to allow reflection-based type discovery."); } + + CompleteViaReflection(context, enclosedMessageTypes, enclosedMessageIdentities); } EnclosedMessageTypes = enclosedMessageTypes.ToImmutableArray(); EnclosedMessageIdentities = enclosedMessageIdentities.ToImmutableArray(); - var interfaces = RuntimeType.GetInterfaces(); - foreach (var interfaceType in interfaces) + IsCompleted = true; + } + + [RequiresUnreferencedCode( + "Walks GetInterfaces / BaseType and registers discovered types. Use the source generator for AOT.")] + private void CompleteViaReflection( + IMessagingConfigurationContext context, + ImmutableArray.Builder enclosedMessageTypes, + ImmutableArray.Builder enclosedMessageIdentities) + { + var allTypes = GetAllTypesInHierarchy(RuntimeType, context); + + // Sort by specificity (most specific first) + var sortedTypes = allTypes + .OrderByDescending(t => allTypes.Count(other => t != other && t.IsAssignableTo(other))) + .ToList(); + + foreach (var type in sortedTypes) + { + DispatchEnclosedType(context, type, enclosedMessageTypes, enclosedMessageIdentities); + } + + foreach (var interfaceType in RuntimeType.GetInterfaces()) { - if (interfaceType.IsGenericType && interfaceType.GetGenericTypeDefinition() == typeof(IEventRequest<>)) + if (interfaceType.IsGenericType + && interfaceType.GetGenericTypeDefinition() == typeof(IEventRequest<>)) { var responseType = interfaceType.GetGenericArguments()[0]; context.Messages.GetOrAdd(context, responseType); } } - - IsCompleted = true; } /// @@ -178,6 +209,7 @@ public MessageTypeDescription Describe() EnclosedMessageIdentities.IsDefaultOrEmpty ? null : EnclosedMessageIdentities); } + [RequiresUnreferencedCode("Uses GetInterfaces and BaseType traversal which may reference trimmed types.")] private static List GetAllTypesInHierarchy(Type type, IMessagingConfigurationContext context) { var interfaces = type.GetInterfaces(); @@ -217,4 +249,22 @@ private static bool IsFrameworkBaseType(Type type) return type == typeof(IEventRequest) || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEventRequest<>)); } + + private static void DispatchEnclosedType( + IMessagingConfigurationContext context, + Type type, + ImmutableArray.Builder enclosedMessageTypes, + ImmutableArray.Builder enclosedMessageIdentities) + { + if (IsFrameworkBaseType(type)) + { + enclosedMessageIdentities.Add(context.Naming.GetMessageIdentity(type)); + } + else + { + var mt = context.Messages.GetOrAdd(context, type); + enclosedMessageTypes.Add(mt); + enclosedMessageIdentities.Add(mt.Identity); + } + } } diff --git a/src/Mocha/src/Mocha/Middlewares/DefaultMessageBus.cs b/src/Mocha/src/Mocha/Middlewares/DefaultMessageBus.cs index b0a09fde230..f09e5bb3591 100644 --- a/src/Mocha/src/Mocha/Middlewares/DefaultMessageBus.cs +++ b/src/Mocha/src/Mocha/Middlewares/DefaultMessageBus.cs @@ -36,6 +36,7 @@ public sealed class DefaultMessageBus( /// The message instance to publish. Must not be . /// A token to cancel the publish operation. public async ValueTask PublishAsync(T message, CancellationToken cancellationToken) + where T : notnull { await PublishAsync(message, PublishOptions.Default, cancellationToken); } @@ -53,8 +54,9 @@ public async ValueTask PublishAsync(T message, CancellationToken cancellation /// Options controlling headers and expiration for this publish operation. /// A token to cancel the publish operation. public async ValueTask PublishAsync(T message, PublishOptions options, CancellationToken cancellationToken) + where T : notnull { - var messageType = runtime.GetMessageType(message!.GetType()); + var messageType = runtime.GetMessageType(message.GetType()); var endpoint = runtime.GetPublishEndpoint(messageType); var context = _contextPool.Get(); @@ -79,9 +81,11 @@ public async ValueTask PublishAsync(T message, PublishOptions options, Cancel /// /// Sends a message to a single consumer endpoint using default send options. /// + /// The type of the message to send. /// The message instance to send. Must not be . /// A token to cancel the send operation. - public ValueTask SendAsync(object message, CancellationToken cancellationToken) + public ValueTask SendAsync(T message, CancellationToken cancellationToken) + where T : notnull { return SendAsync(message, SendOptions.Default, cancellationToken); } @@ -94,10 +98,12 @@ public ValueTask SendAsync(object message, CancellationToken cancellationToken) /// address; otherwise the runtime's router resolves the endpoint by message type. Reply and fault /// addresses from the options are propagated to the dispatch context. /// + /// The type of the message to send. /// The message instance to send. Must not be . /// Options controlling the target endpoint, headers, reply/fault addresses, and expiration. /// A token to cancel the send operation. - public async ValueTask SendAsync(object message, SendOptions options, CancellationToken cancellationToken) + public async ValueTask SendAsync(T message, SendOptions options, CancellationToken cancellationToken) + where T : notnull { var messageType = runtime.GetMessageType(message.GetType()); var endpoint = options.Endpoint is { } address @@ -316,7 +322,7 @@ public async ValueTask SchedulePublishAsync( CancellationToken cancellationToken) where T : notnull { - var messageType = runtime.GetMessageType(message!.GetType()); + var messageType = runtime.GetMessageType(message.GetType()); var endpoint = runtime.GetPublishEndpoint(messageType); var context = _contextPool.Get(); @@ -350,10 +356,11 @@ public async ValueTask SchedulePublishAsync( /// /// Sends a message scheduled for delivery at the specified time using default options. /// - public async ValueTask ScheduleSendAsync( - object message, + public async ValueTask ScheduleSendAsync( + T message, DateTimeOffset scheduledTime, CancellationToken cancellationToken) + where T : notnull { return await ScheduleSendAsync(message, scheduledTime, SendOptions.Default, cancellationToken); } @@ -361,11 +368,12 @@ public async ValueTask ScheduleSendAsync( /// /// Sends a message scheduled for delivery at the specified time with additional options. /// - public async ValueTask ScheduleSendAsync( - object message, + public async ValueTask ScheduleSendAsync( + T message, DateTimeOffset scheduledTime, SendOptions options, CancellationToken cancellationToken) + where T : notnull { var messageType = runtime.GetMessageType(message.GetType()); var endpoint = options.Endpoint is { } address diff --git a/src/Mocha/src/Mocha/Mocha.csproj b/src/Mocha/src/Mocha/Mocha.csproj index f8e95ec0bda..0f023d4cdeb 100644 --- a/src/Mocha/src/Mocha/Mocha.csproj +++ b/src/Mocha/src/Mocha/Mocha.csproj @@ -3,6 +3,7 @@ Mocha Mocha enable + true diff --git a/src/Mocha/src/Mocha/Observability/Configuration/InstrumentationBusExtensions.cs b/src/Mocha/src/Mocha/Observability/Configuration/InstrumentationBusExtensions.cs index d9e64ef62ea..91d6d9306bc 100644 --- a/src/Mocha/src/Mocha/Observability/Configuration/InstrumentationBusExtensions.cs +++ b/src/Mocha/src/Mocha/Observability/Configuration/InstrumentationBusExtensions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -24,7 +25,9 @@ public static IMessageBusHostBuilder AddInstrumentation(this IMessageBusHostBuil /// /// Registers a custom implementation. /// - public static IMessageBusHostBuilder AddDiagnosticEventListener(this IMessageBusHostBuilder builder) + public static IMessageBusHostBuilder AddDiagnosticEventListener< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>( + this IMessageBusHostBuilder builder) where T : class, IMessagingDiagnosticEventListener { ArgumentNullException.ThrowIfNull(builder); diff --git a/src/Mocha/src/Mocha/Sagas/Saga.Initialization.cs b/src/Mocha/src/Mocha/Sagas/Saga.Initialization.cs index 56adaee0085..8281a7a8d30 100644 --- a/src/Mocha/src/Mocha/Sagas/Saga.Initialization.cs +++ b/src/Mocha/src/Mocha/Sagas/Saga.Initialization.cs @@ -29,7 +29,7 @@ public override void Initialize(IMessagingSetupContext context) _logger = context.Services.GetRequiredService>>(); Name = definition.Name ?? throw new SagaInitializationException(this, "Saga name is not defined."); - StateSerializer = + StateSerializer ??= definition.StateSerializer?.Invoke(context.Services) ?? context.Services.GetRequiredService().GetSerializer(typeof(TState)); diff --git a/src/Mocha/src/Mocha/Sagas/Saga.cs b/src/Mocha/src/Mocha/Sagas/Saga.cs index 5b6cd42c1f0..c80c6b5dc29 100644 --- a/src/Mocha/src/Mocha/Sagas/Saga.cs +++ b/src/Mocha/src/Mocha/Sagas/Saga.cs @@ -37,7 +37,7 @@ protected Saga() /// /// Gets the serializer used to persist and restore saga state. /// - public ISagaStateSerializer StateSerializer { get; protected set; } = null!; + public ISagaStateSerializer StateSerializer { get; protected internal set; } = null!; /// /// Gets the logical name of this saga, used for logging, diagnostics, and state store identification. diff --git a/src/Mocha/src/Mocha/Sagas/SagaEventListener.cs b/src/Mocha/src/Mocha/Sagas/SagaEventListener.cs index 7c38237ae7a..5dc9858ddfc 100644 --- a/src/Mocha/src/Mocha/Sagas/SagaEventListener.cs +++ b/src/Mocha/src/Mocha/Sagas/SagaEventListener.cs @@ -9,6 +9,11 @@ namespace Mocha.Sagas; /// The saga definition that this consumer handles. public sealed class SagaConsumer(Saga saga) : Consumer { + /// + /// Gets the saga definition that this consumer handles. + /// + public Saga Saga => saga; + /// protected override void Configure(IConsumerDescriptor descriptor) { diff --git a/src/Mocha/src/Mocha/Sagas/State/Serialization/JsonSagaStateSerializerFactory.cs b/src/Mocha/src/Mocha/Sagas/State/Serialization/JsonSagaStateSerializerFactory.cs index 7fe82d182b2..f54420cade9 100644 --- a/src/Mocha/src/Mocha/Sagas/State/Serialization/JsonSagaStateSerializerFactory.cs +++ b/src/Mocha/src/Mocha/Sagas/State/Serialization/JsonSagaStateSerializerFactory.cs @@ -1,14 +1,24 @@ using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization.Metadata; namespace Mocha.Sagas; -internal sealed class JsonSagaStateSerializerFactory(IEnumerable typeInfos) - : ISagaStateSerializerFactory +internal sealed class JsonSagaStateSerializerFactory( + IEnumerable typeInfos, + IReadOnlyMessagingOptions options) : ISagaStateSerializerFactory { private readonly ImmutableArray _typeInfos = [.. typeInfos]; + [UnconditionalSuppressMessage( + "Trimming", + "IL2026:RequiresUnreferencedCode", + Justification = "Reflection fallback only used when no JsonSerializerContext is provided.")] + [UnconditionalSuppressMessage( + "AOT", + "IL3050:RequiresDynamicCode", + Justification = "Reflection fallback only used when no JsonSerializerContext is provided.")] public ISagaStateSerializer GetSerializer(Type type) { JsonTypeInfo? typeInfo = null; @@ -22,7 +32,18 @@ public ISagaStateSerializer GetSerializer(Type type) } } - typeInfo ??= JsonSerializerOptions.Default.GetTypeInfo(type); + if (typeInfo is null) + { + if (options.IsAotCompatible) + { + throw new InvalidOperationException( + $"No JsonTypeInfo found for saga state type '{type.Name}'. " + + "Register it via [JsonSerializable] on your JsonSerializerContext. " + + "Set IsAotCompatible = false to allow reflection-based serialization."); + } + + typeInfo = JsonSerializerOptions.Default.GetTypeInfo(type); + } return new JsonSagaStateSerializer(typeInfo); } diff --git a/src/Mocha/src/Mocha/Serialization/HeadersConverter.cs b/src/Mocha/src/Mocha/Serialization/HeadersConverter.cs index 4f3f6308e63..03e7ec4e63f 100644 --- a/src/Mocha/src/Mocha/Serialization/HeadersConverter.cs +++ b/src/Mocha/src/Mocha/Serialization/HeadersConverter.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; @@ -18,6 +19,7 @@ public class HeadersJsonConverter : JsonConverter /// public static readonly JsonSerializerOptions Options = new() { Converters = { Instance } }; + /// public override Headers? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.Null) @@ -54,6 +56,7 @@ public class HeadersJsonConverter : JsonConverter throw new JsonException("Unexpected end of JSON"); } + /// public override void Write(Utf8JsonWriter writer, IHeaders value, JsonSerializerOptions options) { writer.WriteStartObject(); @@ -236,9 +239,60 @@ private static void WriteValue(Utf8JsonWriter writer, object? value, JsonSeriali writer.WriteEndObject(); break; + case Guid g: + writer.WriteStringValue(g); + break; + + case TimeSpan t: + writer.WriteStringValue(t.ToString("c", CultureInfo.InvariantCulture)); + break; + + case Uri u: + writer.WriteStringValue(u.ToString()); + break; + + case DateOnly d: + writer.WriteStringValue(d.ToString("O", CultureInfo.InvariantCulture)); + break; + + case TimeOnly to: + writer.WriteStringValue(to.ToString("O", CultureInfo.InvariantCulture)); + break; + + case Enum e: + writer.WriteStringValue(e.ToString()); + break; + + case short s: + writer.WriteNumberValue(s); + break; + + case ushort us: + writer.WriteNumberValue(us); + break; + + case byte b: + writer.WriteNumberValue(b); + break; + + case sbyte sb: + writer.WriteNumberValue(sb); + break; + + case uint ui: + writer.WriteNumberValue(ui); + break; + + case ulong ul: + writer.WriteNumberValue(ul); + break; + + case char c: + writer.WriteStringValue([c]); + break; + default: - // Fallback to default serialization for unknown types - JsonSerializer.Serialize(writer, value, value.GetType(), options); + JsonSerializer.Serialize(writer, value, options.GetTypeInfo(value.GetType())); break; } } diff --git a/src/Mocha/src/Mocha/Serialization/JsonMessageSerializer.cs b/src/Mocha/src/Mocha/Serialization/JsonMessageSerializer.cs index 1e4516f60db..82f292d07e2 100644 --- a/src/Mocha/src/Mocha/Serialization/JsonMessageSerializer.cs +++ b/src/Mocha/src/Mocha/Serialization/JsonMessageSerializer.cs @@ -4,7 +4,7 @@ namespace Mocha; -internal sealed class JsonMessageSerializer(JsonTypeInfo typeInfo) : IMessageSerializer +public sealed class JsonMessageSerializer(JsonTypeInfo typeInfo) : IMessageSerializer { public MessageContentType ContentType => MessageContentType.Json; diff --git a/src/Mocha/src/Mocha/Serialization/JsonMessageSerializerFactory.cs b/src/Mocha/src/Mocha/Serialization/JsonMessageSerializerFactory.cs index a0f9484e3aa..585a6c3a3ba 100644 --- a/src/Mocha/src/Mocha/Serialization/JsonMessageSerializerFactory.cs +++ b/src/Mocha/src/Mocha/Serialization/JsonMessageSerializerFactory.cs @@ -1,16 +1,26 @@ using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization.Metadata; namespace Mocha; -internal sealed class JsonMessageSerializerFactory(IEnumerable typeInfos) - : IMessageSerializerFactory +internal sealed class JsonMessageSerializerFactory( + IEnumerable typeInfos, + IReadOnlyMessagingOptions options) : IMessageSerializerFactory { private readonly ImmutableArray _typeInfos = [.. typeInfos]; public MessageContentType ContentType { get; } = MessageContentType.Json; + [UnconditionalSuppressMessage( + "Trimming", + "IL2026", + Justification = "Fallback path; AOT users provide JsonSerializerContext via MessagingModule attribute.")] + [UnconditionalSuppressMessage( + "AOT", + "IL3050", + Justification = "Fallback path; AOT users provide JsonSerializerContext via MessagingModule attribute.")] public IMessageSerializer GetSerializer(Type type) { JsonTypeInfo? typeInfo = null; @@ -24,7 +34,18 @@ public IMessageSerializer GetSerializer(Type type) } } - typeInfo ??= JsonSerializerOptions.Default.GetTypeInfo(type); + if (typeInfo is null) + { + if (options.IsAotCompatible) + { + throw new InvalidOperationException( + $"No JsonTypeInfo found for type '{type.FullName}'. " + + "Register it via [JsonSerializable] on your JsonSerializerContext. " + + "Set IsAotCompatible = false to allow reflection-based serialization."); + } + + typeInfo = JsonSerializerOptions.Default.GetTypeInfo(type); + } return new JsonMessageSerializer(typeInfo); } diff --git a/src/Mocha/src/Mocha/Serialization/MessageTypeRegistry.cs b/src/Mocha/src/Mocha/Serialization/MessageTypeRegistry.cs index 922b653769e..d36694c8111 100644 --- a/src/Mocha/src/Mocha/Serialization/MessageTypeRegistry.cs +++ b/src/Mocha/src/Mocha/Serialization/MessageTypeRegistry.cs @@ -6,7 +6,10 @@ namespace Mocha; /// Thread-safe implementation of that stores and resolves message type metadata by CLR type and identity string. /// /// The serializer registry used to resolve serializers for message types. -public sealed class MessageTypeRegistry(IMessageSerializerRegistry serializerRegistry) : IMessageTypeRegistry +/// The messaging options controlling strict registration mode. +public sealed class MessageTypeRegistry( + IMessageSerializerRegistry serializerRegistry, + IReadOnlyMessagingOptions options) : IMessageTypeRegistry { public IMessageSerializerRegistry Serializers => serializerRegistry; @@ -56,6 +59,14 @@ public MessageType GetOrAdd(IMessagingConfigurationContext context, Type type) return messageType; } + if (options.IsAotCompatible) + { + throw new InvalidOperationException( + $"Message type '{type.FullName}' was not registered at startup. " + + "Register it via the source generator or AddMessageConfiguration(). " + + "Set IsAotCompatible = false to allow runtime type registration."); + } + lock (_lock) { messageType = GetMessageType(type); diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/DiagnosticTests.cs b/src/Mocha/test/Mocha.Analyzers.Tests/DiagnosticTests.cs index ed0399a28ac..69868999b79 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/DiagnosticTests.cs +++ b/src/Mocha/test/Mocha.Analyzers.Tests/DiagnosticTests.cs @@ -169,6 +169,27 @@ public ValueTask HandleAsync(GenericQuery query, CancellationToken cancell ]).MatchMarkdownAsync(TestContext.Current.CancellationToken); } + [Fact] + public async Task MO0006_OpenGenericHandlerWithConcreteMessage_ReportsInfo() + { + await TestHelper.GetGeneratedSourceSnapshot( + [ + """ + using Mocha.Mediator; + + namespace TestApp; + + public record MyCommand : ICommand; + + public class GenericHandler : ICommandHandler + { + public ValueTask HandleAsync(MyCommand command, CancellationToken cancellationToken) + => default; + } + """ + ]).MatchMarkdownAsync(TestContext.Current.CancellationToken); + } + [Fact] public async Task MO0005_CommandAndNotificationHandler_ReportsError() { @@ -243,4 +264,64 @@ public ValueTask HandleAsync(SomethingHappened notification, CancellationToken c """ ]).MatchMarkdownAsync(TestContext.Current.CancellationToken); } + + [Fact] + public async Task MO0006_OpenGenericNotificationHandler_ReportsInfo() + { + await TestHelper.GetGeneratedSourceSnapshot( + [ + """ + using Mocha.Mediator; + + namespace TestApp; + + public record MyNotif : INotification; + + public class GenericNotif : INotificationHandler + { + public ValueTask HandleAsync(MyNotif notification, CancellationToken cancellationToken) + => default; + } + """ + ]).MatchMarkdownAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task NoWarning_ClosedConcreteHandler_NoDiagnostic() + { + await TestHelper.GetGeneratedSourceSnapshot( + [ + """ + using Mocha.Mediator; + + namespace TestApp; + + public record MyCommand : ICommand; + + public class ConcreteHandler : ICommandHandler + { + public ValueTask HandleAsync(MyCommand command, CancellationToken cancellationToken) + => default; + } + """ + ]).MatchMarkdownAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task NoWarning_UnrelatedGenericClass_NoDiagnostic() + { + await TestHelper.GetGeneratedSourceSnapshot( + [ + """ + using Mocha.Mediator; + + namespace TestApp; + + public class UnrelatedGeneric + { + public T? Value { get; set; } + } + """ + ]).MatchMarkdownAsync(TestContext.Current.CancellationToken); + } } diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/MessagingDiagnosticTests.cs b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingDiagnosticTests.cs index 9d550edc531..8b166adeff5 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/MessagingDiagnosticTests.cs +++ b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingDiagnosticTests.cs @@ -1,3 +1,5 @@ +using Microsoft.CodeAnalysis; + namespace Mocha.Analyzers.Tests; public class MessagingDiagnosticTests @@ -89,4 +91,317 @@ protected override void Configure(ISagaDescriptor descriptor) { } """ ]).MatchMarkdownAsync(TestContext.Current.CancellationToken); } + + [Fact] + public void MO0015_Should_Report_When_PublishAotAndCallSiteRequiresJsonContext() + { + // arrange + var diagnostics = MessagingTestHelper.GetGeneratorDiagnostics( + [ + """ + using System.Threading; + using System.Threading.Tasks; + using Mocha; + + namespace TestApp; + + public record OrderPlacedEvent(int OrderId); + + public sealed class Dispatcher + { + public ValueTask DispatchAsync(IMessageBus bus, CancellationToken cancellationToken) + => bus.PublishAsync(new OrderPlacedEvent(1), cancellationToken); + } + """ + ], + publishAot: true); + + // assert + var diagnostic = Assert.Single(diagnostics, d => d.Id == "MO0015"); + Assert.Contains("Tests", diagnostic.GetMessage()); + } + + [Fact] + public void MO0016_Should_NotReport_When_JsonContextIsPartialAndPublishAotIsFalse() + { + // arrange + var diagnostics = MessagingTestHelper.GetGeneratorDiagnostics( + [ + """ + using System.Text.Json.Serialization; + using Mocha; + + [assembly: MessagingModule("TestApp", JsonContext = typeof(TestApp.TestJsonContext))] + + namespace TestApp; + + [JsonSerializable(typeof(OrderPlacedEvent))] + public partial class TestJsonContext : JsonSerializerContext; + + public record OrderPlacedEvent(int OrderId); + + public record OrderAcceptedEvent(int OrderId); + + public class OrderPlacedHandler : IEventHandler + { + public ValueTask HandleAsync(OrderPlacedEvent message, CancellationToken cancellationToken) + => default; + } + + public class OrderAcceptedHandler : IEventHandler + { + public ValueTask HandleAsync(OrderAcceptedEvent message, CancellationToken cancellationToken) + => default; + } + """ + ]); + + // assert + Assert.DoesNotContain(diagnostics, d => d.Id == "MO0016"); + } + + [Fact] + public void MO0016_Should_Report_When_JsonContextIsPartialAndPublishAotIsTrue() + { + // arrange + var diagnostics = MessagingTestHelper.GetGeneratorDiagnostics( + [ + """ + using System.Text.Json.Serialization; + using Mocha; + + [assembly: MessagingModule("TestApp", JsonContext = typeof(TestApp.TestJsonContext))] + + namespace TestApp; + + [JsonSerializable(typeof(OrderPlacedEvent))] + public partial class TestJsonContext : JsonSerializerContext; + + public record OrderPlacedEvent(int OrderId); + + public record OrderAcceptedEvent(int OrderId); + + public class OrderPlacedHandler : IEventHandler + { + public ValueTask HandleAsync(OrderPlacedEvent message, CancellationToken cancellationToken) + => default; + } + + public class OrderAcceptedHandler : IEventHandler + { + public ValueTask HandleAsync(OrderAcceptedEvent message, CancellationToken cancellationToken) + => default; + } + """ + ], + publishAot: true); + + // assert + var diagnostic = Assert.Single(diagnostics, d => d.Id == "MO0016"); + Assert.Contains("OrderAcceptedEvent", diagnostic.GetMessage()); + } + + [Fact] + public void Generate_Should_NotEnableAotStrictMode_When_JsonContextSpecifiedAndPublishAotIsFalse() + { + // arrange + var source = CreateJsonContextSource(); + + // act + var generated = string.Join( + "\n", + MessagingTestHelper.GetGeneratedSourceTexts([source])); + + // assert + Assert.Contains("AddJsonTypeInfoResolver", generated); + Assert.DoesNotContain("IsAotCompatible = true", generated); + } + + [Fact] + public void Generate_Should_EnableAotStrictMode_When_JsonContextSpecifiedAndPublishAotIsTrue() + { + // arrange + var source = CreateJsonContextSource(); + + // act + var generated = string.Join( + "\n", + MessagingTestHelper.GetGeneratedSourceTexts([source], publishAot: true)); + + // assert + Assert.Contains("AddJsonTypeInfoResolver", generated); + Assert.Contains("IsAotCompatible = true", generated); + } + + [Fact] + public void Generate_Should_Compile_When_SagaOnlyModuleHasNoJsonContext() + { + // arrange + const string source = + """ + using Mocha; + using Mocha.Sagas; + + [assembly: MessagingModule("TestApp")] + + namespace TestApp; + + public class OrderState : SagaStateBase; + + public class OrderSaga : Saga + { + protected override void Configure(ISagaDescriptor descriptor) + { + } + } + """; + + // act + var diagnostics = MessagingTestHelper.GetCompilationDiagnostics([source]); + + // assert + AssertNoErrors(diagnostics); + } + + [Fact] + public void Generate_Should_Compile_When_JsonContextRegistersTypeInfoResolver() + { + // arrange + const string source = + """ + using System; + using System.Text.Json; + using System.Text.Json.Serialization; + using System.Text.Json.Serialization.Metadata; + using System.Threading; + using System.Threading.Tasks; + using Mocha; + + [assembly: MessagingModule("TestApp", JsonContext = typeof(TestApp.TestJsonContext))] + + namespace TestApp; + + [JsonSerializable(typeof(OrderPlacedEvent))] + public sealed class TestJsonContext : IJsonTypeInfoResolver + { + public static TestJsonContext Default { get; } = new(); + + public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options) + => null; + + public JsonTypeInfo? GetTypeInfo(Type type) + => null; + } + + public record OrderPlacedEvent(int OrderId); + + public class OrderPlacedHandler : IEventHandler + { + public ValueTask HandleAsync(OrderPlacedEvent message, CancellationToken cancellationToken) + => default; + } + """; + + // act + var diagnostics = MessagingTestHelper.GetCompilationDiagnostics([source]); + + // assert + AssertNoErrors(diagnostics); + } + + [Fact] + public void MO0018_Should_Report_When_MessageBusCallSiteTypesAreMissingFromJsonContext() + { + // arrange + var diagnostics = MessagingTestHelper.GetGeneratorDiagnostics( + [ + """ + using System.Text.Json.Serialization; + using System.Threading; + using System.Threading.Tasks; + using Mocha; + + [assembly: MessagingModule("TestApp", JsonContext = typeof(TestApp.TestJsonContext))] + + namespace TestApp; + + [JsonSerializable(typeof(RequestMessage))] + public sealed class TestJsonContext; + + public sealed record PublishMessage; + + public sealed record SendMessage; + + public sealed record RequestMessage : IEventRequest; + + public sealed record ResponseMessage; + + public sealed class Dispatcher + { + public async ValueTask DispatchAsync(IMessageBus bus, CancellationToken cancellationToken) + { + await bus.PublishAsync(new PublishMessage(), cancellationToken); + await bus.SendAsync(new SendMessage(), cancellationToken); + await bus.RequestAsync(new RequestMessage(), cancellationToken); + } + } + """ + ], + publishAot: true); + + // assert + var messages = diagnostics + .Where(d => d.Id == "MO0018") + .Select(static d => d.GetMessage()) + .OrderBy(static m => m) + .ToArray(); + + Assert.Collection( + messages, + static m => + { + Assert.Contains("TestApp.PublishMessage", m); + Assert.Contains("Publish", m); + }, + static m => + { + Assert.Contains("TestApp.ResponseMessage", m); + Assert.Contains("Request", m); + }, + static m => + { + Assert.Contains("TestApp.SendMessage", m); + Assert.Contains("Send", m); + }); + } + + private static string CreateJsonContextSource() + => """ + using System.Text.Json.Serialization; + using Mocha; + + [assembly: MessagingModule("TestApp", JsonContext = typeof(TestApp.TestJsonContext))] + + namespace TestApp; + + [JsonSerializable(typeof(OrderPlacedEvent))] + public partial class TestJsonContext : JsonSerializerContext; + + public record OrderPlacedEvent(int OrderId); + + public class OrderPlacedHandler : IEventHandler + { + public ValueTask HandleAsync(OrderPlacedEvent message, CancellationToken cancellationToken) + => default; + } + """; + + private static void AssertNoErrors(IReadOnlyCollection diagnostics) + { + var errors = diagnostics + .Where(static d => d.Severity == Microsoft.CodeAnalysis.DiagnosticSeverity.Error) + .ToArray(); + + Assert.True(errors.Length == 0, string.Join(Environment.NewLine, errors.Select(static d => d.ToString()))); + } } diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/MessagingTestHelper.cs b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingTestHelper.cs index abd53262e86..c4802d47f97 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/MessagingTestHelper.cs +++ b/src/Mocha/test/Mocha.Analyzers.Tests/MessagingTestHelper.cs @@ -3,10 +3,12 @@ using System.Text; using System.Text.Encodings.Web; using System.Text.Json; +using System.Threading.Tasks; using Basic.Reference.Assemblies; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using CookieCrumble; +using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.Extensions.DependencyInjection; namespace Mocha.Analyzers.Tests; @@ -18,7 +20,87 @@ internal static class MessagingTestHelper public static Snapshot GetGeneratedSourceSnapshot( string[] sourceTexts, - string? assemblyName = "Tests") + string? assemblyName = "Tests", + bool publishAot = false) + { + var driver = RunGenerator(sourceTexts, assemblyName, publishAot); + var snapshot = new Snapshot(); + + foreach (var result in driver.GetRunResult().Results) + { + var sources = result.GeneratedSources.OrderBy(s => s.HintName); + foreach (var source in sources) + { + snapshot.Add(source.SourceText.ToString(), source.HintName, MarkdownLanguages.CSharp); + } + + if (result.Diagnostics.Any()) + { + AddDiagnosticsToSnapshot(snapshot, result.Diagnostics, "Generator Diagnostics"); + } + } + + return snapshot; + } + + public static ImmutableArray GetGeneratorDiagnostics( + string[] sourceTexts, + string? assemblyName = "Tests", + bool publishAot = false) + { + var driver = RunGenerator(sourceTexts, assemblyName, publishAot); + + return driver.GetRunResult().Results + .SelectMany(static r => r.Diagnostics) + .ToImmutableArray(); + } + + public static ImmutableArray GetCompilationDiagnostics( + string[] sourceTexts, + string? assemblyName = "Tests", + bool publishAot = false) + { + var compilation = CreateCompilation(sourceTexts, assemblyName); + var driver = CreateDriver(publishAot); + + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out var outputCompilation, + out var generatorDiagnostics); + + return generatorDiagnostics + .Concat(outputCompilation.GetDiagnostics()) + .Where(static d => !s_ignoreCodes.Contains(d.Id)) + .ToImmutableArray(); + } + + public static ImmutableArray GetGeneratedSourceTexts( + string[] sourceTexts, + string? assemblyName = "Tests", + bool publishAot = false) + { + var driver = RunGenerator(sourceTexts, assemblyName, publishAot); + + return driver.GetRunResult().Results + .SelectMany(static r => r.GeneratedSources) + .OrderBy(static s => s.HintName) + .Select(static s => s.SourceText.ToString()) + .ToImmutableArray(); + } + + private static GeneratorDriver RunGenerator( + string[] sourceTexts, + string? assemblyName, + bool publishAot) + { + var compilation = CreateCompilation(sourceTexts, assemblyName); + + return CreateDriver(publishAot).RunGenerators(compilation); + } + + private static CSharpCompilation CreateCompilation( + string[] sourceTexts, + string? assemblyName) { IEnumerable references = [ @@ -35,12 +117,23 @@ public static Snapshot GetGeneratedSourceSnapshot( // Mocha (IConsumer, IBatchEventHandler, Saga, SagaStateBase) MetadataReference.CreateFromFile(typeof(IConsumer).Assembly.Location), +#if NET11_0 + // System.Collections.Immutable + MetadataReference.CreateFromFile(typeof(ImmutableArray<>).Assembly.Location), +#endif + + // System.Text.Json + MetadataReference.CreateFromFile(typeof(JsonSerializerOptions).Assembly.Location), + // Microsoft.Extensions.DependencyInjection.Abstractions MetadataReference.CreateFromFile(typeof(IServiceCollection).Assembly.Location), // System.Runtime.CompilerServices.Unsafe MetadataReference.CreateFromFile(typeof(Unsafe).Assembly.Location), + // System.Threading.Tasks.Extensions + MetadataReference.CreateFromFile(typeof(ValueTask).Assembly.Location), + // System.Runtime from the actual runtime (needed for predefined type resolution // so that assembly-level attribute constructor arguments can be bound) MetadataReference.CreateFromFile( @@ -49,33 +142,18 @@ public static Snapshot GetGeneratedSourceSnapshot( "System.Runtime.dll")) ]; - var compilation = CSharpCompilation.Create( + return CSharpCompilation.Create( assemblyName: assemblyName, syntaxTrees: sourceTexts.Select(s => CSharpSyntaxTree.ParseText(s)), references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } + private static GeneratorDriver CreateDriver(bool publishAot) + { var generator = new MessagingGenerator(); - GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); - driver = driver.RunGenerators(compilation); - - var snapshot = new Snapshot(); - - foreach (var result in driver.GetRunResult().Results) - { - var sources = result.GeneratedSources.OrderBy(s => s.HintName); - foreach (var source in sources) - { - snapshot.Add(source.SourceText.ToString(), source.HintName, MarkdownLanguages.CSharp); - } - - if (result.Diagnostics.Any()) - { - AddDiagnosticsToSnapshot(snapshot, result.Diagnostics, "Generator Diagnostics"); - } - } - - return snapshot; + return CSharpGeneratorDriver.Create(generator) + .WithUpdatedAnalyzerConfigOptions(new TestAnalyzerConfigOptionsProvider(publishAot)); } private static void AddDiagnosticsToSnapshot( @@ -161,4 +239,42 @@ private static void AddDiagnosticsToSnapshot( snapshot.Add(Encoding.UTF8.GetString(stream.ToArray()), title, MarkdownLanguages.Json); } } + + private sealed class TestAnalyzerConfigOptionsProvider(bool publishAot) + : AnalyzerConfigOptionsProvider + { + private readonly AnalyzerConfigOptions _globalOptions = + new TestAnalyzerConfigOptions( + new Dictionary + { + ["build_property.PublishAot"] = publishAot ? "true" : "false" + }); + + public override AnalyzerConfigOptions GlobalOptions => _globalOptions; + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) + => TestAnalyzerConfigOptions.Empty; + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) + => TestAnalyzerConfigOptions.Empty; + } + + private sealed class TestAnalyzerConfigOptions(IReadOnlyDictionary options) + : AnalyzerConfigOptions + { + public static readonly AnalyzerConfigOptions Empty = + new TestAnalyzerConfigOptions(new Dictionary()); + + public override bool TryGetValue(string key, out string value) + { + if (options.TryGetValue(key, out var result)) + { + value = result; + return true; + } + + value = ""; + return false; + } + } } diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/CommandHandlerGeneratorTests.Generate_CommandWithResponseHandler_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/CommandHandlerGeneratorTests.Generate_CommandWithResponseHandler_MatchesSnapshot.md index 654d92630ef..7249f0a5ffd 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/CommandHandlerGeneratorTests.Generate_CommandWithResponseHandler_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/CommandHandlerGeneratorTests.Generate_CommandWithResponseHandler_MatchesSnapshot.md @@ -11,6 +11,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.CreateOrderCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.CreateOrderHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/CommandHandlerGeneratorTests.Generate_MultipleCommandHandlers_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/CommandHandlerGeneratorTests.Generate_MultipleCommandHandlers_MatchesSnapshot.md index 00348330827..48a2eb72939 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/CommandHandlerGeneratorTests.Generate_MultipleCommandHandlers_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/CommandHandlerGeneratorTests.Generate_MultipleCommandHandlers_MatchesSnapshot.md @@ -11,6 +11,18 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.CreateOrderCommand), + typeof(global::TestApp.DeleteOrderCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.CreateOrderHandler), + typeof(global::TestApp.DeleteOrderHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/CommandHandlerGeneratorTests.Generate_VoidCommandHandler_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/CommandHandlerGeneratorTests.Generate_VoidCommandHandler_MatchesSnapshot.md index 6df93870771..14b6a82ce6b 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/CommandHandlerGeneratorTests.Generate_VoidCommandHandler_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/CommandHandlerGeneratorTests.Generate_VoidCommandHandler_MatchesSnapshot.md @@ -11,6 +11,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.DeleteOrderCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.DeleteOrderHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0002_CommandWithTwoHandlers_ReportsError.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0002_CommandWithTwoHandlers_ReportsError.md index ba5dc5bd496..ea9e24ee371 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0002_CommandWithTwoHandlers_ReportsError.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0002_CommandWithTwoHandlers_ReportsError.md @@ -13,6 +13,17 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.CreateOrderCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.CreateOrderHandlerA), + typeof(global::TestApp.CreateOrderHandlerB), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0002_VoidCommandWithTwoHandlers_ReportsError.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0002_VoidCommandWithTwoHandlers_ReportsError.md index d4b08ea56db..bbdfcaec3ab 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0002_VoidCommandWithTwoHandlers_ReportsError.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0002_VoidCommandWithTwoHandlers_ReportsError.md @@ -13,6 +13,17 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.DeleteOrderCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.DeleteOrderHandlerA), + typeof(global::TestApp.DeleteOrderHandlerB), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0004_OpenGenericCommand_ReportsInfo.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0004_OpenGenericCommand_ReportsInfo.md index 7648ba7cb15..f61680ccd8d 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0004_OpenGenericCommand_ReportsInfo.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0004_OpenGenericCommand_ReportsInfo.md @@ -1,41 +1,5 @@ # MO0004_OpenGenericCommand_ReportsInfo -## TestsMediatorBuilderExtensions.kHkt5Slhw0EY-Xbr5K86dQ.g.cs - -```csharp -// - -#nullable enable -#pragma warning disable - -namespace Microsoft.Extensions.DependencyInjection -{ - [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] - public static class TestsMediatorBuilderExtensions - { - public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( - this global::Mocha.Mediator.IMediatorHostBuilder builder) - { - - // Register handler configurations - global::Mocha.Mediator.MediatorHostBuilderHandlerExtensions.AddHandlerConfiguration>(builder, - new global::Mocha.Mediator.MediatorHandlerConfiguration - { - HandlerType = typeof(global::TestApp.GenericCommandHandler), - MessageType = typeof(global::TestApp.GenericCommand), - Kind = global::Mocha.Mediator.MediatorHandlerKind.Command, - Delegate = global::Mocha.Mediator.PipelineBuilder.BuildCommandPipeline, global::TestApp.GenericCommand>() - }); - - return builder; - } - } -} - -``` - -## Generator Diagnostics - ```json [ { @@ -48,6 +12,17 @@ namespace Microsoft.Extensions.DependencyInjection "Message": "Message type 'global::TestApp.GenericCommand' is an open generic and cannot be dispatched at runtime", "Category": "Mediator", "CustomTags": [] + }, + { + "Id": "MO0006", + "Title": "Open generic handler cannot be auto-registered", + "Severity": "Info", + "WarningLevel": 1, + "Location": ": (6,13)-(6,34)", + "MessageFormat": "Handler '{0}' has unbound type parameters; source-generated registration is skipped. Register the closed form manually (e.g. AddHandler>()) if intentional.", + "Message": "Handler 'global::TestApp.GenericCommandHandler' has unbound type parameters; source-generated registration is skipped. Register the closed form manually (e.g. AddHandler>()) if intentional.", + "Category": "Mediator", + "CustomTags": [] } ] ``` diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0004_OpenGenericQuery_ReportsInfo.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0004_OpenGenericQuery_ReportsInfo.md index 79a9e35d339..e842d4e8f68 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0004_OpenGenericQuery_ReportsInfo.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0004_OpenGenericQuery_ReportsInfo.md @@ -1,42 +1,5 @@ # MO0004_OpenGenericQuery_ReportsInfo -## TestsMediatorBuilderExtensions.kHkt5Slhw0EY-Xbr5K86dQ.g.cs - -```csharp -// - -#nullable enable -#pragma warning disable - -namespace Microsoft.Extensions.DependencyInjection -{ - [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] - public static class TestsMediatorBuilderExtensions - { - public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( - this global::Mocha.Mediator.IMediatorHostBuilder builder) - { - - // Register handler configurations - global::Mocha.Mediator.MediatorHostBuilderHandlerExtensions.AddHandlerConfiguration>(builder, - new global::Mocha.Mediator.MediatorHandlerConfiguration - { - HandlerType = typeof(global::TestApp.GenericQueryHandler), - MessageType = typeof(global::TestApp.GenericQuery), - ResponseType = typeof(T), - Kind = global::Mocha.Mediator.MediatorHandlerKind.Query, - Delegate = global::Mocha.Mediator.PipelineBuilder.BuildQueryPipeline, global::TestApp.GenericQuery, T>() - }); - - return builder; - } - } -} - -``` - -## Generator Diagnostics - ```json [ { @@ -49,6 +12,17 @@ namespace Microsoft.Extensions.DependencyInjection "Message": "Message type 'global::TestApp.GenericQuery' is an open generic and cannot be dispatched at runtime", "Category": "Mediator", "CustomTags": [] + }, + { + "Id": "MO0006", + "Title": "Open generic handler cannot be auto-registered", + "Severity": "Info", + "WarningLevel": 1, + "Location": ": (6,13)-(6,32)", + "MessageFormat": "Handler '{0}' has unbound type parameters; source-generated registration is skipped. Register the closed form manually (e.g. AddHandler>()) if intentional.", + "Message": "Handler 'global::TestApp.GenericQueryHandler' has unbound type parameters; source-generated registration is skipped. Register the closed form manually (e.g. AddHandler>()) if intentional.", + "Category": "Mediator", + "CustomTags": [] } ] ``` diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0006_OpenGenericHandlerWithConcreteMessage_ReportsInfo.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0006_OpenGenericHandlerWithConcreteMessage_ReportsInfo.md new file mode 100644 index 00000000000..ff71071dce8 --- /dev/null +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0006_OpenGenericHandlerWithConcreteMessage_ReportsInfo.md @@ -0,0 +1,28 @@ +# MO0006_OpenGenericHandlerWithConcreteMessage_ReportsInfo + +```json +[ + { + "Id": "MO0001", + "Title": "Missing handler for message type", + "Severity": "Warning", + "WarningLevel": 1, + "Location": ": (4,14)-(4,23)", + "MessageFormat": "Message type '{0}' has no registered handler", + "Message": "Message type 'global::TestApp.MyCommand' has no registered handler", + "Category": "Mediator", + "CustomTags": [] + }, + { + "Id": "MO0006", + "Title": "Open generic handler cannot be auto-registered", + "Severity": "Info", + "WarningLevel": 1, + "Location": ": (6,13)-(6,27)", + "MessageFormat": "Handler '{0}' has unbound type parameters; source-generated registration is skipped. Register the closed form manually (e.g. AddHandler>()) if intentional.", + "Message": "Handler 'global::TestApp.GenericHandler' has unbound type parameters; source-generated registration is skipped. Register the closed form manually (e.g. AddHandler>()) if intentional.", + "Category": "Mediator", + "CustomTags": [] + } +] +``` diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0006_OpenGenericNotificationHandler_ReportsInfo.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0006_OpenGenericNotificationHandler_ReportsInfo.md new file mode 100644 index 00000000000..df75e1f23b6 --- /dev/null +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.MO0006_OpenGenericNotificationHandler_ReportsInfo.md @@ -0,0 +1,17 @@ +# MO0006_OpenGenericNotificationHandler_ReportsInfo + +```json +[ + { + "Id": "MO0006", + "Title": "Open generic handler cannot be auto-registered", + "Severity": "Info", + "WarningLevel": 1, + "Location": ": (6,13)-(6,25)", + "MessageFormat": "Handler '{0}' has unbound type parameters; source-generated registration is skipped. Register the closed form manually (e.g. AddHandler>()) if intentional.", + "Message": "Handler 'global::TestApp.GenericNotif' has unbound type parameters; source-generated registration is skipped. Register the closed form manually (e.g. AddHandler>()) if intentional.", + "Category": "Mediator", + "CustomTags": [] + } +] +``` diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.NoWarning_ClosedConcreteHandler_NoDiagnostic.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.NoWarning_ClosedConcreteHandler_NoDiagnostic.md new file mode 100644 index 00000000000..61b31116543 --- /dev/null +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.NoWarning_ClosedConcreteHandler_NoDiagnostic.md @@ -0,0 +1,43 @@ +# NoWarning_ClosedConcreteHandler_NoDiagnostic + +```csharp +// + +#nullable enable +#pragma warning disable + +namespace Microsoft.Extensions.DependencyInjection +{ + [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] + public static class TestsMediatorBuilderExtensions + { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.MyCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.ConcreteHandler), + } + )] + public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( + this global::Mocha.Mediator.IMediatorHostBuilder builder) + { + + // Register handler configurations + global::Mocha.Mediator.MediatorHostBuilderHandlerExtensions.AddHandlerConfiguration(builder, + new global::Mocha.Mediator.MediatorHandlerConfiguration + { + HandlerType = typeof(global::TestApp.ConcreteHandler), + MessageType = typeof(global::TestApp.MyCommand), + Kind = global::Mocha.Mediator.MediatorHandlerKind.Command, + Delegate = global::Mocha.Mediator.PipelineBuilder.BuildCommandPipeline() + }); + + return builder; + } + } +} + +``` diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.NoWarning_CommandWithHandler_NoDiagnostic.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.NoWarning_CommandWithHandler_NoDiagnostic.md index 58fa8e23493..3300993b0e0 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.NoWarning_CommandWithHandler_NoDiagnostic.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.NoWarning_CommandWithHandler_NoDiagnostic.md @@ -11,6 +11,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.DeleteOrderCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.DeleteOrderHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.NoWarning_SingleHandlerInterface_NoDiagnostic.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.NoWarning_SingleHandlerInterface_NoDiagnostic.md index 4f61ee3eb4f..b381e279f49 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.NoWarning_SingleHandlerInterface_NoDiagnostic.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.NoWarning_SingleHandlerInterface_NoDiagnostic.md @@ -11,6 +11,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.SomethingHappened), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.SomethingHappenedHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.NoWarning_UnrelatedGenericClass_NoDiagnostic.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.NoWarning_UnrelatedGenericClass_NoDiagnostic.md new file mode 100644 index 00000000000..2474de325ca --- /dev/null +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/DiagnosticTests.NoWarning_UnrelatedGenericClass_NoDiagnostic.md @@ -0,0 +1,2 @@ +# NoWarning_UnrelatedGenericClass_NoDiagnostic + diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/ExplicitModuleNameTests.Generate_ModuleWithOnlyName_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/ExplicitModuleNameTests.Generate_ModuleWithOnlyName_MatchesSnapshot.md index 7ed08a51542..0424cc62015 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/ExplicitModuleNameTests.Generate_ModuleWithOnlyName_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/ExplicitModuleNameTests.Generate_ModuleWithOnlyName_MatchesSnapshot.md @@ -11,6 +11,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class Test2MediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.DeleteOrderCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.DeleteOrderHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTest2( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/GenericHandlerTests.Generate_MultipleHandlersSameNamespace_DeterministicOrder_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/GenericHandlerTests.Generate_MultipleHandlersSameNamespace_DeterministicOrder_MatchesSnapshot.md index 5ba352e34ae..836371f840b 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/GenericHandlerTests.Generate_MultipleHandlersSameNamespace_DeterministicOrder_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/GenericHandlerTests.Generate_MultipleHandlersSameNamespace_DeterministicOrder_MatchesSnapshot.md @@ -11,6 +11,20 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.AlphaCommand), + typeof(global::TestApp.MidCommand), + typeof(global::TestApp.ZetaCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.AlphaHandler), + typeof(global::TestApp.MidHandler), + typeof(global::TestApp.ZetaHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/GenericHandlerTests.Generate_OpenGenericCommand_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/GenericHandlerTests.Generate_OpenGenericCommand_MatchesSnapshot.md index 0fe2f8de85e..fc16e3b6cc6 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/GenericHandlerTests.Generate_OpenGenericCommand_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/GenericHandlerTests.Generate_OpenGenericCommand_MatchesSnapshot.md @@ -13,6 +13,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.ProcessCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.StringProcessor), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/InternalHandlerTests.Generate_InternalHandler_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/InternalHandlerTests.Generate_InternalHandler_MatchesSnapshot.md index 815a1841400..7604802cfff 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/InternalHandlerTests.Generate_InternalHandler_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/InternalHandlerTests.Generate_InternalHandler_MatchesSnapshot.md @@ -11,6 +11,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.DeleteOrderCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.DeleteOrderHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTest( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/KnownTypeSymbolsTests.Generate_CommandOfTResolution_ICommandGeneric_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/KnownTypeSymbolsTests.Generate_CommandOfTResolution_ICommandGeneric_MatchesSnapshot.md index c3658a948c2..d4ba158f179 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/KnownTypeSymbolsTests.Generate_CommandOfTResolution_ICommandGeneric_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/KnownTypeSymbolsTests.Generate_CommandOfTResolution_ICommandGeneric_MatchesSnapshot.md @@ -11,6 +11,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.ComputeCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.ComputeHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/KnownTypeSymbolsTests.Generate_CommandVoidResolution_ICommandInterface_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/KnownTypeSymbolsTests.Generate_CommandVoidResolution_ICommandInterface_MatchesSnapshot.md index ceb0ea09039..826889fce74 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/KnownTypeSymbolsTests.Generate_CommandVoidResolution_ICommandInterface_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/KnownTypeSymbolsTests.Generate_CommandVoidResolution_ICommandInterface_MatchesSnapshot.md @@ -11,6 +11,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.FireAndForgetCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.FireAndForgetHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/KnownTypeSymbolsTests.Generate_WithAllHandlerTypes_AllSymbolsResolved_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/KnownTypeSymbolsTests.Generate_WithAllHandlerTypes_AllSymbolsResolved_MatchesSnapshot.md index d73bd07dc3c..29ee2fe5ea7 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/KnownTypeSymbolsTests.Generate_WithAllHandlerTypes_AllSymbolsResolved_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/KnownTypeSymbolsTests.Generate_WithAllHandlerTypes_AllSymbolsResolved_MatchesSnapshot.md @@ -11,6 +11,22 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.MyEvent), + typeof(global::TestApp.MyQuery), + typeof(global::TestApp.ResponseCommand), + typeof(global::TestApp.VoidCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.MyEventHandler), + typeof(global::TestApp.MyQueryHandler), + typeof(global::TestApp.ResponseCommandHandler), + typeof(global::TestApp.VoidCommandHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MediatorModuleTests.Generate_DefaultAssemblyName_PrefixesWithLastSegment.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MediatorModuleTests.Generate_DefaultAssemblyName_PrefixesWithLastSegment.md index 72c045f859b..56c885287d7 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MediatorModuleTests.Generate_DefaultAssemblyName_PrefixesWithLastSegment.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MediatorModuleTests.Generate_DefaultAssemblyName_PrefixesWithLastSegment.md @@ -11,6 +11,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.GetItemQuery), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.GetItemHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MediatorModuleTests.Generate_DottedAssemblyName_UsesLastSegment.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MediatorModuleTests.Generate_DottedAssemblyName_UsesLastSegment.md index e6fcf81e6b8..68e10a15ecf 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MediatorModuleTests.Generate_DottedAssemblyName_UsesLastSegment.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MediatorModuleTests.Generate_DottedAssemblyName_UsesLastSegment.md @@ -11,6 +11,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class OrderingMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.PingCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.PingHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddOrdering( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MediatorModuleTests.Generate_ModuleFile_ContainsHandlerRegistrations.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MediatorModuleTests.Generate_ModuleFile_ContainsHandlerRegistrations.md index 3d9cdda9749..96b670f95dc 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MediatorModuleTests.Generate_ModuleFile_ContainsHandlerRegistrations.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MediatorModuleTests.Generate_ModuleFile_ContainsHandlerRegistrations.md @@ -11,6 +11,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.CreateInvoiceCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.CreateInvoiceHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingBatchHandlerGeneratorTests.Generate_BatchEventHandler_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingBatchHandlerGeneratorTests.Generate_BatchEventHandler_MatchesSnapshot.md index d4efcff8bd3..ba5e349d505 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingBatchHandlerGeneratorTests.Generate_BatchEventHandler_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingBatchHandlerGeneratorTests.Generate_BatchEventHandler_MatchesSnapshot.md @@ -11,6 +11,12 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMessageBusBuilderExtensions { + [global::Mocha.MessagingModuleInfo( + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.BulkOrderHandler), + } + )] public static global::Mocha.IMessageBusHostBuilder AddTests( this global::Mocha.IMessageBusHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingConsumerGeneratorTests.Generate_SingleConsumer_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingConsumerGeneratorTests.Generate_SingleConsumer_MatchesSnapshot.md index 993e7a06d99..4c6a405fe59 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingConsumerGeneratorTests.Generate_SingleConsumer_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingConsumerGeneratorTests.Generate_SingleConsumer_MatchesSnapshot.md @@ -11,6 +11,12 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMessageBusBuilderExtensions { + [global::Mocha.MessagingModuleInfo( + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.AuditLogConsumer), + } + )] public static global::Mocha.IMessageBusHostBuilder AddTests( this global::Mocha.IMessageBusHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingDiagnosticTests.MO0011_DuplicateRequestHandler_ReportsError.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingDiagnosticTests.MO0011_DuplicateRequestHandler_ReportsError.md index 5f2a077c15a..45850dd67d8 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingDiagnosticTests.MO0011_DuplicateRequestHandler_ReportsError.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingDiagnosticTests.MO0011_DuplicateRequestHandler_ReportsError.md @@ -13,6 +13,13 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMessageBusBuilderExtensions { + [global::Mocha.MessagingModuleInfo( + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.GetOrderHandlerA), + typeof(global::TestApp.GetOrderHandlerB), + } + )] public static global::Mocha.IMessageBusHostBuilder AddTests( this global::Mocha.IMessageBusHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingEventHandlerGeneratorTests.Generate_MultipleEventHandlers_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingEventHandlerGeneratorTests.Generate_MultipleEventHandlers_MatchesSnapshot.md index 8409c2d5b30..2d1c84c1ed0 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingEventHandlerGeneratorTests.Generate_MultipleEventHandlers_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingEventHandlerGeneratorTests.Generate_MultipleEventHandlers_MatchesSnapshot.md @@ -11,6 +11,13 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMessageBusBuilderExtensions { + [global::Mocha.MessagingModuleInfo( + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.OrderPlacedAuditHandler), + typeof(global::TestApp.OrderPlacedHandler), + } + )] public static global::Mocha.IMessageBusHostBuilder AddTests( this global::Mocha.IMessageBusHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingEventHandlerGeneratorTests.Generate_SingleEventHandler_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingEventHandlerGeneratorTests.Generate_SingleEventHandler_MatchesSnapshot.md index 439c0cea178..974bc318bad 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingEventHandlerGeneratorTests.Generate_SingleEventHandler_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingEventHandlerGeneratorTests.Generate_SingleEventHandler_MatchesSnapshot.md @@ -11,6 +11,12 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMessageBusBuilderExtensions { + [global::Mocha.MessagingModuleInfo( + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.OrderPlacedHandler), + } + )] public static global::Mocha.IMessageBusHostBuilder AddTests( this global::Mocha.IMessageBusHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingMixedHandlerTests.Generate_AllHandlerKinds_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingMixedHandlerTests.Generate_AllHandlerKinds_MatchesSnapshot.md index 00d25838b81..2d3b9bc0d83 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingMixedHandlerTests.Generate_AllHandlerKinds_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingMixedHandlerTests.Generate_AllHandlerKinds_MatchesSnapshot.md @@ -11,10 +11,32 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMessageBusBuilderExtensions { + [global::Mocha.MessagingModuleInfo( + SagaTypes = new global::System.Type[] + { + typeof(global::TestApp.OrderFulfillmentSaga), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.AuditLogConsumer), + typeof(global::TestApp.BulkOrderHandler), + typeof(global::TestApp.GetOrderStatusHandler), + typeof(global::TestApp.OrderPlacedHandler), + } + )] public static global::Mocha.IMessageBusHostBuilder AddTests( this global::Mocha.IMessageBusHostBuilder builder) { + // --- Saga Configuration --- + global::Mocha.MessageBusHostBuilderExtensions.AddSagaConfiguration< + global::TestApp.OrderFulfillmentSaga>( + builder, + new global::Mocha.MessagingSagaConfiguration + { + SagaType = typeof(global::TestApp.OrderFulfillmentSaga), + }); + // --- Batch Handlers --- global::Mocha.MessageBusHostBuilderExtensions.AddHandlerConfiguration(builder, new global::Mocha.MessagingHandlerConfiguration @@ -47,10 +69,6 @@ namespace Microsoft.Extensions.DependencyInjection Factory = global::Mocha.ConsumerFactory.Subscribe() }); - // --- Sagas --- - global::Mocha.MessageBusHostBuilderExtensions.AddSaga< - global::TestApp.OrderFulfillmentSaga>(builder); - return builder; } } diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingModuleTests.Generate_DefaultModuleName_UsesAssemblyName_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingModuleTests.Generate_DefaultModuleName_UsesAssemblyName_MatchesSnapshot.md index 64c064de4d6..989bc735c9a 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingModuleTests.Generate_DefaultModuleName_UsesAssemblyName_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingModuleTests.Generate_DefaultModuleName_UsesAssemblyName_MatchesSnapshot.md @@ -11,6 +11,12 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class ApiMessageBusBuilderExtensions { + [global::Mocha.MessagingModuleInfo( + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.OrderPlacedHandler), + } + )] public static global::Mocha.IMessageBusHostBuilder AddApi( this global::Mocha.IMessageBusHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingModuleTests.Generate_ExplicitModuleName_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingModuleTests.Generate_ExplicitModuleName_MatchesSnapshot.md index 30d6d2e12dc..900f4b07f8f 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingModuleTests.Generate_ExplicitModuleName_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingModuleTests.Generate_ExplicitModuleName_MatchesSnapshot.md @@ -11,6 +11,12 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class OrderServiceMessageBusBuilderExtensions { + [global::Mocha.MessagingModuleInfo( + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.OrderPlacedHandler), + } + )] public static global::Mocha.IMessageBusHostBuilder AddOrderService( this global::Mocha.IMessageBusHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingMultiInterfaceTests.Generate_HandlerWithBatchAndEvent_RegistersAsBatch_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingMultiInterfaceTests.Generate_HandlerWithBatchAndEvent_RegistersAsBatch_MatchesSnapshot.md index 2e6e525c6d1..7832a2d4859 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingMultiInterfaceTests.Generate_HandlerWithBatchAndEvent_RegistersAsBatch_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingMultiInterfaceTests.Generate_HandlerWithBatchAndEvent_RegistersAsBatch_MatchesSnapshot.md @@ -11,6 +11,12 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMessageBusBuilderExtensions { + [global::Mocha.MessagingModuleInfo( + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.OrderBatchAndEventHandler), + } + )] public static global::Mocha.IMessageBusHostBuilder AddTests( this global::Mocha.IMessageBusHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingRequestHandlerGeneratorTests.Generate_RequestResponseHandler_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingRequestHandlerGeneratorTests.Generate_RequestResponseHandler_MatchesSnapshot.md index 22a5f744550..b6668ab7663 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingRequestHandlerGeneratorTests.Generate_RequestResponseHandler_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingRequestHandlerGeneratorTests.Generate_RequestResponseHandler_MatchesSnapshot.md @@ -11,6 +11,12 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMessageBusBuilderExtensions { + [global::Mocha.MessagingModuleInfo( + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.GetOrderStatusHandler), + } + )] public static global::Mocha.IMessageBusHostBuilder AddTests( this global::Mocha.IMessageBusHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingRequestHandlerGeneratorTests.Generate_SendHandler_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingRequestHandlerGeneratorTests.Generate_SendHandler_MatchesSnapshot.md index cfb11c3f857..76c7f18cf41 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingRequestHandlerGeneratorTests.Generate_SendHandler_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingRequestHandlerGeneratorTests.Generate_SendHandler_MatchesSnapshot.md @@ -11,6 +11,12 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMessageBusBuilderExtensions { + [global::Mocha.MessagingModuleInfo( + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.ProcessOrderHandler), + } + )] public static global::Mocha.IMessageBusHostBuilder AddTests( this global::Mocha.IMessageBusHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingSagaGeneratorTests.Generate_SimpleSaga_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingSagaGeneratorTests.Generate_SimpleSaga_MatchesSnapshot.md index de6480149f4..708f31290ed 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingSagaGeneratorTests.Generate_SimpleSaga_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MessagingSagaGeneratorTests.Generate_SimpleSaga_MatchesSnapshot.md @@ -11,13 +11,24 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMessageBusBuilderExtensions { + [global::Mocha.MessagingModuleInfo( + SagaTypes = new global::System.Type[] + { + typeof(global::TestApp.OrderFulfillmentSaga), + } + )] public static global::Mocha.IMessageBusHostBuilder AddTests( this global::Mocha.IMessageBusHostBuilder builder) { - // --- Sagas --- - global::Mocha.MessageBusHostBuilderExtensions.AddSaga< - global::TestApp.OrderFulfillmentSaga>(builder); + // --- Saga Configuration --- + global::Mocha.MessageBusHostBuilderExtensions.AddSagaConfiguration< + global::TestApp.OrderFulfillmentSaga>( + builder, + new global::Mocha.MessagingSagaConfiguration + { + SagaType = typeof(global::TestApp.OrderFulfillmentSaga), + }); return builder; } diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MixedHandlerGeneratorTests.Generate_AllHandlerTypes_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MixedHandlerGeneratorTests.Generate_AllHandlerTypes_MatchesSnapshot.md index bf2d65a1323..cbf38d5a9dd 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MixedHandlerGeneratorTests.Generate_AllHandlerTypes_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MixedHandlerGeneratorTests.Generate_AllHandlerTypes_MatchesSnapshot.md @@ -11,6 +11,23 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.CreateOrderCommand), + typeof(global::TestApp.DeleteOrderCommand), + typeof(global::TestApp.GetUserQuery), + typeof(global::TestApp.OrderCreated), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.CreateOrderHandler), + typeof(global::TestApp.DeleteOrderHandler), + typeof(global::TestApp.GetUserHandler), + typeof(global::TestApp.OrderCreatedEmailHandler), + typeof(global::TestApp.OrderCreatedStatsHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MixedHandlerGeneratorTests.Generate_HandlersInDifferentNamespaces_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MixedHandlerGeneratorTests.Generate_HandlersInDifferentNamespaces_MatchesSnapshot.md index 64c2a35c3fc..c6cff9d1c14 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MixedHandlerGeneratorTests.Generate_HandlersInDifferentNamespaces_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/MixedHandlerGeneratorTests.Generate_HandlersInDifferentNamespaces_MatchesSnapshot.md @@ -11,6 +11,18 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.Orders.CreateOrderCommand), + typeof(global::TestApp.Users.GetUserQuery), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.Orders.CreateOrderHandler), + typeof(global::TestApp.Users.GetUserHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/ModuleNameHelperTests.Generate_AssemblyNameWithHyphen_UsesLastSegmentSanitized_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/ModuleNameHelperTests.Generate_AssemblyNameWithHyphen_UsesLastSegmentSanitized_MatchesSnapshot.md index 0cbb349af3e..3b7b27831b7 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/ModuleNameHelperTests.Generate_AssemblyNameWithHyphen_UsesLastSegmentSanitized_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/ModuleNameHelperTests.Generate_AssemblyNameWithHyphen_UsesLastSegmentSanitized_MatchesSnapshot.md @@ -11,6 +11,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class Order_ProcessingMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.PingCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.PingHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddOrder_Processing( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/ModuleNameHelperTests.Generate_NullAssemblyName_UsesAssemblyDefault_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/ModuleNameHelperTests.Generate_NullAssemblyName_UsesAssemblyDefault_MatchesSnapshot.md index e1d6e37457a..1103d90680e 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/ModuleNameHelperTests.Generate_NullAssemblyName_UsesAssemblyDefault_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/ModuleNameHelperTests.Generate_NullAssemblyName_UsesAssemblyDefault_MatchesSnapshot.md @@ -11,6 +11,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class UnknownMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.PingCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.PingHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddUnknown( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/NestedHandlerTests.Generate_NestedClassHandler_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/NestedHandlerTests.Generate_NestedClassHandler_MatchesSnapshot.md index 212d88fc672..326ce16300c 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/NestedHandlerTests.Generate_NestedClassHandler_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/NestedHandlerTests.Generate_NestedClassHandler_MatchesSnapshot.md @@ -11,6 +11,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.DeleteOrderCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.Outer.DeleteOrderHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/NotificationHandlerGeneratorTests.Generate_MultipleHandlersForSameNotification_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/NotificationHandlerGeneratorTests.Generate_MultipleHandlersForSameNotification_MatchesSnapshot.md index 1412f566583..9e81d700556 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/NotificationHandlerGeneratorTests.Generate_MultipleHandlersForSameNotification_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/NotificationHandlerGeneratorTests.Generate_MultipleHandlersForSameNotification_MatchesSnapshot.md @@ -11,6 +11,17 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.OrderCreated), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.SendEmailHandler), + typeof(global::TestApp.UpdateStatsHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/NotificationHandlerGeneratorTests.Generate_SingleNotificationHandler_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/NotificationHandlerGeneratorTests.Generate_SingleNotificationHandler_MatchesSnapshot.md index 54e548c8e27..c7380066636 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/NotificationHandlerGeneratorTests.Generate_SingleNotificationHandler_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/NotificationHandlerGeneratorTests.Generate_SingleNotificationHandler_MatchesSnapshot.md @@ -11,6 +11,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.OrderCreated), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.OrderCreatedEmailHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/PartialClassHandlerTests.Generate_PartialClassHandler_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/PartialClassHandlerTests.Generate_PartialClassHandler_MatchesSnapshot.md index c64aed506ac..e0e9b6d649a 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/PartialClassHandlerTests.Generate_PartialClassHandler_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/PartialClassHandlerTests.Generate_PartialClassHandler_MatchesSnapshot.md @@ -11,6 +11,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.CreateOrderCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.CreateOrderHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/PartialClassHandlerTests.Generate_PartialQueryHandler_AcrossFiles_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/PartialClassHandlerTests.Generate_PartialQueryHandler_AcrossFiles_MatchesSnapshot.md index 294e0e41b81..fd9456b352f 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/PartialClassHandlerTests.Generate_PartialQueryHandler_AcrossFiles_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/PartialClassHandlerTests.Generate_PartialQueryHandler_AcrossFiles_MatchesSnapshot.md @@ -11,6 +11,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.GetOrderQuery), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.GetOrderQueryHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/PartialClassHandlerTests.Generate_PartialVoidCommandHandler_AcrossFiles_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/PartialClassHandlerTests.Generate_PartialVoidCommandHandler_AcrossFiles_MatchesSnapshot.md index f68357c1540..8aa2d252d7a 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/PartialClassHandlerTests.Generate_PartialVoidCommandHandler_AcrossFiles_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/PartialClassHandlerTests.Generate_PartialVoidCommandHandler_AcrossFiles_MatchesSnapshot.md @@ -11,6 +11,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.ProcessOrderCommand), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.ProcessOrderHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/QueryHandlerGeneratorTests.Generate_MultipleQueryHandlers_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/QueryHandlerGeneratorTests.Generate_MultipleQueryHandlers_MatchesSnapshot.md index bc05067d0f0..8bd87bae89d 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/QueryHandlerGeneratorTests.Generate_MultipleQueryHandlers_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/QueryHandlerGeneratorTests.Generate_MultipleQueryHandlers_MatchesSnapshot.md @@ -11,6 +11,18 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.GetOrderQuery), + typeof(global::TestApp.GetUserQuery), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.GetOrderHandler), + typeof(global::TestApp.GetUserHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/QueryHandlerGeneratorTests.Generate_QueryHandler_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/QueryHandlerGeneratorTests.Generate_QueryHandler_MatchesSnapshot.md index d9e1649ad9b..b2f784147f4 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/QueryHandlerGeneratorTests.Generate_QueryHandler_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/QueryHandlerGeneratorTests.Generate_QueryHandler_MatchesSnapshot.md @@ -11,6 +11,16 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.GetUserQuery), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.GetUserHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/WarmUpGeneratorTests.Generate_WarmUpMethod_WithAllHandlerTypes_MatchesSnapshot.md b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/WarmUpGeneratorTests.Generate_WarmUpMethod_WithAllHandlerTypes_MatchesSnapshot.md index d94e843cedf..47dfc462278 100644 --- a/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/WarmUpGeneratorTests.Generate_WarmUpMethod_WithAllHandlerTypes_MatchesSnapshot.md +++ b/src/Mocha/test/Mocha.Analyzers.Tests/__snapshots__/WarmUpGeneratorTests.Generate_WarmUpMethod_WithAllHandlerTypes_MatchesSnapshot.md @@ -11,6 +11,22 @@ namespace Microsoft.Extensions.DependencyInjection [global::System.CodeDom.Compiler.GeneratedCode("Mocha.Analyzers", "1.0.0")] public static class TestsMediatorBuilderExtensions { + [global::Mocha.Mediator.MediatorModuleInfo( + MessageTypes = new global::System.Type[] + { + typeof(global::TestApp.CreateItemCommand), + typeof(global::TestApp.DeleteItemCommand), + typeof(global::TestApp.GetItemQuery), + typeof(global::TestApp.ItemCreated), + }, + HandlerTypes = new global::System.Type[] + { + typeof(global::TestApp.CreateItemHandler), + typeof(global::TestApp.DeleteItemHandler), + typeof(global::TestApp.GetItemHandler), + typeof(global::TestApp.ItemCreatedHandler), + } + )] public static global::Mocha.Mediator.IMediatorHostBuilder AddTests( this global::Mocha.Mediator.IMediatorHostBuilder builder) { diff --git a/src/Mocha/test/Mocha.Sagas.TestHelpers/TestMessageBus.cs b/src/Mocha/test/Mocha.Sagas.TestHelpers/TestMessageBus.cs index 4bbe9ef60d3..d10ee4493c6 100644 --- a/src/Mocha/test/Mocha.Sagas.TestHelpers/TestMessageBus.cs +++ b/src/Mocha/test/Mocha.Sagas.TestHelpers/TestMessageBus.cs @@ -9,26 +9,26 @@ public sealed class TestMessageBus(TestMessageOutbox outbox) : IMessageBus public List CancelledTokens { get; } = []; - public ValueTask PublishAsync(T message, CancellationToken cancellationToken) + public ValueTask PublishAsync(T message, CancellationToken cancellationToken) where T : notnull { - outbox.Messages.Add(new TestMessageOutbox.Operation(TestMessageOutbox.OperationKind.Publish, message!, null)); + outbox.Messages.Add(new TestMessageOutbox.Operation(TestMessageOutbox.OperationKind.Publish, message, null)); return ValueTask.CompletedTask; } public ValueTask PublishAsync(T message, PublishOptions options, CancellationToken cancellationToken) + where T : notnull { - outbox.Messages.Add( - new TestMessageOutbox.Operation(TestMessageOutbox.OperationKind.Publish, message!, options)); + outbox.Messages.Add(new TestMessageOutbox.Operation(TestMessageOutbox.OperationKind.Publish, message, options)); return ValueTask.CompletedTask; } - public ValueTask SendAsync(object message, CancellationToken cancellationToken) + public ValueTask SendAsync(T message, CancellationToken cancellationToken) where T : notnull { outbox.Messages.Add(new TestMessageOutbox.Operation(TestMessageOutbox.OperationKind.Send, message, null)); return ValueTask.CompletedTask; } - public ValueTask SendAsync(object message, SendOptions options, CancellationToken cancellationToken) + public ValueTask SendAsync(T message, SendOptions options, CancellationToken cancellationToken) where T : notnull { outbox.Messages.Add(new TestMessageOutbox.Operation(TestMessageOutbox.OperationKind.Send, message, options)); return ValueTask.CompletedTask; @@ -104,10 +104,11 @@ public ValueTask SchedulePublishAsync( }); } - public ValueTask ScheduleSendAsync( - object message, + public ValueTask ScheduleSendAsync( + T message, DateTimeOffset scheduledTime, CancellationToken cancellationToken) + where T : notnull { var token = $"test:{Interlocked.Increment(ref _scheduleCounter)}"; outbox.Messages.Add(new TestMessageOutbox.Operation(TestMessageOutbox.OperationKind.Send, message, null)); @@ -120,11 +121,12 @@ public ValueTask ScheduleSendAsync( }); } - public ValueTask ScheduleSendAsync( - object message, + public ValueTask ScheduleSendAsync( + T message, DateTimeOffset scheduledTime, SendOptions options, CancellationToken cancellationToken) + where T : notnull { var token = $"test:{Interlocked.Increment(ref _scheduleCounter)}"; outbox.Messages.Add(new TestMessageOutbox.Operation(TestMessageOutbox.OperationKind.Send, message, options)); diff --git a/src/Mocha/test/Mocha.Tests/MessageTypes/AotMessageTypeTests.cs b/src/Mocha/test/Mocha.Tests/MessageTypes/AotMessageTypeTests.cs new file mode 100644 index 00000000000..7537e5aa9c4 --- /dev/null +++ b/src/Mocha/test/Mocha.Tests/MessageTypes/AotMessageTypeTests.cs @@ -0,0 +1,203 @@ +using System.Buffers; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Mocha.Events; +using Mocha.Transport.InMemory; + +namespace Mocha.Tests; + +public class AotMessageTypeTests +{ + [Fact] + public void Complete_Should_ResolveFrameworkIdentityViaRuntimeNaming_When_AotConfigurationIncludesFrameworkBaseType() + { + // arrange + var runtime = CreateAotRuntime(builder => builder.ConfigureMessageBus(bus => + { + bus.AddMessageConfiguration(new MessagingMessageConfiguration + { + MessageType = typeof(GetOrderStatus), + Serializer = new StubMessageSerializer(), + EnclosedTypes = [typeof(GetOrderStatus), typeof(IEventRequest)] + }); + + bus.AddMessageConfiguration(new MessagingMessageConfiguration + { + MessageType = typeof(OrderStatusResponse), + Serializer = new StubMessageSerializer(), + EnclosedTypes = [typeof(OrderStatusResponse)] + }); + })); + + // act + var messageType = runtime.Messages.GetMessageType(typeof(GetOrderStatus))!; + var expected = runtime.Naming.GetMessageIdentity(typeof(IEventRequest)); + + // assert + Assert.True(messageType.IsCompleted); + Assert.Contains(expected, messageType.EnclosedMessageIdentities); + } + + [Fact] + public void Complete_Should_UseCustomNamingConvention_When_FrameworkBaseTypeResolvedViaRuntimeNaming() + { + // arrange + var runtime = CreateAotRuntime( + builder => + { + // Registered on the outer service provider because MessageBusBuilder.AddCoreServices + // resolves IBusNamingConventions from application services. + builder.Services.AddSingleton(); + + builder.ConfigureMessageBus(bus => + { + bus.AddMessageConfiguration(new MessagingMessageConfiguration + { + MessageType = typeof(GetOrderStatus), + Serializer = new StubMessageSerializer(), + EnclosedTypes = [typeof(GetOrderStatus), typeof(IEventRequest)] + }); + + bus.AddMessageConfiguration(new MessagingMessageConfiguration + { + MessageType = typeof(OrderStatusResponse), + Serializer = new StubMessageSerializer(), + EnclosedTypes = [typeof(OrderStatusResponse)] + }); + }); + }); + + // act + var messageType = runtime.Messages.GetMessageType(typeof(GetOrderStatus))!; + var defaultIdentity = new DefaultNamingConventions(runtime.Host) + .GetMessageIdentity(typeof(IEventRequest)); + + // assert + Assert.True(messageType.IsCompleted); + Assert.Contains( + $"custom:{typeof(IEventRequest).FullName}", + messageType.EnclosedMessageIdentities); + Assert.DoesNotContain(defaultIdentity, messageType.EnclosedMessageIdentities); + } + + [Fact] + public void Complete_Should_Throw_When_AotModeWithoutEnclosedTypes() + { + // act & assert + var exception = Assert.Throws(Act); + Assert.Contains("No enclosed types provided", exception.Message); + + static void Act() + { + CreateAotRuntime( + builder => builder.ConfigureMessageBus(bus => bus.AddMessage(static _ => { }))); + } + } + + [Fact] + public void Complete_Should_RegisterInternalAcknowledgementEvents_When_AotMode() + { + // arrange + var runtime = CreateAotRuntime(static _ => { }); + + // act + var acknowledged = runtime.Messages.GetMessageType(typeof(AcknowledgedEvent))!; + var notAcknowledged = runtime.Messages.GetMessageType(typeof(NotAcknowledgedEvent))!; + + // assert + Assert.True(acknowledged.IsCompleted); + Assert.Contains(acknowledged.Identity, acknowledged.EnclosedMessageIdentities); + Assert.True(notAcknowledged.IsCompleted); + Assert.Contains(notAcknowledged.Identity, notAcknowledged.EnclosedMessageIdentities); + } + + [Fact] + public void GetSerializer_Should_UseConfiguredJsonTypeInfoResolver_When_AotMode() + { + // arrange + var runtime = CreateAotRuntime(builder => + { + builder.AddJsonTypeInfoResolver(AotMessageTypeJsonContext.Default); + builder.ConfigureMessageBus(bus => bus.AddMessage( + static descriptor => descriptor.Extend().Configuration.EnclosedTypes = [typeof(GetOrderStatus)])); + }); + + var messageType = runtime.Messages.GetMessageType(typeof(GetOrderStatus))!; + + // act + var serializer = messageType.GetSerializer(MessageContentType.Json); + var writer = new ArrayBufferWriter(); + serializer!.Serialize(new GetOrderStatus { OrderId = "42" }, writer); + var deserialized = serializer.Deserialize(writer.WrittenMemory); + + // assert + Assert.Equal("42", deserialized?.OrderId); + } + + public sealed class GetOrderStatus : IEventRequest + { + public string OrderId { get; init; } = ""; + } + + public sealed class OrderStatusResponse + { + public string Status { get; init; } = ""; + } + + private sealed class StubMessageSerializer : IMessageSerializer + { + public MessageContentType ContentType => MessageContentType.Json; + + public T? Deserialize(ReadOnlyMemory body) => default; + + public object? Deserialize(ReadOnlyMemory body) => null; + + public void Serialize(T message, IBufferWriter writer) + { + } + + public void Serialize(object message, IBufferWriter writer) + { + } + } + + /// + /// Test double that prefixes every identity with "custom:" so we can observe whether + /// the runtime resolves framework base type identities through the configured convention + /// instead of a compile-time-baked URN. + /// + private sealed class PrefixingNamingConventions : IBusNamingConventions + { + public string GetReceiveEndpointName(InboundRoute route, ReceiveEndpointKind kind) => route.Consumer?.Name ?? "unknown"; + + public string GetReceiveEndpointName(Type handlerType, ReceiveEndpointKind kind) => handlerType.Name; + + public string GetReceiveEndpointName(string name, ReceiveEndpointKind kind) => name; + + public string GetSagaName(Type sagaType) => sagaType.Name; + + public string GetInstanceEndpoint(Guid instanceId) => $"response-{instanceId:N}"; + + public string GetSendEndpointName(Type messageType) => messageType.Name; + + public string GetPublishEndpointName(Type messageType) => messageType.Name; + + public string GetMessageIdentity(Type messageType) => $"custom:{messageType.FullName}"; + } + + private static MessagingRuntime CreateAotRuntime(Action configure) + { + var services = new ServiceCollection(); + var builder = services.AddMessageBus(); + builder.ModifyOptions(static o => o.IsAotCompatible = true); + + configure(builder); + builder.AddInMemory(); + + var provider = services.BuildServiceProvider(); + return (MessagingRuntime)provider.GetRequiredService(); + } +} + +[JsonSerializable(typeof(AotMessageTypeTests.GetOrderStatus))] +internal partial class AotMessageTypeJsonContext : JsonSerializerContext; diff --git a/src/Mocha/test/Mocha.Tests/Middlewares/Receive/ReceiveDeadLetterMiddlewareTests.cs b/src/Mocha/test/Mocha.Tests/Middlewares/Receive/ReceiveDeadLetterMiddlewareTests.cs index 69f75e1b528..bcc2a745587 100644 --- a/src/Mocha/test/Mocha.Tests/Middlewares/Receive/ReceiveDeadLetterMiddlewareTests.cs +++ b/src/Mocha/test/Mocha.Tests/Middlewares/Receive/ReceiveDeadLetterMiddlewareTests.cs @@ -634,6 +634,7 @@ private sealed class StubTransportOptions : IReadOnlyTransportOptions private sealed class StubMessagingOptions : IReadOnlyMessagingOptions { public MessageContentType DefaultContentType => new("application/json"); + public bool IsAotCompatible => false; } private sealed class StubHostInfo : IHostInfo diff --git a/src/Mocha/test/Mocha.Tests/Scheduling/MessageBusSchedulingExtensionsTests.cs b/src/Mocha/test/Mocha.Tests/Scheduling/MessageBusSchedulingExtensionsTests.cs index 54f055d4344..ca0ff8dc11b 100644 --- a/src/Mocha/test/Mocha.Tests/Scheduling/MessageBusSchedulingExtensionsTests.cs +++ b/src/Mocha/test/Mocha.Tests/Scheduling/MessageBusSchedulingExtensionsTests.cs @@ -47,16 +47,16 @@ private sealed class SpyMessageBus : IMessageBus public List<(object Message, DateTimeOffset ScheduledTime)> ScheduledSendMessages { get; } = []; public List<(object Message, DateTimeOffset ScheduledTime)> ScheduledPublishMessages { get; } = []; - public ValueTask SendAsync(object message, CancellationToken cancellationToken) => + public ValueTask SendAsync(T message, CancellationToken cancellationToken) where T : notnull => ValueTask.CompletedTask; - public ValueTask SendAsync(object message, SendOptions options, CancellationToken cancellationToken) => + public ValueTask SendAsync(T message, SendOptions options, CancellationToken cancellationToken) where T : notnull => ValueTask.CompletedTask; - public ValueTask PublishAsync(T message, CancellationToken cancellationToken) => + public ValueTask PublishAsync(T message, CancellationToken cancellationToken) where T : notnull => ValueTask.CompletedTask; - public ValueTask PublishAsync(T message, PublishOptions options, CancellationToken cancellationToken) => + public ValueTask PublishAsync(T message, PublishOptions options, CancellationToken cancellationToken) where T : notnull => ValueTask.CompletedTask; public ValueTask RequestAsync( @@ -101,20 +101,20 @@ public ValueTask SchedulePublishAsync( return ValueTask.FromResult(new SchedulingResult { ScheduledTime = scheduledTime }); } - public ValueTask ScheduleSendAsync( - object message, + public ValueTask ScheduleSendAsync( + T message, DateTimeOffset scheduledTime, - CancellationToken cancellationToken) + CancellationToken cancellationToken) where T : notnull { ScheduledSendMessages.Add((message, scheduledTime)); return ValueTask.FromResult(new SchedulingResult { ScheduledTime = scheduledTime }); } - public ValueTask ScheduleSendAsync( - object message, + public ValueTask ScheduleSendAsync( + T message, DateTimeOffset scheduledTime, SendOptions options, - CancellationToken cancellationToken) + CancellationToken cancellationToken) where T : notnull { ScheduledSendMessages.Add((message, scheduledTime)); return ValueTask.FromResult(new SchedulingResult { ScheduledTime = scheduledTime }); diff --git a/src/Mocha/test/Mocha.Tests/Serialization/HeadersJsonConverterTests.cs b/src/Mocha/test/Mocha.Tests/Serialization/HeadersJsonConverterTests.cs new file mode 100644 index 00000000000..233950a516f --- /dev/null +++ b/src/Mocha/test/Mocha.Tests/Serialization/HeadersJsonConverterTests.cs @@ -0,0 +1,223 @@ +using System.Globalization; +using System.Text.Json; + +namespace Mocha.Tests; + +public class HeadersJsonConverterTests +{ + [Fact] + public void Write_Should_EmitStringValue_When_HeaderIsGuid() + { + // arrange + var headers = new Headers(); + var value = Guid.Parse("12345678-1234-1234-1234-123456789012"); + headers.Set("id", value); + + // act + var json = JsonSerializer.Serialize(headers, HeadersJsonConverter.Options); + + // assert + Assert.Equal("""{"id":"12345678-1234-1234-1234-123456789012"}""", json); + } + + [Fact] + public void Write_Should_EmitIsoString_When_HeaderIsTimeSpan() + { + // arrange + var headers = new Headers(); + var value = new TimeSpan(1, 2, 3, 4); + headers.Set("duration", value); + + // act + var json = JsonSerializer.Serialize(headers, HeadersJsonConverter.Options); + + // assert + var expected = value.ToString("c", CultureInfo.InvariantCulture); + Assert.Equal($$"""{"duration":"{{expected}}"}""", json); + } + + [Fact] + public void Write_Should_EmitStringValue_When_HeaderIsUri() + { + // arrange + var headers = new Headers(); + var value = new Uri("https://example.com/path"); + headers.Set("url", value); + + // act + var json = JsonSerializer.Serialize(headers, HeadersJsonConverter.Options); + + // assert + Assert.Equal("""{"url":"https://example.com/path"}""", json); + } + + [Fact] + public void Write_Should_EmitIsoDate_When_HeaderIsDateOnly() + { + // arrange + var headers = new Headers(); + var value = new DateOnly(2024, 1, 15); + headers.Set("date", value); + + // act + var json = JsonSerializer.Serialize(headers, HeadersJsonConverter.Options); + + // assert + var expected = value.ToString("O", CultureInfo.InvariantCulture); + Assert.Equal($$"""{"date":"{{expected}}"}""", json); + } + + [Fact] + public void Write_Should_EmitIsoTime_When_HeaderIsTimeOnly() + { + // arrange + var headers = new Headers(); + var value = new TimeOnly(10, 30, 45); + headers.Set("time", value); + + // act + var json = JsonSerializer.Serialize(headers, HeadersJsonConverter.Options); + + // assert + var expected = value.ToString("O", CultureInfo.InvariantCulture); + Assert.Equal($$"""{"time":"{{expected}}"}""", json); + } + + [Fact] + public void Write_Should_EmitEnumName_When_HeaderIsEnum() + { + // arrange + var headers = new Headers(); + headers.Set("priority", TestPriority.High); + + // act + var json = JsonSerializer.Serialize(headers, HeadersJsonConverter.Options); + + // assert + Assert.Equal("""{"priority":"High"}""", json); + } + + [Fact] + public void Write_Should_EmitNumber_When_HeaderIsShort() + { + // arrange + var headers = new Headers(); + headers.Set("value", (short)123); + + // act + var json = JsonSerializer.Serialize(headers, HeadersJsonConverter.Options); + + // assert + Assert.Equal("""{"value":123}""", json); + } + + [Fact] + public void Write_Should_EmitNumber_When_HeaderIsUshort() + { + // arrange + var headers = new Headers(); + headers.Set("value", (ushort)456); + + // act + var json = JsonSerializer.Serialize(headers, HeadersJsonConverter.Options); + + // assert + Assert.Equal("""{"value":456}""", json); + } + + [Fact] + public void Write_Should_EmitNumber_When_HeaderIsByte() + { + // arrange + var headers = new Headers(); + headers.Set("value", (byte)7); + + // act + var json = JsonSerializer.Serialize(headers, HeadersJsonConverter.Options); + + // assert + Assert.Equal("""{"value":7}""", json); + } + + [Fact] + public void Write_Should_EmitNumber_When_HeaderIsSbyte() + { + // arrange + var headers = new Headers(); + headers.Set("value", (sbyte)-8); + + // act + var json = JsonSerializer.Serialize(headers, HeadersJsonConverter.Options); + + // assert + Assert.Equal("""{"value":-8}""", json); + } + + [Fact] + public void Write_Should_EmitNumber_When_HeaderIsUint() + { + // arrange + var headers = new Headers(); + headers.Set("value", 4000000000u); + + // act + var json = JsonSerializer.Serialize(headers, HeadersJsonConverter.Options); + + // assert + Assert.Equal("""{"value":4000000000}""", json); + } + + [Fact] + public void Write_Should_EmitNumber_When_HeaderIsUlong() + { + // arrange + var headers = new Headers(); + headers.Set("value", 18000000000000000000ul); + + // act + var json = JsonSerializer.Serialize(headers, HeadersJsonConverter.Options); + + // assert + Assert.Equal("""{"value":18000000000000000000}""", json); + } + + [Fact] + public void Write_Should_EmitStringValue_When_HeaderIsChar() + { + // arrange + var headers = new Headers(); + headers.Set("letter", 'A'); + + // act + var json = JsonSerializer.Serialize(headers, HeadersJsonConverter.Options); + + // assert + Assert.Equal("""{"letter":"A"}""", json); + } + + [Fact] + public void Write_Should_SerializeCustomType_When_HeaderIsCustomType() + { + // arrange + var headers = new Headers(); + headers.Set("custom", new CustomHeaderDto { Name = "test" }); + + // act + var json = JsonSerializer.Serialize(headers, HeadersJsonConverter.Options); + + // assert + Assert.Equal("""{"custom":{"Name":"test"}}""", json); + } + + private enum TestPriority + { + Low, + Normal, + High + } + + private sealed class CustomHeaderDto + { + public string Name { get; init; } = ""; + } +} diff --git a/website-next/content/docs/mocha/diagnostics.md b/website-next/content/docs/mocha/diagnostics.md index 747a0d6042b..a489ebd3476 100644 --- a/website-next/content/docs/mocha/diagnostics.md +++ b/website-next/content/docs/mocha/diagnostics.md @@ -14,10 +14,15 @@ Mocha uses a Roslyn source generator to validate your message handlers, consumer | [MO0003](#mo0003) | Handler is abstract | Warning | | [MO0004](#mo0004) | Open generic message type cannot be dispatched | Info | | [MO0005](#mo0005) | Handler implements multiple mediator handler interfaces | Error | +| [MO0006](#mo0006) | Open generic handler cannot be auto-registered | Info | | [MO0011](#mo0011) | Duplicate handler for request type | Error | | [MO0012](#mo0012) | Open generic messaging handler cannot be auto-registered | Info | | [MO0013](#mo0013) | Messaging handler is abstract | Warning | | [MO0014](#mo0014) | Saga must have a public parameterless constructor | Error | +| [MO0015](#mo0015) | Missing JsonSerializerContext for AOT | Error | +| [MO0016](#mo0016) | Missing JsonSerializable attribute | Error | +| [MO0018](#mo0018) | Type not in JsonSerializerContext | Warning | +| [MO0020](#mo0020) | Command/query sent but no handler found | Warning | # Mediator diagnostics @@ -255,6 +260,79 @@ public class GetOrderHandler : IQueryHandler } ``` +## MO0006 + +**Open generic handler cannot be auto-registered** + +| | | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Severity** | Info | +| **Message** | `Handler '{0}' has unbound type parameters; source-generated registration is skipped. Register the closed form manually (e.g. AddHandler>()) if intentional.` | + +### Cause + +A mediator handler (`ICommandHandler`, `ICommandHandler`, `IQueryHandler`, or `INotificationHandler`) is declared with unbound type parameters. This diagnostic fires on the handler _declaration_ - the source generator cannot produce a registration for an open generic because there is no closed type to register. + +Under AOT, this matters because every dispatched message must correspond to a statically-known handler type; the runtime cannot close the generic for you. + +### Example + +```csharp +using Mocha.Mediator; + +public record MyNotification : INotification; + +// Open generic handler - triggers MO0006 +public class GenericNotificationHandler : INotificationHandler +{ + public ValueTask HandleAsync( + MyNotification notification, + CancellationToken ct) + => ValueTask.CompletedTask; +} +``` + +### Fix + +Either close the generic by declaring a concrete handler, or register the closed form manually and suppress the diagnostic on the declaration. + +Close the generic at the handler declaration: + +```csharp +using Mocha.Mediator; + +public record MyNotification : INotification; + +public class MyNotificationHandler : INotificationHandler +{ + public ValueTask HandleAsync( + MyNotification notification, + CancellationToken ct) + => ValueTask.CompletedTask; +} +``` + +Or, if the open generic is intentional (for example, a reusable handler body parameterized on a secondary type), register the closed form manually and suppress MO0006 on the declaration: + +```csharp +using Mocha.Mediator; + +public record MyNotification : INotification; + +#pragma warning disable MO0006 +public class GenericNotificationHandler : INotificationHandler +{ + public ValueTask HandleAsync( + MyNotification notification, + CancellationToken ct) + => ValueTask.CompletedTask; +} +#pragma warning restore MO0006 + +// Register the closed form explicitly: +// services.AddMediator(m => m.AddHandler>()); +``` + # Messaging diagnostics These diagnostics apply to the [message bus](./handlers-and-consumers.md) - event handlers, request handlers, batch handlers, consumers, and sagas that communicate across service boundaries. @@ -464,3 +542,213 @@ public class RefundSaga : Saga } } ``` + +## MO0015 + +**Missing JsonSerializerContext for AOT** + +| | | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Severity** | Error | +| **Message** | `MessagingModule '{0}' must specify JsonContext when publishing for AOT. Add JsonContext = typeof(YourJsonContext) to the MessagingModule attribute.` | + +### Cause + +The project has `PublishAot` set to `true` but the `[assembly: MessagingModule]` attribute does not include a `JsonContext` property. AOT publishing requires a `JsonSerializerContext` so the source generator can produce trim-safe serialization code for all message types. + +### Example + +```csharp +using Mocha; + +// No JsonContext specified while targeting AOT - triggers MO0015 +[assembly: MessagingModule("OrderService")] +``` + +### Fix + +Create a `JsonSerializerContext` that includes all your message types and reference it from the `MessagingModule` attribute. + +```csharp +using System.Text.Json.Serialization; +using Mocha; + +[assembly: MessagingModule("OrderService", JsonContext = typeof(OrderServiceJsonContext))] + +public record OrderPlaced(Guid OrderId); + +[JsonSerializable(typeof(OrderPlaced))] +public partial class OrderServiceJsonContext : JsonSerializerContext; +``` + +## MO0016 + +**Missing JsonSerializable attribute** + +| | | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| **Severity** | Error | +| **Message** | `Type '{0}' is used as a message type but is not included in JsonSerializerContext '{1}'. Add [JsonSerializable(typeof({0}))] to the context.` | + +### Cause + +A type is used as a message, request, response, or saga state through a handler registration, but it is not declared via `[JsonSerializable(typeof(...))]` on the `JsonSerializerContext` specified in the `MessagingModule` attribute. Without this declaration, the AOT compiler cannot generate serialization code for the type. + +### Example + +```csharp +using System.Text.Json.Serialization; +using Mocha; + +[assembly: MessagingModule("OrderService", JsonContext = typeof(OrderServiceJsonContext))] + +public record OrderPlaced(Guid OrderId); + +public class OrderPlacedHandler : IEventHandler +{ + public ValueTask HandleAsync( + OrderPlaced message, + CancellationToken ct) + => ValueTask.CompletedTask; +} + +// OrderPlaced is missing from the context - triggers MO0016 +[JsonSerializable(typeof(string))] +public partial class OrderServiceJsonContext : JsonSerializerContext; +``` + +### Fix + +Add a `[JsonSerializable]` attribute for every message type used by your handlers. + +```csharp +using System.Text.Json.Serialization; +using Mocha; + +[assembly: MessagingModule("OrderService", JsonContext = typeof(OrderServiceJsonContext))] + +public record OrderPlaced(Guid OrderId); + +public class OrderPlacedHandler : IEventHandler +{ + public ValueTask HandleAsync( + OrderPlaced message, + CancellationToken ct) + => ValueTask.CompletedTask; +} + +[JsonSerializable(typeof(OrderPlaced))] +public partial class OrderServiceJsonContext : JsonSerializerContext; +``` + +## MO0018 + +**Type not in JsonSerializerContext** + +| | | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | +| **Severity** | Warning | +| **Message** | `Type '{0}' is used in a {1} call but is not included in JsonSerializerContext '{2}'. Add [JsonSerializable(typeof({0}))] to the context.` | + +### Cause + +Emitted only when `PublishAot` is enabled and a call site publishes a type not covered by the declared `JsonContext`. For example, `bus.PublishAsync()` is called with a type that has no matching `[JsonSerializable(typeof(T))]` on the `JsonSerializerContext` referenced by `[assembly: MessagingModule(..., JsonContext = typeof(...))]`. This is similar to [MO0016](#mo0016), but applies to types discovered at call sites rather than handler registrations. Without the declaration, the message cannot be serialized at runtime in an AOT environment. + +This diagnostic does not fire when `PublishAot` is unset, even if `JsonContext` is declared on the module - opting into a source-generated context for non-AOT reasons does not require every call-site type to appear in it. + +### Example + +```csharp +using System.Text.Json.Serialization; +using Mocha; + +[assembly: MessagingModule("OrderService", JsonContext = typeof(OrderServiceJsonContext))] + +public record OrderPlaced(Guid OrderId); +public record OrderShipped(Guid OrderId); + +// OrderShipped is published but missing from the context - triggers MO0018 +public class OrderService(IMessageBus bus) +{ + public async Task ShipOrderAsync(Guid orderId, CancellationToken ct) + { + await bus.PublishAsync(new OrderShipped(orderId), ct); + } +} + +[JsonSerializable(typeof(OrderPlaced))] +public partial class OrderServiceJsonContext : JsonSerializerContext; +``` + +### Fix + +Add a `[JsonSerializable]` attribute for every type used at a call site. + +```csharp +using System.Text.Json.Serialization; +using Mocha; + +[assembly: MessagingModule("OrderService", JsonContext = typeof(OrderServiceJsonContext))] + +public record OrderPlaced(Guid OrderId); +public record OrderShipped(Guid OrderId); + +[JsonSerializable(typeof(OrderPlaced))] +[JsonSerializable(typeof(OrderShipped))] +public partial class OrderServiceJsonContext : JsonSerializerContext; +``` + +# Mediator call-site diagnostics + +These diagnostics are reported when the source generator inspects call sites that use `ISender` to dispatch commands or queries. + +## MO0020 + +**Command/query sent but no handler found** + +| | | +| ------------ | ------------------------------------------------------------------------------------------------------- | +| **Severity** | Warning | +| **Message** | `Type '{0}' is sent via {1} but no handler was found in this assembly. Ensure a handler is registered.` | + +### Cause + +A command or query type is dispatched via `ISender.SendAsync` or `ISender.QueryAsync`, but no corresponding handler implementation exists in the assembly. This catches cases where a call site references a message type that was never wired up with a handler. + +### Example + +```csharp +using Mocha.Mediator; + +public record PlaceOrder(Guid OrderId, decimal Total) : ICommand; + +// PlaceOrder is sent but no handler exists - triggers MO0020 +public class OrderController(ISender sender) +{ + public async Task CreateOrderAsync(Guid orderId, CancellationToken ct) + { + await sender.SendAsync(new PlaceOrder(orderId, 99.99m), ct); + } +} +``` + +### Fix + +Implement a handler for the message type, or remove the call site if the handler is intentionally in another assembly. + +```csharp +using Mocha.Mediator; + +public record PlaceOrder(Guid OrderId, decimal Total) : ICommand; + +public class PlaceOrderHandler : ICommandHandler +{ + public ValueTask HandleAsync( + PlaceOrder command, + CancellationToken cancellationToken) + { + // process the order + return ValueTask.CompletedTask; + } +} +``` diff --git a/website/src/docs/mocha/v16/sagas.md b/website/src/docs/mocha/v16/sagas.md index 938355d5453..9de4ffa1f3c 100644 --- a/website/src/docs/mocha/v16/sagas.md +++ b/website/src/docs/mocha/v16/sagas.md @@ -619,11 +619,11 @@ The correlation lookup order is: # Timeouts -A saga that waits for a message that never arrives will stay in its current state forever. Timeouts ensure every saga eventually completes — either through normal processing or by timing out. +A saga that waits for a message that never arrives will stay in its current state forever. Timeouts ensure every saga eventually completes - either through normal processing or by timing out. Mocha provides a saga-level `Timeout()` API that sets a single deadline for the entire saga instance. The timeout is scheduled when the saga is created and automatically cancelled when the saga reaches any final state. -> **Prerequisites:** Durable, cancellable timeouts require a scheduling store. Configure `UsePostgresScheduling()` before using `Timeout()` — see [Scheduling: Set up store-based scheduling](/docs/mocha/v16/scheduling#set-up-store-based-scheduling-for-rabbitmq) for setup instructions. Native transport scheduling (InMemory, PostgreSQL) also works but does not support automatic cancellation. +> **Prerequisites:** Durable, cancellable timeouts require a scheduling store. Configure `UsePostgresScheduling()` before using `Timeout()` - see [Scheduling: Set up store-based scheduling](/docs/mocha/v16/scheduling#set-up-store-based-scheduling-for-rabbitmq) for setup instructions. Native transport scheduling (InMemory, PostgreSQL) also works but does not support automatic cancellation. ## Configure a saga-level timeout @@ -632,7 +632,7 @@ Call `Timeout()` on the saga descriptor to set a deadline that applies to the en ```csharp protected override void Configure(ISagaDescriptor descriptor) { - // 30-minute timeout — saga will time out if it doesn't reach a final state + // 30-minute timeout - saga will time out if it doesn't reach a final state descriptor.Timeout(TimeSpan.FromMinutes(30)) .Respond(state => new OrderTimedOutResponse(state.Id)); @@ -660,9 +660,9 @@ protected override void Configure(ISagaDescriptor descriptor) 2. Returns an `ISagaFinalStateDescriptor` so you can chain `.Respond()` to send a response when the saga times out. 3. Tells the saga framework to schedule a timeout event when a new saga instance is created. -The timeout clock starts when the saga is created — that is, when the first event arrives and a new instance is provisioned. If the saga reaches any final state before the deadline, the pending timeout is automatically cancelled. If the timeout fires, a `SagaTimedOutEvent` is delivered to the saga instance. +The timeout clock starts when the saga is created - that is, when the first event arrives and a new instance is provisioned. If the saga reaches any final state before the deadline, the pending timeout is automatically cancelled. If the timeout fires, a `SagaTimedOutEvent` is delivered to the saga instance. -Use `OnTimeout()` on a state descriptor to define what happens when the timeout arrives. Handle it the same way you handle any other event — run `.Then()` actions, dispatch commands, or transition to a different state. +Use `OnTimeout()` on a state descriptor to define what happens when the timeout arrives. Handle it the same way you handle any other event - run `.Then()` actions, dispatch commands, or transition to a different state. ## Key behaviors @@ -675,7 +675,7 @@ Use `OnTimeout()` on a state descriptor to define what happens when the timeout | Missing handler | If no `OnTimeout()` handler is configured for the current state, the saga throws an execution error. See [troubleshooting](#timeout-troubleshooting) below. | | Recommended pattern | `DuringAny().OnTimeout()` handles the timeout regardless of which state the saga is in. | | Response on timeout | Chain `.Respond()` on the timed-out final state to send a response back to the original requester. | -| Scheduling store | Requires a scheduling store for durable timeouts. Configure `UsePostgresScheduling()` — see [Scheduling](/docs/mocha/v16/scheduling#set-up-store-based-scheduling-for-rabbitmq) for setup. Native transport scheduling (InMemory, PostgreSQL) also works but does not support cancellation. | +| Scheduling store | Requires a scheduling store for durable timeouts. Configure `UsePostgresScheduling()` - see [Scheduling](/docs/mocha/v16/scheduling#set-up-store-based-scheduling-for-rabbitmq) for setup. Native transport scheduling (InMemory, PostgreSQL) also works but does not support cancellation. | ## Timeout troubleshooting @@ -697,9 +697,9 @@ descriptor.DuringAny() | Method | Available on | Parameters | Description | | ----------- | ------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Initially` | `ISagaDescriptor` | — | Returns the initial state descriptor for defining transitions that create new saga instances. | +| `Initially` | `ISagaDescriptor` | - | Returns the initial state descriptor for defining transitions that create new saga instances. | | `During` | `ISagaDescriptor` | `string stateName` | Returns a state descriptor for defining transitions in the named state. | -| `DuringAny` | `ISagaDescriptor` | — | Returns a catch-all state descriptor whose transitions apply to all non-initial, non-final states. | +| `DuringAny` | `ISagaDescriptor` | - | Returns a catch-all state descriptor whose transitions apply to all non-initial, non-final states. | | `Finally` | `ISagaDescriptor` | `string stateName` | Declares a final state. When the saga enters this state, persisted state is deleted and an optional response is sent. | | `Timeout` | `ISagaDescriptor` | `TimeSpan timeout` | Creates a timed-out final state and schedules automatic timeout on saga creation. Returns `ISagaFinalStateDescriptor` for chaining `.Respond()`. | @@ -707,11 +707,11 @@ descriptor.DuringAny() | Method | Available on | Parameters | Description | | -------------- | ------------------------------ | ---------- | ------------------------------------------------------------------------------------------- | -| `OnEvent` | `ISagaStateDescriptor` | — | Registers a transition triggered by a published event. | -| `OnRequest` | `ISagaStateDescriptor` | — | Registers a transition triggered by a request message (captures reply address). | -| `OnReply` | `ISagaStateDescriptor` | — | Registers a transition triggered by a reply to a previously sent command. | -| `OnFault` | `ISagaStateDescriptor` | — | Registers a transition triggered by a `NotAcknowledgedEvent`. | -| `OnTimeout` | `ISagaStateDescriptor` | — | Registers a transition for `SagaTimedOutEvent`. Sugar for `OnRequest()`. | +| `OnEvent` | `ISagaStateDescriptor` | - | Registers a transition triggered by a published event. | +| `OnRequest` | `ISagaStateDescriptor` | - | Registers a transition triggered by a request message (captures reply address). | +| `OnReply` | `ISagaStateDescriptor` | - | Registers a transition triggered by a reply to a previously sent command. | +| `OnFault` | `ISagaStateDescriptor` | - | Registers a transition triggered by a `NotAcknowledgedEvent`. | +| `OnTimeout` | `ISagaStateDescriptor` | - | Registers a transition for `SagaTimedOutEvent`. Sugar for `OnRequest()`. | ## Transition actions @@ -733,8 +733,8 @@ descriptor.DuringAny() | Method | Available on | Parameters | Description | | --------------- | ------------------------------ | ---------- | ------------------------------------------------------------------------------------------ | -| `OnEntry` | `ISagaStateDescriptor` | — | Returns a lifecycle descriptor for actions that run every time the saga enters the state. | -| `WhenCompleted` | `ISagaDescriptor` | — | Returns a lifecycle descriptor for actions that run when the saga reaches any final state. | +| `OnEntry` | `ISagaStateDescriptor` | - | Returns a lifecycle descriptor for actions that run every time the saga enters the state. | +| `WhenCompleted` | `ISagaDescriptor` | - | Returns a lifecycle descriptor for actions that run when the saga reaches any final state. | # Troubleshooting