diff --git a/docs/examples/enterprise-messaging-workflows.md b/docs/examples/enterprise-messaging-workflows.md index a97f9d21..1a9a6b9d 100644 --- a/docs/examples/enterprise-messaging-workflows.md +++ b/docs/examples/enterprise-messaging-workflows.md @@ -23,6 +23,7 @@ Example source: | Inbox/outbox | `ReliabilityExample.cs` | Explicit handoff records for durable integration boundaries owned by the application. | | Source-generated dispatcher | `DispatcherExample.cs` | Compile-time mediator commands, notifications, streams, and paging. | | Source-generated content router | `ContentRouterGeneratorExample.cs` | Attribute-driven content routing without runtime scanning. | +| Source-generated recipient list | `RecipientListGeneratorExample.cs` | Attribute-driven fan-out without runtime scanning. | | Resilient checkout orchestration | `ResilientCheckoutDemo.cs` | Route selection, routing-slip execution, command compensation, and fallback routes. | | Collaborating service mailboxes | `ServiceCollaborationMailboxDemo.cs` | Inventory, payment, shipping, and notification mailboxes collaborating over correlated messages. | | Backplane facade | `BackplaneFacadeDemo.cs` | MassTransit/MediatR-shaped host builder, typed client, request/reply, and pub/sub over an application-owned transport boundary. | diff --git a/docs/examples/generated-recipient-list.md b/docs/examples/generated-recipient-list.md new file mode 100644 index 00000000..c45be4de --- /dev/null +++ b/docs/examples/generated-recipient-list.md @@ -0,0 +1,41 @@ +# Generated Recipient List + +The generated recipient-list example shows event fan-out with both runtime fluent composition and an attribute-driven source-generated factory. + +## Register + +```csharp +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; + +var services = new ServiceCollection() + .AddGeneratedRecipientListExample(); + +using var provider = services.BuildServiceProvider(validateScopes: true); +var example = provider.GetRequiredService(); +``` + +## Fluent Path + +```csharp +var fluent = example.Runner.RunFluent(); +``` + +The fluent path builds a `RecipientList` with predicates and handlers registered in code. + +## Source-Generated Path + +```csharp +var generated = example.Runner.RunGenerated(); +``` + +The generated path uses `[GenerateRecipientList]` on a partial type and `[RecipientListRecipient]` on static recipient handlers. The generator emits a strongly typed factory that builds the same `RecipientList` used by the fluent API. + +## Production Shape + +The TinyBDD scenarios validate that: + +- fluent and generated paths deliver the same recipients in deterministic order +- handler side effects are visible through a scoped `MessageContext` +- the example is importable through `IServiceCollection` +- the example advertises dependency injection, messaging, and source-generation integration surfaces diff --git a/docs/examples/index.md b/docs/examples/index.md index b3c93e5a..3cabc741 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -66,6 +66,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Enterprise Messaging Workflow Suite** End-to-end messaging examples for envelopes, content routing, recipient lists, splitters, aggregators, routing slips, sagas, mailboxes, idempotent receivers, inboxes, outboxes, and generated messaging factories. See [Enterprise Messaging Workflow Suite](enterprise-messaging-workflows.md). +* **Generated Recipient List** + Shows fluent and source-generated recipient-list fan-out side by side, with an importable `IServiceCollection` extension. See [Generated Recipient List](generated-recipient-list.md). + * **Resilient Checkout and Collaborating Mailboxes** Application-shaped messaging demos: checkout route selection, routing-slip execution, command compensation, fallback routes, and service mailboxes collaborating over correlated messages. See [Resilient Checkout and Collaborating Mailboxes](resilient-checkout-and-mailboxes.md). diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 9a644ba1..f0a67577 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -55,6 +55,9 @@ - name: Enterprise Messaging Workflow Suite href: enterprise-messaging-workflows.md +- name: Generated Recipient List + href: generated-recipient-list.md + - name: CQRS Dispatcher href: cqrs-dispatcher.md diff --git a/docs/generators/index.md b/docs/generators/index.md index 561db793..4f6059a2 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -65,6 +65,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato |---|---|---| | [**Dispatcher**](dispatcher.md) | Mediator pattern with commands, notifications, and streams | `[GenerateDispatcher]` | | [**Content Router**](messaging.md#generated-content-router) | Content-based message routing factories | `[GenerateContentRouter]` | +| [**Recipient List**](messaging.md#generated-recipient-list) | Recipient fan-out factories | `[GenerateRecipientList]` | | [**Routing Slip**](messaging.md#generated-routing-slip) | Ordered message itinerary factories | `[GenerateRoutingSlip]` | | [**Saga**](messaging.md#generated-saga) | Typed process-manager transition factories | `[GenerateSaga]` | diff --git a/docs/generators/messaging.md b/docs/generators/messaging.md index 36bcaaad..1894fa63 100644 --- a/docs/generators/messaging.md +++ b/docs/generators/messaging.md @@ -1,9 +1,10 @@ # Messaging Generators -PatternKit includes four messaging-oriented source generators: +PatternKit includes five messaging-oriented source generators: - for source-generated mediator dispatchers. - for content-based message routers. +- for recipient-list fan-out. - for ordered routing-slip factories. - for typed saga/process-manager factories. @@ -88,6 +89,32 @@ Example source: - `src/PatternKit.Examples/Messaging/RoutingSlipExample.cs` - `test/PatternKit.Examples.Tests/Messaging/RoutingSlipExampleTests.cs` +## Generated Recipient List + +`[GenerateRecipientList]` creates a `RecipientList` or `AsyncRecipientList` factory from static recipient methods: + +```csharp +[GenerateRecipientList(typeof(Order))] +public static partial class OrderRecipients +{ + private static bool IsPriority(Message message, MessageContext context) + => message.Payload.Priority == "priority"; + + [RecipientListRecipient("priority-audit", 10, nameof(IsPriority))] + private static void PriorityAudit(Message message, MessageContext context) + { + // deliver to audit sink + } +} +``` + +The generator orders recipients by `RecipientListRecipientAttribute.Order`, validates predicates and handlers, and emits deterministic diagnostics for missing recipients, non-partial host types, invalid signatures, and duplicate names or order values. + +Example files: + +- `src/PatternKit.Examples/Messaging/RecipientListGeneratorExample.cs` +- `test/PatternKit.Examples.Tests/Messaging/RecipientListGeneratorExampleTests.cs` + ## Generated Saga `[GenerateSaga]` emits a process-manager factory from typed transition methods: @@ -122,6 +149,7 @@ Example source: | --- | --- | --- | | `PKDSP001`-`PKDSP004` | Dispatcher | Invalid dispatcher configuration or handler registration. | | `PKCR001`-`PKCR005` | Content Router | Non-partial host, missing routes, invalid signatures, duplicate defaults, or duplicate route identity. | +| `PKRL001`-`PKRL004` | Recipient List | Non-partial host, missing recipients, invalid signatures, or duplicate recipient identity. | | `PKRS001`-`PKRS003` | Routing Slip | Non-partial host, missing steps, or invalid step signatures. | | `PKSG001`-`PKSG004` | Saga | Non-partial host, missing transitions, invalid transition signatures, or invalid completion checks. | diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 8613ce08..1be57f44 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -46,7 +46,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | --- | --- | --- | --- | | Enterprise Integration | Message Envelope | `Message`, headers, context | Tracked in [#215](https://github.com/JerrettDavis/PatternKit/issues/215) | | Enterprise Integration | Content-Based Router | `ContentRouter` | Messaging generator | -| Enterprise Integration | Recipient List | `RecipientList` | Tracked in [#210](https://github.com/JerrettDavis/PatternKit/issues/210) | +| Enterprise Integration | Recipient List | `RecipientList` | Messaging generator | | Enterprise Integration | Splitter | `Splitter` | Tracked in [#211](https://github.com/JerrettDavis/PatternKit/issues/211) | | Enterprise Integration | Aggregator | `Aggregator` | Tracked in [#211](https://github.com/JerrettDavis/PatternKit/issues/211) | | Enterprise Integration | Routing Slip | `RoutingSlip` | Messaging generator | diff --git a/docs/patterns/messaging/enterprise-generators.md b/docs/patterns/messaging/enterprise-generators.md index b4c74c65..ff7e5269 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, routing-slip steps, or saga transitions are static enough to validate at compile time and you want AOT-friendly factories without reflection. +Use generators when routes, recipient lists, routing-slip steps, or saga transitions are static enough to validate at compile time and you want AOT-friendly factories without reflection. ## Generated Content Router @@ -44,6 +44,31 @@ bool Predicate(Message message, MessageContext context) The generator orders routes by `ContentRouteAttribute.Order`, then by route name. Route names and orders must be unique so the generated first-match behavior is clear. +## Generated Recipient List + +`[GenerateRecipientList]` creates a `RecipientList` or `AsyncRecipientList` factory from static recipient predicates and handlers. + +```csharp +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; + +[GenerateRecipientList(typeof(Order))] +public static partial class OrderRecipients +{ + private static bool IsPriority(Message message, MessageContext context) + => message.Payload.Priority == "priority"; + + [RecipientListRecipient("priority-audit", 10, nameof(IsPriority))] + private static void PriorityAudit(Message message, MessageContext context) + { + } +} + +var result = OrderRecipients.Create().Dispatch(Message.Create(order)); +``` + +The generator orders recipients by `RecipientListRecipientAttribute.Order`, then by recipient name. Recipient names and orders must be unique so fan-out order stays deterministic. + ## Existing Enterprise Generators Routing-slip generation is documented in [Routing Slip](routing-slip.md). It discovers `[RoutingSlipStep]` methods and emits sync or async itinerary factories. @@ -61,6 +86,10 @@ Mailbox and reliability helpers stay runtime-only for now. Their registration is | `PKCR003` | A route handler or referenced predicate has an invalid signature. | | `PKCR004` | The default route handler has an invalid signature or more than one default handler is declared. | | `PKCR005` | A route name or route order is duplicated. | +| `PKRL001` | `[GenerateRecipientList]` was placed on a non-partial type. | +| `PKRL002` | The generated recipient list has no `[RecipientListRecipient]` methods. | +| `PKRL003` | A recipient handler or referenced predicate has an invalid signature. | +| `PKRL004` | A recipient name or recipient order is duplicated. | | `PKRS001`-`PKRS003` | Routing-slip generator validation. | | `PKSG001`-`PKSG004` | Saga generator validation. | @@ -68,8 +97,8 @@ Mailbox and reliability helpers stay runtime-only for now. Their registration is - Make the generated host type `partial`. - Keep route, step, and saga methods `static`; generated factories reference them directly. -- Use `nameof(PredicateMethod)` in `[ContentRoute]` so renames remain compile-time safe. -- Use unique route names and orders. Content routers are first-match, so ambiguous ordering should fail at build time. +- 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. - Ensure generated code builds under nullable enabled; the tests compile generated examples with Release settings. ## API @@ -77,14 +106,19 @@ Mailbox and reliability helpers stay runtime-only for now. Their registration is - - - +- +- - - - - - - +- ## Example Source - `src/PatternKit.Examples/Messaging/ContentRouterGeneratorExample.cs` - `test/PatternKit.Examples.Tests/Messaging/ContentRouterGeneratorExampleTests.cs` +- `src/PatternKit.Examples/Messaging/RecipientListGeneratorExample.cs` +- `test/PatternKit.Examples.Tests/Messaging/RecipientListGeneratorExampleTests.cs` diff --git a/docs/patterns/messaging/message-routing.md b/docs/patterns/messaging/message-routing.md index 5003b0f5..af652d6a 100644 --- a/docs/patterns/messaging/message-routing.md +++ b/docs/patterns/messaging/message-routing.md @@ -40,6 +40,25 @@ var result = recipients.Dispatch(Message.Create(new Order("order-1", 150m Use `AsyncRecipientList` for async recipient handlers. +Use `[GenerateRecipientList]` when the recipient map is part of application structure and should be compiled into a strongly typed factory: + +```csharp +[GenerateRecipientList(typeof(Order))] +public static partial class OrderRecipients +{ + private static bool IsPriority(Message message, MessageContext context) + => message.Payload.Priority == "priority"; + + [RecipientListRecipient("priority-audit", 10, nameof(IsPriority))] + private static void PriorityAudit(Message message, MessageContext context) + { + // deliver to audit sink + } +} +``` + +The generated factory returns the same `RecipientList` runtime type as the fluent API. + ## Splitter `Splitter` turns one message into item messages. Child messages preserve the parent headers. When the parent has a message id and no causation id, child messages set `CausationId` to the parent `MessageId`. @@ -95,6 +114,8 @@ Use external infrastructure for: - - - +- +- - - - diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index f46cd768..3b777e58 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -84,6 +84,7 @@ public sealed record PosTenderVisitorExample(TypeDispatcher RunAsync); public sealed record EventProcessingVisitorExample(Func RunAsync); public sealed record MessageRouterVisitorExample(Func Run); +public sealed record GeneratedRecipientListExample(RecipientListGeneratorExampleRunner Runner); public sealed record PatternsShowcaseExample(ShowcaseFacade Facade); public sealed record SourceGeneratorApplicationSuiteExample(Func> BuildProductionAsync); public sealed record EnterpriseMessagingWorkflowSuiteExample(Func Run); @@ -123,6 +124,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddApiExceptionMappingVisitorExample() .AddEventProcessingVisitorExample() .AddMessageRouterVisitorExample() + .AddGeneratedRecipientListExample() .AddPatternsShowcaseExample() .AddSourceGeneratorApplicationSuiteExample() .AddEnterpriseMessagingWorkflowSuiteExample() @@ -322,6 +324,13 @@ public static IServiceCollection AddMessageRouterVisitorExample(this IServiceCol return services.RegisterExample("Message Router Visitor", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddGeneratedRecipientListExample(this IServiceCollection services) + { + services.AddRecipientListGeneratorExample(); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Generated Recipient List", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); + } + public static IServiceCollection AddPatternsShowcaseExample(this IServiceCollection services) { services.AddSingleton(_ => PatternShowcase.PatternShowcase.Build()); diff --git a/src/PatternKit.Examples/Messaging/RecipientListGeneratorExample.cs b/src/PatternKit.Examples/Messaging/RecipientListGeneratorExample.cs new file mode 100644 index 00000000..cd5c1b48 --- /dev/null +++ b/src/PatternKit.Examples/Messaging/RecipientListGeneratorExample.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; + +namespace PatternKit.Examples.Messaging; + +/// +/// Demonstrates fluent and source-generated Recipient List integration for event fan-out. +/// +public static class RecipientListGeneratorExample +{ + public static RecipientListSummary RunFluent() + { + var deliveries = new List(); + var message = Message.Create(new("order-42", "priority", 125m)); + var context = MessageContext.From(message).WithItem(GeneratedShipmentRecipients.DeliveryLogKey, deliveries); + + var list = PatternKit.Messaging.Routing.RecipientList.Create() + .When("priority-audit", static (msg, _) => msg.Payload.Priority == "priority") + .Then(static (_, ctx) => GeneratedShipmentRecipients.Record(ctx, "priority-audit")) + .When("billing-ledger", static (msg, _) => msg.Payload.Total >= 100m) + .Then(static (_, ctx) => GeneratedShipmentRecipients.Record(ctx, "billing-ledger")) + .Build(); + + var result = list.Dispatch(message, context); + return new RecipientListSummary("fluent", result.DeliveredRecipients, deliveries); + } + + public static RecipientListSummary RunGenerated() + { + var deliveries = new List(); + var message = Message.Create(new("order-42", "priority", 125m)); + var context = MessageContext.From(message).WithItem(GeneratedShipmentRecipients.DeliveryLogKey, deliveries); + + var result = GeneratedShipmentRecipients.Create().Dispatch(message, context); + return new RecipientListSummary("source-generated", result.DeliveredRecipients, deliveries); + } + + public static IServiceCollection AddRecipientListGeneratorExample(this IServiceCollection services) + => services.AddSingleton(new RecipientListGeneratorExampleRunner(RunFluent, RunGenerated)); +} + +public sealed record RecipientListGeneratorExampleRunner( + Func RunFluent, + Func RunGenerated); + +public sealed record GeneratedShipmentEvent(string OrderId, string Priority, decimal Total); + +public sealed record RecipientListSummary( + string Path, + IReadOnlyList DeliveredRecipients, + IReadOnlyList DeliveryLog); + +[GenerateRecipientList(typeof(GeneratedShipmentEvent))] +public static partial class GeneratedShipmentRecipients +{ + internal const string DeliveryLogKey = "recipient-list-deliveries"; + + internal static void Record(MessageContext context, string recipient) + { + if (context.TryGetItem>(DeliveryLogKey, out var deliveries) && deliveries is not null) + deliveries.Add(recipient); + } + + private static bool IsPriority(Message message, MessageContext context) + => message.Payload.Priority == "priority"; + + private static bool IsBillable(Message message, MessageContext context) + => message.Payload.Total >= 100m; + + [RecipientListRecipient("priority-audit", 10, nameof(IsPriority))] + private static void PriorityAudit(Message message, MessageContext context) + => Record(context, "priority-audit"); + + [RecipientListRecipient("billing-ledger", 20, nameof(IsBillable))] + private static void BillingLedger(Message message, MessageContext context) + => Record(context, "billing-ledger"); +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index a1b4c278..e24673c7 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -232,6 +232,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator, ["ContentRouter", "RecipientList", "Splitter", "Aggregator", "RoutingSlip", "Saga", "Mailbox"], ["idempotency", "inbox/outbox", "generated dispatcher"]), + Descriptor( + "Generated Recipient List", + "src/PatternKit.Examples/Messaging/RecipientListGeneratorExample.cs", + "test/PatternKit.Examples.Tests/Messaging/RecipientListGeneratorExampleTests.cs", + "docs/examples/generated-recipient-list.md", + ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, + ["RecipientList"], + ["fan-out routing", "source-generated factory", "DI composition"]), Descriptor( "CQRS Dispatcher", "src/PatternKit.Examples/Messaging/CqrsPatternExample.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 2acd036c..19d58ea7 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -393,14 +393,14 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "docs/patterns/messaging/message-routing.md", "src/PatternKit.Core/Messaging/Routing/RecipientList.cs", "test/PatternKit.Tests/Messaging/Routing/RecipientListTests.cs", + "docs/generators/messaging.md", + "src/PatternKit.Generators/Messaging/RecipientListGenerator.cs", + "test/PatternKit.Generators.Tests/RecipientListGeneratorTests.cs", null, - null, - null, - "https://github.com/JerrettDavis/PatternKit/issues/210", - "docs/examples/enterprise-messaging-workflows.md", - "src/PatternKit.Examples/Messaging/MessageRoutingExample.cs", - "test/PatternKit.Examples.Tests/Messaging/MessageRoutingExampleTests.cs", - ["fluent recipient list", "generated recipient list tracked", "fan-out routing example"]), + "docs/examples/generated-recipient-list.md", + "src/PatternKit.Examples/Messaging/RecipientListGeneratorExample.cs", + "test/PatternKit.Examples.Tests/Messaging/RecipientListGeneratorExampleTests.cs", + ["fluent recipient list", "generated recipient list", "DI-importable fan-out example"]), Pattern("Splitter", PatternFamily.EnterpriseIntegration, "docs/patterns/messaging/message-routing.md", diff --git a/src/PatternKit.Generators.Abstractions/Messaging/RecipientListAttributes.cs b/src/PatternKit.Generators.Abstractions/Messaging/RecipientListAttributes.cs new file mode 100644 index 00000000..d99baa46 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Messaging/RecipientListAttributes.cs @@ -0,0 +1,55 @@ +using System; + +namespace PatternKit.Generators.Messaging; + +/// +/// Generates typed factory methods for a recipient-list class. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateRecipientListAttribute : Attribute +{ + /// Creates a recipient-list generator attribute. + public GenerateRecipientListAttribute(Type payloadType) + { + PayloadType = payloadType ?? throw new ArgumentNullException(nameof(payloadType)); + } + + /// Message payload type dispatched by generated recipient lists. + public Type PayloadType { get; } + + /// Name of the generated sync factory method. + public string FactoryName { get; set; } = "Create"; + + /// Name of the generated async factory method. + public string AsyncFactoryName { get; set; } = "CreateAsync"; +} + +/// +/// Marks a static method as a generated recipient-list handler. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class RecipientListRecipientAttribute : Attribute +{ + /// Creates a recipient-list recipient attribute. + public RecipientListRecipientAttribute(string name, int order, string predicateMethodName) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Recipient name cannot be null, empty, or whitespace.", nameof(name)); + + if (string.IsNullOrWhiteSpace(predicateMethodName)) + throw new ArgumentException("Recipient predicate method name cannot be null, empty, or whitespace.", nameof(predicateMethodName)); + + Name = name; + Order = order; + PredicateMethodName = predicateMethodName; + } + + /// Recipient name returned in delivered recipient results. + public string Name { get; } + + /// Recipient order in the generated recipient list. + public int Order { get; } + + /// Name of the static predicate method used by this recipient. + public string PredicateMethodName { get; } +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 4f534739..8f558f45 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -170,3 +170,7 @@ PKCR002 | PatternKit.Generators.Messaging | Error | Content router must declare PKCR003 | PatternKit.Generators.Messaging | Error | Content route handler or predicate signature is invalid. PKCR004 | PatternKit.Generators.Messaging | Error | Content router default handler signature is invalid. PKCR005 | PatternKit.Generators.Messaging | Error | Content router route name or order 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. +PKRL004 | PatternKit.Generators.Messaging | Error | Recipient name or order is duplicated. diff --git a/src/PatternKit.Generators/Messaging/RecipientListGenerator.cs b/src/PatternKit.Generators/Messaging/RecipientListGenerator.cs new file mode 100644 index 00000000..67080157 --- /dev/null +++ b/src/PatternKit.Generators/Messaging/RecipientListGenerator.cs @@ -0,0 +1,296 @@ +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 RecipientListGenerator : IIncrementalGenerator +{ + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKRL001", + "Recipient list type must be partial", + "Type '{0}' is marked with [GenerateRecipientList] but is not declared as partial", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingRecipients = new( + "PKRL002", + "Recipient list has no recipients", + "Type '{0}' is marked with [GenerateRecipientList] but does not declare any [RecipientListRecipient] methods", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidRecipient = new( + "PKRL003", + "Recipient list recipient signature is invalid", + "Recipient list handler '{0}' must be static and return void or ValueTask with the required message/context parameters and matching predicate", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor DuplicateRecipient = new( + "PKRL004", + "Recipient list recipient name or order is duplicated", + "Recipient list recipient '{0}' duplicates another recipient name or order in '{1}'", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + "PatternKit.Generators.Messaging.GenerateRecipientListAttribute", + 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.GenerateRecipientListAttribute"); + if (attr is null) + return; + + 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 == 1 + ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol + : null; + if (payloadType is null) + return; + + var hasRecipientAttributes = type.GetMembers().OfType().Any(static method => + method.GetAttributes().Any(static attr => + attr.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.RecipientListRecipientAttribute")); + var recipients = GetRecipients(type, payloadType, context); + if (recipients.Length == 0) + { + if (!hasRecipientAttributes) + context.ReportDiagnostic(Diagnostic.Create(MissingRecipients, node.Identifier.GetLocation(), type.Name)); + return; + } + + if (HasDuplicates(recipients, out var duplicate)) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateRecipient, duplicate.Location, duplicate.Name, type.Name)); + return; + } + + var syncRecipients = recipients.Where(static recipient => !recipient.IsAsync).OrderBy(static recipient => recipient.Order).ThenBy(static recipient => recipient.Name).ToArray(); + var asyncRecipients = recipients.Where(static recipient => recipient.IsAsync).OrderBy(static recipient => recipient.Order).ThenBy(static recipient => recipient.Name).ToArray(); + var config = new RecipientListConfig( + GetNamedString(attribute, "FactoryName") ?? "Create", + GetNamedString(attribute, "AsyncFactoryName") ?? "CreateAsync"); + + context.AddSource($"{type.Name}.RecipientList.g.cs", SourceText.From(GenerateSource(type, payloadType, syncRecipients, asyncRecipients, config), Encoding.UTF8)); + } + + private static ImmutableArray GetRecipients( + INamedTypeSymbol type, + INamedTypeSymbol payloadType, + SourceProductionContext context) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var method in type.GetMembers().OfType()) + { + var attr = method.GetAttributes().FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.RecipientListRecipientAttribute"); + if (attr is null) + continue; + + if (!TryGetRecipient(type, method, payloadType, attr, out var recipient)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidRecipient, method.Locations.FirstOrDefault(), method.Name)); + continue; + } + + builder.Add(recipient); + } + + return builder.ToImmutable(); + } + + private static bool TryGetRecipient( + INamedTypeSymbol type, + IMethodSymbol handler, + INamedTypeSymbol payloadType, + AttributeData attribute, + out Recipient recipient) + { + recipient = default; + if (!handler.IsStatic || attribute.ConstructorArguments.Length != 3) + return false; + + var name = attribute.ConstructorArguments[0].Value as string; + var order = attribute.ConstructorArguments[1].Value as int? ?? 0; + var predicateName = attribute.ConstructorArguments[2].Value as string; + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(predicateName)) + return false; + + var predicate = type.GetMembers(predicateName!).OfType().FirstOrDefault(); + if (predicate is null) + return false; + + if (IsSyncHandler(handler, payloadType) && IsSyncPredicate(predicate, payloadType)) + { + recipient = new Recipient(name!, order, predicate.Name, handler.Name, false, handler.Locations.FirstOrDefault()); + return true; + } + + if (IsAsyncHandler(handler, payloadType) && IsAsyncPredicate(predicate, payloadType)) + { + recipient = new Recipient(name!, order, predicate.Name, handler.Name, true, handler.Locations.FirstOrDefault()); + return true; + } + + return false; + } + + private static bool IsSyncPredicate(IMethodSymbol method, INamedTypeSymbol payloadType) + => method.IsStatic && + method.ReturnType.SpecialType == SpecialType.System_Boolean && + method.Parameters.Length == 2 && + IsMessageOfPayload(method.Parameters[0].Type, payloadType) && + method.Parameters[1].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext"; + + private static bool IsSyncHandler(IMethodSymbol method, INamedTypeSymbol payloadType) + => method.IsStatic && + method.ReturnsVoid && + method.Parameters.Length == 2 && + IsMessageOfPayload(method.Parameters[0].Type, payloadType) && + method.Parameters[1].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext"; + + private static bool IsAsyncPredicate(IMethodSymbol method, INamedTypeSymbol payloadType) + => method.IsStatic && + IsValueTaskOfBoolean(method.ReturnType) && + method.Parameters.Length == 3 && + IsMessageOfPayload(method.Parameters[0].Type, payloadType) && + method.Parameters[1].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext" && + method.Parameters[2].Type.ToDisplayString() == "System.Threading.CancellationToken"; + + private static bool IsAsyncHandler(IMethodSymbol method, INamedTypeSymbol payloadType) + => method.IsStatic && + method.ReturnType.ToDisplayString() == "System.Threading.Tasks.ValueTask" && + method.Parameters.Length == 3 && + IsMessageOfPayload(method.Parameters[0].Type, payloadType) && + method.Parameters[1].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext" && + method.Parameters[2].Type.ToDisplayString() == "System.Threading.CancellationToken"; + + private static bool IsMessageOfPayload(ITypeSymbol type, INamedTypeSymbol payloadType) + => type is INamedTypeSymbol named && + named.ConstructedFrom.ToDisplayString() == "PatternKit.Messaging.Message" && + SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], payloadType); + + private static bool IsValueTaskOfBoolean(ITypeSymbol type) + => type is INamedTypeSymbol named && + named.ConstructedFrom.ToDisplayString() == "System.Threading.Tasks.ValueTask" && + named.TypeArguments.Length == 1 && + named.TypeArguments[0].SpecialType == SpecialType.System_Boolean; + + private static bool HasDuplicates(IReadOnlyList recipients, out Recipient duplicate) + { + var names = new HashSet(System.StringComparer.Ordinal); + var orders = new HashSet(); + foreach (var recipient in recipients) + { + if (!names.Add(recipient.Name) || !orders.Add(recipient.Order)) + { + duplicate = recipient; + return true; + } + } + + duplicate = default; + return false; + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol payloadType, + IReadOnlyList syncRecipients, + IReadOnlyList asyncRecipients, + RecipientListConfig config) + { + 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(GetKind(type)).Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + if (syncRecipients.Count > 0) + { + sb.Append(" public static global::PatternKit.Messaging.Routing.RecipientList<") + .Append(payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append("> ") + .Append(config.FactoryName) + .AppendLine("()"); + sb.AppendLine(" => global::PatternKit.Messaging.Routing.RecipientList<" + payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ">.Create()"); + foreach (var recipient in syncRecipients) + sb.Append(" .When(\"").Append(Escape(recipient.Name)).Append("\", ").Append(recipient.PredicateMethodName).Append(").Then(").Append(recipient.HandlerMethodName).AppendLine(")"); + sb.AppendLine(" .Build();"); + sb.AppendLine(); + } + + if (asyncRecipients.Count > 0) + { + sb.Append(" public static global::PatternKit.Messaging.Routing.AsyncRecipientList<") + .Append(payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append("> ") + .Append(config.AsyncFactoryName) + .AppendLine("()"); + sb.AppendLine(" => global::PatternKit.Messaging.Routing.AsyncRecipientList<" + payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ">.Create()"); + foreach (var recipient in asyncRecipients) + sb.Append(" .When(\"").Append(Escape(recipient.Name)).Append("\", ").Append(recipient.PredicateMethodName).Append(").Then(").Append(recipient.HandlerMethodName).AppendLine(")"); + sb.AppendLine(" .Build();"); + } + + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string GetKind(INamedTypeSymbol type) + => type.TypeKind == TypeKind.Struct ? "struct" : "class"; + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private readonly record struct Recipient( + string Name, + int Order, + string PredicateMethodName, + string HandlerMethodName, + bool IsAsync, + Location? Location); + + private readonly record struct RecipientListConfig(string FactoryName, string AsyncFactoryName); +} diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index 69846563..5f86f70f 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -80,6 +80,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var template = provider.GetRequiredService(); var asyncTemplate = provider.GetRequiredService(); var routing = provider.GetRequiredService(); + var generatedRecipients = provider.GetRequiredService(); var envelope = provider.GetRequiredService(); var cqrs = provider.GetRequiredService(); var checkout = provider.GetRequiredService(); @@ -108,6 +109,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() .GetResult(); var state = asyncState.RunAsync(["connect", "ok"]).GetAwaiter().GetResult(); var asyncResult = asyncTemplate.Pipeline.ExecuteAsync(7, CancellationToken.None).GetAwaiter().GetResult(); + var generatedRecipientList = generatedRecipients.Runner.RunGenerated(); var cqrsFluent = cqrs.RunFluentAsync(CancellationToken.None).GetAwaiter().GetResult(); var cqrsGenerated = cqrs.RunSourceGeneratedAsync(provider, CancellationToken.None).GetAwaiter().GetResult(); editor.Editor.Insert("hello"); @@ -134,6 +136,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("template method counts words", template.Processor.Execute("one two") == 2), ("async template method formats payloads", asyncResult == "PAYLOAD:7"), ("message router visitor aggregates totals", routing.Run().AggregatedTotal == 100m), + ("generated recipient list delivers billing and audit recipients", generatedRecipientList.DeliveredRecipients.Count == 2), ("message envelope example tracks first attempt", envelope.Run().Attempt == 1), ("CQRS fluent path matches command writes to query reads", cqrsFluent.QueryMatchedCommand), ("CQRS generated path matches command writes to query reads", cqrsGenerated.QueryMatchedCommand), diff --git a/test/PatternKit.Examples.Tests/Messaging/RecipientListGeneratorExampleTests.cs b/test/PatternKit.Examples.Tests/Messaging/RecipientListGeneratorExampleTests.cs new file mode 100644 index 00000000..5d2e1533 --- /dev/null +++ b/test/PatternKit.Examples.Tests/Messaging/RecipientListGeneratorExampleTests.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.Messaging; +using PatternKit.Examples.ProductionReadiness; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.Messaging; + +[Feature("Generated recipient-list example")] +public sealed class RecipientListGeneratorExampleTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent and generated recipient lists deliver the same recipients")] + [Fact] + public Task Fluent_And_Generated_Recipient_Lists_Deliver_The_Same_Recipients() + => Given("recipient-list example entry points", () => + new RecipientListExampleEntrypoints( + RecipientListGeneratorExample.RunFluent, + RecipientListGeneratorExample.RunGenerated)) + .When("running both recipient-list paths", runners => new + { + Fluent = runners.Fluent(), + Generated = runners.Generated() + }) + .Then("both paths fan out to the same recipients", result => + ScenarioExpect.Equal(result.Fluent.DeliveredRecipients, result.Generated.DeliveredRecipients)) + .And("both handlers record delivery side effects", result => + ScenarioExpect.Equal(["priority-audit", "billing-ledger"], result.Generated.DeliveryLog)) + .And("the generated path advertises its source-generated route", result => + ScenarioExpect.Equal("source-generated", result.Generated.Path)) + .AssertPassed(); + + [Scenario("Generated recipient-list example is importable through IServiceCollection")] + [Fact] + public Task Generated_Recipient_List_Example_Is_Importable_Through_IServiceCollection() + => Given("a service collection using the PatternKit recipient-list extension", () => + { + var services = new ServiceCollection(); + services.AddGeneratedRecipientListExample(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("resolving and running the generated recipient-list example", provider => + { + using (provider) + { + var example = provider.GetRequiredService(); + var summary = example.Runner.RunGenerated(); + var descriptor = provider.GetServices() + .Single(descriptor => descriptor.ExampleName == "Generated Recipient List"); + + return new RecipientListImportRun(summary, descriptor.Integration); + } + }) + .Then("the generated runner dispatches to expected recipients", result => + ScenarioExpect.Equal(["priority-audit", "billing-ledger"], result.Summary.DeliveredRecipients)) + .And("the descriptor advertises DI and source generation", result => + result.Integration.HasFlag(ExampleIntegrationSurface.DependencyInjection) + && result.Integration.HasFlag(ExampleIntegrationSurface.SourceGenerator)) + .AssertPassed(); + + private sealed record RecipientListImportRun( + RecipientListSummary Summary, + ExampleIntegrationSurface Integration); + + private sealed record RecipientListExampleEntrypoints( + Func Fluent, + Func Generated); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index fee1cedd..2f865faa 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -129,7 +129,6 @@ public Task Each_Pattern_Has_Fluent_Generated_Documented_And_Example_Paths() "Message Envelope has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/215", "Outbox has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/213", "Publish-Subscribe has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/214", - "Recipient List has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/210", "Request-Reply has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/214", "Splitter has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/211" ], tracked); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 4b3e331d..f9d08968 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -333,6 +333,12 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf FactoryName = "BuildRouter" }; var route = new ContentRouteAttribute("priority", 4, "IsPriority"); + var recipientList = new GenerateRecipientListAttribute(typeof(string)) + { + FactoryName = "BuildRecipients", + AsyncFactoryName = "BuildRecipientsAsync" + }; + var recipient = new RecipientListRecipientAttribute("priority-audit", 5, "IsPriority"); ScenarioExpect.Equal(typeof(string), flyweight.KeyType); ScenarioExpect.Equal("SymbolCache", flyweight.CacheTypeName); @@ -363,6 +369,12 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Equal("priority", route.Name); ScenarioExpect.Equal(4, route.Order); ScenarioExpect.Equal("IsPriority", route.PredicateMethodName); + ScenarioExpect.Equal(typeof(string), recipientList.PayloadType); + ScenarioExpect.Equal("BuildRecipients", recipientList.FactoryName); + ScenarioExpect.Equal("BuildRecipientsAsync", recipientList.AsyncFactoryName); + ScenarioExpect.Equal("priority-audit", recipient.Name); + ScenarioExpect.Equal(5, recipient.Order); + ScenarioExpect.Equal("IsPriority", recipient.PredicateMethodName); ScenarioExpect.Throws(() => new GenerateRoutingSlipAttribute(null!)); ScenarioExpect.Throws(() => new RoutingSlipStepAttribute("", 1)); ScenarioExpect.Throws(() => new GenerateSagaAttribute(null!)); @@ -371,6 +383,9 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Throws(() => new GenerateContentRouterAttribute(typeof(string), null!)); ScenarioExpect.Throws(() => new ContentRouteAttribute("", 1, "Predicate")); ScenarioExpect.Throws(() => new ContentRouteAttribute("name", 1, "")); + ScenarioExpect.Throws(() => new GenerateRecipientListAttribute(null!)); + ScenarioExpect.Throws(() => new RecipientListRecipientAttribute("", 1, "Predicate")); + ScenarioExpect.Throws(() => new RecipientListRecipientAttribute("name", 1, "")); ScenarioExpect.IsType(new SagaCompleteWhenAttribute()); ScenarioExpect.IsType(new ContentRouteDefaultAttribute()); ScenarioExpect.IsType(new FlyweightFactoryAttribute()); diff --git a/test/PatternKit.Generators.Tests/RecipientListGeneratorTests.cs b/test/PatternKit.Generators.Tests/RecipientListGeneratorTests.cs new file mode 100644 index 00000000..815de57c --- /dev/null +++ b/test/PatternKit.Generators.Tests/RecipientListGeneratorTests.cs @@ -0,0 +1,225 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.Messaging; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class RecipientListGeneratorTests +{ + [Scenario("Generates sync recipient-list factory")] + [Fact] + public void GeneratesSyncRecipientListFactory() + { + var source = """ + using System.Linq; + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + + namespace MyApp; + + public sealed record Order(string Channel); + + [GenerateRecipientList(typeof(Order), FactoryName = "Build")] + public static partial class OrderRecipients + { + private static bool IsRetail(Message message, MessageContext context) + => message.Payload.Channel == "retail"; + + private static bool IsWholesale(Message message, MessageContext context) + => message.Payload.Channel == "wholesale"; + + [RecipientListRecipient("wholesale-audit", 20, nameof(IsWholesale))] + private static void WholesaleAudit(Message message, MessageContext context) { } + + [RecipientListRecipient("retail-audit", 10, nameof(IsRetail))] + private static void RetailAudit(Message message, MessageContext context) { } + } + + public static class Demo + { + public static string[] Run() + => OrderRecipients.Build() + .Dispatch(Message.Create(new Order("retail"))) + .DeliveredRecipients + .ToArray(); + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesSyncRecipientListFactory)); + var gen = new RecipientListGenerator(); + _ = 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("OrderRecipients.RecipientList.g.cs", generated.HintName); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains(".When(\"retail-audit\", IsRetail).Then(RetailAudit)", text); + ScenarioExpect.Contains(".When(\"wholesale-audit\", IsWholesale).Then(WholesaleAudit)", text); + ScenarioExpect.True(text.IndexOf("retail-audit", StringComparison.Ordinal) < text.IndexOf("wholesale-audit", StringComparison.Ordinal)); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Generates async recipient-list factory")] + [Fact] + public void GeneratesAsyncRecipientListFactory() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + + namespace MyApp; + + public sealed record Order(string Channel); + + [GenerateRecipientList(typeof(Order), AsyncFactoryName = "BuildAsync")] + public static partial class OrderRecipients + { + private static ValueTask IsPriority(Message message, MessageContext context, CancellationToken cancellationToken) + => new(message.Payload.Channel == "priority"); + + [RecipientListRecipient("priority-audit", 10, nameof(IsPriority))] + private static ValueTask PriorityAudit(Message message, MessageContext context, CancellationToken cancellationToken) + => ValueTask.CompletedTask; + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesAsyncRecipientListFactory)); + var gen = new RecipientListGenerator(); + _ = 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.Contains("AsyncRecipientList", generated.SourceText.ToString()); + ScenarioExpect.Contains(".When(\"priority-audit\", IsPriority).Then(PriorityAudit)", generated.SourceText.ToString()); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Reports diagnostic for non-partial recipient list")] + [Fact] + public void ReportsDiagnosticForNonPartialRecipientList() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + + namespace MyApp; + + public sealed record Order(string Channel); + + [GenerateRecipientList(typeof(Order))] + public static class OrderRecipients + { + private static bool IsRetail(Message message, MessageContext context) => true; + + [RecipientListRecipient("retail", 10, nameof(IsRetail))] + private static void Retail(Message message, MessageContext context) { } + } + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForNonPartialRecipientList)); + var gen = new RecipientListGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKRL001", diagnostic.Id); + } + + [Scenario("Reports diagnostic for missing recipients")] + [Fact] + public void ReportsDiagnosticForMissingRecipients() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record Order(string Channel); + + [GenerateRecipientList(typeof(Order))] + public static partial class OrderRecipients; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForMissingRecipients)); + var gen = new RecipientListGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKRL002", diagnostic.Id); + } + + [Scenario("Reports diagnostic for invalid recipient signature")] + [Fact] + public void ReportsDiagnosticForInvalidRecipientSignature() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + + namespace MyApp; + + public sealed record Order(string Channel); + + [GenerateRecipientList(typeof(Order))] + public static partial class OrderRecipients + { + private static bool IsRetail(Message message, MessageContext context) => true; + + [RecipientListRecipient("retail", 10, nameof(IsRetail))] + private static int Retail(Message message, MessageContext context) => 1; + } + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForInvalidRecipientSignature)); + var gen = new RecipientListGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKRL003", diagnostic.Id); + } + + [Scenario("Reports diagnostic for duplicate recipient name or order")] + [Fact] + public void ReportsDiagnosticForDuplicateRecipientNameOrOrder() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + + namespace MyApp; + + public sealed record Order(string Channel); + + [GenerateRecipientList(typeof(Order))] + public static partial class OrderRecipients + { + private static bool Always(Message message, MessageContext context) => true; + + [RecipientListRecipient("audit", 10, nameof(Always))] + private static void Audit(Message message, MessageContext context) { } + + [RecipientListRecipient("billing", 10, nameof(Always))] + private static void Billing(Message message, MessageContext context) { } + } + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForDuplicateRecipientNameOrOrder)); + var gen = new RecipientListGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKRL004", diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: MetadataReference.CreateFromFile(typeof(PatternKit.Messaging.Message<>).Assembly.Location)); +}