diff --git a/docs/examples/generated-message-translator.md b/docs/examples/generated-message-translator.md new file mode 100644 index 00000000..d24fb091 --- /dev/null +++ b/docs/examples/generated-message-translator.md @@ -0,0 +1,47 @@ +# Generated Message Translator + +This example normalizes partner order events into an application-owned commerce event. It shows the fluent runtime path, the source-generated path, and the `IServiceCollection` integration an importing app can use. + +Source: + +- `src/PatternKit.Examples/Messaging/PartnerEventTranslatorExample.cs` +- `test/PatternKit.Examples.Tests/Messaging/PartnerEventTranslatorExampleTests.cs` + +## Runtime Path + +```csharp +var translator = PartnerOrderTranslatorPolicies.CreateFluentTranslator(); +var result = translator.Translate(PartnerEventTranslatorExample.CreatePartnerMessage( + "partner-a", + "EXT-100", + 125m)); +``` + +The translator preserves correlation headers, drops the raw partner signature header, and writes the normalized content type. + +## Generated Path + +```csharp +[GenerateMessageTranslator(typeof(PartnerOrderAccepted), typeof(CommerceOrderAccepted), TranslatorName = "partner-order-translator")] +[MessageTranslatorDropHeader("raw-signature")] +[MessageTranslatorHeader(MessageHeaderNames.ContentType, "application/vnd.patternkit.commerce-order-accepted+json")] +public static partial class GeneratedPartnerOrderTranslator; +``` + +The generated factory returns the same runtime translator type: + +```csharp +var translator = GeneratedPartnerOrderTranslator.Create(); +var result = translator.Translate(partnerMessage); +``` + +## DI Integration + +```csharp +services.AddPartnerEventTranslatorExample(); + +var importer = provider.GetRequiredService(); +var summary = importer.Import(partnerMessage); +``` + +`AddPatternKitExamples()` also registers this example through `GeneratedMessageTranslatorExample` so catalog consumers can resolve it with the rest of the production-ready examples. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 4675a67b..cdf0cbff 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -58,6 +58,9 @@ - name: Generated Message Envelope href: generated-message-envelope.md +- name: Generated Message Translator + href: generated-message-translator.md + - name: Generated Recipient List href: generated-recipient-list.md diff --git a/docs/generators/index.md b/docs/generators/index.md index 260f50fb..d076f33c 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -69,6 +69,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato |---|---|---| | [**Dispatcher**](dispatcher.md) | Mediator pattern with commands, notifications, and streams | `[GenerateDispatcher]` | | [**Message Envelope**](messaging.md#generated-message-envelope) | Required message metadata contracts | `[GenerateMessageEnvelope]` | +| [**Message Translator**](message-translator.md) | Partner and transport event normalization | `[GenerateMessageTranslator]` | | [**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]` | | [**Splitter / Aggregator**](messaging.md#generated-splitter-and-aggregator) | Split/rejoin message routing factories | `[GenerateSplitter]` / `[GenerateAggregator]` | @@ -159,6 +160,10 @@ public abstract partial class DataProcessor { } [GenerateAntiCorruptionLayer(typeof(LegacyOrderDto), typeof(CommerceOrder))] public static partial class LegacyOrderAcl { } +// Message translator - partner event normalization +[GenerateMessageTranslator(typeof(PartnerOrderAccepted), typeof(CommerceOrderAccepted))] +public static partial class PartnerOrderTranslator { } + // Visitor - type-safe double dispatch [GenerateVisitor] public interface IDocumentVisitor { } diff --git a/docs/generators/message-translator.md b/docs/generators/message-translator.md new file mode 100644 index 00000000..d33a31a8 --- /dev/null +++ b/docs/generators/message-translator.md @@ -0,0 +1,38 @@ +# Message Translator Generator + +`[GenerateMessageTranslator]` creates a factory for `MessageTranslator`. + +Use it when partner or transport event normalization is part of the application contract and should be validated by the compiler. + +```csharp +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; + +[GenerateMessageTranslator(typeof(PartnerOrderAccepted), typeof(CommerceOrderAccepted), FactoryName = "Build")] +[MessageTranslatorDropHeader("raw-signature")] +[MessageTranslatorHeader(MessageHeaderNames.ContentType, "application/vnd.myapp.order+json")] +public static partial class PartnerOrderTranslator +{ + [MessageTranslatorHandler] + private static CommerceOrderAccepted Translate(Message message, MessageContext context) + => new($"commerce-{message.Payload.ExternalOrderId}", message.Payload.Amount, message.Payload.PartnerId); +} +``` + +Generated output: + +```csharp +var translator = PartnerOrderTranslator.Build(); +var result = translator.Translate(partnerMessage); +``` + +## Diagnostics + +- `PKMT001`: the translator host must be partial. +- `PKMT002`: exactly one `[MessageTranslatorHandler]` method is required. +- `PKMT003`: the handler must be static, return the output payload type, and accept `Message` plus `MessageContext`. + +## Example + +- `src/PatternKit.Examples/Messaging/PartnerEventTranslatorExample.cs` +- `test/PatternKit.Examples.Tests/Messaging/PartnerEventTranslatorExampleTests.cs` diff --git a/docs/generators/messaging.md b/docs/generators/messaging.md index 48adf89a..e87f7e85 100644 --- a/docs/generators/messaging.md +++ b/docs/generators/messaging.md @@ -1,9 +1,10 @@ # Messaging Generators -PatternKit includes ten messaging-oriented source generators: +PatternKit includes eleven messaging-oriented source generators: - for source-generated mediator dispatchers. - for required message-envelope contracts. +- for partner and transport message normalization. - for content-based message routers. - for recipient-list fan-out. - and for split/rejoin routing. @@ -56,6 +57,32 @@ Example source: - `src/PatternKit.Examples/Messaging/MessageEnvelopeExample.cs` - `test/PatternKit.Examples.Tests/Messaging/MessageEnvelopeExampleTests.cs` +## Generated Message Translator + +`[GenerateMessageTranslator]` creates a `MessageTranslator` factory from one static handler method: + +```csharp +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; + +[GenerateMessageTranslator(typeof(PartnerOrderAccepted), typeof(CommerceOrderAccepted))] +[MessageTranslatorDropHeader("raw-signature")] +[MessageTranslatorHeader(MessageHeaderNames.ContentType, "application/vnd.myapp.order+json")] +public static partial class PartnerOrderTranslator +{ + [MessageTranslatorHandler] + private static CommerceOrderAccepted Translate(Message message, MessageContext context) + => new($"commerce-{message.Payload.ExternalOrderId}", message.Payload.Amount, message.Payload.PartnerId); +} +``` + +The generated factory preserves headers by default, then applies declared drop/set policies. See [Message Translator Generator](message-translator.md) for diagnostics and examples. + +Example source: + +- `src/PatternKit.Examples/Messaging/PartnerEventTranslatorExample.cs` +- `test/PatternKit.Examples.Tests/Messaging/PartnerEventTranslatorExampleTests.cs` + ## Generated Content Router `[GenerateContentRouter]` creates a `ContentRouter` factory from static route methods: diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 09f18b70..da35ce35 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -64,6 +64,9 @@ - name: Messaging Generators href: messaging.md +- name: Message Translator + href: message-translator.md + - name: Prototype href: prototype.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index ce21875c..66879abe 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -45,6 +45,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 | Messaging generator | +| Enterprise Integration | Message Translator | `MessageTranslator` | Message Translator generator | | Enterprise Integration | Content-Based Router | `ContentRouter` | Messaging generator | | Enterprise Integration | Recipient List | `RecipientList` | Messaging generator | | Enterprise Integration | Splitter | `Splitter` | Messaging generator | diff --git a/docs/patterns/messaging/message-translator.md b/docs/patterns/messaging/message-translator.md new file mode 100644 index 00000000..4153d303 --- /dev/null +++ b/docs/patterns/messaging/message-translator.md @@ -0,0 +1,74 @@ +# Message Translator + +`MessageTranslator` translates one message contract into another while applying an explicit header policy. Use it at integration boundaries where partner, vendor, or transport-specific events need to become application-owned contracts before they enter routers, sagas, mailboxes, or reliability pipelines. + +## Runtime Path + +```csharp +using PatternKit.Messaging; +using PatternKit.Messaging.Transformation; + +var translator = MessageTranslator + .Create("partner-order-translator") + .TranslateWith(static (message, context) => new CommerceOrderAccepted( + $"commerce-{message.Payload.ExternalOrderId}", + message.Payload.Amount, + message.Payload.PartnerId)) + .DropHeader("raw-signature") + .SetHeader(MessageHeaderNames.ContentType, "application/vnd.myapp.order+json") + .Build(); + +var result = translator.Translate(partnerMessage); +``` + +The translator returns `MessageTranslationResult` instead of leaking transformation exceptions into routing code. Invalid translator output or header policy failures produce a failed result with the captured exception. + +## Header Policies + +Headers are preserved by default so correlation, causation, message identifiers, and tenant metadata survive the contract change. Fluent policies can remove sensitive transport headers, keep only an allow-list, or set normalized headers: + +```csharp +var translator = MessageTranslator + .Create("normalizer") + .TranslateWith(static (message, _) => new NormalizedEvent(message.Payload.Id)) + .KeepHeaders(MessageHeaderNames.CorrelationId, "tenant-id") + .SetHeader(MessageHeaderNames.ContentType, "application/vnd.myapp.normalized+json") + .Build(); +``` + +## Source-Generated Path + +Use `[GenerateMessageTranslator]` when a translator contract should be compile-time visible and reusable from dependency injection setup: + +```csharp +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; + +[GenerateMessageTranslator(typeof(PartnerOrderAccepted), typeof(CommerceOrderAccepted), TranslatorName = "partner-order-translator")] +[MessageTranslatorDropHeader("raw-signature")] +[MessageTranslatorHeader(MessageHeaderNames.ContentType, "application/vnd.myapp.order+json")] +public static partial class PartnerOrderTranslator +{ + [MessageTranslatorHandler] + private static CommerceOrderAccepted Translate(Message message, MessageContext context) + => new($"commerce-{message.Payload.ExternalOrderId}", message.Payload.Amount, message.Payload.PartnerId); +} +``` + +The generated factory returns `MessageTranslator` and emits the same runtime builder calls. + +## DI Integration + +Examples can be imported through `Microsoft.Extensions.DependencyInjection`: + +```csharp +services.AddPartnerEventTranslatorExample(); + +var service = provider.GetRequiredService(); +var summary = service.Import(partnerMessage); +``` + +Production example: + +- `src/PatternKit.Examples/Messaging/PartnerEventTranslatorExample.cs` +- `test/PatternKit.Examples.Tests/Messaging/PartnerEventTranslatorExampleTests.cs` diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 7ddbb817..e9a319fc 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -297,6 +297,8 @@ items: - name: Message Envelope and Context href: messaging/message-envelope.md + - name: Message Translator + href: messaging/message-translator.md - name: Enterprise Message Routing href: messaging/message-routing.md - name: Routing Slip diff --git a/src/PatternKit.Core/Messaging/Transformation/MessageTranslator.cs b/src/PatternKit.Core/Messaging/Transformation/MessageTranslator.cs new file mode 100644 index 00000000..3c40084b --- /dev/null +++ b/src/PatternKit.Core/Messaging/Transformation/MessageTranslator.cs @@ -0,0 +1,158 @@ +namespace PatternKit.Messaging.Transformation; + +/// +/// Outcome returned by a message translator. +/// +public sealed class MessageTranslationResult +{ + private MessageTranslationResult(Message? message, bool translated, Exception? exception) + { + Message = message; + Translated = translated; + Exception = exception; + } + + public Message? Message { get; } + public bool Translated { get; } + public bool Failed => !Translated; + public Exception? Exception { get; } + + public static MessageTranslationResult Success(Message message) + => new(message ?? throw new ArgumentNullException(nameof(message)), true, null); + + public static MessageTranslationResult Failure(Exception exception) + => new(null, false, exception ?? throw new ArgumentNullException(nameof(exception))); +} + +/// +/// Translates one message contract into another while applying explicit header policies. +/// +public sealed class MessageTranslator +{ + public delegate TOutput Translator(Message message, MessageContext context); + public delegate MessageHeaders HeaderPolicy(MessageHeaders headers); + + private readonly Translator _translator; + private readonly HeaderPolicy _headerPolicy; + + private MessageTranslator(string name, Translator translator, HeaderPolicy headerPolicy) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Message translator name is required.", nameof(name)); + + Name = name; + _translator = translator ?? throw new ArgumentNullException(nameof(translator)); + _headerPolicy = headerPolicy ?? throw new ArgumentNullException(nameof(headerPolicy)); + } + + public string Name { get; } + + public static Builder Create(string name = "message-translator") => new(name); + + public MessageTranslationResult Translate(Message message, MessageContext? context = null) + { + if (message is null) + throw new ArgumentNullException(nameof(message)); + + var effectiveContext = context ?? MessageContext.From(message); + try + { + var payload = _translator(message, effectiveContext); + if (payload is null) + return MessageTranslationResult.Failure(new InvalidOperationException("Message translator returned a null payload.")); + + var headers = _headerPolicy(message.Headers); + if (headers is null) + return MessageTranslationResult.Failure(new InvalidOperationException("Message translator header policy returned null.")); + + return MessageTranslationResult.Success(new Message(payload, headers)); + } + catch (Exception ex) + { + return MessageTranslationResult.Failure(ex); + } + } + + public async ValueTask> TranslateAsync( + Message message, + MessageContext? context = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return await new ValueTask>(Translate(message, context)).ConfigureAwait(false); + } + + public sealed class Builder + { + private readonly string _name; + private readonly List _policies = []; + private Translator? _translator; + private bool _preserveHeaders = true; + + internal Builder(string name) => _name = name; + + public Builder TranslateWith(Translator translator) + { + _translator = translator ?? throw new ArgumentNullException(nameof(translator)); + return this; + } + + public Builder PreserveHeaders(bool preserve = true) + { + _preserveHeaders = preserve; + return this; + } + + public Builder DropHeader(string name) + { + RequireHeaderName(name); + _policies.Add(headers => headers.Without(name)); + return this; + } + + public Builder KeepHeaders(params string[] names) + { + if (names is null) + throw new ArgumentNullException(nameof(names)); + + var allowed = new HashSet(names.Select(RequireHeaderName), StringComparer.OrdinalIgnoreCase); + _policies.Add(headers => new MessageHeaders(headers.Where(pair => allowed.Contains(pair.Key)))); + return this; + } + + public Builder SetHeader(string name, object? value) + { + RequireHeaderName(name); + _policies.Add(headers => headers.With(name, value)); + return this; + } + + public Builder ConfigureHeaders(HeaderPolicy policy) + { + _policies.Add(policy ?? throw new ArgumentNullException(nameof(policy))); + return this; + } + + public MessageTranslator Build() + { + if (_translator is null) + throw new InvalidOperationException("Message translator delegate is required."); + + return new(_name, _translator, ApplyPolicies); + } + + private MessageHeaders ApplyPolicies(MessageHeaders headers) + { + var current = _preserveHeaders ? headers : MessageHeaders.Empty; + foreach (var policy in _policies) + current = policy(current) ?? throw new InvalidOperationException("Message translator header policy returned null."); + + return current; + } + + private static string RequireHeaderName(string name) + => string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Header name is required.", nameof(name)) + : name; + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 2afd63ab..697c69fc 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -45,6 +45,7 @@ using PatternKit.Examples.TemplateDemo; using PatternKit.Examples.VisitorDemo; using PatternKit.Messaging.Routing; +using PatternKit.Messaging.Transformation; using PatternKit.Structural.Decorator; using PatternKit.Structural.Proxy; using CheckoutRequest = PatternKit.Examples.Messaging.CheckoutRequest; @@ -104,6 +105,7 @@ public sealed record ApiExceptionMappingVisitorExample(Func RunAsync); public sealed record EventProcessingVisitorExample(Func RunAsync); public sealed record MessageRouterVisitorExample(Func Run); public sealed record GeneratedMessageEnvelopeExample(MessageEnvelopeExampleRunner Runner); +public sealed record GeneratedMessageTranslatorExample(PartnerEventTranslatorExampleRunner Runner, PartnerOrderImportService Service); public sealed record GeneratedRecipientListExample(RecipientListGeneratorExampleRunner Runner); public sealed record GeneratedSplitterAggregatorExample(MessageRoutingExampleRunner Runner); public sealed record PatternsShowcaseExample(ShowcaseFacade Facade); @@ -157,6 +159,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddEventProcessingVisitorExample() .AddMessageRouterVisitorExample() .AddGeneratedMessageEnvelopeExample() + .AddGeneratedMessageTranslatorExample() .AddGeneratedRecipientListExample() .AddGeneratedSplitterAggregatorExample() .AddPatternsShowcaseExample() @@ -388,6 +391,15 @@ public static IServiceCollection AddGeneratedMessageEnvelopeExample(this IServic return services.RegisterExample("Generated Message Envelope", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddGeneratedMessageTranslatorExample(this IServiceCollection services) + { + services.AddPartnerEventTranslatorExample(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("Generated Message Translator", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); + } + public static IServiceCollection AddGeneratedRecipientListExample(this IServiceCollection services) { services.AddRecipientListGeneratorExample(); diff --git a/src/PatternKit.Examples/Messaging/PartnerEventTranslatorExample.cs b/src/PatternKit.Examples/Messaging/PartnerEventTranslatorExample.cs new file mode 100644 index 00000000..d6e6de16 --- /dev/null +++ b/src/PatternKit.Examples/Messaging/PartnerEventTranslatorExample.cs @@ -0,0 +1,113 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; +using PatternKit.Messaging.Transformation; + +namespace PatternKit.Examples.Messaging; + +/// +/// Demonstrates the Message Translator pattern with partner event normalization. +/// +public static class PartnerEventTranslatorExample +{ + public static PartnerOrderImportSummary RunFluent() + { + var translator = PartnerOrderTranslatorPolicies.CreateFluentTranslator(); + var message = CreatePartnerMessage("partner-a", "EXT-100", 125m); + var result = translator.Translate(message); + + return PartnerOrderImportSummary.From("fluent", result); + } + + public static PartnerOrderImportSummary RunGenerated() + { + var translator = GeneratedPartnerOrderTranslator.Create(); + var message = CreatePartnerMessage("partner-a", "EXT-100", 125m); + var result = translator.Translate(message); + + return PartnerOrderImportSummary.From("source-generated", result); + } + + public static Message CreatePartnerMessage(string partnerId, string externalOrderId, decimal amount) + => Message + .Create(new PartnerOrderAccepted(partnerId, externalOrderId, amount, "USD")) + .WithMessageId($"partner:{externalOrderId}") + .WithCorrelationId(externalOrderId) + .WithHeader("partner-id", partnerId) + .WithHeader("raw-signature", "demo-signature"); +} + +public static class PartnerOrderTranslatorPolicies +{ + public static MessageTranslator CreateFluentTranslator() + => MessageTranslator + .Create("partner-order-translator") + .TranslateWith(static (message, _) => new CommerceOrderAccepted( + $"commerce-{message.Payload.ExternalOrderId}", + message.Payload.Amount, + message.Payload.PartnerId)) + .DropHeader("raw-signature") + .SetHeader(MessageHeaderNames.ContentType, "application/vnd.patternkit.commerce-order-accepted+json") + .Build(); +} + +public sealed class PartnerOrderImportService(MessageTranslator translator) +{ + public PartnerOrderImportSummary Import(Message message) + => PartnerOrderImportSummary.From("di", translator.Translate(message)); +} + +public static class PartnerEventTranslatorServiceCollectionExtensions +{ + public static IServiceCollection AddPartnerEventTranslatorExample(this IServiceCollection services) + { + services.AddSingleton(PartnerOrderTranslatorPolicies.CreateFluentTranslator()); + services.AddSingleton(); + services.AddSingleton(new PartnerEventTranslatorExampleRunner( + PartnerEventTranslatorExample.RunFluent, + PartnerEventTranslatorExample.RunGenerated)); + return services; + } +} + +public sealed record PartnerEventTranslatorExampleRunner( + Func RunFluent, + Func RunGenerated); + +[GenerateMessageTranslator(typeof(PartnerOrderAccepted), typeof(CommerceOrderAccepted), TranslatorName = "partner-order-translator")] +[MessageTranslatorDropHeader("raw-signature")] +[MessageTranslatorHeader(MessageHeaderNames.ContentType, "application/vnd.patternkit.commerce-order-accepted+json")] +public static partial class GeneratedPartnerOrderTranslator +{ + [MessageTranslatorHandler] + private static CommerceOrderAccepted Translate(Message message, MessageContext context) + => new($"commerce-{message.Payload.ExternalOrderId}", message.Payload.Amount, message.Payload.PartnerId); +} + +public sealed record PartnerOrderAccepted(string PartnerId, string ExternalOrderId, decimal Amount, string Currency); +public sealed record CommerceOrderAccepted(string OrderId, decimal Total, string SourcePartnerId); + +public sealed record PartnerOrderImportSummary( + string Path, + bool Accepted, + string? OrderId, + decimal? Total, + string? SourcePartnerId, + string? CorrelationId, + string? ContentType, + bool RawSignatureRemoved) +{ + public static PartnerOrderImportSummary From(string path, MessageTranslationResult result) + { + var message = result.Message; + return new( + path, + result.Translated, + message?.Payload.OrderId, + message?.Payload.Total, + message?.Payload.SourcePartnerId, + message?.Headers.CorrelationId, + message?.Headers.ContentType, + message?.Headers.ContainsKey("raw-signature") == false); + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index caf8cdc2..3a42e25a 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -240,6 +240,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, ["MessageEnvelope"], ["required headers", "source-generated factory", "DI composition"]), + Descriptor( + "Generated Message Translator", + "src/PatternKit.Examples/Messaging/PartnerEventTranslatorExample.cs", + "test/PatternKit.Examples.Tests/Messaging/PartnerEventTranslatorExampleTests.cs", + "docs/examples/generated-message-translator.md", + ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, + ["MessageTranslator"], + ["partner event normalization", "source-generated translator", "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 7771bb42..8fdb7f87 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -376,6 +376,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/Messaging/MessageEnvelopeExampleTests.cs", ["runtime envelope and headers", "generated required-header contract", "DI-importable envelope example"]), + Pattern("Message Translator", PatternFamily.EnterpriseIntegration, + "docs/patterns/messaging/message-translator.md", + "src/PatternKit.Core/Messaging/Transformation/MessageTranslator.cs", + "test/PatternKit.Tests/Messaging/Transformation/MessageTranslatorTests.cs", + "docs/generators/message-translator.md", + "src/PatternKit.Generators/Messaging/MessageTranslatorGenerator.cs", + "test/PatternKit.Generators.Tests/MessageTranslatorGeneratorTests.cs", + null, + "docs/examples/generated-message-translator.md", + "src/PatternKit.Examples/Messaging/PartnerEventTranslatorExample.cs", + "test/PatternKit.Examples.Tests/Messaging/PartnerEventTranslatorExampleTests.cs", + ["fluent payload translator", "generated translator factory", "DI-importable partner event normalization example"]), + Pattern("Content-Based Router", PatternFamily.EnterpriseIntegration, "docs/patterns/messaging/message-routing.md", "src/PatternKit.Core/Messaging/Routing/ContentRouter.cs", diff --git a/src/PatternKit.Generators.Abstractions/Messaging/MessageTranslatorAttributes.cs b/src/PatternKit.Generators.Abstractions/Messaging/MessageTranslatorAttributes.cs new file mode 100644 index 00000000..425d2b9b --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Messaging/MessageTranslatorAttributes.cs @@ -0,0 +1,34 @@ +using System; + +namespace PatternKit.Generators.Messaging; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateMessageTranslatorAttribute(Type inputType, Type outputType) : Attribute +{ + public Type InputType { get; } = inputType ?? throw new ArgumentNullException(nameof(inputType)); + public Type OutputType { get; } = outputType ?? throw new ArgumentNullException(nameof(outputType)); + public string FactoryName { get; set; } = "Create"; + public string TranslatorName { get; set; } = "message-translator"; + public bool PreserveHeaders { get; set; } = true; +} + +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public sealed class MessageTranslatorHandlerAttribute : Attribute; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true, Inherited = false)] +public sealed class MessageTranslatorDropHeaderAttribute(string name) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Header name is required.", nameof(name)) + : name; +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true, Inherited = false)] +public sealed class MessageTranslatorHeaderAttribute(string name, string value) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Header name is required.", nameof(name)) + : name; + + public string Value { get; } = value ?? throw new ArgumentNullException(nameof(value)); +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 903a0b3f..2344ffa7 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -178,6 +178,9 @@ PKME001 | PatternKit.Generators.Messaging | Error | Message envelope type must b 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. +PKMT001 | PatternKit.Generators.Messaging | Error | Message translator host must be partial. +PKMT002 | PatternKit.Generators.Messaging | Error | Message translator must declare exactly one handler. +PKMT003 | PatternKit.Generators.Messaging | Error | Message translator handler signature is invalid. PKMB001 | PatternKit.Generators.Messaging | Error | Mailbox type must be partial. PKMB002 | PatternKit.Generators.Messaging | Error | Mailbox must declare exactly one handler. PKMB003 | PatternKit.Generators.Messaging | Error | Mailbox handler signature is invalid. diff --git a/src/PatternKit.Generators/Messaging/MessageTranslatorGenerator.cs b/src/PatternKit.Generators/Messaging/MessageTranslatorGenerator.cs new file mode 100644 index 00000000..d00fbab1 --- /dev/null +++ b/src/PatternKit.Generators/Messaging/MessageTranslatorGenerator.cs @@ -0,0 +1,199 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.Messaging; + +[Generator] +public sealed class MessageTranslatorGenerator : IIncrementalGenerator +{ + private const string GenerateAttributeName = "PatternKit.Generators.Messaging.GenerateMessageTranslatorAttribute"; + private const string HandlerAttributeName = "PatternKit.Generators.Messaging.MessageTranslatorHandlerAttribute"; + private const string DropHeaderAttributeName = "PatternKit.Generators.Messaging.MessageTranslatorDropHeaderAttribute"; + private const string SetHeaderAttributeName = "PatternKit.Generators.Messaging.MessageTranslatorHeaderAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKMT001", + "Message translator host must be partial", + "Type '{0}' is marked with [GenerateMessageTranslator] but is not declared as partial", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingHandler = new( + "PKMT002", + "Message translator handler is missing", + "Message translator '{0}' must declare exactly one [MessageTranslatorHandler] method", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidHandler = new( + "PKMT003", + "Message translator handler signature is invalid", + "Message translator handler '{0}' must be static, return the output payload, and accept Message plus MessageContext", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + GenerateAttributeName, + 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(static a => a.AttributeClass?.ToDisplayString() == GenerateAttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol type, TypeDeclarationSyntax node, AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var inputType = attribute.ConstructorArguments.Length >= 1 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + var outputType = attribute.ConstructorArguments.Length >= 2 ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol : null; + if (inputType is null || outputType is null) + return; + + var handlers = type.GetMembers().OfType() + .Where(static method => method.GetAttributes().Any(static attr => attr.AttributeClass?.ToDisplayString() == HandlerAttributeName)) + .ToArray(); + if (handlers.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingHandler, node.Identifier.GetLocation(), type.Name)); + return; + } + + var handler = handlers[0]; + if (!IsHandler(handler, inputType, outputType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidHandler, handler.Locations.FirstOrDefault(), handler.Name)); + return; + } + + var factoryName = GetNamedString(attribute, "FactoryName") ?? "Create"; + var translatorName = GetNamedString(attribute, "TranslatorName") ?? "message-translator"; + var preserveHeaders = GetNamedBool(attribute, "PreserveHeaders") ?? true; + var drops = GetHeaderNames(type, DropHeaderAttributeName); + var sets = GetSetHeaders(type); + context.AddSource($"{type.Name}.MessageTranslator.g.cs", SourceText.From( + GenerateSource(type, inputType, outputType, handler.Name, factoryName, translatorName, preserveHeaders, drops, sets), + Encoding.UTF8)); + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol inputType, + INamedTypeSymbol outputType, + string handlerName, + string factoryName, + string translatorName, + bool preserveHeaders, + IReadOnlyList drops, + IReadOnlyList sets) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var inputName = inputType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var outputName = outputType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsAbstract && type.TypeKind == TypeKind.Class) + sb.Append("abstract "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Messaging.Transformation.MessageTranslator<") + .Append(inputName).Append(", ").Append(outputName).Append("> ").Append(factoryName).AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" var builder = global::PatternKit.Messaging.Transformation.MessageTranslator<") + .Append(inputName).Append(", ").Append(outputName).Append(">.Create(\"").Append(Escape(translatorName)).AppendLine("\")"); + sb.Append(" .PreserveHeaders(").Append(preserveHeaders ? "true" : "false").AppendLine(")"); + sb.Append(" .TranslateWith(static (message, context) => ").Append(handlerName).AppendLine("(message, context));"); + + foreach (var drop in drops) + sb.Append(" builder.DropHeader(\"").Append(Escape(drop)).AppendLine("\");"); + foreach (var set in sets) + sb.Append(" builder.SetHeader(\"").Append(Escape(set.Name)).Append("\", \"").Append(Escape(set.Value)).AppendLine("\");"); + + sb.AppendLine(" return builder.Build();"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static bool IsHandler(IMethodSymbol method, ITypeSymbol inputType, ITypeSymbol outputType) + => method.IsStatic + && !method.IsGenericMethod + && SymbolEqualityComparer.Default.Equals(method.ReturnType, outputType) + && method.Parameters.Length == 2 + && IsMessageOfPayload(method.Parameters[0].Type, inputType) + && method.Parameters[1].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext"; + + private static bool IsMessageOfPayload(ITypeSymbol type, ITypeSymbol payloadType) + => type is INamedTypeSymbol named + && named.ConstructedFrom.ToDisplayString() == "PatternKit.Messaging.Message" + && SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], payloadType); + + private static IReadOnlyList GetHeaderNames(INamedTypeSymbol type, string attributeName) + => type.GetAttributes() + .Where(attr => attr.AttributeClass?.ToDisplayString() == attributeName && attr.ConstructorArguments.Length == 1) + .Select(attr => attr.ConstructorArguments[0].Value as string) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value!) + .ToArray(); + + private static IReadOnlyList GetSetHeaders(INamedTypeSymbol type) + => type.GetAttributes() + .Where(attr => attr.AttributeClass?.ToDisplayString() == SetHeaderAttributeName && attr.ConstructorArguments.Length == 2) + .Select(static attr => new HeaderAssignment( + attr.ConstructorArguments[0].Value as string ?? string.Empty, + attr.ConstructorArguments[1].Value as string ?? string.Empty)) + .Where(static header => !string.IsNullOrWhiteSpace(header.Name)) + .ToArray(); + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static bool? GetNamedBool(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as bool?; + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; + + private readonly record struct HeaderAssignment(string Name, string Value); +} diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index 034e9a4d..57ce9529 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -5,6 +5,7 @@ using PatternKit.Examples.CacheAsideDemo; using PatternKit.Examples.CircuitBreakerDemo; using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.Messaging; using PatternKit.Examples.ObserverDemo; using PatternKit.Examples.PointOfSale; using PatternKit.Examples.ProductionReadiness; @@ -89,6 +90,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var antiCorruption = provider.GetRequiredService(); var routing = provider.GetRequiredService(); var generatedRecipients = provider.GetRequiredService(); + var generatedTranslator = provider.GetRequiredService(); var envelope = provider.GetRequiredService(); var cqrs = provider.GetRequiredService(); var checkout = provider.GetRequiredService(); @@ -153,6 +155,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("async template method formats payloads", asyncResult == "PAYLOAD:7"), ("generated anti-corruption layer imports legacy orders", antiCorruption.Service.Import(new LegacyOrderDto("ORD-100", 125m, "USD", "cust-42")).Accepted), ("message router visitor aggregates totals", routing.Run().AggregatedTotal == 100m), + ("generated message translator normalizes partner events", generatedTranslator.Service.Import(PartnerEventTranslatorExample.CreatePartnerMessage("partner-a", "EXT-100", 125m)).Accepted), ("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), diff --git a/test/PatternKit.Examples.Tests/Messaging/PartnerEventTranslatorExampleTests.cs b/test/PatternKit.Examples.Tests/Messaging/PartnerEventTranslatorExampleTests.cs new file mode 100644 index 00000000..b09222a7 --- /dev/null +++ b/test/PatternKit.Examples.Tests/Messaging/PartnerEventTranslatorExampleTests.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.Messaging; +using TinyBDD; + +namespace PatternKit.Examples.Tests.Messaging; + +public sealed class PartnerEventTranslatorExampleTests +{ + [Scenario("Fluent partner event translator normalizes partner order events")] + [Fact] + public void FluentPartnerEventTranslator_Normalizes_Partner_Order_Events() + { + var summary = PartnerEventTranslatorExample.RunFluent(); + + ScenarioExpect.True(summary.Accepted); + ScenarioExpect.Equal("fluent", summary.Path); + ScenarioExpect.Equal("commerce-EXT-100", summary.OrderId); + ScenarioExpect.Equal(125m, summary.Total); + ScenarioExpect.Equal("partner-a", summary.SourcePartnerId); + ScenarioExpect.Equal("EXT-100", summary.CorrelationId); + ScenarioExpect.Equal("application/vnd.patternkit.commerce-order-accepted+json", summary.ContentType); + ScenarioExpect.True(summary.RawSignatureRemoved); + } + + [Scenario("Generated partner event translator preserves the production contract")] + [Fact] + public void GeneratedPartnerEventTranslator_Preserves_The_Production_Contract() + { + var summary = PartnerEventTranslatorExample.RunGenerated(); + + ScenarioExpect.True(summary.Accepted); + ScenarioExpect.Equal("source-generated", summary.Path); + ScenarioExpect.Equal("commerce-EXT-100", summary.OrderId); + ScenarioExpect.Equal("EXT-100", summary.CorrelationId); + ScenarioExpect.Equal("application/vnd.patternkit.commerce-order-accepted+json", summary.ContentType); + ScenarioExpect.True(summary.RawSignatureRemoved); + } + + [Scenario("Partner event translator is importable through IServiceCollection")] + [Fact] + public void PartnerEventTranslator_Is_Importable_Through_IServiceCollection() + { + using var provider = new ServiceCollection() + .AddPartnerEventTranslatorExample() + .BuildServiceProvider(); + + var service = provider.GetRequiredService(); + var summary = service.Import(PartnerEventTranslatorExample.CreatePartnerMessage("partner-b", "EXT-200", 74m)); + + ScenarioExpect.True(summary.Accepted); + ScenarioExpect.Equal("di", summary.Path); + ScenarioExpect.Equal("commerce-EXT-200", summary.OrderId); + ScenarioExpect.Equal("partner-b", summary.SourcePartnerId); + ScenarioExpect.True(summary.RawSignatureRemoved); + } +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 4bb73286..b38bb128 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -39,6 +39,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti private static readonly string[] EnterprisePatternAdditions = [ "Message Envelope", + "Message Translator", "Content-Based Router", "Recipient List", "Splitter", @@ -99,7 +100,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(EnterprisePatternAdditions.OrderBy(static x => x), patterns.Select(static p => p.Name).OrderBy(static x => x))) .And("enterprise entries are grouped by integration reliability and architecture families", patterns => { - ScenarioExpect.Equal(10, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); + ScenarioExpect.Equal(11, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(5, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index ad18df3b..59562f11 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -126,6 +126,10 @@ private enum TestTrigger { typeof(BackplaneSubscriptionAttribute), AttributeTargets.Class | AttributeTargets.Struct, true, false }, { typeof(GenerateMessageEnvelopeAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(MessageEnvelopeHeaderAttribute), AttributeTargets.Class | AttributeTargets.Struct, true, false }, + { typeof(GenerateMessageTranslatorAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(MessageTranslatorHandlerAttribute), AttributeTargets.Method, false, false }, + { typeof(MessageTranslatorDropHeaderAttribute), AttributeTargets.Class | AttributeTargets.Struct, true, false }, + { typeof(MessageTranslatorHeaderAttribute), 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 }, @@ -630,6 +634,14 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf { ParameterName = "tenantId" }; + var translator = new GenerateMessageTranslatorAttribute(typeof(string), typeof(int)) + { + FactoryName = "BuildTranslator", + TranslatorName = "orders", + PreserveHeaders = false + }; + var translatorDrop = new MessageTranslatorDropHeaderAttribute("raw-signature"); + var translatorHeader = new MessageTranslatorHeaderAttribute("content-type", "application/vnd.demo+json"); ScenarioExpect.Equal(typeof(string), flyweight.KeyType); ScenarioExpect.Equal("SymbolCache", flyweight.CacheTypeName); @@ -722,6 +734,14 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Equal("tenant-id", envelopeHeader.Name); ScenarioExpect.Equal(typeof(string), envelopeHeader.ValueType); ScenarioExpect.Equal("tenantId", envelopeHeader.ParameterName); + ScenarioExpect.Equal(typeof(string), translator.InputType); + ScenarioExpect.Equal(typeof(int), translator.OutputType); + ScenarioExpect.Equal("BuildTranslator", translator.FactoryName); + ScenarioExpect.Equal("orders", translator.TranslatorName); + ScenarioExpect.False(translator.PreserveHeaders); + ScenarioExpect.Equal("raw-signature", translatorDrop.Name); + ScenarioExpect.Equal("content-type", translatorHeader.Name); + ScenarioExpect.Equal("application/vnd.demo+json", translatorHeader.Value); ScenarioExpect.Throws(() => new GenerateRoutingSlipAttribute(null!)); ScenarioExpect.Throws(() => new RoutingSlipStepAttribute("", 1)); ScenarioExpect.Throws(() => new GenerateSagaAttribute(null!)); @@ -754,6 +774,12 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Throws(() => new GenerateMessageEnvelopeAttribute(null!)); ScenarioExpect.Throws(() => new MessageEnvelopeHeaderAttribute("", typeof(string))); ScenarioExpect.Throws(() => new MessageEnvelopeHeaderAttribute("tenant-id", null!)); + ScenarioExpect.Throws(() => new GenerateMessageTranslatorAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateMessageTranslatorAttribute(typeof(string), null!)); + ScenarioExpect.Throws(() => new MessageTranslatorDropHeaderAttribute("")); + ScenarioExpect.Throws(() => new MessageTranslatorHeaderAttribute("", "value")); + ScenarioExpect.Throws(() => new MessageTranslatorHeaderAttribute("content-type", null!)); + ScenarioExpect.IsType(new MessageTranslatorHandlerAttribute()); ScenarioExpect.IsType(new SagaCompleteWhenAttribute()); ScenarioExpect.IsType(new ContentRouteDefaultAttribute()); ScenarioExpect.IsType(new SplitterProjectionAttribute()); diff --git a/test/PatternKit.Generators.Tests/MessageTranslatorGeneratorTests.cs b/test/PatternKit.Generators.Tests/MessageTranslatorGeneratorTests.cs new file mode 100644 index 00000000..63df0147 --- /dev/null +++ b/test/PatternKit.Generators.Tests/MessageTranslatorGeneratorTests.cs @@ -0,0 +1,133 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class MessageTranslatorGeneratorTests +{ + [Scenario("Generates message translator factory")] + [Fact] + public void GeneratesMessageTranslatorFactory() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + + namespace Demo; + + public sealed record PartnerOrder(string Id, decimal Amount); + public sealed record Order(string OrderId, decimal Total); + + [GenerateMessageTranslator(typeof(PartnerOrder), typeof(Order), FactoryName = "Build", TranslatorName = "partner-orders")] + [MessageTranslatorDropHeader("raw-signature")] + [MessageTranslatorHeader("content-type", "application/vnd.demo.order+json")] + public static partial class PartnerOrderTranslator + { + [MessageTranslatorHandler] + private static Order Translate(Message message, MessageContext context) + => new(message.Payload.Id, message.Payload.Amount); + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesMessageTranslatorFactory)); + var gen = new MessageTranslatorGenerator(); + _ = 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)); + var text = generated.SourceText.ToString(); + ScenarioExpect.Equal("PartnerOrderTranslator.MessageTranslator.g.cs", generated.HintName); + ScenarioExpect.Contains("Build()", text); + ScenarioExpect.Contains("MessageTranslator.Create(\"partner-orders\")", text); + ScenarioExpect.Contains(".PreserveHeaders(true)", text); + ScenarioExpect.Contains(".TranslateWith(static (message, context) => Translate(message, context));", text); + ScenarioExpect.Contains("builder.DropHeader(\"raw-signature\");", text); + ScenarioExpect.Contains("builder.SetHeader(\"content-type\", \"application/vnd.demo.order+json\");", text); + ScenarioExpect.True(updated.Emit(Stream.Null).Success); + } + + [Scenario("Reports diagnostic for non-partial message translator host")] + [Fact] + public void ReportsDiagnosticForNonPartialMessageTranslatorHost() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace Demo; + + [GenerateMessageTranslator(typeof(string), typeof(int))] + public static class Host; + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForNonPartialMessageTranslatorHost)); + + ScenarioExpect.Equal("PKMT001", diagnostic.Id); + } + + [Scenario("Reports diagnostic for missing message translator handler")] + [Fact] + public void ReportsDiagnosticForMissingMessageTranslatorHandler() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace Demo; + + [GenerateMessageTranslator(typeof(string), typeof(int))] + public static partial class Host; + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForMissingMessageTranslatorHandler)); + + ScenarioExpect.Equal("PKMT002", diagnostic.Id); + } + + [Scenario("Reports diagnostic for invalid message translator handler")] + [Fact] + public void ReportsDiagnosticForInvalidMessageTranslatorHandler() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + + namespace Demo; + + [GenerateMessageTranslator(typeof(string), typeof(int))] + public static partial class Host + { + [MessageTranslatorHandler] + private static string Translate(Message message, MessageContext context) => message.Payload; + } + """; + + var diagnostic = RunAndGetSingleDiagnostic(source, nameof(ReportsDiagnosticForInvalidMessageTranslatorHandler)); + + ScenarioExpect.Equal("PKMT003", diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: + [ + MetadataReference.CreateFromFile(GetAbstractionsAssemblyPath()), + MetadataReference.CreateFromFile(typeof(Message<>).Assembly.Location) + ]); + + private static string GetAbstractionsAssemblyPath() + => Path.Combine( + Path.GetDirectoryName(typeof(MessageTranslatorGenerator).Assembly.Location)!, + "PatternKit.Generators.Abstractions.dll"); + + private static Diagnostic RunAndGetSingleDiagnostic(string source, string assemblyName) + { + var comp = CreateCompilation(source, assemblyName); + var gen = new MessageTranslatorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + return ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + } +} diff --git a/test/PatternKit.Tests/Messaging/Transformation/MessageTranslatorTests.cs b/test/PatternKit.Tests/Messaging/Transformation/MessageTranslatorTests.cs new file mode 100644 index 00000000..cfb04654 --- /dev/null +++ b/test/PatternKit.Tests/Messaging/Transformation/MessageTranslatorTests.cs @@ -0,0 +1,104 @@ +using PatternKit.Messaging; +using PatternKit.Messaging.Transformation; +using TinyBDD; + +namespace PatternKit.Tests.Messaging.Transformation; + +public sealed class MessageTranslatorTests +{ + private sealed record PartnerOrderSubmitted(string PartnerOrderId, decimal Amount, string Tenant); + private sealed record OrderSubmitted(string OrderId, decimal Total); + + [Scenario("Message translator transforms payloads and preserves headers")] + [Fact] + public void MessageTranslator_Transforms_Payloads_And_Preserves_Headers() + { + var translator = CreateTranslator(); + var message = Message + .Create(new PartnerOrderSubmitted("PO-100", 42m, "northwind")) + .WithCorrelationId("corr-1") + .WithHeader("tenant-id", "northwind"); + + var result = translator.Translate(message); + + ScenarioExpect.True(result.Translated); + ScenarioExpect.Equal(new OrderSubmitted("PO-100", 42m), result.Message!.Payload); + ScenarioExpect.Equal("corr-1", result.Message.Headers.CorrelationId); + ScenarioExpect.Equal("northwind", result.Message.Headers.GetString("tenant-id")); + ScenarioExpect.Equal("application/vnd.patternkit.order-submitted+json", result.Message.Headers.ContentType); + } + + [Scenario("Message translator can explicitly filter headers")] + [Fact] + public void MessageTranslator_Can_Explicitly_Filter_Headers() + { + var translator = MessageTranslator + .Create("partner-orders") + .TranslateWith(static (message, _) => new OrderSubmitted(message.Payload.PartnerOrderId, message.Payload.Amount)) + .KeepHeaders(MessageHeaderNames.CorrelationId) + .Build(); + var message = Message + .Create(new PartnerOrderSubmitted("PO-100", 42m, "northwind")) + .WithCorrelationId("corr-1") + .WithHeader("tenant-id", "northwind"); + + var result = translator.Translate(message); + + ScenarioExpect.True(result.Translated); + ScenarioExpect.Equal("corr-1", result.Message!.Headers.CorrelationId); + ScenarioExpect.False(result.Message.Headers.ContainsKey("tenant-id")); + } + + [Scenario("Message translator reports translation failures")] + [Fact] + public void MessageTranslator_Reports_Translation_Failures() + { + var translator = MessageTranslator + .Create("partner-orders") + .TranslateWith(static (_, _) => throw new InvalidOperationException("partner payload was invalid")) + .Build(); + + var result = translator.Translate(Message.Create(new PartnerOrderSubmitted("PO-100", 42m, "northwind"))); + + ScenarioExpect.True(result.Failed); + ScenarioExpect.IsType(result.Exception); + ScenarioExpect.Equal("partner payload was invalid", result.Exception!.Message); + } + + [Scenario("Async message translator preserves cancellation")] + [Fact] + public async Task AsyncMessageTranslator_Preserves_Cancellation() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var translator = CreateTranslator(); + + await ScenarioExpect.ThrowsAsync(() => + translator.TranslateAsync( + Message.Create(new PartnerOrderSubmitted("PO-100", 42m, "northwind")), + cancellationToken: cts.Token).AsTask()); + } + + [Scenario("Message translator rejects invalid configuration")] + [Fact] + public void MessageTranslator_Rejects_Invalid_Configuration() + { + var translator = CreateTranslator(); + + ScenarioExpect.Throws(() => MessageTranslator.Create("").TranslateWith(static (message, _) => new OrderSubmitted(message.Payload.PartnerOrderId, message.Payload.Amount)).Build()); + ScenarioExpect.Throws(() => MessageTranslator.Create().TranslateWith(null!)); + ScenarioExpect.Throws(() => MessageTranslator.Create().DropHeader("")); + ScenarioExpect.Throws(() => MessageTranslator.Create().KeepHeaders(null!)); + ScenarioExpect.Throws(() => MessageTranslator.Create().ConfigureHeaders(null!)); + ScenarioExpect.Throws(() => MessageTranslator.Create().Build()); + ScenarioExpect.Throws(() => translator.Translate(null!)); + } + + private static MessageTranslator CreateTranslator() + => MessageTranslator + .Create("partner-orders") + .TranslateWith(static (message, _) => new OrderSubmitted(message.Payload.PartnerOrderId, message.Payload.Amount)) + .DropHeader("raw-signature") + .SetHeader(MessageHeaderNames.ContentType, "application/vnd.patternkit.order-submitted+json") + .Build(); +}