From 09c098445058f8d74162fde59ed4132ce9c48e03 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Tue, 19 May 2026 22:53:56 -0500 Subject: [PATCH 1/2] feat: add message envelope generator --- .../enterprise-messaging-workflows.md | 3 +- docs/examples/generated-message-envelope.md | 60 +++++ docs/examples/index.md | 3 + docs/examples/toc.yml | 3 + docs/generators/index.md | 6 + docs/generators/messaging.md | 25 +- docs/guides/pattern-coverage.md | 2 +- docs/patterns/messaging/message-envelope.md | 30 +++ ...rnKitExampleServiceCollectionExtensions.cs | 9 + .../Messaging/MessageEnvelopeExample.cs | 64 ++++- .../PatternKitExampleCatalog.cs | 12 +- .../PatternKitPatternCatalog.cs | 10 +- .../Messaging/MessageEnvelopeAttributes.cs | 51 ++++ .../AnalyzerReleases.Unshipped.md | 4 + .../Messaging/MessageEnvelopeGenerator.cs | 245 ++++++++++++++++++ .../Messaging/MessageEnvelopeExampleTests.cs | 84 +++++- .../PatternKitPatternCatalogTests.cs | 1 - .../AbstractionsAttributeCoverageTests.cs | 20 ++ .../MessageEnvelopeGeneratorTests.cs | 156 +++++++++++ 19 files changed, 762 insertions(+), 26 deletions(-) create mode 100644 docs/examples/generated-message-envelope.md create mode 100644 src/PatternKit.Generators.Abstractions/Messaging/MessageEnvelopeAttributes.cs create mode 100644 src/PatternKit.Generators/Messaging/MessageEnvelopeGenerator.cs create mode 100644 test/PatternKit.Generators.Tests/MessageEnvelopeGeneratorTests.cs diff --git a/docs/examples/enterprise-messaging-workflows.md b/docs/examples/enterprise-messaging-workflows.md index 1a9a6b9d..bd5ece62 100644 --- a/docs/examples/enterprise-messaging-workflows.md +++ b/docs/examples/enterprise-messaging-workflows.md @@ -12,6 +12,7 @@ Example source: | Pattern | Example source | What it demonstrates | | --- | --- | --- | | Message envelope/context | `MessageEnvelopeExample.cs` | Correlation, causation, idempotency, headers, typed payloads, and execution-scoped context. | +| Source-generated message envelope | `MessageEnvelopeExample.cs` | Required-header contract factories for stable integration boundaries. | | Content router | `MessageRoutingExample.cs` | First-match routing of orders to named destinations. | | Recipient list | `MessageRoutingExample.cs` | Fan-out to multiple interested recipients. | | Splitter | `MessageRoutingExample.cs` | Splitting one aggregate message into line-level messages. | @@ -32,7 +33,7 @@ Example source: A production application usually combines these primitives in layers: -1. Accept or create a `Message` at the boundary with correlation, causation, and idempotency headers. +1. Accept or create a `Message` at the boundary with correlation, causation, and idempotency headers. Use generated envelope contracts when the header set is stable. 2. Route the message through a content router, recipient list, splitter, or routing slip. 3. Serialize stateful handlers through a mailbox when concurrency must be constrained. 4. Use a saga/process manager when multiple messages update long-running state. diff --git a/docs/examples/generated-message-envelope.md b/docs/examples/generated-message-envelope.md new file mode 100644 index 00000000..98238b50 --- /dev/null +++ b/docs/examples/generated-message-envelope.md @@ -0,0 +1,60 @@ +# Generated Message Envelope + +This example shows the runtime `Message` envelope path beside a source-generated contract factory. Use this shape when an application boundary must always attach the same headers before a message enters routers, routing slips, sagas, mailboxes, or reliability components. + +Source: + +- `src/PatternKit.Examples/Messaging/MessageEnvelopeExample.cs` +- `test/PatternKit.Examples.Tests/Messaging/MessageEnvelopeExampleTests.cs` + +## Runtime Path + +```csharp +var message = Message + .Create(new OrderAccepted("order-42", 199.95m)) + .WithMessageId("msg-100") + .WithCorrelationId("order-42") + .WithCausationId("checkout-7") + .WithIdempotencyKey("order-42:accepted") + .WithContentType("application/vnd.patternkit.order+json"); +``` + +The fluent path is useful when headers come from configuration, a transport adapter, or user-owned middleware. + +## Generated Path + +```csharp +[GenerateMessageEnvelope(typeof(OrderAccepted), FactoryName = "CreateAccepted")] +[MessageEnvelopeHeader("message-id", typeof(string), ParameterName = "messageId")] +[MessageEnvelopeHeader("correlation-id", typeof(string), ParameterName = "correlationId")] +[MessageEnvelopeHeader("causation-id", typeof(string), ParameterName = "causationId")] +[MessageEnvelopeHeader("idempotency-key", typeof(string), ParameterName = "idempotencyKey")] +[MessageEnvelopeHeader("content-type", typeof(string), ParameterName = "contentType")] +public static partial class GeneratedOrderAcceptedEnvelope; +``` + +The generated factory requires every declared header as a typed parameter: + +```csharp +var message = GeneratedOrderAcceptedEnvelope.CreateAccepted( + new OrderAccepted("order-42", 199.95m), + "msg-100", + "order-42", + "checkout-7", + "order-42:accepted", + "application/vnd.patternkit.order+json"); + +var context = GeneratedOrderAcceptedEnvelope.CreateContext(message); +``` + +## DI Integration + +The example is importable through the standard container: + +```csharp +services.AddGeneratedMessageEnvelopeExample(); +var runner = provider.GetRequiredService().Runner; +var generated = runner.RunGenerated(); +``` + +The extension registers the runner and production-readiness descriptor used by the examples catalog. diff --git a/docs/examples/index.md b/docs/examples/index.md index 3cabc741..54c1dc84 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 Message Envelope** + Shows fluent and source-generated message envelope contracts side by side, with an importable `IServiceCollection` extension. See [Generated Message Envelope](generated-message-envelope.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). diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index f0a67577..f2425ebf 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 Message Envelope + href: generated-message-envelope.md + - name: Generated Recipient List href: generated-recipient-list.md diff --git a/docs/generators/index.md b/docs/generators/index.md index 4f6059a2..264e9c41 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -64,6 +64,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | Generator | Description | Attribute | |---|---|---| | [**Dispatcher**](dispatcher.md) | Mediator pattern with commands, notifications, and streams | `[GenerateDispatcher]` | +| [**Message Envelope**](messaging.md#generated-message-envelope) | Required message metadata contracts | `[GenerateMessageEnvelope]` | | [**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]` | @@ -137,6 +138,11 @@ public interface IDocumentVisitor { } // Dispatcher - mediator pattern [assembly: GenerateDispatcher(Namespace = "MyApp", Name = "Dispatcher")] +// Message envelope - generated required-header contract +[GenerateMessageEnvelope(typeof(OrderAccepted))] +[MessageEnvelopeHeader("correlation-id", typeof(string))] +public static partial class OrderAcceptedEnvelope { } + // Content router - generated first-match route factory [GenerateContentRouter(typeof(Order), typeof(string))] public static partial class OrderRouter { } diff --git a/docs/generators/messaging.md b/docs/generators/messaging.md index 1894fa63..905b95d2 100644 --- a/docs/generators/messaging.md +++ b/docs/generators/messaging.md @@ -1,8 +1,9 @@ # Messaging Generators -PatternKit includes five messaging-oriented source generators: +PatternKit includes six messaging-oriented source generators: - for source-generated mediator dispatchers. +- for required message-envelope contracts. - for content-based message routers. - for recipient-list fan-out. - for ordered routing-slip factories. @@ -30,6 +31,27 @@ Example source: - `src/PatternKit.Examples/MediatorComprehensiveDemo/ComprehensiveDemo.cs` - `test/PatternKit.Examples.Tests/Messaging/DispatcherExampleTests.cs` +## Generated Message Envelope + +`[GenerateMessageEnvelope]` creates typed factories for message contracts that require a stable set of headers: + +```csharp +using PatternKit.Generators.Messaging; + +[GenerateMessageEnvelope(typeof(OrderAccepted), FactoryName = "CreateAccepted")] +[MessageEnvelopeHeader("message-id", typeof(string), ParameterName = "messageId")] +[MessageEnvelopeHeader("correlation-id", typeof(string), ParameterName = "correlationId")] +[MessageEnvelopeHeader("tenant-id", typeof(string), ParameterName = "tenantId")] +public static partial class OrderAcceptedEnvelope; +``` + +The generated factory returns `Message` and writes every required header. It also emits a context factory so the same contract can start routing, saga, mailbox, or reliability workflows without manual `MessageContext.From(...)` boilerplate. + +Example source: + +- `src/PatternKit.Examples/Messaging/MessageEnvelopeExample.cs` +- `test/PatternKit.Examples.Tests/Messaging/MessageEnvelopeExampleTests.cs` + ## Generated Content Router `[GenerateContentRouter]` creates a `ContentRouter` factory from static route methods: @@ -148,6 +170,7 @@ Example source: | ID | Generator | Meaning | | --- | --- | --- | | `PKDSP001`-`PKDSP004` | Dispatcher | Invalid dispatcher configuration or handler registration. | +| `PKME001`-`PKME004` | Message Envelope | Non-partial host, missing headers, invalid header configuration, or duplicate names. | | `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. | diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 1be57f44..3ec20591 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -44,7 +44,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Family | Pattern | Fluent/runtime path | Source-generated path | | --- | --- | --- | --- | -| Enterprise Integration | Message Envelope | `Message`, headers, context | Tracked in [#215](https://github.com/JerrettDavis/PatternKit/issues/215) | +| Enterprise Integration | Message Envelope | `Message`, headers, context | Messaging generator | | Enterprise Integration | Content-Based Router | `ContentRouter` | Messaging generator | | Enterprise Integration | Recipient List | `RecipientList` | Messaging generator | | Enterprise Integration | Splitter | `Splitter` | Tracked in [#211](https://github.com/JerrettDavis/PatternKit/issues/211) | diff --git a/docs/patterns/messaging/message-envelope.md b/docs/patterns/messaging/message-envelope.md index bc5669af..2ca233f4 100644 --- a/docs/patterns/messaging/message-envelope.md +++ b/docs/patterns/messaging/message-envelope.md @@ -94,6 +94,34 @@ if (context.TryGetItem("attempt", out var attempt)) } ``` +## Source-Generated Contracts + +Use `[GenerateMessageEnvelope]` when an application boundary has a stable envelope contract and every message must include the same required headers: + +```csharp +using PatternKit.Generators.Messaging; + +[GenerateMessageEnvelope(typeof(OrderAccepted), FactoryName = "CreateAccepted")] +[MessageEnvelopeHeader("message-id", typeof(string), ParameterName = "messageId")] +[MessageEnvelopeHeader("correlation-id", typeof(string), ParameterName = "correlationId")] +[MessageEnvelopeHeader("idempotency-key", typeof(string), ParameterName = "idempotencyKey")] +public static partial class OrderAcceptedEnvelope; +``` + +The generated factory returns `Message` and requires each header as a typed parameter. The generated context factory starts an execution context from the same contract: + +```csharp +var message = OrderAcceptedEnvelope.CreateAccepted( + new OrderAccepted("order-42", 199.95m), + "msg-100", + "order-42", + "order-42:accepted"); + +var context = OrderAcceptedEnvelope.CreateContext(message); +``` + +Prefer the fluent runtime API when the header set is dynamic. Prefer the generated path when the contract is stable and should fail at compile time if a required header is omitted. + ## Relationship To Other Patterns `Message` and `MessageContext` are not a replacement for a mediator, observer, or broker. They are shared metadata primitives for higher-level patterns: @@ -110,6 +138,8 @@ if (context.TryGetItem("attempt", out var attempt)) - - - +- +- ## Example Source diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 3b777e58..66320a9f 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 GeneratedMessageEnvelopeExample(MessageEnvelopeExampleRunner Runner); public sealed record GeneratedRecipientListExample(RecipientListGeneratorExampleRunner Runner); public sealed record PatternsShowcaseExample(ShowcaseFacade Facade); public sealed record SourceGeneratorApplicationSuiteExample(Func> BuildProductionAsync); @@ -124,6 +125,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddApiExceptionMappingVisitorExample() .AddEventProcessingVisitorExample() .AddMessageRouterVisitorExample() + .AddGeneratedMessageEnvelopeExample() .AddGeneratedRecipientListExample() .AddPatternsShowcaseExample() .AddSourceGeneratorApplicationSuiteExample() @@ -324,6 +326,13 @@ public static IServiceCollection AddMessageRouterVisitorExample(this IServiceCol return services.RegisterExample("Message Router Visitor", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddGeneratedMessageEnvelopeExample(this IServiceCollection services) + { + services.AddMessageEnvelopeExample(); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Generated Message Envelope", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); + } + public static IServiceCollection AddGeneratedRecipientListExample(this IServiceCollection services) { services.AddRecipientListGeneratorExample(); diff --git a/src/PatternKit.Examples/Messaging/MessageEnvelopeExample.cs b/src/PatternKit.Examples/Messaging/MessageEnvelopeExample.cs index 82ee31cf..54e9a86e 100644 --- a/src/PatternKit.Examples/Messaging/MessageEnvelopeExample.cs +++ b/src/PatternKit.Examples/Messaging/MessageEnvelopeExample.cs @@ -1,4 +1,6 @@ using PatternKit.Messaging; +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Generators.Messaging; namespace PatternKit.Examples.Messaging; @@ -10,7 +12,12 @@ public static class MessageEnvelopeExample /// /// Builds an enriched message and context, returning the metadata needed by tests and docs. /// - public static Summary Run() + public static Summary Run() => RunFluent(); + + /// + /// Builds the message envelope through the fluent runtime API. + /// + public static Summary RunFluent() { var message = Message .Create(new OrderAccepted("order-42", 199.95m)) @@ -28,6 +35,39 @@ public static Summary Run() context.TryGetItem("attempt", out var attempt); return new Summary( + "fluent", + message.Payload.OrderId, + message.Headers.MessageId!, + message.Headers.CorrelationId!, + message.Headers.CausationId!, + message.Headers.IdempotencyKey!, + message.Headers.ContentType!, + context.Headers.GetString("route")!, + attempt); + } + + /// + /// Builds the same message envelope through a generated contract factory. + /// + public static Summary RunGenerated() + { + var message = GeneratedOrderAcceptedEnvelope.CreateAccepted( + new OrderAccepted("order-42", 199.95m), + "msg-100", + "order-42", + "checkout-7", + "order-42:accepted", + "application/vnd.patternkit.order+json"); + + var context = GeneratedOrderAcceptedEnvelope + .CreateContext(message) + .WithHeader("route", "billing") + .WithItem("attempt", 1); + + context.TryGetItem("attempt", out var attempt); + + return new Summary( + "source-generated", message.Payload.OrderId, message.Headers.MessageId!, message.Headers.CorrelationId!, @@ -39,11 +79,33 @@ public static Summary Run() } } +/// +/// Registers the message-envelope example with a standard .NET service collection. +/// +public static class MessageEnvelopeExampleServiceCollectionExtensions +{ + /// Adds the generated and fluent message-envelope example runner. + public static IServiceCollection AddMessageEnvelopeExample(this IServiceCollection services) + => services.AddSingleton(new MessageEnvelopeExampleRunner(MessageEnvelopeExample.RunFluent, MessageEnvelopeExample.RunGenerated)); +} + +/// DI-importable runner for the message-envelope example. +public sealed record MessageEnvelopeExampleRunner(Func RunFluent, Func RunGenerated); + +[GenerateMessageEnvelope(typeof(OrderAccepted), FactoryName = "CreateAccepted")] +[MessageEnvelopeHeader("message-id", typeof(string), ParameterName = "messageId")] +[MessageEnvelopeHeader("correlation-id", typeof(string), ParameterName = "correlationId")] +[MessageEnvelopeHeader("causation-id", typeof(string), ParameterName = "causationId")] +[MessageEnvelopeHeader("idempotency-key", typeof(string), ParameterName = "idempotencyKey")] +[MessageEnvelopeHeader("content-type", typeof(string), ParameterName = "contentType")] +public static partial class GeneratedOrderAcceptedEnvelope; + /// Example payload for the message envelope demo. public sealed record OrderAccepted(string OrderId, decimal Total); /// Example output for the message envelope demo. public sealed record Summary( + string Path, string OrderId, string MessageId, string CorrelationId, diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index e24673c7..8982e8df 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -230,8 +230,16 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog "test/PatternKit.Examples.Tests/Messaging/MessageEnvelopeExampleTests.cs", "docs/examples/enterprise-messaging-workflows.md", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator, - ["ContentRouter", "RecipientList", "Splitter", "Aggregator", "RoutingSlip", "Saga", "Mailbox"], - ["idempotency", "inbox/outbox", "generated dispatcher"]), + ["MessageEnvelope", "ContentRouter", "RecipientList", "Splitter", "Aggregator", "RoutingSlip", "Saga", "Mailbox"], + ["idempotency", "inbox/outbox", "generated envelope contracts", "generated dispatcher"]), + Descriptor( + "Generated Message Envelope", + "src/PatternKit.Examples/Messaging/MessageEnvelopeExample.cs", + "test/PatternKit.Examples.Tests/Messaging/MessageEnvelopeExampleTests.cs", + "docs/examples/generated-message-envelope.md", + ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, + ["MessageEnvelope"], + ["required headers", "source-generated factory", "DI composition"]), Descriptor( "Generated Recipient List", "src/PatternKit.Examples/Messaging/RecipientListGeneratorExample.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 19d58ea7..5b5e6bba 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -367,14 +367,14 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "docs/patterns/messaging/message-envelope.md", "src/PatternKit.Core/Messaging/Message.cs", "test/PatternKit.Tests/Messaging/MessageTests.cs", + "docs/generators/messaging.md", + "src/PatternKit.Generators/Messaging/MessageEnvelopeGenerator.cs", + "test/PatternKit.Generators.Tests/MessageEnvelopeGeneratorTests.cs", null, - null, - null, - "https://github.com/JerrettDavis/PatternKit/issues/215", - "docs/examples/enterprise-messaging-workflows.md", + "docs/examples/generated-message-envelope.md", "src/PatternKit.Examples/Messaging/MessageEnvelopeExample.cs", "test/PatternKit.Examples.Tests/Messaging/MessageEnvelopeExampleTests.cs", - ["runtime envelope and headers", "generated contract path tracked", "enterprise workflow example"]), + ["runtime envelope and headers", "generated required-header contract", "DI-importable envelope example"]), Pattern("Content-Based Router", PatternFamily.EnterpriseIntegration, "docs/patterns/messaging/message-routing.md", diff --git a/src/PatternKit.Generators.Abstractions/Messaging/MessageEnvelopeAttributes.cs b/src/PatternKit.Generators.Abstractions/Messaging/MessageEnvelopeAttributes.cs new file mode 100644 index 00000000..4421103e --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Messaging/MessageEnvelopeAttributes.cs @@ -0,0 +1,51 @@ +using System; + +namespace PatternKit.Generators.Messaging; + +/// +/// Generates typed factories for a message envelope contract. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateMessageEnvelopeAttribute : Attribute +{ + /// Creates a message-envelope generator attribute. + public GenerateMessageEnvelopeAttribute(Type payloadType) + { + PayloadType = payloadType ?? throw new ArgumentNullException(nameof(payloadType)); + } + + /// Payload type carried by the generated message factory. + public Type PayloadType { get; } + + /// Name of the generated message factory method. + public string FactoryName { get; set; } = "Create"; + + /// Name of the generated context factory method. + public string ContextFactoryName { get; set; } = "CreateContext"; +} + +/// +/// Declares a required header for a generated message envelope contract. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true, Inherited = false)] +public sealed class MessageEnvelopeHeaderAttribute : Attribute +{ + /// Creates a message-envelope header declaration. + public MessageEnvelopeHeaderAttribute(string name, Type valueType) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Header name cannot be null, empty, or whitespace.", nameof(name)); + + Name = name; + ValueType = valueType ?? throw new ArgumentNullException(nameof(valueType)); + } + + /// Header name written into MessageHeaders. + public string Name { get; } + + /// Type of the generated factory parameter for this header. + public Type ValueType { get; } + + /// Optional generated factory parameter name. When omitted, the header name is converted to a camel-case identifier. + public string? ParameterName { get; set; } +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 8f558f45..85fe85ad 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -170,6 +170,10 @@ 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. +PKME001 | PatternKit.Generators.Messaging | Error | Message envelope type must be partial. +PKME002 | PatternKit.Generators.Messaging | Error | Message envelope must declare at least one required header. +PKME003 | PatternKit.Generators.Messaging | Error | Message envelope header configuration is invalid. +PKME004 | PatternKit.Generators.Messaging | Error | Message envelope header name or generated parameter name 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/MessageEnvelopeGenerator.cs b/src/PatternKit.Generators/Messaging/MessageEnvelopeGenerator.cs new file mode 100644 index 00000000..585bddac --- /dev/null +++ b/src/PatternKit.Generators/Messaging/MessageEnvelopeGenerator.cs @@ -0,0 +1,245 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +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 MessageEnvelopeGenerator : IIncrementalGenerator +{ + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKME001", + "Message envelope type must be partial", + "Type '{0}' is marked with [GenerateMessageEnvelope] but is not declared as partial", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingHeaders = new( + "PKME002", + "Message envelope has no required headers", + "Type '{0}' is marked with [GenerateMessageEnvelope] but does not declare any [MessageEnvelopeHeader] headers", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidHeader = new( + "PKME003", + "Message envelope header is invalid", + "Message envelope header '{0}' must declare a non-empty name, a value type, and a valid C# parameter name", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor DuplicateHeader = new( + "PKME004", + "Message envelope header is duplicated", + "Message envelope header '{0}' duplicates another header name or generated parameter name in '{1}'", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + "PatternKit.Generators.Messaging.GenerateMessageEnvelopeAttribute", + 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.GenerateMessageEnvelopeAttribute"); + 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 headers = GetHeaders(type, context); + if (headers.Length == 0) + { + context.ReportDiagnostic(Diagnostic.Create(MissingHeaders, node.Identifier.GetLocation(), type.Name)); + return; + } + + if (HasDuplicates(headers, out var duplicate)) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateHeader, duplicate.Location, duplicate.Name, type.Name)); + return; + } + + var config = new MessageEnvelopeConfig( + GetNamedString(attribute, "FactoryName") ?? "Create", + GetNamedString(attribute, "ContextFactoryName") ?? "CreateContext"); + + context.AddSource($"{type.Name}.MessageEnvelope.g.cs", SourceText.From(GenerateSource(type, payloadType, headers, config), Encoding.UTF8)); + } + + private static ImmutableArray
GetHeaders(INamedTypeSymbol type, SourceProductionContext context) + { + var builder = ImmutableArray.CreateBuilder
(); + foreach (var attr in type.GetAttributes().Where(static attr => + attr.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.MessageEnvelopeHeaderAttribute")) + { + if (!TryGetHeader(attr, out var header)) + { + var name = attr.ConstructorArguments.Length > 0 ? attr.ConstructorArguments[0].Value as string : null; + context.ReportDiagnostic(Diagnostic.Create(InvalidHeader, attr.ApplicationSyntaxReference?.GetSyntax().GetLocation(), name ?? type.Name)); + continue; + } + + builder.Add(header); + } + + return builder.ToImmutable(); + } + + private static bool TryGetHeader(AttributeData attribute, out Header header) + { + header = default; + if (attribute.ConstructorArguments.Length != 2) + return false; + + var name = attribute.ConstructorArguments[0].Value as string; + var valueType = attribute.ConstructorArguments[1].Value as ITypeSymbol; + if (string.IsNullOrWhiteSpace(name) || valueType is null) + return false; + + var parameterName = GetNamedString(attribute, "ParameterName") ?? ToParameterName(name!); + if (!IsValidIdentifier(parameterName)) + return false; + + header = new Header( + name!, + parameterName, + valueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation()); + return true; + } + + private static bool HasDuplicates(IReadOnlyList
headers, out Header duplicate) + { + var names = new HashSet(System.StringComparer.OrdinalIgnoreCase); + var parameters = new HashSet(System.StringComparer.Ordinal); + foreach (var header in headers) + { + if (!names.Add(header.Name) || !parameters.Add(header.ParameterName)) + { + duplicate = header; + return true; + } + } + + duplicate = default; + return false; + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol payloadType, + IReadOnlyList
headers, + MessageEnvelopeConfig config) + { + var payload = payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + 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("{"); + sb.Append(" public static global::PatternKit.Messaging.Message<").Append(payload).Append("> ").Append(config.FactoryName).Append('(').Append(payload).Append(" payload"); + foreach (var header in headers) + sb.Append(", ").Append(header.ValueType).Append(' ').Append(header.ParameterName); + sb.AppendLine(")"); + sb.Append(" => global::PatternKit.Messaging.Message<").Append(payload).AppendLine(">.Create(payload)"); + foreach (var header in headers) + sb.Append(" .WithHeader(\"").Append(Escape(header.Name)).Append("\", ").Append(header.ParameterName).AppendLine(")"); + sb.AppendLine(" ;"); + sb.AppendLine(); + + sb.Append(" public static global::PatternKit.Messaging.MessageContext ").Append(config.ContextFactoryName) + .Append("(global::PatternKit.Messaging.Message<").Append(payload).Append("> message, global::System.Threading.CancellationToken cancellationToken = default)"); + sb.AppendLine(); + sb.AppendLine(" {"); + sb.AppendLine(" if (message is null)"); + sb.AppendLine(" throw new global::System.ArgumentNullException(nameof(message));"); + sb.AppendLine(); + sb.AppendLine(" return global::PatternKit.Messaging.MessageContext.From(message, cancellationToken);"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string ToParameterName(string headerName) + { + var sb = new StringBuilder(); + var uppercaseNext = false; + foreach (var ch in headerName) + { + if (char.IsLetterOrDigit(ch) || ch == '_') + { + sb.Append(sb.Length == 0 ? char.ToLowerInvariant(ch) : uppercaseNext ? char.ToUpperInvariant(ch) : ch); + uppercaseNext = false; + } + else + { + uppercaseNext = sb.Length > 0; + } + } + + if (sb.Length == 0 || char.IsDigit(sb[0])) + sb.Insert(0, "header"); + + return sb.ToString(); + } + + private static bool IsValidIdentifier(string value) + => !string.IsNullOrWhiteSpace(value) + && SyntaxFacts.IsValidIdentifier(value) + && SyntaxFacts.GetKeywordKind(value) == SyntaxKind.None; + + 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 Header(string Name, string ParameterName, string ValueType, Location? Location); + + private readonly record struct MessageEnvelopeConfig(string FactoryName, string ContextFactoryName); +} diff --git a/test/PatternKit.Examples.Tests/Messaging/MessageEnvelopeExampleTests.cs b/test/PatternKit.Examples.Tests/Messaging/MessageEnvelopeExampleTests.cs index 216d36c5..2792059d 100644 --- a/test/PatternKit.Examples.Tests/Messaging/MessageEnvelopeExampleTests.cs +++ b/test/PatternKit.Examples.Tests/Messaging/MessageEnvelopeExampleTests.cs @@ -1,23 +1,79 @@ +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; -public sealed class MessageEnvelopeExampleTests +[Feature("Generated message-envelope example")] +public sealed class MessageEnvelopeExampleTests(ITestOutputHelper output) : TinyBddXunitBase(output) { - [Scenario("Run ReturnsExpectedEnvelopeAndContextMetadata")] + [Scenario("Fluent and generated envelope contracts produce the same metadata")] [Fact] - public void Run_ReturnsExpectedEnvelopeAndContextMetadata() - { - var summary = MessageEnvelopeExample.Run(); + public Task Fluent_And_Generated_Envelope_Contracts_Produce_The_Same_Metadata() + => Given("message-envelope example entry points", () => + new MessageEnvelopeEntrypoints(MessageEnvelopeExample.RunFluent, MessageEnvelopeExample.RunGenerated)) + .When("running both envelope paths", runners => new + { + Fluent = runners.Fluent(), + Generated = runners.Generated() + }) + .Then("both paths carry the same envelope metadata", result => + { + ScenarioExpect.Equal(result.Fluent.OrderId, result.Generated.OrderId); + ScenarioExpect.Equal(result.Fluent.MessageId, result.Generated.MessageId); + ScenarioExpect.Equal(result.Fluent.CorrelationId, result.Generated.CorrelationId); + ScenarioExpect.Equal(result.Fluent.CausationId, result.Generated.CausationId); + ScenarioExpect.Equal(result.Fluent.IdempotencyKey, result.Generated.IdempotencyKey); + ScenarioExpect.Equal(result.Fluent.ContentType, result.Generated.ContentType); + }) + .And("both paths preserve execution context metadata", result => + { + ScenarioExpect.Equal(result.Fluent.Route, result.Generated.Route); + ScenarioExpect.Equal(result.Fluent.Attempt, result.Generated.Attempt); + }) + .And("the generated path advertises its source-generated contract", result => + ScenarioExpect.Equal("source-generated", result.Generated.Path)) + .AssertPassed(); - ScenarioExpect.Equal("order-42", summary.OrderId); - ScenarioExpect.Equal("msg-100", summary.MessageId); - ScenarioExpect.Equal("order-42", summary.CorrelationId); - ScenarioExpect.Equal("checkout-7", summary.CausationId); - ScenarioExpect.Equal("order-42:accepted", summary.IdempotencyKey); - ScenarioExpect.Equal("application/vnd.patternkit.order+json", summary.ContentType); - ScenarioExpect.Equal("billing", summary.Route); - ScenarioExpect.Equal(1, summary.Attempt); - } + [Scenario("Generated message-envelope example is importable through IServiceCollection")] + [Fact] + public Task Generated_Message_Envelope_Example_Is_Importable_Through_IServiceCollection() + => Given("a service collection using the PatternKit message-envelope extension", () => + { + var services = new ServiceCollection(); + services.AddGeneratedMessageEnvelopeExample(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("resolving and running the generated message-envelope example", provider => + { + using (provider) + { + var example = provider.GetRequiredService(); + var summary = example.Runner.RunGenerated(); + var descriptor = provider.GetServices() + .Single(descriptor => descriptor.ExampleName == "Generated Message Envelope"); + + return new MessageEnvelopeImportRun(summary, descriptor.Integration); + } + }) + .Then("the generated runner returns expected envelope metadata", result => + { + ScenarioExpect.Equal("order-42", result.Summary.OrderId); + ScenarioExpect.Equal("msg-100", result.Summary.MessageId); + ScenarioExpect.Equal("order-42", result.Summary.CorrelationId); + ScenarioExpect.Equal("billing", result.Summary.Route); + }) + .And("the descriptor advertises DI source generation and messaging", result => + result.Integration.HasFlag(ExampleIntegrationSurface.DependencyInjection) + && result.Integration.HasFlag(ExampleIntegrationSurface.SourceGenerator) + && result.Integration.HasFlag(ExampleIntegrationSurface.Messaging)) + .AssertPassed(); + + private sealed record MessageEnvelopeEntrypoints(Func Fluent, Func Generated); + + private sealed record MessageEnvelopeImportRun(Summary Summary, ExampleIntegrationSurface Integration); } diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 2f865faa..8d338279 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -126,7 +126,6 @@ public Task Each_Pattern_Has_Fluent_Generated_Documented_And_Example_Paths() "Inbox has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/213", "Interpreter has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/206", "Mailbox has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/209", - "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", "Request-Reply has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/214", diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index f9d08968..74738ff5 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -79,6 +79,8 @@ private enum TestTrigger { typeof(GenerateContentRouterAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(ContentRouteAttribute), AttributeTargets.Method, false, false }, { typeof(ContentRouteDefaultAttribute), AttributeTargets.Method, false, 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 }, { typeof(ObserverHubAttribute), AttributeTargets.Class, false, false }, { typeof(ObservedEventAttribute), AttributeTargets.Property, false, false }, @@ -339,6 +341,15 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf AsyncFactoryName = "BuildRecipientsAsync" }; var recipient = new RecipientListRecipientAttribute("priority-audit", 5, "IsPriority"); + var envelope = new GenerateMessageEnvelopeAttribute(typeof(string)) + { + FactoryName = "BuildEnvelope", + ContextFactoryName = "BuildContext" + }; + var envelopeHeader = new MessageEnvelopeHeaderAttribute("tenant-id", typeof(string)) + { + ParameterName = "tenantId" + }; ScenarioExpect.Equal(typeof(string), flyweight.KeyType); ScenarioExpect.Equal("SymbolCache", flyweight.CacheTypeName); @@ -375,6 +386,12 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Equal("priority-audit", recipient.Name); ScenarioExpect.Equal(5, recipient.Order); ScenarioExpect.Equal("IsPriority", recipient.PredicateMethodName); + ScenarioExpect.Equal(typeof(string), envelope.PayloadType); + ScenarioExpect.Equal("BuildEnvelope", envelope.FactoryName); + ScenarioExpect.Equal("BuildContext", envelope.ContextFactoryName); + ScenarioExpect.Equal("tenant-id", envelopeHeader.Name); + ScenarioExpect.Equal(typeof(string), envelopeHeader.ValueType); + ScenarioExpect.Equal("tenantId", envelopeHeader.ParameterName); ScenarioExpect.Throws(() => new GenerateRoutingSlipAttribute(null!)); ScenarioExpect.Throws(() => new RoutingSlipStepAttribute("", 1)); ScenarioExpect.Throws(() => new GenerateSagaAttribute(null!)); @@ -386,6 +403,9 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Throws(() => new GenerateRecipientListAttribute(null!)); ScenarioExpect.Throws(() => new RecipientListRecipientAttribute("", 1, "Predicate")); ScenarioExpect.Throws(() => new RecipientListRecipientAttribute("name", 1, "")); + ScenarioExpect.Throws(() => new GenerateMessageEnvelopeAttribute(null!)); + ScenarioExpect.Throws(() => new MessageEnvelopeHeaderAttribute("", typeof(string))); + ScenarioExpect.Throws(() => new MessageEnvelopeHeaderAttribute("tenant-id", null!)); ScenarioExpect.IsType(new SagaCompleteWhenAttribute()); ScenarioExpect.IsType(new ContentRouteDefaultAttribute()); ScenarioExpect.IsType(new FlyweightFactoryAttribute()); diff --git a/test/PatternKit.Generators.Tests/MessageEnvelopeGeneratorTests.cs b/test/PatternKit.Generators.Tests/MessageEnvelopeGeneratorTests.cs new file mode 100644 index 00000000..bde9d74b --- /dev/null +++ b/test/PatternKit.Generators.Tests/MessageEnvelopeGeneratorTests.cs @@ -0,0 +1,156 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.Messaging; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class MessageEnvelopeGeneratorTests +{ + [Scenario("Generates typed envelope and context factories")] + [Fact] + public void GeneratesTypedEnvelopeAndContextFactories() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + + namespace MyApp; + + public sealed record OrderAccepted(string OrderId); + + [GenerateMessageEnvelope(typeof(OrderAccepted), FactoryName = "CreateAccepted", ContextFactoryName = "ContextFor")] + [MessageEnvelopeHeader("message-id", typeof(string), ParameterName = "messageId")] + [MessageEnvelopeHeader("correlation-id", typeof(string), ParameterName = "correlationId")] + [MessageEnvelopeHeader("tenant-id", typeof(string), ParameterName = "tenantId")] + public static partial class OrderAcceptedEnvelope; + + public static class Demo + { + public static string Run() + { + var message = OrderAcceptedEnvelope.CreateAccepted(new OrderAccepted("order-1"), "msg-1", "corr-1", "north"); + var context = OrderAcceptedEnvelope.ContextFor(message); + return $"{message.Payload.OrderId}:{context.Headers.CorrelationId}:{context.Headers.GetString("tenant-id")}"; + } + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesTypedEnvelopeAndContextFactories)); + var gen = new MessageEnvelopeGenerator(); + _ = 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("OrderAcceptedEnvelope.MessageEnvelope.g.cs", generated.HintName); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("CreateAccepted(global::MyApp.OrderAccepted payload, string messageId, string correlationId, string tenantId)", text); + ScenarioExpect.Contains(".WithHeader(\"tenant-id\", tenantId)", text); + ScenarioExpect.Contains("ContextFor(global::PatternKit.Messaging.Message message", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Reports diagnostic for non-partial envelope contract")] + [Fact] + public void ReportsDiagnosticForNonPartialEnvelopeContract() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record OrderAccepted(string OrderId); + + [GenerateMessageEnvelope(typeof(OrderAccepted))] + [MessageEnvelopeHeader("message-id", typeof(string))] + public static class OrderAcceptedEnvelope; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForNonPartialEnvelopeContract)); + var gen = new MessageEnvelopeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKME001", diagnostic.Id); + } + + [Scenario("Reports diagnostic for missing envelope headers")] + [Fact] + public void ReportsDiagnosticForMissingEnvelopeHeaders() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record OrderAccepted(string OrderId); + + [GenerateMessageEnvelope(typeof(OrderAccepted))] + public static partial class OrderAcceptedEnvelope; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForMissingEnvelopeHeaders)); + var gen = new MessageEnvelopeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKME002", diagnostic.Id); + } + + [Scenario("Reports diagnostic for invalid generated parameter name")] + [Fact] + public void ReportsDiagnosticForInvalidGeneratedParameterName() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record OrderAccepted(string OrderId); + + [GenerateMessageEnvelope(typeof(OrderAccepted))] + [MessageEnvelopeHeader("tenant-id", typeof(string), ParameterName = "class")] + public static partial class OrderAcceptedEnvelope; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForInvalidGeneratedParameterName)); + var gen = new MessageEnvelopeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKME003", diagnostic.Id); + } + + [Scenario("Reports diagnostic for duplicate header names")] + [Fact] + public void ReportsDiagnosticForDuplicateHeaderNames() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record OrderAccepted(string OrderId); + + [GenerateMessageEnvelope(typeof(OrderAccepted))] + [MessageEnvelopeHeader("tenant-id", typeof(string), ParameterName = "tenantId")] + [MessageEnvelopeHeader("Tenant-Id", typeof(string), ParameterName = "tenant")] + public static partial class OrderAcceptedEnvelope; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForDuplicateHeaderNames)); + var gen = new MessageEnvelopeGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKME004", diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: MetadataReference.CreateFromFile(typeof(PatternKit.Messaging.Message<>).Assembly.Location)); +} From 83d93694fff4dd66159b87968a6b60c6c87ef6a5 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Tue, 19 May 2026 23:17:16 -0500 Subject: [PATCH 2/2] fix: allow compiler diagnostics in envelope generator test --- .../MessageEnvelopeGeneratorTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/PatternKit.Generators.Tests/MessageEnvelopeGeneratorTests.cs b/test/PatternKit.Generators.Tests/MessageEnvelopeGeneratorTests.cs index bde9d74b..4cfcac4d 100644 --- a/test/PatternKit.Generators.Tests/MessageEnvelopeGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/MessageEnvelopeGeneratorTests.cs @@ -119,8 +119,8 @@ public static partial class OrderAcceptedEnvelope; var gen = new MessageEnvelopeGenerator(); _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); - var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); - ScenarioExpect.Equal("PKME003", diagnostic.Id); + var diagnostics = run.Results.SelectMany(result => result.Diagnostics).Select(static diagnostic => diagnostic.Id); + ScenarioExpect.Contains("PKME003", diagnostics); } [Scenario("Reports diagnostic for duplicate header names")]