diff --git a/docs/examples/messaging-backplane-facade.md b/docs/examples/messaging-backplane-facade.md index 3117206..c87d144 100644 --- a/docs/examples/messaging-backplane-facade.md +++ b/docs/examples/messaging-backplane-facade.md @@ -44,28 +44,33 @@ The example uses `InMemoryBackplaneTransport` for deterministic tests. A product The demo now starts from a host builder, which is the shape a production application would usually expose from its composition root: ```csharp -await using var host = await BackplaneHost.Create() - .UseTransport(() => transport) - .UseOutbox(outbox) - .UseIdempotencyStore(idempotency) - .MapCommand( - static (message, _) => message.Payload.CustomerTier == CustomerTier.Vip, - "orders.priority") - .MapDefaultCommand("orders.standard") - .ReceiveEndpoint("orders.standard", endpoint => - endpoint.HandleCommand(services.AcceptStandardOrderAsync)) - .ReceiveEndpoint("billing-service", endpoint => - endpoint.Subscribe("orders.submitted", services.CapturePaymentAsync)) - .ReceiveEndpoint("notification-service", endpoint => - { - endpoint.Subscribe("payments.declined", services.NotifyPaymentDeclinedAsync); - endpoint.Subscribe("shipments.scheduled", services.NotifyShipmentScheduledAsync); - }) +await using var host = await GeneratedBackplaneTopology.Configure( + BackplaneHost.Create() + .UseTransport(() => transport) + .UseOutbox(outbox) + .UseIdempotencyStore(idempotency), + services) .BuildAsync(cancellationToken); ``` `BackplaneHost` owns the bus, typed client, transport, endpoint subscriptions, outbox, idempotency store, and topology metadata. Application code uses `host.Client`, while advanced integrations can still reach the lower-level `host.Bus`. +The generated topology comes from declarative attributes on a partial class: + +```csharp +[GenerateBackplaneTopology(typeof(BackplaneDemoServices), HostBuilderType = typeof(BackplaneHostBuilder))] +[BackplaneRequestReply(typeof(SubmitOrder), typeof(BackplaneOrderAccepted), "orders.priority", nameof(BackplaneDemoServices.AcceptPriorityOrderAsync), PredicateMethodName = nameof(IsVipOrder))] +[BackplaneRequestReply(typeof(SubmitOrder), typeof(BackplaneOrderAccepted), "orders.standard", nameof(BackplaneDemoServices.AcceptStandardOrderAsync))] +[BackplaneSubscription(typeof(BackplaneOrderSubmitted), "orders.submitted", "billing-service", nameof(BackplaneDemoServices.CapturePaymentAsync))] +public static partial class GeneratedBackplaneTopology +{ + private static bool IsVipOrder(Message message, MessageContext context) + => message.Payload.CustomerTier == CustomerTier.Vip; +} +``` + +The same host builder also remains fluent, so applications can mix generated, reviewed topology with environment-specific transport, outbox, idempotency, and observability wiring. + ## Request/Reply The client exposes a typed request/reply API: @@ -118,7 +123,9 @@ BackplaneHost.Create() The tests assert that: - Standard orders route to `orders.standard` and VIP orders route to `orders.priority`. +- The generated topology registers request/reply routes and publish/subscribe endpoints. - The host builder configures transport, outbox, idempotency, endpoint topology, and the typed client surface. +- The example can be imported through `IServiceCollection` with `AddMessagingBackplaneFacadeExample`. - Duplicate commands replay the original response and do not duplicate outbox side effects. - Published events fan out to independent services. - Every event is recorded in the outbox before transport dispatch. diff --git a/docs/generators/index.md b/docs/generators/index.md index 82dae97..a6ff1a2 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -72,6 +72,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Saga**](messaging.md#generated-saga) | Typed process-manager transition factories | `[GenerateSaga]` | | [**Mailbox**](messaging.md#generated-mailbox) | Serialized in-process inbox factories | `[GenerateMailbox]` | | [**Reliability Pipeline**](messaging.md#generated-reliability-pipeline) | Idempotent receiver, inbox, and outbox factories | `[GenerateReliabilityPipeline]` | +| [**Backplane Topology**](messaging.md#generated-backplane-topology) | Request/reply routes and publish/subscribe endpoint topology | `[GenerateBackplaneTopology]` | ## Quick Reference @@ -165,6 +166,12 @@ public static partial class OrderMailbox { } [GenerateReliabilityPipeline(typeof(AcceptOrder), typeof(string), typeof(OrderAccepted))] public static partial class OrderReliability { } +// Backplane topology - generated request/reply and pub/sub host wiring +[GenerateBackplaneTopology(typeof(OrderBackplaneServices), HostBuilderType = typeof(OrderBackplaneHostBuilder))] +[BackplaneRequestReply(typeof(SubmitOrder), typeof(OrderAccepted), "orders", nameof(OrderBackplaneServices.AcceptAsync))] +[BackplaneSubscription(typeof(OrderSubmitted), "orders.submitted", "audit-service", nameof(OrderBackplaneServices.AuditAsync))] +public static partial class OrderBackplane { } + // Routing slip - generated ordered itinerary factory [GenerateRoutingSlip(typeof(Order))] public static partial class OrderSlip { } diff --git a/docs/generators/messaging.md b/docs/generators/messaging.md index b657859..48adf89 100644 --- a/docs/generators/messaging.md +++ b/docs/generators/messaging.md @@ -1,6 +1,6 @@ # Messaging Generators -PatternKit includes nine messaging-oriented source generators: +PatternKit includes ten messaging-oriented source generators: - for source-generated mediator dispatchers. - for required message-envelope contracts. @@ -11,6 +11,7 @@ PatternKit includes nine messaging-oriented source generators: - for typed saga/process-manager factories. - for serialized in-process inbox factories. - for idempotent receiver, inbox, and outbox factories. +- for compile-time request/reply and publish/subscribe host topology. Use these generators when the message topology is known at compile time and should remain explicit, AOT-friendly, and validated by the compiler. They generate factories and fluent builders; they do not discover handlers from assemblies at runtime and they do not replace brokers, durable queues, or workflow engines. @@ -235,6 +236,33 @@ Example files: - `src/PatternKit.Examples/Messaging/ReliabilityExample.cs` - `test/PatternKit.Examples.Tests/Messaging/ReliabilityExampleTests.cs` +## Generated Backplane Topology + +`[GenerateBackplaneTopology]` wires request/reply routes and publish/subscribe endpoints into a `BackplaneHostBuilder` from declarative attributes: + +```csharp +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; + +[GenerateBackplaneTopology(typeof(OrderBackplaneServices), HostBuilderType = typeof(OrderBackplaneHostBuilder))] +[BackplaneRequestReply(typeof(SubmitOrder), typeof(OrderAccepted), "orders.priority", nameof(OrderBackplaneServices.AcceptPriorityAsync), PredicateMethodName = nameof(IsPriority))] +[BackplaneRequestReply(typeof(SubmitOrder), typeof(OrderAccepted), "orders.standard", nameof(OrderBackplaneServices.AcceptStandardAsync))] +[BackplaneSubscription(typeof(OrderSubmitted), "orders.submitted", "billing-service", nameof(OrderBackplaneServices.CapturePaymentAsync))] +public static partial class OrderBackplane +{ + private static bool IsPriority(Message message, MessageContext context) + => message.Payload.CustomerTier == CustomerTier.Vip; +} +``` + +The generated `Configure` method applies content-router request routes before default routes, registers command handlers, and registers topic subscriptions. It keeps broker infrastructure application-owned: the caller still supplies the transport, outbox, idempotency store, service object, and host-builder type used at the composition root. + +Example files: + +- `src/PatternKit.Examples/Messaging/BackplaneFacadeDemo.cs` +- `test/PatternKit.Examples.Tests/Messaging/BackplaneFacadeDemoTests.cs` +- `test/PatternKit.Generators.Tests/BackplaneTopologyGeneratorTests.cs` + ## Generated Saga `[GenerateSaga]` emits a process-manager factory from typed transition methods: @@ -276,6 +304,7 @@ Example source: | `PKSG001`-`PKSG004` | Saga | Non-partial host, missing transitions, invalid transition signatures, or invalid completion checks. | | `PKMB001`-`PKMB005` | Mailbox | Non-partial host, missing handler, invalid handler signatures, or invalid configuration. | | `PKRP001`-`PKRP005` | Reliability Pipeline | Non-partial host, missing handler, invalid handler/key selector signatures, or invalid configuration. | +| `PKBT001`-`PKBT005` | Backplane Topology | Non-partial host, missing topology, invalid route/subscription signatures, or duplicate default routes. | ## Related Runtime Patterns @@ -285,3 +314,4 @@ Example source: - [Saga / Process Manager](../patterns/messaging/saga.md) - [Mailbox](../patterns/messaging/mailbox.md) - [Idempotent Receiver, Inbox, and Outbox](../patterns/messaging/reliability.md) +- [Messaging Backplane Facade](../examples/messaging-backplane-facade.md) diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 1dfc0d7..84c5a15 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -55,8 +55,8 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Messaging Reliability | Idempotent Receiver | `IdempotentReceiver` | Reliability pipeline generator | | Messaging Reliability | Inbox | `InboxProcessor` | Reliability pipeline generator | | Messaging Reliability | Outbox | `InMemoryOutbox` and dispatcher contracts | Reliability pipeline generator | -| Enterprise Integration | Request-Reply | Messaging backplane facade example | Tracked in [#214](https://github.com/JerrettDavis/PatternKit/issues/214) | -| Enterprise Integration | Publish-Subscribe | Messaging backplane facade example | Tracked in [#214](https://github.com/JerrettDavis/PatternKit/issues/214) | +| Enterprise Integration | Request-Reply | Messaging backplane facade example | Backplane topology generator | +| Enterprise Integration | Publish-Subscribe | Messaging backplane facade example | Backplane topology generator | | Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator | ## Research Baselines diff --git a/docs/patterns/messaging/enterprise-generators.md b/docs/patterns/messaging/enterprise-generators.md index f5f36c9..81ab1b9 100644 --- a/docs/patterns/messaging/enterprise-generators.md +++ b/docs/patterns/messaging/enterprise-generators.md @@ -2,7 +2,7 @@ PatternKit source generators remove repetitive registration code for explicit enterprise integration patterns. They do not scan assemblies implicitly; each generated factory is opt-in through attributes on a partial type. -Use generators when routes, recipient lists, splitter/aggregator contracts, routing-slip steps, saga transitions, or mailbox inbox policies are static enough to validate at compile time and you want AOT-friendly factories without reflection. +Use generators when routes, recipient lists, splitter/aggregator contracts, routing-slip steps, saga transitions, mailbox inbox policies, or request/reply and publish/subscribe host topology are static enough to validate at compile time and you want AOT-friendly factories without reflection. ## Generated Content Router @@ -79,6 +79,8 @@ Mailbox generation is documented in [Mailbox](mailbox.md). It discovers one `[Ma Reliability helpers also have a generated path through `[GenerateReliabilityPipeline]`, which emits idempotent receiver, inbox, and outbox factories while keeping durable storage implementation owned by the application. +Backplane topology generation is documented in [Messaging Generators](../../generators/messaging.md#generated-backplane-topology). It discovers `[BackplaneRequestReply]` and `[BackplaneSubscription]` declarations on a partial topology type and emits a public `Configure` method for wiring the declared host-builder type at the application composition root. + ## Diagnostics | ID | Meaning | @@ -96,6 +98,7 @@ Reliability helpers also have a generated path through `[GenerateReliabilityPipe | `PKRS001`-`PKRS003` | Routing-slip generator validation. | | `PKSG001`-`PKSG004` | Saga generator validation. | | `PKMB001`-`PKMB005` | Mailbox generator validation. | +| `PKBT001`-`PKBT005` | Backplane topology generator validation. | ## Troubleshooting @@ -103,6 +106,7 @@ Reliability helpers also have a generated path through `[GenerateReliabilityPipe - Keep route, step, and saga methods `static`; generated factories reference them directly. - Use `nameof(PredicateMethod)` in `[ContentRoute]` and `[RecipientListRecipient]` so renames remain compile-time safe. - Use unique route and recipient names and orders. Content routers are first-match, and recipient lists are ordered fan-out, so ambiguous ordering should fail at build time. +- Use one default request/reply route per request type in generated backplane topology; predicate routes are emitted before defaults so first-match routing stays deterministic. - Ensure generated code builds under nullable enabled; the tests compile generated examples with Release settings. ## API @@ -121,6 +125,9 @@ Reliability helpers also have a generated path through `[GenerateReliabilityPipe - - - +- +- +- - - @@ -130,3 +137,5 @@ Reliability helpers also have a generated path through `[GenerateReliabilityPipe - `test/PatternKit.Examples.Tests/Messaging/ContentRouterGeneratorExampleTests.cs` - `src/PatternKit.Examples/Messaging/RecipientListGeneratorExample.cs` - `test/PatternKit.Examples.Tests/Messaging/RecipientListGeneratorExampleTests.cs` +- `src/PatternKit.Examples/Messaging/BackplaneFacadeDemo.cs` +- `test/PatternKit.Examples.Tests/Messaging/BackplaneFacadeDemoTests.cs` diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 2a79ab7..1aad013 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -409,7 +409,7 @@ public static IServiceCollection AddResilientCheckoutMailboxesExample(this IServ public static IServiceCollection AddMessagingBackplaneFacadeExample(this IServiceCollection services) { services.AddSingleton(new MessagingBackplaneFacadeExample(BackplaneFacadeDemo.RunAsync)); - return services.RegisterExample("Messaging Backplane Facade", ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.ExternalInfrastructure | ExampleIntegrationSurface.DependencyInjection); + return services.RegisterExample("Messaging Backplane Facade", ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.ExternalInfrastructure | ExampleIntegrationSurface.DependencyInjection); } public static IServiceCollection AddPrototypeGameCharacterFactoryExample(this IServiceCollection services) diff --git a/src/PatternKit.Examples/Messaging/BackplaneFacadeDemo.cs b/src/PatternKit.Examples/Messaging/BackplaneFacadeDemo.cs index 1b73399..f3222fd 100644 --- a/src/PatternKit.Examples/Messaging/BackplaneFacadeDemo.cs +++ b/src/PatternKit.Examples/Messaging/BackplaneFacadeDemo.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using PatternKit.Generators.Messaging; using PatternKit.Messaging; using PatternKit.Messaging.Mailboxes; using PatternKit.Messaging.Reliability; @@ -25,29 +26,12 @@ public static async ValueTask RunAsync(CancellationToken c var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var services = new BackplaneDemoServices(audit, endpoints, notifications, completed); - await using var host = await BackplaneHost.Create() - .UseTransport(() => transport) - .UseOutbox(outbox) - .UseIdempotencyStore(idempotency) - .MapCommand( - static (message, _) => message.Payload.CustomerTier == CustomerTier.Vip, - "orders.priority") - .MapDefaultCommand("orders.standard") - .ReceiveEndpoint("orders.standard", endpoint => - endpoint.HandleCommand(services.AcceptStandardOrderAsync)) - .ReceiveEndpoint("orders.priority", endpoint => - endpoint.HandleCommand(services.AcceptPriorityOrderAsync)) - .ReceiveEndpoint("billing-service", endpoint => - endpoint.Subscribe("orders.submitted", services.CapturePaymentAsync)) - .ReceiveEndpoint("audit-service", endpoint => - endpoint.Subscribe("orders.submitted", services.AuditSubmittedOrderAsync)) - .ReceiveEndpoint("fulfillment-service", endpoint => - endpoint.Subscribe("payments.captured", services.ScheduleShipmentAsync)) - .ReceiveEndpoint("notification-service", endpoint => - { - endpoint.Subscribe("payments.declined", services.NotifyPaymentDeclinedAsync); - endpoint.Subscribe("shipments.scheduled", services.NotifyShipmentScheduledAsync); - }) + await using var host = await GeneratedBackplaneTopology.Configure( + BackplaneHost.Create() + .UseTransport(() => transport) + .UseOutbox(outbox) + .UseIdempotencyStore(idempotency), + services) .BuildAsync(cancellationToken).ConfigureAwait(false); services.AttachClient(host.Client); @@ -90,6 +74,24 @@ async ValueTask SubmitAsync( } } +/// Source-generated request/reply and publish/subscribe topology for the backplane demo. +[GenerateBackplaneTopology(typeof(BackplaneDemoServices), HostBuilderType = typeof(BackplaneHostBuilder))] +[BackplaneRequestReply(typeof(SubmitOrder), typeof(BackplaneOrderAccepted), "orders.priority", nameof(BackplaneDemoServices.AcceptPriorityOrderAsync), PredicateMethodName = nameof(IsVipOrder))] +[BackplaneRequestReply(typeof(SubmitOrder), typeof(BackplaneOrderAccepted), "orders.standard", nameof(BackplaneDemoServices.AcceptStandardOrderAsync))] +[BackplaneSubscription(typeof(BackplaneOrderSubmitted), "orders.submitted", "billing-service", nameof(BackplaneDemoServices.CapturePaymentAsync))] +[BackplaneSubscription(typeof(BackplaneOrderSubmitted), "orders.submitted", "audit-service", nameof(BackplaneDemoServices.AuditSubmittedOrderAsync))] +[BackplaneSubscription(typeof(PaymentCaptured), "payments.captured", "fulfillment-service", nameof(BackplaneDemoServices.ScheduleShipmentAsync))] +[BackplaneSubscription(typeof(PaymentDeclined), "payments.declined", "notification-service", nameof(BackplaneDemoServices.NotifyPaymentDeclinedAsync))] +[BackplaneSubscription(typeof(ShipmentScheduled), "shipments.scheduled", "notification-service", nameof(BackplaneDemoServices.NotifyShipmentScheduledAsync))] +public static partial class GeneratedBackplaneTopology +{ + private static bool IsVipOrder(Message message, MessageContext context) + { + _ = context; + return message.Payload.CustomerTier == CustomerTier.Vip; + } +} + /// Application host that owns the bus facade, typed client, transport, and active subscriptions. public sealed class BackplaneHost : IAsyncDisposable { @@ -891,7 +893,7 @@ public void MarkDispatched(string id, int delivered) } } -internal sealed class BackplaneDemoServices +public sealed class BackplaneDemoServices { private readonly ConcurrentQueue _audit; private readonly ConcurrentDictionary _endpoints; @@ -900,7 +902,7 @@ internal sealed class BackplaneDemoServices private BackplaneClient? _client; private int _submittedAuditCount; - internal BackplaneDemoServices( + public BackplaneDemoServices( ConcurrentQueue audit, ConcurrentDictionary endpoints, ConcurrentQueue notifications, @@ -912,24 +914,24 @@ internal BackplaneDemoServices( _completed = completed; } - internal void AttachClient(BackplaneClient client) + public void AttachClient(BackplaneClient client) { _client = client ?? throw new ArgumentNullException(nameof(client)); } - internal ValueTask AcceptStandardOrderAsync( + public ValueTask AcceptStandardOrderAsync( Message message, MessageContext context, CancellationToken cancellationToken) => AcceptOrderAsync("orders.standard", message, context, cancellationToken); - internal ValueTask AcceptPriorityOrderAsync( + public ValueTask AcceptPriorityOrderAsync( Message message, MessageContext context, CancellationToken cancellationToken) => AcceptOrderAsync("orders.priority", message, context, cancellationToken); - internal async ValueTask CapturePaymentAsync( + public async ValueTask CapturePaymentAsync( Message message, MessageContext context, CancellationToken cancellationToken) @@ -952,7 +954,7 @@ await Client.PublishAsync( cancellationToken).ConfigureAwait(false); } - internal ValueTask AuditSubmittedOrderAsync( + public ValueTask AuditSubmittedOrderAsync( Message message, MessageContext context, CancellationToken cancellationToken) @@ -964,7 +966,7 @@ internal ValueTask AuditSubmittedOrderAsync( return default; } - internal async ValueTask ScheduleShipmentAsync( + public async ValueTask ScheduleShipmentAsync( Message message, MessageContext context, CancellationToken cancellationToken) @@ -977,7 +979,7 @@ await Client.PublishAsync( cancellationToken).ConfigureAwait(false); } - internal ValueTask NotifyPaymentDeclinedAsync( + public ValueTask NotifyPaymentDeclinedAsync( Message message, MessageContext context, CancellationToken cancellationToken) @@ -990,7 +992,7 @@ internal ValueTask NotifyPaymentDeclinedAsync( return default; } - internal ValueTask NotifyShipmentScheduledAsync( + public ValueTask NotifyShipmentScheduledAsync( Message message, MessageContext context, CancellationToken cancellationToken) diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 39c12c5..cbfb37c 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -293,9 +293,9 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog "src/PatternKit.Examples/Messaging/BackplaneFacadeDemo.cs", "test/PatternKit.Examples.Tests/Messaging/BackplaneFacadeDemoTests.cs", "docs/examples/messaging-backplane-facade.md", - ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.ExternalInfrastructure, + ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.ExternalInfrastructure, ["Facade", "Mailbox", "Outbox", "IdempotentReceiver"], - ["host setup", "request/reply", "pub/sub", "transport boundary"]), + ["host setup", "generated request/reply topology", "generated pub/sub topology", "transport boundary"]), Descriptor( "Prototype Game Character Factory", "src/PatternKit.Examples/PrototypeDemo/PrototypeDemo.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 44c1114..5d6b10a 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -510,27 +510,27 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "docs/examples/messaging-backplane-facade.md", "src/PatternKit.Examples/Messaging/BackplaneFacadeDemo.cs", "test/PatternKit.Examples.Tests/Messaging/BackplaneFacadeDemoTests.cs", + "docs/generators/messaging.md", + "src/PatternKit.Generators/Messaging/BackplaneTopologyGenerator.cs", + "test/PatternKit.Generators.Tests/BackplaneTopologyGeneratorTests.cs", null, - null, - null, - "https://github.com/JerrettDavis/PatternKit/issues/214", "docs/examples/messaging-backplane-facade.md", "src/PatternKit.Examples/Messaging/BackplaneFacadeDemo.cs", "test/PatternKit.Examples.Tests/Messaging/BackplaneFacadeDemoTests.cs", - ["typed request/reply client", "generated backplane topology tracked", "hosted backplane example"]), + ["typed request/reply client", "generated backplane topology", "hosted backplane example"]), Pattern("Publish-Subscribe", PatternFamily.EnterpriseIntegration, "docs/examples/messaging-backplane-facade.md", "src/PatternKit.Examples/Messaging/BackplaneFacadeDemo.cs", "test/PatternKit.Examples.Tests/Messaging/BackplaneFacadeDemoTests.cs", + "docs/generators/messaging.md", + "src/PatternKit.Generators/Messaging/BackplaneTopologyGenerator.cs", + "test/PatternKit.Generators.Tests/BackplaneTopologyGeneratorTests.cs", null, - null, - null, - "https://github.com/JerrettDavis/PatternKit/issues/214", "docs/examples/messaging-backplane-facade.md", "src/PatternKit.Examples/Messaging/BackplaneFacadeDemo.cs", "test/PatternKit.Examples.Tests/Messaging/BackplaneFacadeDemoTests.cs", - ["typed publish/subscribe", "generated backplane topology tracked", "transport boundary example"]), + ["typed publish/subscribe", "generated backplane topology", "transport boundary example"]), Pattern("CQRS", PatternFamily.ApplicationArchitecture, "docs/generators/dispatcher.md", diff --git a/src/PatternKit.Generators.Abstractions/Messaging/BackplaneTopologyAttributes.cs b/src/PatternKit.Generators.Abstractions/Messaging/BackplaneTopologyAttributes.cs new file mode 100644 index 0000000..5b8cefa --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Messaging/BackplaneTopologyAttributes.cs @@ -0,0 +1,109 @@ +using System; + +namespace PatternKit.Generators.Messaging; + +/// +/// Generates a typed backplane topology method for request/reply routes and publish/subscribe endpoints. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateBackplaneTopologyAttribute : Attribute +{ + /// Creates a generated backplane topology attribute. + public GenerateBackplaneTopologyAttribute(Type servicesType) + { + ServicesType = servicesType ?? throw new ArgumentNullException(nameof(servicesType)); + } + + /// Type that owns the handler methods referenced by route and subscription declarations. + public Type ServicesType { get; } + + /// Host builder type that exposes the backplane topology fluent methods. + public Type? HostBuilderType { get; set; } + + /// Name of the generated method that applies topology to a host builder. + public string ConfigureMethodName { get; set; } = "Configure"; +} + +/// +/// Declares a generated request/reply command endpoint and route. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true, Inherited = false)] +public sealed class BackplaneRequestReplyAttribute : Attribute +{ + /// Creates a request/reply route declaration. + public BackplaneRequestReplyAttribute( + Type requestType, + Type responseType, + string endpointName, + string handlerMethodName) + { + RequestType = requestType ?? throw new ArgumentNullException(nameof(requestType)); + ResponseType = responseType ?? throw new ArgumentNullException(nameof(responseType)); + + if (string.IsNullOrWhiteSpace(endpointName)) + throw new ArgumentException("Endpoint name cannot be null, empty, or whitespace.", nameof(endpointName)); + + if (string.IsNullOrWhiteSpace(handlerMethodName)) + throw new ArgumentException("Handler method name cannot be null, empty, or whitespace.", nameof(handlerMethodName)); + + EndpointName = endpointName; + HandlerMethodName = handlerMethodName; + } + + /// Request payload type routed by the generated topology. + public Type RequestType { get; } + + /// Response payload type returned by the request handler. + public Type ResponseType { get; } + + /// Backplane endpoint that receives the request. + public string EndpointName { get; } + + /// Method on the services type used as the request handler. + public string HandlerMethodName { get; } + + /// Optional static predicate method on the topology type. When omitted, this is the default route for the request type. + public string? PredicateMethodName { get; set; } +} + +/// +/// Declares a generated publish/subscribe topic subscription. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true, Inherited = false)] +public sealed class BackplaneSubscriptionAttribute : Attribute +{ + /// Creates a subscription declaration. + public BackplaneSubscriptionAttribute( + Type eventType, + string topic, + string endpointName, + string handlerMethodName) + { + EventType = eventType ?? throw new ArgumentNullException(nameof(eventType)); + + if (string.IsNullOrWhiteSpace(topic)) + throw new ArgumentException("Topic cannot be null, empty, or whitespace.", nameof(topic)); + + if (string.IsNullOrWhiteSpace(endpointName)) + throw new ArgumentException("Endpoint name cannot be null, empty, or whitespace.", nameof(endpointName)); + + if (string.IsNullOrWhiteSpace(handlerMethodName)) + throw new ArgumentException("Handler method name cannot be null, empty, or whitespace.", nameof(handlerMethodName)); + + Topic = topic; + EndpointName = endpointName; + HandlerMethodName = handlerMethodName; + } + + /// Event payload type consumed by the subscription. + public Type EventType { get; } + + /// Topic address consumed by the subscription. + public string Topic { get; } + + /// Backplane endpoint that receives the subscription. + public string EndpointName { get; } + + /// Method on the services type used as the event handler. + public string HandlerMethodName { get; } +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 800c53a..0385107 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -184,6 +184,11 @@ PKRP002 | PatternKit.Generators.Messaging | Error | Reliability pipeline must de PKRP003 | PatternKit.Generators.Messaging | Error | Reliability pipeline handler signature is invalid. PKRP004 | PatternKit.Generators.Messaging | Error | Reliability key selector signature is invalid. PKRP005 | PatternKit.Generators.Messaging | Error | Reliability pipeline configuration is invalid. +PKBT001 | PatternKit.Generators.Messaging | Error | Backplane topology type must be partial. +PKBT002 | PatternKit.Generators.Messaging | Error | Backplane topology must declare at least one request/reply route or subscription. +PKBT003 | PatternKit.Generators.Messaging | Error | Backplane request/reply declaration is invalid. +PKBT004 | PatternKit.Generators.Messaging | Error | Backplane subscription declaration is invalid. +PKBT005 | PatternKit.Generators.Messaging | Error | Backplane request default route is duplicated. PKRL001 | PatternKit.Generators.Messaging | Error | Recipient list type must be partial. PKRL002 | PatternKit.Generators.Messaging | Error | Recipient list must declare at least one recipient. PKRL003 | PatternKit.Generators.Messaging | Error | Recipient handler or predicate signature is invalid. diff --git a/src/PatternKit.Generators/Messaging/BackplaneTopologyGenerator.cs b/src/PatternKit.Generators/Messaging/BackplaneTopologyGenerator.cs new file mode 100644 index 0000000..acc0f4a --- /dev/null +++ b/src/PatternKit.Generators/Messaging/BackplaneTopologyGenerator.cs @@ -0,0 +1,396 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.Messaging; + +[Generator] +public sealed class BackplaneTopologyGenerator : IIncrementalGenerator +{ + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKBT001", + "Backplane topology type must be partial", + "Type '{0}' is marked with [GenerateBackplaneTopology] but is not declared as partial", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingTopology = new( + "PKBT002", + "Backplane topology is empty", + "Type '{0}' is marked with [GenerateBackplaneTopology] but does not declare any request/reply routes or subscriptions", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidRequestReply = new( + "PKBT003", + "Backplane request/reply declaration is invalid", + "Request/reply declaration '{0}' must reference valid request/response types, endpoint, handler, and optional predicate", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidSubscription = new( + "PKBT004", + "Backplane subscription declaration is invalid", + "Subscription declaration '{0}' must reference a valid event type, topic, endpoint, and handler", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor DuplicateRequestDefault = new( + "PKBT005", + "Backplane request default route is duplicated", + "Request type '{0}' has multiple default request/reply routes in '{1}'", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + "PatternKit.Generators.Messaging.GenerateBackplaneTopologyAttribute", + 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.GenerateBackplaneTopologyAttribute"); + 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 servicesType = attribute.ConstructorArguments.Length == 1 + ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol + : null; + if (servicesType is null) + return; + + var hasTopologyAttributes = HasTopologyAttributes(type); + var requests = GetRequestReplies(type, servicesType, context); + var subscriptions = GetSubscriptions(type, servicesType, context); + if (requests.Length == 0 && subscriptions.Length == 0) + { + if (!hasTopologyAttributes) + context.ReportDiagnostic(Diagnostic.Create(MissingTopology, node.Identifier.GetLocation(), type.Name)); + + return; + } + + if (HasDuplicateDefaults(requests, out var duplicate)) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateRequestDefault, duplicate.Location, duplicate.RequestTypeName, type.Name)); + return; + } + + var configureMethodName = GetNamedString(attribute, "ConfigureMethodName") ?? "Configure"; + var hostBuilderTypeName = GetNamedType(attribute, "HostBuilderType") + ?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ?? "global::PatternKit.Examples.Messaging.BackplaneHostBuilder"; + context.AddSource($"{type.Name}.BackplaneTopology.g.cs", SourceText.From( + GenerateSource(type, servicesType, hostBuilderTypeName, requests, subscriptions, configureMethodName), + Encoding.UTF8)); + } + + private static ImmutableArray GetRequestReplies( + INamedTypeSymbol type, + INamedTypeSymbol servicesType, + SourceProductionContext context) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var attr in type.GetAttributes().Where(static attr => + attr.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.BackplaneRequestReplyAttribute")) + { + if (!TryGetRequestReply(type, servicesType, attr, out var request)) + { + var endpoint = attr.ConstructorArguments.Length > 2 ? attr.ConstructorArguments[2].Value as string : null; + context.ReportDiagnostic(Diagnostic.Create(InvalidRequestReply, attr.ApplicationSyntaxReference?.GetSyntax().GetLocation(), endpoint ?? type.Name)); + continue; + } + + builder.Add(request); + } + + return builder.ToImmutable(); + } + + private static bool TryGetRequestReply( + INamedTypeSymbol type, + INamedTypeSymbol servicesType, + AttributeData attribute, + out RequestReply request) + { + request = default; + if (attribute.ConstructorArguments.Length != 4) + return false; + + var requestType = attribute.ConstructorArguments[0].Value as INamedTypeSymbol; + var responseType = attribute.ConstructorArguments[1].Value as INamedTypeSymbol; + var endpointName = attribute.ConstructorArguments[2].Value as string; + var handlerName = attribute.ConstructorArguments[3].Value as string; + if (requestType is null || responseType is null || string.IsNullOrWhiteSpace(endpointName) || string.IsNullOrWhiteSpace(handlerName)) + return false; + + var handler = servicesType.GetMembers(handlerName!).OfType().FirstOrDefault(); + if (handler is null || !IsRequestHandler(handler, requestType, responseType)) + return false; + + var predicateName = GetNamedString(attribute, "PredicateMethodName"); + if (!string.IsNullOrWhiteSpace(predicateName)) + { + var predicate = type.GetMembers(predicateName!).OfType().FirstOrDefault(); + if (predicate is null || !IsRoutePredicate(predicate, requestType)) + return false; + } + + request = new RequestReply( + requestType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + responseType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + endpointName!, + handler.Name, + string.IsNullOrWhiteSpace(predicateName) ? null : predicateName, + attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation()); + return true; + } + + private static ImmutableArray GetSubscriptions( + INamedTypeSymbol type, + INamedTypeSymbol servicesType, + SourceProductionContext context) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var attr in type.GetAttributes().Where(static attr => + attr.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.BackplaneSubscriptionAttribute")) + { + if (!TryGetSubscription(servicesType, attr, out var subscription)) + { + var endpoint = attr.ConstructorArguments.Length > 2 ? attr.ConstructorArguments[2].Value as string : null; + context.ReportDiagnostic(Diagnostic.Create(InvalidSubscription, attr.ApplicationSyntaxReference?.GetSyntax().GetLocation(), endpoint ?? type.Name)); + continue; + } + + builder.Add(subscription); + } + + return builder.ToImmutable(); + } + + private static bool TryGetSubscription( + INamedTypeSymbol servicesType, + AttributeData attribute, + out Subscription subscription) + { + subscription = default; + if (attribute.ConstructorArguments.Length != 4) + return false; + + var eventType = attribute.ConstructorArguments[0].Value as INamedTypeSymbol; + var topic = attribute.ConstructorArguments[1].Value as string; + var endpointName = attribute.ConstructorArguments[2].Value as string; + var handlerName = attribute.ConstructorArguments[3].Value as string; + if (eventType is null || string.IsNullOrWhiteSpace(topic) || string.IsNullOrWhiteSpace(endpointName) || string.IsNullOrWhiteSpace(handlerName)) + return false; + + var handler = servicesType.GetMembers(handlerName!).OfType().FirstOrDefault(); + if (handler is null || !IsEventHandler(handler, eventType)) + return false; + + subscription = new Subscription( + eventType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + topic!, + endpointName!, + handler.Name, + attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation()); + return true; + } + + private static bool IsRoutePredicate(IMethodSymbol method, INamedTypeSymbol requestType) + => method.IsStatic && + method.ReturnType.SpecialType == SpecialType.System_Boolean && + method.Parameters.Length == 2 && + IsMessageOf(method.Parameters[0].Type, requestType) && + method.Parameters[1].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext"; + + private static bool IsRequestHandler(IMethodSymbol method, INamedTypeSymbol requestType, INamedTypeSymbol responseType) + => !method.IsStatic && + IsValueTaskOf(method.ReturnType, responseType) && + method.Parameters.Length == 3 && + IsMessageOf(method.Parameters[0].Type, requestType) && + method.Parameters[1].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext" && + method.Parameters[2].Type.ToDisplayString() == "System.Threading.CancellationToken"; + + private static bool IsEventHandler(IMethodSymbol method, INamedTypeSymbol eventType) + => !method.IsStatic && + method.ReturnType.ToDisplayString() == "System.Threading.Tasks.ValueTask" && + method.Parameters.Length == 3 && + IsMessageOf(method.Parameters[0].Type, eventType) && + method.Parameters[1].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext" && + method.Parameters[2].Type.ToDisplayString() == "System.Threading.CancellationToken"; + + private static bool IsValueTaskOf(ITypeSymbol type, INamedTypeSymbol resultType) + => type is INamedTypeSymbol named && + named.ConstructedFrom.ToDisplayString() == "System.Threading.Tasks.ValueTask" && + SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], resultType); + + private static bool IsMessageOf(ITypeSymbol type, INamedTypeSymbol payloadType) + => type is INamedTypeSymbol named && + named.ConstructedFrom.ToDisplayString() == "PatternKit.Messaging.Message" && + SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], payloadType); + + private static bool HasDuplicateDefaults(IReadOnlyList requests, out RequestReply duplicate) + { + var defaults = new HashSet(System.StringComparer.Ordinal); + foreach (var request in requests) + { + if (request.PredicateMethodName is not null) + continue; + + if (!defaults.Add(request.RequestTypeName)) + { + duplicate = request; + return true; + } + } + + duplicate = default; + return false; + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol servicesType, + string hostBuilderTypeName, + IReadOnlyList requests, + IReadOnlyList subscriptions, + string configureMethodName) + { + 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 ").Append(hostBuilderTypeName).Append(' ') + .Append(configureMethodName) + .Append('(').Append(hostBuilderTypeName).Append(" builder, ") + .Append(servicesType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .AppendLine(" services)"); + sb.AppendLine(" {"); + sb.AppendLine(" if (builder is null)"); + sb.AppendLine(" throw new global::System.ArgumentNullException(nameof(builder));"); + sb.AppendLine(" if (services is null)"); + sb.AppendLine(" throw new global::System.ArgumentNullException(nameof(services));"); + sb.AppendLine(); + + foreach (var request in requests + .OrderBy(static r => r.PredicateMethodName is null ? 1 : 0) + .ThenBy(static r => r.EndpointName, System.StringComparer.Ordinal)) + { + if (request.PredicateMethodName is null) + { + sb.Append(" builder.MapDefaultCommand<") + .Append(request.RequestTypeName).Append(", ").Append(request.ResponseTypeName) + .Append(">(\"").Append(Escape(request.EndpointName)).AppendLine("\");"); + } + else + { + sb.Append(" builder.MapCommand<") + .Append(request.RequestTypeName).Append(", ").Append(request.ResponseTypeName) + .Append(">(").Append(request.PredicateMethodName) + .Append(", \"").Append(Escape(request.EndpointName)).AppendLine("\");"); + } + } + + sb.AppendLine(); + foreach (var group in requests.GroupBy(static r => r.EndpointName).OrderBy(static g => g.Key, System.StringComparer.Ordinal)) + { + sb.Append(" builder.ReceiveEndpoint(\"").Append(Escape(group.Key)).AppendLine("\", endpoint =>"); + sb.AppendLine(" {"); + foreach (var request in group.OrderBy(static r => r.HandlerMethodName, System.StringComparer.Ordinal)) + { + sb.Append(" endpoint.HandleCommand<") + .Append(request.RequestTypeName).Append(", ").Append(request.ResponseTypeName) + .Append(">(services.").Append(request.HandlerMethodName).AppendLine(");"); + } + + sb.AppendLine(" });"); + } + + foreach (var group in subscriptions.GroupBy(static s => s.EndpointName).OrderBy(static g => g.Key, System.StringComparer.Ordinal)) + { + sb.Append(" builder.ReceiveEndpoint(\"").Append(Escape(group.Key)).AppendLine("\", endpoint =>"); + sb.AppendLine(" {"); + foreach (var subscription in group.OrderBy(static s => s.Topic, System.StringComparer.Ordinal).ThenBy(static s => s.HandlerMethodName, System.StringComparer.Ordinal)) + { + sb.Append(" endpoint.Subscribe<") + .Append(subscription.EventTypeName) + .Append(">(\"").Append(Escape(subscription.Topic)) + .Append("\", services.").Append(subscription.HandlerMethodName).AppendLine(");"); + } + + sb.AppendLine(" });"); + } + + sb.AppendLine(); + sb.AppendLine(" return builder;"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static bool HasTopologyAttributes(INamedTypeSymbol type) + => type.GetAttributes().Any(static attr => + attr.AttributeClass?.ToDisplayString() is + "PatternKit.Generators.Messaging.BackplaneRequestReplyAttribute" or + "PatternKit.Generators.Messaging.BackplaneSubscriptionAttribute"); + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static INamedTypeSymbol? GetNamedType(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as INamedTypeSymbol; + + private readonly record struct RequestReply( + string RequestTypeName, + string ResponseTypeName, + string EndpointName, + string HandlerMethodName, + string? PredicateMethodName, + Location? Location); + + private readonly record struct Subscription( + string EventTypeName, + string Topic, + string EndpointName, + string HandlerMethodName, + Location? Location); +} diff --git a/test/PatternKit.Examples.Tests/Messaging/BackplaneFacadeDemoTests.cs b/test/PatternKit.Examples.Tests/Messaging/BackplaneFacadeDemoTests.cs index 66d2894..09d6b1d 100644 --- a/test/PatternKit.Examples.Tests/Messaging/BackplaneFacadeDemoTests.cs +++ b/test/PatternKit.Examples.Tests/Messaging/BackplaneFacadeDemoTests.cs @@ -1,4 +1,7 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; using PatternKit.Examples.Messaging; +using PatternKit.Examples.ProductionReadiness; using PatternKit.Messaging; using PatternKit.Messaging.Reliability; using TinyBDD; @@ -43,6 +46,50 @@ public async Task BackplaneHostBuilder_ConfiguresNativeMessagingPlatformSurface( ScenarioExpect.Contains(transport.DeliveryLog, static entry => entry.Contains("orders.standard->orders.standard", StringComparison.Ordinal)); } + [Scenario("GeneratedBackplaneTopology ConfiguresRequestReplyAndPubSubEndpoints")] + [Fact] + public async Task GeneratedBackplaneTopology_ConfiguresRequestReplyAndPubSubEndpoints() + { + var transport = new InMemoryBackplaneTransport(); + var outbox = new BackplaneOutbox(); + var idempotency = new InMemoryIdempotencyStore(); + var services = BackplaneFacadeDemoTestServices.Create(); + + await using var host = await GeneratedBackplaneTopology.Configure( + BackplaneHost.Create() + .UseTransport(() => transport) + .UseOutbox(outbox) + .UseIdempotencyStore(idempotency), + services.Services) + .BuildAsync(); + + ScenarioExpect.Contains(host.Endpoints, static endpoint => endpoint.Name == "orders.standard"); + ScenarioExpect.Contains(host.Endpoints, static endpoint => endpoint.Name == "orders.priority"); + ScenarioExpect.Contains(host.Endpoints, static endpoint => endpoint.Name == "billing-service"); + ScenarioExpect.Contains(host.Endpoints, static endpoint => endpoint.Name == "audit-service"); + ScenarioExpect.Contains(host.Endpoints, static endpoint => endpoint.Name == "fulfillment-service"); + ScenarioExpect.Contains(host.Endpoints, static endpoint => endpoint.Name == "notification-service"); + } + + [Scenario("Messaging backplane facade is importable through IServiceCollection")] + [Fact] + public async Task Messaging_Backplane_Facade_Is_Importable_Through_IServiceCollection() + { + var services = new ServiceCollection(); + services.AddMessagingBackplaneFacadeExample(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var example = provider.GetRequiredService(); + var descriptor = provider.GetServices() + .Single(descriptor => descriptor.ExampleName == "Messaging Backplane Facade"); + var summary = await example.RunAsync(CancellationToken.None); + + ScenarioExpect.Equal(4, summary.Accepted.Count); + ScenarioExpect.True(descriptor.Integration.HasFlag(ExampleIntegrationSurface.DependencyInjection)); + ScenarioExpect.True(descriptor.Integration.HasFlag(ExampleIntegrationSurface.SourceGenerator)); + ScenarioExpect.True(descriptor.Integration.HasFlag(ExampleIntegrationSurface.Messaging)); + } + [Scenario("RunAsync RoutesRequestsAndPublishesEventsThroughBackplane")] [Fact] public async Task RunAsync_RoutesRequestsAndPublishesEventsThroughBackplane() @@ -118,3 +165,15 @@ public async Task RunAsync_PreservesCorrelationAcrossServices() notification is { OrderId: "order-declined", Kind: "payment-declined", CorrelationId: "corr-order-declined" }); } } + +internal sealed record BackplaneFacadeDemoTestServices(BackplaneDemoServices Services) +{ + internal static BackplaneFacadeDemoTestServices Create() + { + var audit = new System.Collections.Concurrent.ConcurrentQueue(); + var endpoints = new System.Collections.Concurrent.ConcurrentDictionary(); + var notifications = new System.Collections.Concurrent.ConcurrentQueue(); + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + return new BackplaneFacadeDemoTestServices(new BackplaneDemoServices(audit, endpoints, notifications, completed)); + } +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 9720e44..d3958dd 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -121,9 +121,7 @@ public Task Each_Pattern_Has_Fluent_Generated_Documented_And_Example_Paths() ScenarioExpect.Equal( [ "Abstract Factory has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/207", - "Interpreter has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/206", - "Publish-Subscribe has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/214", - "Request-Reply has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/214" + "Interpreter has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/206" ], tracked); }) .AssertPassed(); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index d730690..ed2e190 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -92,6 +92,9 @@ private enum TestTrigger { typeof(GenerateReliabilityPipelineAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(ReliabilityHandlerAttribute), AttributeTargets.Method, false, false }, { typeof(ReliabilityKeySelectorAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateBackplaneTopologyAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(BackplaneRequestReplyAttribute), AttributeTargets.Class | AttributeTargets.Struct, true, false }, + { typeof(BackplaneSubscriptionAttribute), AttributeTargets.Class | AttributeTargets.Struct, true, false }, { typeof(GenerateMessageEnvelopeAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(MessageEnvelopeHeaderAttribute), AttributeTargets.Class | AttributeTargets.Struct, true, false }, { typeof(ObserverAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, @@ -378,6 +381,16 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf DuplicatePolicy = "ReplayCompleted", MissingKeyPolicy = "Process" }; + var backplane = new GenerateBackplaneTopologyAttribute(typeof(object)) + { + HostBuilderType = typeof(List<>), + ConfigureMethodName = "ApplyTopology" + }; + var requestReply = new BackplaneRequestReplyAttribute(typeof(string), typeof(int), "orders", "Handle") + { + PredicateMethodName = "IsPriority" + }; + var subscription = new BackplaneSubscriptionAttribute(typeof(decimal), "orders.submitted", "audit", "Audit"); var envelope = new GenerateMessageEnvelopeAttribute(typeof(string)) { FactoryName = "BuildEnvelope", @@ -444,6 +457,18 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Equal("BuildOutbox", reliability.OutboxFactoryName); ScenarioExpect.Equal("ReplayCompleted", reliability.DuplicatePolicy); ScenarioExpect.Equal("Process", reliability.MissingKeyPolicy); + ScenarioExpect.Equal(typeof(object), backplane.ServicesType); + ScenarioExpect.Equal(typeof(List<>), backplane.HostBuilderType); + ScenarioExpect.Equal("ApplyTopology", backplane.ConfigureMethodName); + ScenarioExpect.Equal(typeof(string), requestReply.RequestType); + ScenarioExpect.Equal(typeof(int), requestReply.ResponseType); + ScenarioExpect.Equal("orders", requestReply.EndpointName); + ScenarioExpect.Equal("Handle", requestReply.HandlerMethodName); + ScenarioExpect.Equal("IsPriority", requestReply.PredicateMethodName); + ScenarioExpect.Equal(typeof(decimal), subscription.EventType); + ScenarioExpect.Equal("orders.submitted", subscription.Topic); + ScenarioExpect.Equal("audit", subscription.EndpointName); + ScenarioExpect.Equal("Audit", subscription.HandlerMethodName); ScenarioExpect.Equal(typeof(string), envelope.PayloadType); ScenarioExpect.Equal("BuildEnvelope", envelope.FactoryName); ScenarioExpect.Equal("BuildContext", envelope.ContextFactoryName); @@ -470,6 +495,15 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Throws(() => new GenerateReliabilityPipelineAttribute(null!, typeof(int), typeof(decimal))); ScenarioExpect.Throws(() => new GenerateReliabilityPipelineAttribute(typeof(string), null!, typeof(decimal))); ScenarioExpect.Throws(() => new GenerateReliabilityPipelineAttribute(typeof(string), typeof(int), null!)); + ScenarioExpect.Throws(() => new GenerateBackplaneTopologyAttribute(null!)); + ScenarioExpect.Throws(() => new BackplaneRequestReplyAttribute(null!, typeof(int), "orders", "Handle")); + ScenarioExpect.Throws(() => new BackplaneRequestReplyAttribute(typeof(string), null!, "orders", "Handle")); + ScenarioExpect.Throws(() => new BackplaneRequestReplyAttribute(typeof(string), typeof(int), "", "Handle")); + ScenarioExpect.Throws(() => new BackplaneRequestReplyAttribute(typeof(string), typeof(int), "orders", "")); + ScenarioExpect.Throws(() => new BackplaneSubscriptionAttribute(null!, "orders", "audit", "Handle")); + ScenarioExpect.Throws(() => new BackplaneSubscriptionAttribute(typeof(string), "", "audit", "Handle")); + ScenarioExpect.Throws(() => new BackplaneSubscriptionAttribute(typeof(string), "orders", "", "Handle")); + ScenarioExpect.Throws(() => new BackplaneSubscriptionAttribute(typeof(string), "orders", "audit", "")); ScenarioExpect.Throws(() => new GenerateMessageEnvelopeAttribute(null!)); ScenarioExpect.Throws(() => new MessageEnvelopeHeaderAttribute("", typeof(string))); ScenarioExpect.Throws(() => new MessageEnvelopeHeaderAttribute("tenant-id", null!)); diff --git a/test/PatternKit.Generators.Tests/BackplaneTopologyGeneratorTests.cs b/test/PatternKit.Generators.Tests/BackplaneTopologyGeneratorTests.cs new file mode 100644 index 0000000..bb75c83 --- /dev/null +++ b/test/PatternKit.Generators.Tests/BackplaneTopologyGeneratorTests.cs @@ -0,0 +1,140 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.Messaging; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class BackplaneTopologyGeneratorTests +{ + [Scenario("Generates backplane request-reply and subscription topology")] + [Fact] + public void GeneratesBackplaneRequestReplyAndSubscriptionTopology() + { + var source = """ + using System; + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + + namespace PatternKit.Examples.Messaging; + + public sealed class BackplaneHostBuilder + { + public BackplaneHostBuilder MapCommand(Func, MessageContext, bool> predicate, string endpointName) => this; + public BackplaneHostBuilder MapDefaultCommand(string endpointName) => this; + public BackplaneHostBuilder ReceiveEndpoint(string endpointName, Action configure) + { + configure(new BackplaneEndpointBuilder()); + return this; + } + } + + public sealed class BackplaneEndpointBuilder + { + public void HandleCommand(Func, MessageContext, CancellationToken, ValueTask> handler) { } + public void Subscribe(string topic, Func, MessageContext, CancellationToken, ValueTask> handler) { } + } + + public sealed record SubmitOrder(string Id, bool Priority); + public sealed record OrderAccepted(string Id); + public sealed record OrderSubmitted(string Id); + + public sealed class OrderServices + { + public ValueTask AcceptPriorityAsync(Message message, MessageContext context, CancellationToken cancellationToken) + => new(new OrderAccepted(message.Payload.Id)); + + public ValueTask AcceptStandardAsync(Message message, MessageContext context, CancellationToken cancellationToken) + => new(new OrderAccepted(message.Payload.Id)); + + public ValueTask AuditAsync(Message message, MessageContext context, CancellationToken cancellationToken) + => default; + } + + [GenerateBackplaneTopology(typeof(OrderServices), ConfigureMethodName = "Apply", HostBuilderType = typeof(BackplaneHostBuilder))] + [BackplaneRequestReply(typeof(SubmitOrder), typeof(OrderAccepted), "orders.priority", nameof(OrderServices.AcceptPriorityAsync), PredicateMethodName = nameof(IsPriority))] + [BackplaneRequestReply(typeof(SubmitOrder), typeof(OrderAccepted), "orders.standard", nameof(OrderServices.AcceptStandardAsync))] + [BackplaneSubscription(typeof(OrderSubmitted), "orders.submitted", "audit-service", nameof(OrderServices.AuditAsync))] + public static partial class OrderBackplane + { + private static bool IsPriority(Message message, MessageContext context) + => message.Payload.Priority; + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesBackplaneRequestReplyAndSubscriptionTopology)); + var gen = new BackplaneTopologyGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, 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)); + ScenarioExpect.Equal("OrderBackplane.BackplaneTopology.g.cs", generated.HintName); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("public static global::PatternKit.Examples.Messaging.BackplaneHostBuilder Apply(global::PatternKit.Examples.Messaging.BackplaneHostBuilder builder", text); + ScenarioExpect.Contains("Apply(", text); + ScenarioExpect.Contains("MapCommand(IsPriority, \"orders.priority\")", text); + ScenarioExpect.Contains("MapDefaultCommand(\"orders.standard\")", text); + ScenarioExpect.Contains("endpoint.HandleCommand(services.AcceptPriorityAsync)", text); + ScenarioExpect.Contains("endpoint.Subscribe(\"orders.submitted\", services.AuditAsync)", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Reports diagnostic for non-partial backplane topology")] + [Fact] + public void ReportsDiagnosticForNonPartialBackplaneTopology() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace PatternKit.Examples.Messaging; + + public sealed class OrderServices { } + + [GenerateBackplaneTopology(typeof(OrderServices))] + public static class OrderBackplane; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForNonPartialBackplaneTopology)); + var gen = new BackplaneTopologyGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKBT001", diagnostic.Id); + } + + [Scenario("Reports diagnostic for invalid request-reply handler")] + [Fact] + public void ReportsDiagnosticForInvalidRequestReplyHandler() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace PatternKit.Examples.Messaging; + + public sealed record SubmitOrder(string Id); + public sealed record OrderAccepted(string Id); + public sealed class OrderServices { public string Accept(SubmitOrder order) => order.Id; } + + [GenerateBackplaneTopology(typeof(OrderServices))] + [BackplaneRequestReply(typeof(SubmitOrder), typeof(OrderAccepted), "orders", nameof(OrderServices.Accept))] + public static partial class OrderBackplane; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForInvalidRequestReplyHandler)); + var gen = new BackplaneTopologyGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKBT003", diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: MetadataReference.CreateFromFile(typeof(PatternKit.Messaging.Message<>).Assembly.Location)); +}