diff --git a/docs/examples/erp-channel-adapter.md b/docs/examples/erp-channel-adapter.md new file mode 100644 index 00000000..3d76fd54 --- /dev/null +++ b/docs/examples/erp-channel-adapter.md @@ -0,0 +1,12 @@ +# ERP Channel Adapter + +The ERP channel adapter example translates partner ERP order documents into internal PatternKit messages and back out to ERP documents. + +```csharp +services.AddErpChannelAdapterDemo(); + +var service = provider.GetRequiredService(); +var summary = service.RoundTrip(new ErpOrderDocument("ERP-100", "42.50")); +``` + +The example includes fluent and source-generated construction, inbound and outbound message channels, and `IServiceCollection` registration for existing .NET applications. diff --git a/docs/examples/index.md b/docs/examples/index.md index b06c6b87..fe120d6d 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -69,6 +69,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Order Event-Driven Consumer** Shows fluent and source-generated push consumers side by side, with an importable `IServiceCollection` extension. See [Order Event-Driven Consumer](order-event-driven-consumer.md). +* **ERP Channel Adapter** + Shows fluent and source-generated external DTO adapters over PatternKit message channels, with an importable `IServiceCollection` extension. See [ERP Channel Adapter](erp-channel-adapter.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). diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 3269cb2b..14e257d7 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -55,6 +55,9 @@ - name: Order Event-Driven Consumer href: order-event-driven-consumer.md +- name: ERP Channel Adapter + href: erp-channel-adapter.md + - name: Patterns Showcase — Integrated Order Processing href: patterns-showcase.md diff --git a/docs/generators/channel-adapter.md b/docs/generators/channel-adapter.md new file mode 100644 index 00000000..75ac04ed --- /dev/null +++ b/docs/generators/channel-adapter.md @@ -0,0 +1,27 @@ +# Channel Adapter Generator + +`[GenerateChannelAdapter]` creates a typed `ChannelAdapter` factory. + +```csharp +[GenerateChannelAdapter(typeof(ErpOrderDocument), typeof(OrderIntegrationMessage), FactoryName = "Create", AdapterName = "erp-orders-adapter")] +public static partial class ErpOrdersAdapter +{ + [ChannelAdapterInbound] + private static Message ToMessage(ErpOrderDocument document, MessageContext context) + => Message.Create(new(document.ExternalOrderId, decimal.Parse(document.Total))); + + [ChannelAdapterOutbound] + private static ErpOrderDocument ToExternal(Message message, MessageContext context) + => new(message.Payload.OrderId, message.Payload.Total.ToString("0.00")); +} +``` + +The generated factory accepts inbound and outbound `MessageChannel` instances so the adapter can be composed through `IServiceCollection`. + +Diagnostics: + +- `PKCAD001`: host type must be partial. +- `PKCAD002`: exactly one inbound translator is required. +- `PKCAD003`: exactly one outbound translator is required. +- `PKCAD004`: inbound translator signature is invalid. +- `PKCAD005`: outbound translator signature is invalid. diff --git a/docs/generators/index.md b/docs/generators/index.md index a74be529..97919260 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -83,6 +83,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Message Channel**](message-channel.md) | Typed channel factories for in-process message queues | `[GenerateMessageChannel]` | | [**Polling Consumer**](polling-consumer.md) | Pull-based message consumer factories | `[GeneratePollingConsumer]` | | [**Event-Driven Consumer**](event-driven-consumer.md) | Push-based message consumer factories | `[GenerateEventDrivenConsumer]` | +| [**Channel Adapter**](channel-adapter.md) | External DTO to message-channel adapter factories | `[GenerateChannelAdapter]` | | [**Message Envelope**](messaging.md#generated-message-envelope) | Required message metadata contracts | `[GenerateMessageEnvelope]` | | [**Message Translator**](message-translator.md) | Partner and transport event normalization | `[GenerateMessageTranslator]` | | [**Claim Check**](claim-check.md) | External payload storage references | `[GenerateClaimCheck]` | diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index b753416e..4441f420 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -94,6 +94,9 @@ - name: Event-Driven Consumer href: event-driven-consumer.md +- name: Channel Adapter + href: channel-adapter.md + - name: Message Translator href: message-translator.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index be0660f2..06133451 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -47,6 +47,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Enterprise Integration | Message Channel | `MessageChannel` | Message Channel generator | | Enterprise Integration | Polling Consumer | `PollingConsumer` | Polling Consumer generator | | Enterprise Integration | Event-Driven Consumer | `EventDrivenConsumer` | Event-Driven Consumer generator | +| Enterprise Integration | Channel Adapter | `ChannelAdapter` | Channel Adapter generator | | Enterprise Integration | Message Envelope | `Message`, headers, context | Messaging generator | | Enterprise Integration | Message Translator | `MessageTranslator` | Message Translator generator | | Enterprise Integration | Claim Check | `ClaimCheck` | Claim Check generator | diff --git a/docs/patterns/messaging/README.md b/docs/patterns/messaging/README.md index 7716b099..4f56673a 100644 --- a/docs/patterns/messaging/README.md +++ b/docs/patterns/messaging/README.md @@ -38,6 +38,12 @@ Push-based consumers handle messages when a broker callback, background service, [Learn More](event-driven-consumer.md) +## Channel Adapter + +Channel adapters translate external transport DTOs into PatternKit message channels and translate outbound channel messages back to the transport shape. + +[Learn More](channel-adapter.md) + ## Idempotent Receiver, Inbox, and Outbox Idempotency and handoff helpers compose message handlers with pluggable stores, inbox boundaries, and outbox records without claiming broker durability or exactly-once delivery. diff --git a/docs/patterns/messaging/channel-adapter.md b/docs/patterns/messaging/channel-adapter.md new file mode 100644 index 00000000..a8f5931f --- /dev/null +++ b/docs/patterns/messaging/channel-adapter.md @@ -0,0 +1,17 @@ +# Channel Adapter + +Channel Adapter bridges an external transport shape to PatternKit message channels. + +```csharp +var adapter = ChannelAdapter + .Create("erp-orders-adapter") + .ReceiveInto(inboundChannel) + .SendFrom(outboundChannel) + .MapInbound((document, context) => Message.Create(command)) + .MapOutbound((message, context) => ToErpDocument(message.Payload)) + .Build(); +``` + +Use it at application boundaries where a broker callback, HTTP webhook, file reader, or partner SDK exposes a DTO that should be translated into internal messages. The outbound path translates internal messages back into the transport DTO without making PatternKit own the external infrastructure. + +The source-generated path uses `[GenerateChannelAdapter]`, `[ChannelAdapterInbound]`, and `[ChannelAdapterOutbound]`. Import the ERP example through `AddErpChannelAdapterDemo()` or `AddPatternKitExamples()`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 339761ed..f6885af0 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -301,6 +301,8 @@ href: messaging/polling-consumer.md - name: Event-Driven Consumer href: messaging/event-driven-consumer.md + - name: Channel Adapter + href: messaging/channel-adapter.md - name: Message Envelope and Context href: messaging/message-envelope.md - name: Message Translator diff --git a/src/PatternKit.Core/Messaging/Adapters/ChannelAdapter.cs b/src/PatternKit.Core/Messaging/Adapters/ChannelAdapter.cs new file mode 100644 index 00000000..5d406088 --- /dev/null +++ b/src/PatternKit.Core/Messaging/Adapters/ChannelAdapter.cs @@ -0,0 +1,137 @@ +using PatternKit.Messaging.Channels; + +namespace PatternKit.Messaging.Adapters; + +/// Bridges external transport messages to PatternKit message channels. +public sealed class ChannelAdapter +{ + public delegate Message InboundTranslator(TExternal external, MessageContext context); + + public delegate TExternal OutboundTranslator(Message message, MessageContext context); + + private readonly MessageChannel _inboundChannel; + private readonly MessageChannel _outboundChannel; + private readonly InboundTranslator _inbound; + private readonly OutboundTranslator _outbound; + + private ChannelAdapter( + string name, + MessageChannel inboundChannel, + MessageChannel outboundChannel, + InboundTranslator inbound, + OutboundTranslator outbound) + => (Name, _inboundChannel, _outboundChannel, _inbound, _outbound) = (name, inboundChannel, outboundChannel, inbound, outbound); + + public string Name { get; } + + public ChannelAdapterInboundResult AcceptExternal(TExternal external, MessageContext? context = null) + { + var message = _inbound(external, context ?? MessageContext.Empty); + if (message is null) + throw new InvalidOperationException("Inbound channel adapter translator returned null."); + + var result = _inboundChannel.Send(message); + return new ChannelAdapterInboundResult(Name, message, result); + } + + public ChannelAdapterOutboundResult TryTakeExternal(MessageContext? context = null) + { + var received = _outboundChannel.TryReceive(); + if (!received.Received) + return ChannelAdapterOutboundResult.Empty(Name, received.ChannelName); + + var external = _outbound(received.Message!, context ?? MessageContext.From(received.Message!)); + return ChannelAdapterOutboundResult.Success(Name, received.ChannelName, external); + } + + public static Builder Create(string name = "channel-adapter") => new(name); + + public sealed class Builder + { + private readonly string _name; + private MessageChannel? _inboundChannel; + private MessageChannel? _outboundChannel; + private InboundTranslator? _inbound; + private OutboundTranslator? _outbound; + + internal Builder(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Channel adapter name cannot be null, empty, or whitespace.", nameof(name)); + + _name = name; + } + + public Builder ReceiveInto(MessageChannel channel) + { + _inboundChannel = channel ?? throw new ArgumentNullException(nameof(channel)); + return this; + } + + public Builder SendFrom(MessageChannel channel) + { + _outboundChannel = channel ?? throw new ArgumentNullException(nameof(channel)); + return this; + } + + public Builder MapInbound(InboundTranslator translator) + { + _inbound = translator ?? throw new ArgumentNullException(nameof(translator)); + return this; + } + + public Builder MapOutbound(OutboundTranslator translator) + { + _outbound = translator ?? throw new ArgumentNullException(nameof(translator)); + return this; + } + + public ChannelAdapter Build() + { + if (_inboundChannel is null) + throw new InvalidOperationException("Channel adapter requires an inbound channel."); + if (_outboundChannel is null) + throw new InvalidOperationException("Channel adapter requires an outbound channel."); + if (_inbound is null) + throw new InvalidOperationException("Channel adapter requires an inbound translator."); + if (_outbound is null) + throw new InvalidOperationException("Channel adapter requires an outbound translator."); + + return new(_name, _inboundChannel, _outboundChannel, _inbound, _outbound); + } + } +} + +public sealed class ChannelAdapterInboundResult +{ + internal ChannelAdapterInboundResult(string adapterName, Message message, MessageChannelSendResult channelResult) + => (AdapterName, Message, ChannelResult) = (adapterName, message, channelResult); + + public string AdapterName { get; } + + public Message Message { get; } + + public MessageChannelSendResult ChannelResult { get; } + + public bool Accepted => ChannelResult.Accepted; +} + +public sealed class ChannelAdapterOutboundResult +{ + private ChannelAdapterOutboundResult(string adapterName, string channelName, bool produced, TExternal? external) + => (AdapterName, ChannelName, Produced, External) = (adapterName, channelName, produced, external); + + public string AdapterName { get; } + + public string ChannelName { get; } + + public bool Produced { get; } + + public TExternal? External { get; } + + internal static ChannelAdapterOutboundResult Success(string adapterName, string channelName, TExternal external) + => new(adapterName, channelName, true, external); + + internal static ChannelAdapterOutboundResult Empty(string adapterName, string channelName) + => new(adapterName, channelName, false, default); +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index eb856b18..ef9b5fc1 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -61,6 +61,7 @@ using PatternKit.Examples.VisitorDemo; using PatternKit.Messaging.Channels; using PatternKit.Messaging.Consumers; +using PatternKit.Messaging.Adapters; using PatternKit.Messaging.Routing; using PatternKit.Messaging.Storage; using PatternKit.Messaging.ControlBus; @@ -128,6 +129,7 @@ public sealed record MessageRouterVisitorExample(Func Run); public sealed record InventoryMessageChannelExampleService(MessageChannel Channel, InventoryMessageChannelService Service); public sealed record WarehousePollingConsumerExampleService(PollingConsumer Consumer, WarehousePollingConsumerService Service); public sealed record OrderEventDrivenConsumerExampleService(EventDrivenConsumer Consumer, OrderEventDrivenConsumerService Service); +public sealed record ErpChannelAdapterExampleService(ChannelAdapter Adapter, ErpChannelAdapterService Service); public sealed record GeneratedMessageEnvelopeExample(MessageEnvelopeExampleRunner Runner); public sealed record GeneratedMessageTranslatorExample(PartnerEventTranslatorExampleRunner Runner, PartnerOrderImportService Service); public sealed record GeneratedClaimCheckExample(LargeDocumentClaimCheckExampleRunner Runner, LargeDocumentWorkflow Workflow); @@ -209,6 +211,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddInventoryMessageChannelExample() .AddWarehousePollingConsumerExample() .AddOrderEventDrivenConsumerExample() + .AddErpChannelAdapterExample() .AddGeneratedMessageEnvelopeExample() .AddGeneratedMessageTranslatorExample() .AddGeneratedClaimCheckExample() @@ -486,6 +489,15 @@ public static IServiceCollection AddOrderEventDrivenConsumerExample(this IServic return services.RegisterExample("Order Event-Driven Consumer", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddErpChannelAdapterExample(this IServiceCollection services) + { + services.AddErpChannelAdapterDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService())); + return services.RegisterExample("ERP Channel Adapter", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddGeneratedMessageEnvelopeExample(this IServiceCollection services) { services.AddMessageEnvelopeExample(); diff --git a/src/PatternKit.Examples/Messaging/ErpChannelAdapterExample.cs b/src/PatternKit.Examples/Messaging/ErpChannelAdapterExample.cs new file mode 100644 index 00000000..3a32817f --- /dev/null +++ b/src/PatternKit.Examples/Messaging/ErpChannelAdapterExample.cs @@ -0,0 +1,110 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; +using PatternKit.Messaging.Adapters; +using PatternKit.Messaging.Channels; +using System.Globalization; + +namespace PatternKit.Examples.Messaging; + +public sealed record ErpOrderDocument(string ExternalOrderId, string Total); + +public sealed record OrderIntegrationMessage(string OrderId, decimal Total); + +public sealed record ErpChannelAdapterSummary(bool Imported, string? OrderId, bool Exported, string? ExternalTotal); + +public sealed record ErpChannelAdapterChannels( + MessageChannel Inbound, + MessageChannel Outbound); + +public sealed class ErpChannelAdapterService( + ChannelAdapter adapter, + ErpChannelAdapterChannels channels) +{ + public ErpChannelAdapterSummary RoundTrip(ErpOrderDocument document) + { + var inbound = adapter.AcceptExternal(document); + var imported = channels.Inbound.TryReceive(); + if (imported.Message is not null) + channels.Outbound.Send(imported.Message); + + var exported = adapter.TryTakeExternal(); + return new( + inbound.Accepted, + imported.Message?.Payload.OrderId, + exported.Produced, + exported.External?.Total); + } +} + +public static class ErpChannelAdapters +{ + public static ChannelAdapter Create( + MessageChannel inboundChannel, + MessageChannel outboundChannel) + => ChannelAdapter.Create("erp-orders-adapter") + .ReceiveInto(inboundChannel) + .SendFrom(outboundChannel) + .MapInbound(ToMessage) + .MapOutbound(ToExternal) + .Build(); + + public static Message ToMessage(ErpOrderDocument document, MessageContext context) + => Message.Create(new( + document.ExternalOrderId, + decimal.Parse(document.Total, CultureInfo.InvariantCulture))); + + public static ErpOrderDocument ToExternal(Message message, MessageContext context) + => new( + message.Payload.OrderId, + message.Payload.Total.ToString("0.00", CultureInfo.InvariantCulture)); +} + +[GenerateChannelAdapter(typeof(ErpOrderDocument), typeof(OrderIntegrationMessage), FactoryName = "Create", AdapterName = "erp-orders-adapter")] +public static partial class GeneratedErpChannelAdapter +{ + [ChannelAdapterInbound] + private static Message ToMessage(ErpOrderDocument document, MessageContext context) + => ErpChannelAdapters.ToMessage(document, context); + + [ChannelAdapterOutbound] + private static ErpOrderDocument ToExternal(Message message, MessageContext context) + => ErpChannelAdapters.ToExternal(message, context); +} + +public sealed class ErpChannelAdapterExampleRunner(ErpChannelAdapterService service) +{ + public ErpChannelAdapterSummary RunGenerated(ErpOrderDocument document) => service.RoundTrip(document); + + public static ErpChannelAdapterSummary RunFluent(ErpOrderDocument document) + { + var inbound = MessageChannel.Create("erp-inbound").Build(); + var outbound = MessageChannel.Create("erp-outbound").Build(); + var adapter = ErpChannelAdapters.Create(inbound, outbound); + return new ErpChannelAdapterService(adapter, new(inbound, outbound)).RoundTrip(document); + } + + public static ErpChannelAdapterSummary RunGeneratedStatic(ErpOrderDocument document) + { + var inbound = MessageChannel.Create("erp-inbound").Build(); + var outbound = MessageChannel.Create("erp-outbound").Build(); + var adapter = GeneratedErpChannelAdapter.Create(inbound, outbound); + return new ErpChannelAdapterService(adapter, new(inbound, outbound)).RoundTrip(document); + } +} + +public static class ErpChannelAdapterExampleServiceCollectionExtensions +{ + public static IServiceCollection AddErpChannelAdapterDemo(this IServiceCollection services) + { + services.AddSingleton(_ => new ErpChannelAdapterChannels( + MessageChannel.Create("erp-inbound").Build(), + MessageChannel.Create("erp-outbound").Build())); + services.AddSingleton(sp => GeneratedErpChannelAdapter.Create( + sp.GetRequiredService().Inbound, + sp.GetRequiredService().Outbound)); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index bfbb6c2b..4dd30364 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -232,6 +232,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["EventDrivenConsumer"], ["push-based order workflow", "source-generated event handler factory", "DI composition"]), + Descriptor( + "ERP Channel Adapter", + "src/PatternKit.Examples/Messaging/ErpChannelAdapterExample.cs", + "test/PatternKit.Examples.Tests/Messaging/ErpChannelAdapterExampleTests.cs", + "docs/examples/erp-channel-adapter.md", + ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["ChannelAdapter", "MessageChannel"], + ["external ERP DTO bridge", "source-generated adapter factory", "DI composition"]), Descriptor( "Patterns Showcase", "src/PatternKit.Examples/PatternShowcase/PatternShowcase.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 83f6f6c3..d7024837 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -402,6 +402,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/Messaging/OrderEventDrivenConsumerExampleTests.cs", ["fluent push consumer", "generated event handler factory", "DI-importable order event example"]), + Pattern("Channel Adapter", PatternFamily.EnterpriseIntegration, + "docs/patterns/messaging/channel-adapter.md", + "src/PatternKit.Core/Messaging/Adapters/ChannelAdapter.cs", + "test/PatternKit.Tests/Messaging/Adapters/ChannelAdapterTests.cs", + "docs/generators/channel-adapter.md", + "src/PatternKit.Generators/Messaging/ChannelAdapterGenerator.cs", + "test/PatternKit.Generators.Tests/ChannelAdapterGeneratorTests.cs", + null, + "docs/examples/erp-channel-adapter.md", + "src/PatternKit.Examples/Messaging/ErpChannelAdapterExample.cs", + "test/PatternKit.Examples.Tests/Messaging/ErpChannelAdapterExampleTests.cs", + ["fluent external channel bridge", "generated adapter factory", "DI-importable ERP integration example"]), + Pattern("Message Envelope", PatternFamily.EnterpriseIntegration, "docs/patterns/messaging/message-envelope.md", "src/PatternKit.Core/Messaging/Message.cs", diff --git a/src/PatternKit.Generators.Abstractions/Messaging/ChannelAdapterAttributes.cs b/src/PatternKit.Generators.Abstractions/Messaging/ChannelAdapterAttributes.cs new file mode 100644 index 00000000..420be335 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Messaging/ChannelAdapterAttributes.cs @@ -0,0 +1,31 @@ +using System; + +namespace PatternKit.Generators.Messaging; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateChannelAdapterAttribute : Attribute +{ + public GenerateChannelAdapterAttribute(Type externalType, Type payloadType) + { + ExternalType = externalType ?? throw new ArgumentNullException(nameof(externalType)); + PayloadType = payloadType ?? throw new ArgumentNullException(nameof(payloadType)); + } + + public Type ExternalType { get; } + + public Type PayloadType { get; } + + public string FactoryName { get; set; } = "Create"; + + public string AdapterName { get; set; } = "channel-adapter"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class ChannelAdapterInboundAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class ChannelAdapterOutboundAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 22dd67ed..3b9f04a5 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -320,3 +320,8 @@ PKPOLL003 | PatternKit.Generators.Messaging | Error | Polling Consumer source si PKEVT001 | PatternKit.Generators.Messaging | Error | Event-Driven Consumer host type must be partial. PKEVT002 | PatternKit.Generators.Messaging | Error | Event-Driven Consumer must declare at least one handler. PKEVT003 | PatternKit.Generators.Messaging | Error | Event-Driven Consumer handler signature is invalid. +PKCAD001 | PatternKit.Generators.Messaging | Error | Channel Adapter host type must be partial. +PKCAD002 | PatternKit.Generators.Messaging | Error | Channel Adapter must declare exactly one inbound translator. +PKCAD003 | PatternKit.Generators.Messaging | Error | Channel Adapter must declare exactly one outbound translator. +PKCAD004 | PatternKit.Generators.Messaging | Error | Channel Adapter inbound translator signature is invalid. +PKCAD005 | PatternKit.Generators.Messaging | Error | Channel Adapter outbound translator signature is invalid. diff --git a/src/PatternKit.Generators/Messaging/ChannelAdapterGenerator.cs b/src/PatternKit.Generators/Messaging/ChannelAdapterGenerator.cs new file mode 100644 index 00000000..f0cd818a --- /dev/null +++ b/src/PatternKit.Generators/Messaging/ChannelAdapterGenerator.cs @@ -0,0 +1,141 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.Messaging; + +[Generator] +public sealed class ChannelAdapterGenerator : IIncrementalGenerator +{ + private static readonly DiagnosticDescriptor MustBePartial = new("PKCAD001", "Channel adapter type must be partial", "Type '{0}' is marked with [GenerateChannelAdapter] but is not declared as partial", "PatternKit.Generators.Messaging", DiagnosticSeverity.Error, true); + private static readonly DiagnosticDescriptor MissingInbound = new("PKCAD002", "Channel adapter inbound translator is missing", "Type '{0}' must declare exactly one [ChannelAdapterInbound] method", "PatternKit.Generators.Messaging", DiagnosticSeverity.Error, true); + private static readonly DiagnosticDescriptor MissingOutbound = new("PKCAD003", "Channel adapter outbound translator is missing", "Type '{0}' must declare exactly one [ChannelAdapterOutbound] method", "PatternKit.Generators.Messaging", DiagnosticSeverity.Error, true); + private static readonly DiagnosticDescriptor InvalidInbound = new("PKCAD004", "Channel adapter inbound translator signature is invalid", "Inbound translator '{0}' must be static and return Message with TExternal and MessageContext parameters", "PatternKit.Generators.Messaging", DiagnosticSeverity.Error, true); + private static readonly DiagnosticDescriptor InvalidOutbound = new("PKCAD005", "Channel adapter outbound translator signature is invalid", "Outbound translator '{0}' must be static and return TExternal with Message and MessageContext parameters", "PatternKit.Generators.Messaging", DiagnosticSeverity.Error, true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + "PatternKit.Generators.Messaging.GenerateChannelAdapterAttribute", + 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.GenerateChannelAdapterAttribute"); + 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 externalType = attribute.ConstructorArguments.Length > 0 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + var payloadType = attribute.ConstructorArguments.Length > 1 ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol : null; + if (externalType is null || payloadType is null) + return; + + var inbound = type.GetMembers().OfType().Where(static method => + method.GetAttributes().Any(static attr => attr.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.ChannelAdapterInboundAttribute")).ToArray(); + if (inbound.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingInbound, node.Identifier.GetLocation(), type.Name)); + return; + } + + var outbound = type.GetMembers().OfType().Where(static method => + method.GetAttributes().Any(static attr => attr.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.ChannelAdapterOutboundAttribute")).ToArray(); + if (outbound.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingOutbound, node.Identifier.GetLocation(), type.Name)); + return; + } + + if (!IsInbound(inbound[0], externalType, payloadType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidInbound, inbound[0].Locations.FirstOrDefault(), inbound[0].Name)); + return; + } + + if (!IsOutbound(outbound[0], externalType, payloadType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidOutbound, outbound[0].Locations.FirstOrDefault(), outbound[0].Name)); + return; + } + + var factoryName = GetNamedString(attribute, "FactoryName") ?? "Create"; + var adapterName = GetNamedString(attribute, "AdapterName") ?? "channel-adapter"; + + context.AddSource($"{type.Name}.ChannelAdapter.g.cs", SourceText.From(GenerateSource(type, externalType, payloadType, inbound[0].Name, outbound[0].Name, factoryName, adapterName), Encoding.UTF8)); + } + + private static bool IsInbound(IMethodSymbol method, INamedTypeSymbol externalType, INamedTypeSymbol payloadType) + => method.IsStatic && + IsMessageOf(method.ReturnType, payloadType) && + method.Parameters.Length == 2 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, externalType) && + method.Parameters[1].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext"; + + private static bool IsOutbound(IMethodSymbol method, INamedTypeSymbol externalType, INamedTypeSymbol payloadType) + => method.IsStatic && + SymbolEqualityComparer.Default.Equals(method.ReturnType, externalType) && + method.Parameters.Length == 2 && + IsMessageOf(method.Parameters[0].Type, payloadType) && + method.Parameters[1].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext"; + + private static bool IsMessageOf(ITypeSymbol type, INamedTypeSymbol payloadType) + => type is INamedTypeSymbol named && + named.ConstructedFrom.ToDisplayString() == "PatternKit.Messaging.Message" && + SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], payloadType); + + private static string GenerateSource(INamedTypeSymbol type, INamedTypeSymbol externalType, INamedTypeSymbol payloadType, string inbound, string outbound, string factoryName, string adapterName) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Messaging.Adapters.ChannelAdapter<") + .Append(externalType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)).Append(", ") + .Append(payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append("> ").Append(factoryName).AppendLine("("); + sb.Append(" global::PatternKit.Messaging.Channels.MessageChannel<") + .Append(payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .AppendLine("> inboundChannel,"); + sb.Append(" global::PatternKit.Messaging.Channels.MessageChannel<") + .Append(payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .AppendLine("> outboundChannel)"); + sb.Append(" => global::PatternKit.Messaging.Adapters.ChannelAdapter<") + .Append(externalType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)).Append(", ") + .Append(payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append(">.Create(").Append(ToLiteral(adapterName)).AppendLine(")"); + sb.AppendLine(" .ReceiveInto(inboundChannel)"); + sb.AppendLine(" .SendFrom(outboundChannel)"); + sb.Append(" .MapInbound(").Append(inbound).AppendLine(")"); + sb.Append(" .MapOutbound(").Append(outbound).AppendLine(")"); + sb.AppendLine(" .Build();"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static string ToLiteral(string value) => "@\"" + value.Replace("\"", "\"\"") + "\""; +} diff --git a/test/PatternKit.Examples.Tests/Messaging/ErpChannelAdapterExampleTests.cs b/test/PatternKit.Examples.Tests/Messaging/ErpChannelAdapterExampleTests.cs new file mode 100644 index 00000000..c0aad83c --- /dev/null +++ b/test/PatternKit.Examples.Tests/Messaging/ErpChannelAdapterExampleTests.cs @@ -0,0 +1,68 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.Messaging; +using TinyBDD; + +namespace PatternKit.Examples.Tests.Messaging; + +public sealed class ErpChannelAdapterExampleTests +{ + [Scenario("FluentChannelAdapter ImportsAndExportsErpOrder")] + [Fact] + public void FluentChannelAdapter_ImportsAndExportsErpOrder() + { + var summary = ErpChannelAdapterExampleRunner.RunFluent(new("ERP-100", "42.50")); + + ScenarioExpect.True(summary.Imported); + ScenarioExpect.True(summary.Exported); + ScenarioExpect.Equal("ERP-100", summary.OrderId); + ScenarioExpect.Equal("42.50", summary.ExternalTotal); + } + + [Scenario("GeneratedChannelAdapter MatchesFluentAdapter")] + [Fact] + public void GeneratedChannelAdapter_MatchesFluentAdapter() + { + var generated = ErpChannelAdapterExampleRunner.RunGeneratedStatic(new("ERP-100", "42.50")); + var fluent = ErpChannelAdapterExampleRunner.RunFluent(new("ERP-100", "42.50")); + + ScenarioExpect.Equal(fluent.Imported, generated.Imported); + ScenarioExpect.Equal(fluent.Exported, generated.Exported); + ScenarioExpect.Equal(fluent.OrderId, generated.OrderId); + ScenarioExpect.Equal(fluent.ExternalTotal, generated.ExternalTotal); + } + + [Scenario("ServiceCollection ImportsChannelAdapterExample")] + [Fact] + public void ServiceCollection_ImportsChannelAdapterExample() + { + var services = new ServiceCollection(); + services.AddErpChannelAdapterDemo(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var service = provider.GetRequiredService(); + + var summary = service.RoundTrip(new("ERP-100", "42.50")); + + ScenarioExpect.True(summary.Imported); + ScenarioExpect.True(summary.Exported); + ScenarioExpect.Equal("ERP-100", summary.OrderId); + } + + [Scenario("AggregateServiceCollection ImportsChannelAdapterExample")] + [Fact] + public void AggregateServiceCollection_ImportsChannelAdapterExample() + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var example = provider.GetRequiredService(); + + var summary = example.Service.RoundTrip(new("ERP-100", "42.50")); + + ScenarioExpect.True(summary.Imported); + ScenarioExpect.True(summary.Exported); + ScenarioExpect.Equal("ERP-100", summary.OrderId); + } +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index ce46869d..ff2d03c4 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -41,6 +41,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Message Channel", "Polling Consumer", "Event-Driven Consumer", + "Channel Adapter", "Message Envelope", "Message Translator", "Claim Check", @@ -127,7 +128,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(24, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); + ScenarioExpect.Equal(25, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(7, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); ScenarioExpect.Equal(15, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 9f24747c..52157fb7 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -136,6 +136,9 @@ private enum TestTrigger { typeof(PollingConsumerSourceAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateEventDrivenConsumerAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(EventDrivenConsumerHandlerAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateChannelAdapterAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(ChannelAdapterInboundAttribute), AttributeTargets.Method, false, false }, + { typeof(ChannelAdapterOutboundAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateRoutingSlipAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GenerateCompetingConsumerGroupAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GeneratePipesAndFiltersPipelineAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, @@ -760,6 +763,11 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ConsumerName = "order-events" }; var eventDrivenHandler = new EventDrivenConsumerHandlerAttribute("audit"); + var channelAdapter = new GenerateChannelAdapterAttribute(typeof(string), typeof(int)) + { + FactoryName = "BuildAdapter", + AdapterName = "erp-orders" + }; var routingSlip = new GenerateRoutingSlipAttribute(typeof(string)) { FactoryName = "Build", @@ -928,6 +936,10 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Equal("BuildConsumer", eventDrivenConsumer.FactoryName); ScenarioExpect.Equal("order-events", eventDrivenConsumer.ConsumerName); ScenarioExpect.Equal("audit", eventDrivenHandler.Name); + ScenarioExpect.Equal(typeof(string), channelAdapter.ExternalType); + ScenarioExpect.Equal(typeof(int), channelAdapter.PayloadType); + ScenarioExpect.Equal("BuildAdapter", channelAdapter.FactoryName); + ScenarioExpect.Equal("erp-orders", channelAdapter.AdapterName); ScenarioExpect.Equal(typeof(string), routingSlip.PayloadType); ScenarioExpect.Equal("Build", routingSlip.FactoryName); ScenarioExpect.Equal("BuildAsync", routingSlip.AsyncFactoryName); @@ -1045,6 +1057,10 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.IsType(new PollingConsumerSourceAttribute()); ScenarioExpect.Throws(() => new GenerateEventDrivenConsumerAttribute(null!)); ScenarioExpect.Throws(() => new EventDrivenConsumerHandlerAttribute("")); + ScenarioExpect.Throws(() => new GenerateChannelAdapterAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateChannelAdapterAttribute(typeof(string), null!)); + ScenarioExpect.IsType(new ChannelAdapterInboundAttribute()); + ScenarioExpect.IsType(new ChannelAdapterOutboundAttribute()); ScenarioExpect.Throws(() => new GenerateRoutingSlipAttribute(null!)); ScenarioExpect.Throws(() => new RoutingSlipStepAttribute("", 1)); ScenarioExpect.Throws(() => new GenerateSagaAttribute(null!)); diff --git a/test/PatternKit.Generators.Tests/ChannelAdapterGeneratorTests.cs b/test/PatternKit.Generators.Tests/ChannelAdapterGeneratorTests.cs new file mode 100644 index 00000000..9cc8c96c --- /dev/null +++ b/test/PatternKit.Generators.Tests/ChannelAdapterGeneratorTests.cs @@ -0,0 +1,117 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.Messaging; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class ChannelAdapterGeneratorTests +{ + [Scenario("GeneratesChannelAdapterFactory")] + [Fact] + public void GeneratesChannelAdapterFactory() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + namespace MyApp; + public sealed record ExternalCommand(string Sku); + public sealed record Command(string Sku); + [GenerateChannelAdapter(typeof(ExternalCommand), typeof(Command), FactoryName = "Build", AdapterName = "erp")] + public static partial class ErpAdapter + { + [ChannelAdapterInbound] + private static Message Inbound(ExternalCommand external, MessageContext context) + => Message.Create(new Command(external.Sku)); + + [ChannelAdapterOutbound] + private static ExternalCommand Outbound(Message message, MessageContext context) + => new ExternalCommand(message.Payload.Sku); + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesChannelAdapterFactory)); + _ = RoslynTestHelpers.Run(comp, new ChannelAdapterGenerator(), out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("ChannelAdapter", text); + ScenarioExpect.Contains(".MapInbound(Inbound)", text); + ScenarioExpect.Contains(".MapOutbound(Outbound)", text); + ScenarioExpect.True(updated.Emit(Stream.Null).Success); + } + + [Scenario("ReportsChannelAdapterDiagnostics")] + [Theory] + [InlineData("public static class ErpAdapter { }", "PKCAD001")] + [InlineData("public static partial class ErpAdapter { }", "PKCAD002")] + [InlineData(""" + public static partial class ErpAdapter + { + [ChannelAdapterInbound] + private static Message Inbound(ExternalCommand external, MessageContext context) + => Message.Create(new Command(external.Sku)); + } + """, "PKCAD003")] + public void ReportsChannelAdapterDiagnostics(string declaration, string expected) + { + var source = $$""" + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + namespace MyApp; + public sealed record ExternalCommand(string Sku); + public sealed record Command(string Sku); + [GenerateChannelAdapter(typeof(ExternalCommand), typeof(Command))] + {{declaration}} + """; + + var comp = CreateCompilation(source, nameof(ReportsChannelAdapterDiagnostics) + expected); + _ = RoslynTestHelpers.Run(comp, new ChannelAdapterGenerator(), out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal(expected, diagnostic.Id); + } + + [Scenario("ReportsInvalidChannelAdapterTranslators")] + [Theory] + [InlineData("private static string Inbound(ExternalCommand external, MessageContext context) => \"bad\";", "PKCAD004")] + [InlineData("private static string Outbound(Message message, MessageContext context) => \"bad\";", "PKCAD005")] + public void ReportsInvalidChannelAdapterTranslators(string invalidMethod, string expected) + { + var inbound = expected == "PKCAD004" + ? invalidMethod + : "private static Message Inbound(ExternalCommand external, MessageContext context) => Message.Create(new Command(external.Sku));"; + var outbound = expected == "PKCAD005" + ? invalidMethod + : "private static ExternalCommand Outbound(Message message, MessageContext context) => new ExternalCommand(message.Payload.Sku);"; + var source = $$""" + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + namespace MyApp; + public sealed record ExternalCommand(string Sku); + public sealed record Command(string Sku); + [GenerateChannelAdapter(typeof(ExternalCommand), typeof(Command))] + public static partial class ErpAdapter + { + [ChannelAdapterInbound] + {{inbound}} + + [ChannelAdapterOutbound] + {{outbound}} + } + """; + + var comp = CreateCompilation(source, nameof(ReportsInvalidChannelAdapterTranslators) + expected); + _ = RoslynTestHelpers.Run(comp, new ChannelAdapterGenerator(), out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal(expected, diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: MetadataReference.CreateFromFile(typeof(global::PatternKit.Messaging.Adapters.ChannelAdapter<,>).Assembly.Location)); +} diff --git a/test/PatternKit.Tests/Messaging/Adapters/ChannelAdapterTests.cs b/test/PatternKit.Tests/Messaging/Adapters/ChannelAdapterTests.cs new file mode 100644 index 00000000..905153d7 --- /dev/null +++ b/test/PatternKit.Tests/Messaging/Adapters/ChannelAdapterTests.cs @@ -0,0 +1,76 @@ +using PatternKit.Messaging; +using PatternKit.Messaging.Adapters; +using PatternKit.Messaging.Channels; +using TinyBDD; + +namespace PatternKit.Tests.Messaging.Adapters; + +public sealed class ChannelAdapterTests +{ + [Scenario("AcceptExternal TranslatesAndEnqueuesMessage")] + [Fact] + public void AcceptExternal_TranslatesAndEnqueuesMessage() + { + var inbound = MessageChannel.Create("inbound").Build(); + var outbound = MessageChannel.Create("outbound").Build(); + var adapter = ChannelAdapter.Create("erp") + .ReceiveInto(inbound) + .SendFrom(outbound) + .MapInbound((external, _) => Message.Create(new(external.Sku, external.Quantity))) + .MapOutbound((message, _) => new(message.Payload.Sku, message.Payload.Quantity)) + .Build(); + + var result = adapter.AcceptExternal(new("sku-1", 3)); + var received = inbound.TryReceive(); + + ScenarioExpect.True(result.Accepted); + ScenarioExpect.Equal("erp", result.AdapterName); + ScenarioExpect.True(received.Received); + ScenarioExpect.Equal("sku-1", received.Message!.Payload.Sku); + } + + [Scenario("TryTakeExternal TranslatesOutboundMessage")] + [Fact] + public void TryTakeExternal_TranslatesOutboundMessage() + { + var inbound = MessageChannel.Create("inbound").Build(); + var outbound = MessageChannel.Create("outbound").Build(); + outbound.Send(Message.Create(new("sku-1", 3))); + var adapter = ChannelAdapter.Create() + .ReceiveInto(inbound) + .SendFrom(outbound) + .MapInbound((external, _) => Message.Create(new(external.Sku, external.Quantity))) + .MapOutbound((message, _) => new(message.Payload.Sku, message.Payload.Quantity)) + .Build(); + + var first = adapter.TryTakeExternal(); + var second = adapter.TryTakeExternal(); + + ScenarioExpect.True(first.Produced); + ScenarioExpect.Equal("sku-1", first.External!.Sku); + ScenarioExpect.False(second.Produced); + ScenarioExpect.Null(second.External); + } + + [Scenario("Builder RejectsInvalidConfiguration")] + [Fact] + public void Builder_RejectsInvalidConfiguration() + { + var channel = MessageChannel.Create().Build(); + + ScenarioExpect.Throws(() => ChannelAdapter.Create("")); + ScenarioExpect.Throws(() => ChannelAdapter.Create().ReceiveInto(null!)); + ScenarioExpect.Throws(() => ChannelAdapter.Create().SendFrom(null!)); + ScenarioExpect.Throws(() => ChannelAdapter.Create().MapInbound(null!)); + ScenarioExpect.Throws(() => ChannelAdapter.Create().MapOutbound(null!)); + ScenarioExpect.Throws(() => ChannelAdapter.Create() + .ReceiveInto(channel) + .SendFrom(channel) + .MapInbound((external, _) => Message.Create(new(external.Sku, external.Quantity))) + .Build()); + } + + public sealed record ExternalCommand(string Sku, int Quantity); + + public sealed record Command(string Sku, int Quantity); +}