diff --git a/README.md b/README.md index d667abb8..89d0b51a 100644 --- a/README.md +++ b/README.md @@ -454,7 +454,7 @@ PatternKit currently tracks 92 production-readiness patterns. Each catalog patte | Behavioral | 11 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 17 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig | | Creational | 5 | Abstract Factory, Builder, Factory Method, Prototype, Singleton | -| Enterprise Integration | 34 | Aggregator, Canonical Data Model, Channel Adapter, Channel Purger, Claim Check, Competing Consumers, Content-Based Router, Control Bus, Dead Letter Channel, Durable Subscriber, Dynamic Router, Event Notification, Event-Carried State Transfer, Event-Driven Consumer, Invalid Message Channel, Mailbox, Message Channel, Message Envelope, Message Filter, Message Store, Message Translator, Messaging Gateway, Pipes and Filters, Polling Consumer, Publish-Subscribe, Recipient List, Request-Reply, Resequencer, Routing Slip, Saga / Process Manager, Scatter-Gather, Service Activator, Splitter, Wire Tap | +| Enterprise Integration | 35 | Aggregator, Canonical Data Model, Channel Adapter, Channel Purger, Claim Check, Competing Consumers, Content-Based Router, Control Bus, Dead Letter Channel, Durable Subscriber, Dynamic Router, Event Notification, Event-Carried State Transfer, Event-Driven Consumer, Invalid Message Channel, Mailbox, Message Bus, Message Channel, Message Envelope, Message Filter, Message Store, Message Translator, Messaging Gateway, Pipes and Filters, Polling Consumer, Publish-Subscribe, Recipient List, Request-Reply, Resequencer, Routing Slip, Saga / Process Manager, Scatter-Gather, Service Activator, Splitter, Wire Tap | | Messaging Reliability | 3 | Idempotent Receiver, Inbox, Outbox | | Structural | 7 | Adapter, Bridge, Composite, Decorator, Facade, Flyweight, Proxy | @@ -516,6 +516,8 @@ BenchmarkDotNet guidance is documented in [docs/guides/benchmarks.md](docs/guide | Durable Subscriber | Execution | 711.81 ns | 3,912 B | 605.65 ns | 3,128 B | Generated reduced catch-up projection replay time and allocation in this microbenchmark. | | Dynamic Router | Construction | 213.4 ns | 1.29 KB | 214.0 ns | 1.29 KB | Fluent and generated route-table construction were effectively equivalent. | | Dynamic Router | Execution | 406.7 ns | 2.83 KB | 415.2 ns | 2.83 KB | Same allocation; fluent was slightly faster for fulfillment route-table dispatch. | +| Message Bus | Construction | 186.7 ns | 1,264 B | 160.0 ns | 904 B | Generated reduced construction time and allocation for topic bus topology setup. | +| Message Bus | Execution | 503.8 ns | 3,192 B | 587.7 ns | 3,232 B | Fluent was faster and allocated slightly less for publishing order events to topic subscribers. | | Decorator | Construction | 34.293 ns | 264 B | 17.669 ns | 168 B | Generated decorator composition was faster and allocated less. | | Decorator | Execution | 60.765 ns | 384 B | 35.551 ns | 304 B | Generated decorator execution was faster and allocated less for decorated storage reads. | | Domain Event | Construction | 199.5 ns | 1.34 KB | 157.6 ns | 1.04 KB | Generated reduced construction time and allocation in this microbenchmark. | diff --git a/benchmarks/PatternKit.Benchmarks/Messaging/MessageBusBenchmarks.cs b/benchmarks/PatternKit.Benchmarks/Messaging/MessageBusBenchmarks.cs new file mode 100644 index 00000000..51dd63bf --- /dev/null +++ b/benchmarks/PatternKit.Benchmarks/Messaging/MessageBusBenchmarks.cs @@ -0,0 +1,40 @@ +using BenchmarkDotNet.Attributes; +using PatternKit.Examples.Messaging; +using PatternKit.Messaging.Channels; + +namespace PatternKit.Benchmarks.Messaging; + +[BenchmarkCategory("EnterpriseIntegration", "Messaging", "MessageBus")] +public class MessageBusBenchmarks +{ + private static readonly BusOrderEvent[] Events = + [ + new("O-100", "accepted", 125m), + new("O-101", "paid", 250m) + ]; + + [Benchmark(Baseline = true, Description = "Fluent: create message bus")] + [BenchmarkCategory("Fluent", "Construction")] + public MessageBus Fluent_CreateMessageBus() + { + var fulfillment = MessageChannel.Create("fulfillment-orders").Build(); + var billing = MessageChannel.Create("billing-orders").Build(); + var audit = MessageChannel.Create("order-audit").Build(); + return OrderMessageBuses.Create(fulfillment, billing, audit); + } + + [Benchmark(Description = "Generated: create message bus")] + [BenchmarkCategory("Generated", "Construction")] + public MessageBus Generated_CreateMessageBus() + => GeneratedOrderMessageBus.Create(); + + [Benchmark(Description = "Fluent: publish order events")] + [BenchmarkCategory("Fluent", "Execution")] + public OrderMessageBusSummary Fluent_PublishOrderEvents() + => OrderMessageBusExampleRunner.RunFluent(Events); + + [Benchmark(Description = "Generated: publish order events")] + [BenchmarkCategory("Generated", "Execution")] + public OrderMessageBusSummary Generated_PublishOrderEvents() + => OrderMessageBusExampleRunner.RunGeneratedStatic(Events); +} diff --git a/docs/examples/order-message-bus.md b/docs/examples/order-message-bus.md new file mode 100644 index 00000000..33e721f3 --- /dev/null +++ b/docs/examples/order-message-bus.md @@ -0,0 +1,19 @@ +# Order Message Bus Example + +The order message bus example shows a production-oriented topic bus with standard dependency injection: + +- fluent `OrderMessageBuses.Create(...)` construction from application-owned channels; +- source-generated `GeneratedOrderMessageBus.Create()` topology; +- `AddOrderMessageBusDemo()` for `IServiceCollection` integration; +- TinyBDD coverage for fluent behavior, generated parity, direct DI registration, and aggregate `AddPatternKitExamples()` import. + +```csharp +var services = new ServiceCollection(); +services.AddOrderMessageBusDemo(); + +using var provider = services.BuildServiceProvider(validateScopes: true); +var runner = provider.GetRequiredService(); +var summary = runner.RunGenerated(orderEvents); +``` + +The service publishes accepted orders to fulfillment and audit subscribers, while paid orders flow to billing and audit subscribers. diff --git a/docs/generators/index.md b/docs/generators/index.md index 22b37d36..c8e00a3d 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -81,6 +81,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato |---|---|---| | [**Dispatcher**](dispatcher.md) | Mediator pattern with commands, notifications, and streams | `[GenerateDispatcher]` | | [**Message Channel**](message-channel.md) | Typed channel factories for in-process message queues | `[GenerateMessageChannel]` | +| [**Message Bus**](message-bus.md) | Topic bus topology factories over message channels | `[GenerateMessageBus]` | | [**Channel Purger**](channel-purger.md) | Channel maintenance purger factories for in-process message queues | `[GenerateChannelPurger]` | | [**Invalid Message Channel**](invalid-message-channel.md) | Invalid-message channel builder factories for validation boundaries | `[GenerateInvalidMessageChannel]` | | [**Polling Consumer**](polling-consumer.md) | Pull-based message consumer factories | `[GeneratePollingConsumer]` | diff --git a/docs/generators/message-bus.md b/docs/generators/message-bus.md new file mode 100644 index 00000000..52930c75 --- /dev/null +++ b/docs/generators/message-bus.md @@ -0,0 +1,23 @@ +# Message Bus Generator + +`[GenerateMessageBus]` emits a `MessageBus` factory from static channel factory methods marked with `[MessageBusRoute]`. + +```csharp +[GenerateMessageBus(typeof(BusOrderEvent), FactoryName = "Create", BusName = "order-bus")] +public static partial class GeneratedOrderMessageBus +{ + [MessageBusRoute("accepted")] + private static MessageChannel Fulfillment() + => MessageChannel.Create("fulfillment-orders").Build(); +} +``` + +The generated factory returns a normal `MessageBus`, so applications can still call `Subscribe` for runtime bolt-ons after the generated topology is created. + +Diagnostics: + +| ID | Meaning | +| --- | --- | +| `PKBUS001` | The host type must be partial. | +| `PKBUS002` | At least one `[MessageBusRoute]` method is required. | +| `PKBUS003` | Route methods must be static, parameterless, and return `MessageChannel`. | diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index e80cb94d..d8fbcdfd 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -100,6 +100,9 @@ - name: Message Channel href: message-channel.md +- name: Message Bus + href: message-bus.md + - name: Channel Purger href: channel-purger.md diff --git a/docs/guides/benchmark-results.md b/docs/guides/benchmark-results.md index 13fa0adc..4b46bcd5 100644 --- a/docs/guides/benchmark-results.md +++ b/docs/guides/benchmark-results.md @@ -63,6 +63,8 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 | Durable Subscriber | Execution | 711.81 ns | 3,912 B | 605.65 ns | 3,128 B | Generated reduced catch-up projection replay time and allocation in this microbenchmark. | | Dynamic Router | Construction | 213.4 ns | 1.29 KB | 214.0 ns | 1.29 KB | Fluent and generated route-table construction were effectively equivalent. | | Dynamic Router | Execution | 406.7 ns | 2.83 KB | 415.2 ns | 2.83 KB | Same allocation; fluent was slightly faster for fulfillment route-table dispatch. | +| Message Bus | Construction | 186.7 ns | 1,264 B | 160.0 ns | 904 B | Generated reduced construction time and allocation for topic bus topology setup. | +| Message Bus | Execution | 503.8 ns | 3,192 B | 587.7 ns | 3,232 B | Fluent was faster and allocated slightly less for publishing order events to topic subscribers. | | Decorator | Construction | 34.293 ns | 264 B | 17.669 ns | 168 B | Generated decorator composition was faster and allocated less. | | Decorator | Execution | 60.765 ns | 384 B | 35.551 ns | 304 B | Generated decorator execution was faster and allocated less for decorated storage reads. | | Domain Event | Construction | 199.5 ns | 1.34 KB | 157.6 ns | 1.04 KB | Generated reduced construction time and allocation in this microbenchmark. | @@ -202,7 +204,7 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 ## Coverage Matrix Summary -The coverage matrix currently publishes 92 catalog patterns and 368 pattern route results. Each pattern has four BenchmarkDotNet routes: fluent construction, fluent execution, source-generated construction, and source-generated execution. +The coverage matrix currently publishes 93 catalog patterns and 372 pattern route results. Each pattern has four BenchmarkDotNet routes: fluent construction, fluent execution, source-generated construction, and source-generated execution. | Category | Patterns | Published route results | | --- | ---: | ---: | @@ -214,7 +216,7 @@ The coverage matrix currently publishes 92 catalog patterns and 368 pattern rout | Messaging Reliability | 3 | 12 | | Structural | 7 | 28 | -The generator matrix currently publishes 88 generator source route results. +The generator matrix currently publishes 89 generator source route results. ## Pattern Matrix Results @@ -280,6 +282,7 @@ The generator matrix currently publishes 88 generator source route results. | Enterprise Integration | Dead Letter Channel | Covered | Covered | Covered | Covered | | Enterprise Integration | Durable Subscriber | Covered | Covered | Covered | Covered | | Enterprise Integration | Dynamic Router | Covered | Covered | Covered | Covered | +| Enterprise Integration | Message Bus | Covered | Covered | Covered | Covered | | Enterprise Integration | Event Notification | Covered | Covered | Covered | Covered | | Enterprise Integration | Event-Carried State Transfer | Covered | Covered | Covered | Covered | | Enterprise Integration | Event-Driven Consumer | Covered | Covered | Covered | Covered | @@ -366,8 +369,9 @@ The generator matrix currently publishes 88 generator source route results. | DurableSubscriberGenerator | `src/PatternKit.Generators/Messaging/DurableSubscriberGenerator.cs` | Covered | | DynamicRouterGenerator | `src/PatternKit.Generators/Messaging/DynamicRouterGenerator.cs` | Covered | | EventDrivenConsumerGenerator | `src/PatternKit.Generators/Messaging/EventDrivenConsumerGenerator.cs` | Covered | -| MailboxGenerator | `src/PatternKit.Generators/Messaging/MailboxGenerator.cs` | Covered | -| MessageChannelGenerator | `src/PatternKit.Generators/Messaging/MessageChannelGenerator.cs` | Covered | +| MailboxGenerator | `src/PatternKit.Generators/Messaging/MailboxGenerator.cs` | Covered | +| MessageBusGenerator | `src/PatternKit.Generators/Messaging/MessageBusGenerator.cs` | Covered | +| MessageChannelGenerator | `src/PatternKit.Generators/Messaging/MessageChannelGenerator.cs` | Covered | | MessageEnvelopeGenerator | `src/PatternKit.Generators/Messaging/MessageEnvelopeGenerator.cs` | Covered | | MessageFilterGenerator | `src/PatternKit.Generators/Messaging/MessageFilterGenerator.cs` | Covered | | MessageStoreGenerator | `src/PatternKit.Generators/Messaging/MessageStoreGenerator.cs` | Covered | diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 0d2d4e3d..153b360f 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -61,6 +61,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Enterprise Integration | Dead Letter Channel | `DeadLetterChannel` | Dead Letter Channel generator | | Enterprise Integration | Content-Based Router | `ContentRouter` | Messaging generator | | Enterprise Integration | Dynamic Router | `DynamicRouter` | Dynamic Router generator | +| Enterprise Integration | Message Bus | `MessageBus` | Message Bus generator | | Enterprise Integration | Message Filter | `MessageFilter` | Message Filter generator | | Enterprise Integration | Message Store | `MessageStore` | Message Store generator | | Enterprise Integration | Wire Tap | `WireTap` | Wire Tap generator | diff --git a/docs/patterns/messaging/README.md b/docs/patterns/messaging/README.md index e549e659..49907eb3 100644 --- a/docs/patterns/messaging/README.md +++ b/docs/patterns/messaging/README.md @@ -44,6 +44,12 @@ Durable subscribers catch up from a message store and checkpoint the last succes [Learn More](durable-subscriber.md) +## Message Bus + +Typed message buses publish messages to named topic subscribers backed by PatternKit message channels, with fluent and source-generated topology paths. + +[Learn More](message-bus.md) + ## Channel Adapter Channel adapters translate external transport DTOs into PatternKit message channels and translate outbound channel messages back to the transport shape. diff --git a/docs/patterns/messaging/message-bus.md b/docs/patterns/messaging/message-bus.md new file mode 100644 index 00000000..8becdfbc --- /dev/null +++ b/docs/patterns/messaging/message-bus.md @@ -0,0 +1,29 @@ +# Message Bus + +`MessageBus` provides a typed in-process topic bus over `MessageChannel` subscribers. Use it when an application needs one integration surface for publishing domain or integration events to multiple internal consumers without coupling publishers to channel instances. + +```csharp +var bus = MessageBus.Create("order-bus") + .Route("accepted", fulfillmentChannel) + .Route("accepted", auditChannel) + .Route("paid", billingChannel) + .Build(); + +var result = bus.Publish("accepted", Message.Create(orderAccepted)); +``` + +The fluent path supports runtime `Subscribe(topic, channel)` calls, so host bootstrapping can add tenant, module, or feature-specific routes from normal `IServiceCollection` registrations. + +The source-generated path uses `[GenerateMessageBus]` and `[MessageBusRoute]` to create a strongly typed topology factory: + +```csharp +[GenerateMessageBus(typeof(OrderEvent), BusName = "order-bus")] +public static partial class GeneratedOrderBus +{ + [MessageBusRoute("accepted")] + private static MessageChannel Accepted() + => MessageChannel.Create("accepted-orders").Build(); +} +``` + +`OrderMessageBusExample` demonstrates fluent and generated publishing. Import it with `AddOrderMessageBusDemo()` or the aggregate `AddPatternKitExamples()` registration. diff --git a/src/PatternKit.Core/Messaging/Channels/MessageChannel.cs b/src/PatternKit.Core/Messaging/Channels/MessageChannel.cs index bc6b6a68..6f75e04a 100644 --- a/src/PatternKit.Core/Messaging/Channels/MessageChannel.cs +++ b/src/PatternKit.Core/Messaging/Channels/MessageChannel.cs @@ -157,6 +157,132 @@ private MessageChannelReceiveResult(string channelName, bool received, Message Empty(string channelName) => new(channelName, false, null, 0); } +public sealed class MessageBus +{ + private readonly object _gate = new(); + private readonly Dictionary>> _routes; + + private MessageBus(string name, Dictionary>> routes) + => (Name, _routes) = (name, routes); + + public string Name { get; } + + public IReadOnlyList Topics + { + get + { + lock (_gate) + return _routes.Keys.OrderBy(static key => key, StringComparer.Ordinal).ToArray(); + } + } + + public void Subscribe(string topic, MessageChannel channel) + { + if (string.IsNullOrWhiteSpace(topic)) + throw new ArgumentException("Message bus topic cannot be null, empty, or whitespace.", nameof(topic)); + if (channel is null) + throw new ArgumentNullException(nameof(channel)); + + lock (_gate) + { + if (!_routes.TryGetValue(topic, out var channels)) + { + channels = []; + _routes[topic] = channels; + } + + channels.Add(channel); + } + } + + public MessageBusPublishResult Publish(string topic, Message message) + { + if (string.IsNullOrWhiteSpace(topic)) + throw new ArgumentException("Message bus topic cannot be null, empty, or whitespace.", nameof(topic)); + if (message is null) + throw new ArgumentNullException(nameof(message)); + + MessageChannel[] channels; + lock (_gate) + channels = _routes.TryGetValue(topic, out var routed) ? routed.ToArray() : []; + + var deliveries = new List(channels.Length); + foreach (var channel in channels) + { + var result = channel.Send(message); + deliveries.Add(new(channel.Name, result.Accepted, result.Count, result.RejectionReason)); + } + + return new(Name, topic, deliveries); + } + + public static Builder Create(string name = "message-bus") => new(name); + + public sealed class Builder + { + private readonly string _name; + private readonly Dictionary>> _routes = new(StringComparer.Ordinal); + + internal Builder(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Message bus name cannot be null, empty, or whitespace.", nameof(name)); + + _name = name; + } + + public Builder Route(string topic, MessageChannel channel) + { + if (string.IsNullOrWhiteSpace(topic)) + throw new ArgumentException("Message bus topic cannot be null, empty, or whitespace.", nameof(topic)); + if (channel is null) + throw new ArgumentNullException(nameof(channel)); + + if (!_routes.TryGetValue(topic, out var channels)) + { + channels = []; + _routes[topic] = channels; + } + + channels.Add(channel); + return this; + } + + public MessageBus Build() + => new(_name, _routes.ToDictionary(static route => route.Key, static route => route.Value.ToList(), StringComparer.Ordinal)); + } +} + +public sealed class MessageBusDelivery +{ + public MessageBusDelivery(string channelName, bool accepted, int count, string? rejectionReason) + => (ChannelName, Accepted, Count, RejectionReason) = (channelName, accepted, count, rejectionReason); + + public string ChannelName { get; } + + public bool Accepted { get; } + + public int Count { get; } + + public string? RejectionReason { get; } +} + +public sealed class MessageBusPublishResult +{ + public MessageBusPublishResult(string busName, string topic, IReadOnlyList deliveries) + => (BusName, Topic, Deliveries) = (busName, topic, deliveries); + + public string BusName { get; } + + public string Topic { get; } + + public IReadOnlyList Deliveries { get; } + + public int AcceptedCount => Deliveries.Count(static delivery => delivery.Accepted); + + public int RejectedCount => Deliveries.Count(static delivery => !delivery.Accepted); +} + public sealed class ChannelPurger { private readonly MessageChannel _channel; diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index b0a2dc09..3ab62c13 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -164,6 +164,7 @@ public sealed record OrderMessageFilterExampleService(MessageFilter Store, OrderMessageStoreService Service); public sealed record OrderDurableSubscriberExampleService(DurableSubscriber Subscriber, OrderDurableSubscriberService Service); public sealed record OrderDynamicRouterExampleService(DynamicRouter Router, FulfillmentRoutingService Service); +public sealed record OrderMessageBusExampleService(MessageBus Bus, OrderMessageBusExampleRunner Runner); public sealed record OrderWireTapExampleService(WireTap Tap, OrderWireTapService Service); public sealed record FulfillmentControlBusExampleService(ControlBus Bus, FulfillmentControlBusService Service); public sealed record SupplierQuoteScatterGatherExampleService(ScatterGather ScatterGather, SupplierQuoteService Service); @@ -265,6 +266,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddOrderMessageStoreExample() .AddOrderDurableSubscriberExample() .AddOrderDynamicRouterExample() + .AddOrderMessageBusExample() .AddOrderWireTapExample() .AddFulfillmentControlBusExample() .AddSupplierQuoteScatterGatherExample() @@ -700,6 +702,15 @@ public static IServiceCollection AddOrderDynamicRouterExample(this IServiceColle return services.RegisterExample("Order Dynamic Router", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddOrderMessageBusExample(this IServiceCollection services) + { + services.AddOrderMessageBusDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService())); + return services.RegisterExample("Order Message Bus", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddOrderWireTapExample(this IServiceCollection services) { services.AddOrderWireTapDemo(); diff --git a/src/PatternKit.Examples/Messaging/OrderMessageBusExample.cs b/src/PatternKit.Examples/Messaging/OrderMessageBusExample.cs new file mode 100644 index 00000000..7107dd61 --- /dev/null +++ b/src/PatternKit.Examples/Messaging/OrderMessageBusExample.cs @@ -0,0 +1,109 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; +using PatternKit.Messaging.Channels; + +namespace PatternKit.Examples.Messaging; + +public sealed record BusOrderEvent(string OrderId, string Status, decimal Total); + +public sealed record OrderMessageBusSummary(int FulfillmentCount, int BillingCount, int AuditCount, IReadOnlyList Topics); + +public sealed record OrderMessageBusChannels( + MessageChannel Fulfillment, + MessageChannel Billing, + MessageChannel Audit); + +public sealed class OrderMessageBusService( + MessageBus bus, + OrderMessageBusChannels channels) +{ + public OrderMessageBusSummary Publish(IEnumerable events) + { + foreach (var orderEvent in events) + { + var topic = orderEvent.Status == "paid" ? "paid" : "accepted"; + bus.Publish(topic, Message.Create(orderEvent).WithCorrelationId(orderEvent.OrderId)); + } + + return new(channels.Fulfillment.Count, channels.Billing.Count, channels.Audit.Count, bus.Topics); + } +} + +public static class OrderMessageBuses +{ + public static MessageBus Create( + MessageChannel fulfillment, + MessageChannel billing, + MessageChannel audit) + => MessageBus.Create("order-bus") + .Route("accepted", fulfillment) + .Route("accepted", audit) + .Route("paid", billing) + .Route("paid", audit) + .Build(); +} + +[GenerateMessageBus(typeof(BusOrderEvent), FactoryName = "Create", BusName = "order-bus")] +public static partial class GeneratedOrderMessageBus +{ + private static readonly MessageChannel Fulfillment = MessageChannel.Create("fulfillment-orders").Build(); + private static readonly MessageChannel Billing = MessageChannel.Create("billing-orders").Build(); + private static readonly MessageChannel Audit = MessageChannel.Create("order-audit").Build(); + + [MessageBusRoute("accepted")] + private static MessageChannel AcceptedFulfillment() => Fulfillment; + + [MessageBusRoute("accepted")] + private static MessageChannel AcceptedAudit() => Audit; + + [MessageBusRoute("paid")] + private static MessageChannel PaidBilling() => Billing; + + [MessageBusRoute("paid")] + private static MessageChannel PaidAudit() => Audit; + + public static (MessageChannel Fulfillment, MessageChannel Billing, MessageChannel Audit) Channels() + => (Fulfillment, Billing, Audit); +} + +public sealed class OrderMessageBusExampleRunner(OrderMessageBusService service) +{ + public OrderMessageBusSummary RunGenerated(IEnumerable events) => service.Publish(events); + + public static OrderMessageBusSummary RunFluent(IEnumerable events) + { + var fulfillment = MessageChannel.Create("fulfillment-orders").Build(); + var billing = MessageChannel.Create("billing-orders").Build(); + var audit = MessageChannel.Create("order-audit").Build(); + var channels = new OrderMessageBusChannels(fulfillment, billing, audit); + return new OrderMessageBusService(OrderMessageBuses.Create(fulfillment, billing, audit), channels).Publish(events); + } + + public static OrderMessageBusSummary RunGeneratedStatic(IEnumerable events) + { + var channels = GeneratedOrderMessageBus.Channels(); + channels.Fulfillment.Drain(); + channels.Billing.Drain(); + channels.Audit.Drain(); + return new OrderMessageBusService(GeneratedOrderMessageBus.Create(), new(channels.Fulfillment, channels.Billing, channels.Audit)).Publish(events); + } +} + +public static class OrderMessageBusServiceCollectionExtensions +{ + public static IServiceCollection AddOrderMessageBusDemo(this IServiceCollection services) + { + services.AddSingleton(_ => new OrderMessageBusChannels( + MessageChannel.Create("fulfillment-orders").Build(), + MessageChannel.Create("billing-orders").Build(), + MessageChannel.Create("order-audit").Build())); + services.AddSingleton(sp => OrderMessageBuses.Create( + sp.GetRequiredService().Fulfillment, + sp.GetRequiredService().Billing, + sp.GetRequiredService().Audit)); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 8a756503..d14b6a02 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -400,6 +400,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["DynamicRouter"], ["runtime route replacement", "source-generated initial route table", "DI composition"]), + Descriptor( + "Order Message Bus", + "src/PatternKit.Examples/Messaging/OrderMessageBusExample.cs", + "test/PatternKit.Examples.Tests/Messaging/OrderMessageBusExampleTests.cs", + "docs/examples/order-message-bus.md", + ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["MessageBus", "MessageChannel"], + ["topic bus", "source-generated topology", "DI composition"]), Descriptor( "Order Wire Tap", "src/PatternKit.Examples/Messaging/OrderWireTapExample.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index bc3a9c32..2c76f009 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -623,6 +623,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/Messaging/OrderDynamicRouterExampleTests.cs", ["fluent runtime route table", "generated initial route table", "DI-importable fulfillment routing example"]), + Pattern("Message Bus", PatternFamily.EnterpriseIntegration, + "docs/patterns/messaging/message-bus.md", + "src/PatternKit.Core/Messaging/Channels/MessageChannel.cs", + "test/PatternKit.Tests/Messaging/Channels/MessageBusTests.cs", + "docs/generators/message-bus.md", + "src/PatternKit.Generators/Messaging/MessageBusGenerator.cs", + "test/PatternKit.Generators.Tests/MessageBusGeneratorTests.cs", + null, + "docs/examples/order-message-bus.md", + "src/PatternKit.Examples/Messaging/OrderMessageBusExample.cs", + "test/PatternKit.Examples.Tests/Messaging/OrderMessageBusExampleTests.cs", + ["fluent topic bus", "generated bus topology", "DI-importable order event bus example"]), + Pattern("Wire Tap", PatternFamily.EnterpriseIntegration, "docs/patterns/messaging/wire-tap.md", "src/PatternKit.Core/Messaging/Routing/WireTap.cs", diff --git a/src/PatternKit.Generators.Abstractions/Messaging/MessageChannelAttributes.cs b/src/PatternKit.Generators.Abstractions/Messaging/MessageChannelAttributes.cs index 5950e21e..32a06aa7 100644 --- a/src/PatternKit.Generators.Abstractions/Messaging/MessageChannelAttributes.cs +++ b/src/PatternKit.Generators.Abstractions/Messaging/MessageChannelAttributes.cs @@ -18,3 +18,30 @@ public GenerateMessageChannelAttribute(Type payloadType) public string BackpressurePolicy { get; set; } = "Reject"; } + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateMessageBusAttribute : Attribute +{ + public GenerateMessageBusAttribute(Type payloadType) + => PayloadType = payloadType ?? throw new ArgumentNullException(nameof(payloadType)); + + public Type PayloadType { get; } + + public string FactoryName { get; set; } = "Create"; + + public string BusName { get; set; } = "message-bus"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public sealed class MessageBusRouteAttribute : Attribute +{ + public MessageBusRouteAttribute(string topic) + { + if (string.IsNullOrWhiteSpace(topic)) + throw new ArgumentException("Message bus topic cannot be null, empty, or whitespace.", nameof(topic)); + + Topic = topic; + } + + public string Topic { get; } +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 0e788537..088731df 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -370,6 +370,9 @@ PKDR002 | PatternKit.Generators.Messaging | Error | Dynamic Router must declare PKDR003 | PatternKit.Generators.Messaging | Error | Dynamic Router route signature is invalid. PKDR004 | PatternKit.Generators.Messaging | Error | Dynamic Router default signature is invalid. PKDR005 | PatternKit.Generators.Messaging | Error | Dynamic Router route name or order is duplicated. +PKBUS001 | PatternKit.Generators.Messaging | Error | Message Bus host type must be partial. +PKBUS002 | PatternKit.Generators.Messaging | Error | Message Bus must declare at least one route. +PKBUS003 | PatternKit.Generators.Messaging | Error | Message Bus route signature is invalid. PKCAD001 | PatternKit.Generators.Messaging | Error | Channel Adapter host type must be partial. PKCAD002 | PatternKit.Generators.Messaging | Error | Channel Adapter must declare exactly one inbound translator. PKCAD003 | PatternKit.Generators.Messaging | Error | Channel Adapter must declare exactly one outbound translator. diff --git a/src/PatternKit.Generators/Messaging/MessageBusGenerator.cs b/src/PatternKit.Generators/Messaging/MessageBusGenerator.cs new file mode 100644 index 00000000..05dacf50 --- /dev/null +++ b/src/PatternKit.Generators/Messaging/MessageBusGenerator.cs @@ -0,0 +1,122 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Immutable; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.Messaging; + +[Generator] +public sealed class MessageBusGenerator : IIncrementalGenerator +{ + private static readonly DiagnosticDescriptor MustBePartial = new("PKBUS001", "Message bus type must be partial", "Type '{0}' is marked with [GenerateMessageBus] but is not declared as partial", "PatternKit.Generators.Messaging", DiagnosticSeverity.Error, true); + private static readonly DiagnosticDescriptor MissingRoutes = new("PKBUS002", "Message bus has no routes", "Type '{0}' is marked with [GenerateMessageBus] but does not declare any [MessageBusRoute] methods", "PatternKit.Generators.Messaging", DiagnosticSeverity.Error, true); + private static readonly DiagnosticDescriptor InvalidRoute = new("PKBUS003", "Message bus route signature is invalid", "Message bus route '{0}' must be static and return MessageChannel", "PatternKit.Generators.Messaging", DiagnosticSeverity.Error, true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + "PatternKit.Generators.Messaging.GenerateMessageBusAttribute", + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.GenerateMessageBusAttribute"); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol type, TypeDeclarationSyntax node, AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var payloadType = attribute.ConstructorArguments.Length > 0 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + if (payloadType is null) + return; + + var hasRouteAttributes = type.GetMembers().OfType().Any(static method => + method.GetAttributes().Any(static attr => attr.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.MessageBusRouteAttribute")); + var routes = GetRoutes(type, payloadType, context); + if (routes.Length == 0) + { + if (!hasRouteAttributes) + context.ReportDiagnostic(Diagnostic.Create(MissingRoutes, node.Identifier.GetLocation(), type.Name)); + return; + } + + var factoryName = GetNamedString(attribute, "FactoryName") ?? "Create"; + var busName = GetNamedString(attribute, "BusName") ?? "message-bus"; + context.AddSource($"{type.Name}.MessageBus.g.cs", SourceText.From(GenerateSource(type, payloadType, routes, factoryName, busName), Encoding.UTF8)); + } + + private static ImmutableArray GetRoutes(INamedTypeSymbol type, INamedTypeSymbol payloadType, SourceProductionContext context) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var method in type.GetMembers().OfType()) + { + foreach (var attr in method.GetAttributes().Where(static a => a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.MessageBusRouteAttribute")) + { + if (!method.IsStatic || !ReturnsMessageChannel(method, payloadType) || attr.ConstructorArguments.Length != 1 || attr.ConstructorArguments[0].Value is not string topic || string.IsNullOrWhiteSpace(topic)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidRoute, method.Locations.FirstOrDefault(), method.Name)); + continue; + } + + builder.Add(new(topic, method.Name)); + } + } + + return builder.ToImmutable(); + } + + private static bool ReturnsMessageChannel(IMethodSymbol method, INamedTypeSymbol payloadType) + => method.Parameters.Length == 0 && + method.ReturnType is INamedTypeSymbol named && + named.ConstructedFrom.ToDisplayString() == "PatternKit.Messaging.Channels.MessageChannel" && + SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], payloadType); + + private static string GenerateSource(INamedTypeSymbol type, INamedTypeSymbol payloadType, ImmutableArray routes, string factoryName, string busName) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Messaging.Channels.MessageBus<") + .Append(payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append("> ").Append(factoryName).AppendLine("()"); + sb.Append(" => global::PatternKit.Messaging.Channels.MessageBus<") + .Append(payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append(">.Create(").Append(ToLiteral(busName)).AppendLine(")"); + + foreach (var route in routes) + sb.Append(" .Route(").Append(ToLiteral(route.Topic)).Append(", ").Append(route.MethodName).AppendLine("())"); + + sb.AppendLine(" .Build();"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static string ToLiteral(string value) => "@\"" + value.Replace("\"", "\"\"") + "\""; + + private readonly record struct Route(string Topic, string MethodName); +} diff --git a/test/PatternKit.Examples.Tests/Messaging/OrderMessageBusExampleTests.cs b/test/PatternKit.Examples.Tests/Messaging/OrderMessageBusExampleTests.cs new file mode 100644 index 00000000..6570d362 --- /dev/null +++ b/test/PatternKit.Examples.Tests/Messaging/OrderMessageBusExampleTests.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.Messaging; +using PatternKit.Messaging.Channels; +using TinyBDD; + +namespace PatternKit.Examples.Tests.Messaging; + +public sealed class OrderMessageBusExampleTests +{ + [Scenario("Fluent bus publishes to topic subscribers")] + [Fact] + public void Fluent_Bus_Publishes_To_Topic_Subscribers() + { + var summary = OrderMessageBusExampleRunner.RunFluent(CreateEvents()); + + ScenarioExpect.Equal(1, summary.FulfillmentCount); + ScenarioExpect.Equal(1, summary.BillingCount); + ScenarioExpect.Equal(2, summary.AuditCount); + ScenarioExpect.Equal(["accepted", "paid"], summary.Topics); + } + + [Scenario("Generated bus matches fluent topology")] + [Fact] + public void Generated_Bus_Matches_Fluent_Topology() + { + var fluent = OrderMessageBusExampleRunner.RunFluent(CreateEvents()); + var generated = OrderMessageBusExampleRunner.RunGeneratedStatic(CreateEvents()); + + ScenarioExpect.Equal(fluent.FulfillmentCount, generated.FulfillmentCount); + ScenarioExpect.Equal(fluent.BillingCount, generated.BillingCount); + ScenarioExpect.Equal(fluent.AuditCount, generated.AuditCount); + ScenarioExpect.Equal(fluent.Topics, generated.Topics); + } + + [Scenario("ServiceCollection imports message bus example")] + [Fact] + public void ServiceCollection_Imports_MessageBusExample() + { + var services = new ServiceCollection(); + services.AddOrderMessageBusDemo(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var runner = provider.GetRequiredService(); + var summary = runner.RunGenerated(CreateEvents()); + + ScenarioExpect.Equal(2, summary.AuditCount); + ScenarioExpect.NotNull(provider.GetRequiredService>()); + } + + [Scenario("Aggregate examples import message bus example")] + [Fact] + public void Aggregate_Examples_Import_MessageBusExample() + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var example = provider.GetRequiredService(); + var summary = example.Runner.RunGenerated(CreateEvents()); + + ScenarioExpect.Equal(1, summary.FulfillmentCount); + ScenarioExpect.NotNull(example.Bus); + } + + private static BusOrderEvent[] CreateEvents() + => + [ + new("O-100", "accepted", 125m), + new("O-101", "paid", 250m) + ]; +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs index 7157143b..060f31a0 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs @@ -88,7 +88,7 @@ public Task Published_Benchmark_Results_Include_Every_Catalog_Pattern() .Then("every catalog pattern appears in the benchmark results matrix", ctx => ScenarioExpect.Empty(ctx.MissingPatterns)) .And("the guide publishes the route result total", ctx => - ScenarioExpect.Contains("368 pattern route results", ctx.ResultsGuide)) + ScenarioExpect.Contains("372 pattern route results", ctx.ResultsGuide)) .AssertPassed(); [Scenario("Published benchmark results include every generator source")] diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 3b917d82..7ddf0c57 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -55,6 +55,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Dead Letter Channel", "Durable Subscriber", "Dynamic Router", + "Message Bus", "Content-Based Router", "Message Filter", "Message Store", @@ -147,7 +148,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(EnterprisePatternAdditions.OrderBy(static x => x), patterns.Select(static p => p.Name).OrderBy(static x => x))) .And("enterprise entries are grouped by integration reliability and architecture families", patterns => { - ScenarioExpect.Equal(34, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); + ScenarioExpect.Equal(35, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(17, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); ScenarioExpect.Equal(15, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 40d39410..a678c0a9 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -156,6 +156,8 @@ private enum TestTrigger { typeof(TraversalChildrenAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateDispatcherAttribute), AttributeTargets.Assembly, false, true }, { typeof(GenerateMessageChannelAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(GenerateMessageBusAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(MessageBusRouteAttribute), AttributeTargets.Method, true, false }, { typeof(GenerateChannelPurgerAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GenerateInvalidMessageChannelAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GeneratePollingConsumerAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, @@ -1123,6 +1125,12 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf Capacity = 12, BackpressurePolicy = "DropOldest" }; + var messageBus = new GenerateMessageBusAttribute(typeof(string)) + { + FactoryName = "BuildBus", + BusName = "orders" + }; + var messageBusRoute = new MessageBusRouteAttribute("accepted"); var channelPurger = new GenerateChannelPurgerAttribute(typeof(string)) { FactoryName = "BuildPurger", @@ -1458,6 +1466,12 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Equal("content-type", translatorHeader.Name); ScenarioExpect.Equal("application/vnd.demo+json", translatorHeader.Value); ScenarioExpect.Throws(() => new GenerateMessageChannelAttribute(null!)); + ScenarioExpect.Equal(typeof(string), messageBus.PayloadType); + ScenarioExpect.Equal("BuildBus", messageBus.FactoryName); + ScenarioExpect.Equal("orders", messageBus.BusName); + ScenarioExpect.Equal("accepted", messageBusRoute.Topic); + ScenarioExpect.Throws(() => new GenerateMessageBusAttribute(null!)); + ScenarioExpect.Throws(() => new MessageBusRouteAttribute("")); ScenarioExpect.Throws(() => new GenerateChannelPurgerAttribute(null!)); ScenarioExpect.Throws(() => new GenerateInvalidMessageChannelAttribute(null!)); ScenarioExpect.Throws(() => new GeneratePollingConsumerAttribute(null!)); diff --git a/test/PatternKit.Generators.Tests/MessageBusGeneratorTests.cs b/test/PatternKit.Generators.Tests/MessageBusGeneratorTests.cs new file mode 100644 index 00000000..f44cba51 --- /dev/null +++ b/test/PatternKit.Generators.Tests/MessageBusGeneratorTests.cs @@ -0,0 +1,87 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.Messaging; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class MessageBusGeneratorTests +{ + [Scenario("GeneratesMessageBusFactory")] + [Fact] + public void GeneratesMessageBusFactory() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging.Channels; + namespace MyApp; + public sealed record OrderEvent(string OrderId); + [GenerateMessageBus(typeof(OrderEvent), FactoryName = "Build", BusName = "orders")] + public static partial class OrderBus + { + [MessageBusRoute("accepted")] + private static MessageChannel Accepted() + => MessageChannel.Create("accepted-orders").Build(); + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesMessageBusFactory)); + _ = RoslynTestHelpers.Run(comp, new MessageBusGenerator(), out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("MessageBus", text); + ScenarioExpect.Contains(".Route(@\"accepted\", Accepted())", text); + ScenarioExpect.True(updated.Emit(Stream.Null).Success); + } + + [Scenario("ReportsMessageBusDiagnostics")] + [Theory] + [InlineData("public static class OrderBus { }", "PKBUS001")] + [InlineData("public static partial class OrderBus { }", "PKBUS002")] + public void ReportsMessageBusDiagnostics(string declaration, string expected) + { + var source = $$""" + using PatternKit.Generators.Messaging; + namespace MyApp; + public sealed record OrderEvent(string OrderId); + [GenerateMessageBus(typeof(OrderEvent))] + {{declaration}} + """; + + var comp = CreateCompilation(source, nameof(ReportsMessageBusDiagnostics) + expected); + _ = RoslynTestHelpers.Run(comp, new MessageBusGenerator(), out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal(expected, diagnostic.Id); + } + + [Scenario("ReportsInvalidRouteDiagnostics")] + [Fact] + public void ReportsInvalidRouteDiagnostics() + { + var source = """ + using PatternKit.Generators.Messaging; + namespace MyApp; + public sealed record OrderEvent(string OrderId); + [GenerateMessageBus(typeof(OrderEvent))] + public static partial class OrderBus + { + [MessageBusRoute("accepted")] + private static string Accepted() => "bad"; + } + """; + + var comp = CreateCompilation(source, nameof(ReportsInvalidRouteDiagnostics)); + _ = RoslynTestHelpers.Run(comp, new MessageBusGenerator(), out var run, out _); + + ScenarioExpect.Equal("PKBUS003", ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)).Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: MetadataReference.CreateFromFile(typeof(global::PatternKit.Messaging.Channels.MessageBus<>).Assembly.Location)); +} diff --git a/test/PatternKit.Tests/Messaging/Channels/MessageBusTests.cs b/test/PatternKit.Tests/Messaging/Channels/MessageBusTests.cs new file mode 100644 index 00000000..9ca60b2a --- /dev/null +++ b/test/PatternKit.Tests/Messaging/Channels/MessageBusTests.cs @@ -0,0 +1,76 @@ +using PatternKit.Messaging; +using PatternKit.Messaging.Channels; +using TinyBDD; + +namespace PatternKit.Tests.Messaging.Channels; + +public sealed class MessageBusTests +{ + [Scenario("Publish DeliversToAllSubscribedChannels")] + [Fact] + public void Publish_DeliversToAllSubscribedChannels() + { + var fulfillment = MessageChannel.Create("fulfillment").Build(); + var audit = MessageChannel.Create("audit").Build(); + var bus = MessageBus.Create("orders") + .Route("accepted", fulfillment) + .Route("accepted", audit) + .Build(); + + var result = bus.Publish("accepted", Message.Create(new("O-100", "accepted"))); + + ScenarioExpect.Equal(2, result.AcceptedCount); + ScenarioExpect.Equal(0, result.RejectedCount); + ScenarioExpect.Equal("O-100", fulfillment.TryReceive().Message!.Payload.OrderId); + ScenarioExpect.Equal("O-100", audit.TryReceive().Message!.Payload.OrderId); + } + + [Scenario("Publish CapturesRejectedDeliveries")] + [Fact] + public void Publish_CapturesRejectedDeliveries() + { + var bounded = MessageChannel.Create("bounded").WithCapacity(1).Build(); + var bus = MessageBus.Create().Route("accepted", bounded).Build(); + + bus.Publish("accepted", Message.Create(new("O-100", "accepted"))); + var result = bus.Publish("accepted", Message.Create(new("O-101", "accepted"))); + + var delivery = ScenarioExpect.Single(result.Deliveries); + ScenarioExpect.False(delivery.Accepted); + ScenarioExpect.Equal("Channel capacity has been reached.", delivery.RejectionReason); + ScenarioExpect.Equal(1, result.RejectedCount); + } + + [Scenario("Bus AllowsRuntimeSubscriptions")] + [Fact] + public void Bus_AllowsRuntimeSubscriptions() + { + var bus = MessageBus.Create("orders").Build(); + var audit = MessageChannel.Create("audit").Build(); + + bus.Subscribe("accepted", audit); + var result = bus.Publish("accepted", Message.Create(new("O-100", "accepted"))); + + ScenarioExpect.Equal(["accepted"], bus.Topics); + ScenarioExpect.Equal(1, result.AcceptedCount); + ScenarioExpect.Equal("O-100", audit.TryReceive().Message!.Payload.OrderId); + } + + [Scenario("Builder RejectsInvalidConfiguration")] + [Fact] + public void Builder_RejectsInvalidConfiguration() + { + var channel = MessageChannel.Create("orders").Build(); + var bus = MessageBus.Create().Build(); + + ScenarioExpect.Throws(() => MessageBus.Create("")); + ScenarioExpect.Throws(() => MessageBus.Create().Route("", channel)); + ScenarioExpect.Throws(() => MessageBus.Create().Route("accepted", null!)); + ScenarioExpect.Throws(() => bus.Subscribe("", channel)); + ScenarioExpect.Throws(() => bus.Subscribe("accepted", null!)); + ScenarioExpect.Throws(() => bus.Publish("", Message.Create(new("O-100", "accepted")))); + ScenarioExpect.Throws(() => bus.Publish("accepted", null!)); + } + + public sealed record OrderEvent(string OrderId, string Status); +}