diff --git a/docs/examples/index.md b/docs/examples/index.md index fe120d6d..44589739 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -72,6 +72,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **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). +* **Payment Messaging Gateway** + Shows fluent and source-generated request/response gateways over PatternKit message channels, with an importable `IServiceCollection` extension. See [Payment Messaging Gateway](payment-messaging-gateway.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/payment-messaging-gateway.md b/docs/examples/payment-messaging-gateway.md new file mode 100644 index 00000000..4ae9cef6 --- /dev/null +++ b/docs/examples/payment-messaging-gateway.md @@ -0,0 +1,12 @@ +# Payment Messaging Gateway + +The payment messaging gateway example exposes a typed authorization method while sending requests through a PatternKit message channel. + +```csharp +services.AddPaymentMessagingGatewayDemo(); + +var service = provider.GetRequiredService(); +var summary = service.Authorize(new PaymentAuthorizationRequest("ORDER-100", 42.50m)); +``` + +The example includes fluent and source-generated construction, a message-backed request boundary, and `IServiceCollection` registration for existing .NET applications. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 14e257d7..3d7c24d4 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -58,6 +58,9 @@ - name: ERP Channel Adapter href: erp-channel-adapter.md +- name: Payment Messaging Gateway + href: payment-messaging-gateway.md + - name: Patterns Showcase — Integrated Order Processing href: patterns-showcase.md diff --git a/docs/generators/index.md b/docs/generators/index.md index 97919260..7cf742e8 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -84,6 +84,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**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]` | +| [**Messaging Gateway**](messaging-gateway.md) | Typed request/response gateway factories | `[GenerateMessagingGateway]` | | [**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/messaging-gateway.md b/docs/generators/messaging-gateway.md new file mode 100644 index 00000000..96a9ca6f --- /dev/null +++ b/docs/generators/messaging-gateway.md @@ -0,0 +1,21 @@ +# Messaging Gateway Generator + +`[GenerateMessagingGateway]` creates a typed `MessagingGateway` factory. + +```csharp +[GenerateMessagingGateway(typeof(PaymentAuthorizationRequest), typeof(PaymentAuthorizationDecision), FactoryName = "Create", GatewayName = "payment-authorization-gateway")] +public static partial class PaymentGateway +{ + [MessagingGatewayHandler] + private static Message Authorize(Message request, MessageContext context) + => Message.Create(new("AUTH-100", true)); +} +``` + +The generated factory accepts a `MessageChannel` so the gateway can be composed through `IServiceCollection`. + +Diagnostics: + +- `PKGWY001`: host type must be partial. +- `PKGWY002`: exactly one messaging gateway handler is required. +- `PKGWY003`: messaging gateway handler signature is invalid. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 4441f420..a3730917 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -97,6 +97,9 @@ - name: Channel Adapter href: channel-adapter.md +- name: Messaging Gateway + href: messaging-gateway.md + - name: Message Translator href: message-translator.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 06133451..d51e5fb7 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -48,6 +48,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | 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 | Messaging Gateway | `MessagingGateway` | Messaging Gateway 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 4f56673a..6f1590c2 100644 --- a/docs/patterns/messaging/README.md +++ b/docs/patterns/messaging/README.md @@ -44,6 +44,12 @@ Channel adapters translate external transport DTOs into PatternKit message chann [Learn More](channel-adapter.md) +## Messaging Gateway + +Messaging gateways expose typed request/response methods while hiding message envelope and channel plumbing from application services. + +[Learn More](messaging-gateway.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/messaging-gateway.md b/docs/patterns/messaging/messaging-gateway.md new file mode 100644 index 00000000..24c622f9 --- /dev/null +++ b/docs/patterns/messaging/messaging-gateway.md @@ -0,0 +1,17 @@ +# Messaging Gateway + +Messaging Gateway exposes an application-friendly request/response method while hiding message channel and envelope plumbing. + +```csharp +var gateway = MessagingGateway + .Create("payment-authorization-gateway") + .SendTo(requestChannel) + .Handle((message, context) => Message.Create(decision)) + .Build(); + +var result = gateway.Invoke(new PaymentAuthorizationRequest("ORDER-100", 42.50m)); +``` + +Use it when application services should call a typed API while the implementation still sends a message through a channel and handles a message-shaped response. The gateway reports channel rejection explicitly so callers can distinguish backpressure from business decisions. + +The source-generated path uses `[GenerateMessagingGateway]` and `[MessagingGatewayHandler]`. Import the payment example through `AddPaymentMessagingGatewayDemo()` or `AddPatternKitExamples()`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index f6885af0..f1d7bb9e 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -303,6 +303,8 @@ href: messaging/event-driven-consumer.md - name: Channel Adapter href: messaging/channel-adapter.md + - name: Messaging Gateway + href: messaging/messaging-gateway.md - name: Message Envelope and Context href: messaging/message-envelope.md - name: Message Translator diff --git a/src/PatternKit.Core/Messaging/Gateways/MessagingGateway.cs b/src/PatternKit.Core/Messaging/Gateways/MessagingGateway.cs new file mode 100644 index 00000000..2b092351 --- /dev/null +++ b/src/PatternKit.Core/Messaging/Gateways/MessagingGateway.cs @@ -0,0 +1,104 @@ +using PatternKit.Messaging.Channels; + +namespace PatternKit.Messaging.Gateways; + +/// Typed request/response facade over message channels and handlers. +public sealed class MessagingGateway +{ + public delegate Message GatewayHandler(Message request, MessageContext context); + + private readonly MessageChannel _requestChannel; + private readonly GatewayHandler _handler; + + private MessagingGateway(string name, MessageChannel requestChannel, GatewayHandler handler) + => (Name, _requestChannel, _handler) = (name, requestChannel, handler); + + public string Name { get; } + + public MessagingGatewayResult Invoke(TRequest request, MessageContext? context = null) + { + var requestMessage = Message.Create(request); + var effectiveContext = context ?? MessageContext.From(requestMessage); + var send = _requestChannel.Send(requestMessage); + if (!send.Accepted) + return MessagingGatewayResult.CreateRejected(Name, requestMessage, send); + + var response = _handler(requestMessage, effectiveContext); + if (response is null) + throw new InvalidOperationException("Messaging gateway handler returned null."); + + return MessagingGatewayResult.CreateCompleted(Name, requestMessage, response, send); + } + + public static Builder Create(string name = "messaging-gateway") => new(name); + + public sealed class Builder + { + private readonly string _name; + private MessageChannel? _requestChannel; + private GatewayHandler? _handler; + + internal Builder(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Messaging gateway name cannot be null, empty, or whitespace.", nameof(name)); + + _name = name; + } + + public Builder SendTo(MessageChannel channel) + { + _requestChannel = channel ?? throw new ArgumentNullException(nameof(channel)); + return this; + } + + public Builder Handle(GatewayHandler handler) + { + _handler = handler ?? throw new ArgumentNullException(nameof(handler)); + return this; + } + + public MessagingGateway Build() + { + if (_requestChannel is null) + throw new InvalidOperationException("Messaging gateway requires a request channel."); + if (_handler is null) + throw new InvalidOperationException("Messaging gateway requires a handler."); + + return new(_name, _requestChannel, _handler); + } + } +} + +public sealed class MessagingGatewayResult +{ + private MessagingGatewayResult( + string gatewayName, + Message request, + Message? response, + MessageChannelSendResult channelResult) + => (GatewayName, Request, Response, ChannelResult) = (gatewayName, request, response, channelResult); + + public string GatewayName { get; } + + public Message Request { get; } + + public Message? Response { get; } + + public MessageChannelSendResult ChannelResult { get; } + + public bool Completed => ChannelResult.Accepted && Response is not null; + + internal static MessagingGatewayResult CreateCompleted( + string gatewayName, + Message request, + Message response, + MessageChannelSendResult channelResult) + => new(gatewayName, request, response, channelResult); + + internal static MessagingGatewayResult CreateRejected( + string gatewayName, + Message request, + MessageChannelSendResult channelResult) + => new(gatewayName, request, null, channelResult); +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index ef9b5fc1..4ad47d2d 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -62,6 +62,7 @@ using PatternKit.Messaging.Channels; using PatternKit.Messaging.Consumers; using PatternKit.Messaging.Adapters; +using PatternKit.Messaging.Gateways; using PatternKit.Messaging.Routing; using PatternKit.Messaging.Storage; using PatternKit.Messaging.ControlBus; @@ -130,6 +131,7 @@ public sealed record InventoryMessageChannelExampleService(MessageChannel Consumer, WarehousePollingConsumerService Service); public sealed record OrderEventDrivenConsumerExampleService(EventDrivenConsumer Consumer, OrderEventDrivenConsumerService Service); public sealed record ErpChannelAdapterExampleService(ChannelAdapter Adapter, ErpChannelAdapterService Service); +public sealed record PaymentMessagingGatewayExampleService(MessagingGateway Gateway, PaymentMessagingGatewayService Service); public sealed record GeneratedMessageEnvelopeExample(MessageEnvelopeExampleRunner Runner); public sealed record GeneratedMessageTranslatorExample(PartnerEventTranslatorExampleRunner Runner, PartnerOrderImportService Service); public sealed record GeneratedClaimCheckExample(LargeDocumentClaimCheckExampleRunner Runner, LargeDocumentWorkflow Workflow); @@ -212,6 +214,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddWarehousePollingConsumerExample() .AddOrderEventDrivenConsumerExample() .AddErpChannelAdapterExample() + .AddPaymentMessagingGatewayExample() .AddGeneratedMessageEnvelopeExample() .AddGeneratedMessageTranslatorExample() .AddGeneratedClaimCheckExample() @@ -498,6 +501,15 @@ public static IServiceCollection AddErpChannelAdapterExample(this IServiceCollec return services.RegisterExample("ERP Channel Adapter", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddPaymentMessagingGatewayExample(this IServiceCollection services) + { + services.AddPaymentMessagingGatewayDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService())); + return services.RegisterExample("Payment Messaging Gateway", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddGeneratedMessageEnvelopeExample(this IServiceCollection services) { services.AddMessageEnvelopeExample(); diff --git a/src/PatternKit.Examples/Messaging/PaymentMessagingGatewayExample.cs b/src/PatternKit.Examples/Messaging/PaymentMessagingGatewayExample.cs new file mode 100644 index 00000000..86bf4a55 --- /dev/null +++ b/src/PatternKit.Examples/Messaging/PaymentMessagingGatewayExample.cs @@ -0,0 +1,79 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; +using PatternKit.Messaging.Channels; +using PatternKit.Messaging.Gateways; + +namespace PatternKit.Examples.Messaging; + +public sealed record PaymentAuthorizationRequest(string OrderId, decimal Amount); + +public sealed record PaymentAuthorizationDecision(string AuthorizationCode, bool Approved); + +public sealed record PaymentGatewaySummary(bool Completed, bool Approved, string? AuthorizationCode, int RequestCount); + +public sealed class PaymentMessagingGatewayService(MessagingGateway gateway, MessageChannel requests) +{ + public PaymentGatewaySummary Authorize(PaymentAuthorizationRequest request) + { + var result = gateway.Invoke(request); + return new( + result.Completed, + result.Response?.Payload.Approved ?? false, + result.Response?.Payload.AuthorizationCode, + requests.Count); + } +} + +public static class PaymentMessagingGateways +{ + public static MessagingGateway Create(MessageChannel requests) + => MessagingGateway.Create("payment-authorization-gateway") + .SendTo(requests) + .Handle(Authorize) + .Build(); + + public static Message Authorize(Message request, MessageContext context) + { + var approved = request.Payload.Amount <= 500m; + var code = approved ? $"AUTH-{request.Payload.OrderId}" : "DECLINED"; + return Message.Create(new(code, approved)); + } +} + +[GenerateMessagingGateway(typeof(PaymentAuthorizationRequest), typeof(PaymentAuthorizationDecision), FactoryName = "Create", GatewayName = "payment-authorization-gateway")] +public static partial class GeneratedPaymentMessagingGateway +{ + [MessagingGatewayHandler] + private static Message Authorize(Message request, MessageContext context) + => PaymentMessagingGateways.Authorize(request, context); +} + +public sealed class PaymentMessagingGatewayExampleRunner(PaymentMessagingGatewayService service) +{ + public PaymentGatewaySummary RunGenerated(PaymentAuthorizationRequest request) => service.Authorize(request); + + public static PaymentGatewaySummary RunFluent(PaymentAuthorizationRequest request) + { + var channel = MessageChannel.Create("payment-requests").Build(); + return new PaymentMessagingGatewayService(PaymentMessagingGateways.Create(channel), channel).Authorize(request); + } + + public static PaymentGatewaySummary RunGeneratedStatic(PaymentAuthorizationRequest request) + { + var channel = MessageChannel.Create("payment-requests").Build(); + return new PaymentMessagingGatewayService(GeneratedPaymentMessagingGateway.Create(channel), channel).Authorize(request); + } +} + +public static class PaymentMessagingGatewayExampleServiceCollectionExtensions +{ + public static IServiceCollection AddPaymentMessagingGatewayDemo(this IServiceCollection services) + { + services.AddSingleton(_ => MessageChannel.Create("payment-requests").Build()); + services.AddSingleton(sp => GeneratedPaymentMessagingGateway.Create(sp.GetRequiredService>())); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 4dd30364..d57b161f 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 | ExampleIntegrationSurface.GenericHost, ["ChannelAdapter", "MessageChannel"], ["external ERP DTO bridge", "source-generated adapter factory", "DI composition"]), + Descriptor( + "Payment Messaging Gateway", + "src/PatternKit.Examples/Messaging/PaymentMessagingGatewayExample.cs", + "test/PatternKit.Examples.Tests/Messaging/PaymentMessagingGatewayExampleTests.cs", + "docs/examples/payment-messaging-gateway.md", + ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["MessagingGateway", "MessageChannel"], + ["typed authorization gateway", "source-generated gateway 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 d7024837..663bc2ab 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -415,6 +415,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/Messaging/ErpChannelAdapterExampleTests.cs", ["fluent external channel bridge", "generated adapter factory", "DI-importable ERP integration example"]), + Pattern("Messaging Gateway", PatternFamily.EnterpriseIntegration, + "docs/patterns/messaging/messaging-gateway.md", + "src/PatternKit.Core/Messaging/Gateways/MessagingGateway.cs", + "test/PatternKit.Tests/Messaging/Gateways/MessagingGatewayTests.cs", + "docs/generators/messaging-gateway.md", + "src/PatternKit.Generators/Messaging/MessagingGatewayGenerator.cs", + "test/PatternKit.Generators.Tests/MessagingGatewayGeneratorTests.cs", + null, + "docs/examples/payment-messaging-gateway.md", + "src/PatternKit.Examples/Messaging/PaymentMessagingGatewayExample.cs", + "test/PatternKit.Examples.Tests/Messaging/PaymentMessagingGatewayExampleTests.cs", + ["fluent request-response facade", "generated gateway factory", "DI-importable payment authorization 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/MessagingGatewayAttributes.cs b/src/PatternKit.Generators.Abstractions/Messaging/MessagingGatewayAttributes.cs new file mode 100644 index 00000000..26505516 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Messaging/MessagingGatewayAttributes.cs @@ -0,0 +1,26 @@ +using System; + +namespace PatternKit.Generators.Messaging; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateMessagingGatewayAttribute : Attribute +{ + public GenerateMessagingGatewayAttribute(Type requestType, Type responseType) + { + RequestType = requestType ?? throw new ArgumentNullException(nameof(requestType)); + ResponseType = responseType ?? throw new ArgumentNullException(nameof(responseType)); + } + + public Type RequestType { get; } + + public Type ResponseType { get; } + + public string FactoryName { get; set; } = "Create"; + + public string GatewayName { get; set; } = "messaging-gateway"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class MessagingGatewayHandlerAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 3b9f04a5..993c6245 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -325,3 +325,6 @@ PKCAD002 | PatternKit.Generators.Messaging | Error | Channel Adapter must declar 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. +PKGWY001 | PatternKit.Generators.Messaging | Error | Messaging Gateway host type must be partial. +PKGWY002 | PatternKit.Generators.Messaging | Error | Messaging Gateway must declare exactly one handler. +PKGWY003 | PatternKit.Generators.Messaging | Error | Messaging Gateway handler signature is invalid. diff --git a/src/PatternKit.Generators/Messaging/MessagingGatewayGenerator.cs b/src/PatternKit.Generators/Messaging/MessagingGatewayGenerator.cs new file mode 100644 index 00000000..e73850c4 --- /dev/null +++ b/src/PatternKit.Generators/Messaging/MessagingGatewayGenerator.cs @@ -0,0 +1,112 @@ +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 MessagingGatewayGenerator : IIncrementalGenerator +{ + private static readonly DiagnosticDescriptor MustBePartial = new("PKGWY001", "Messaging gateway type must be partial", "Type '{0}' is marked with [GenerateMessagingGateway] but is not declared as partial", "PatternKit.Generators.Messaging", DiagnosticSeverity.Error, true); + private static readonly DiagnosticDescriptor MissingHandler = new("PKGWY002", "Messaging gateway handler is missing", "Type '{0}' must declare exactly one [MessagingGatewayHandler] method", "PatternKit.Generators.Messaging", DiagnosticSeverity.Error, true); + private static readonly DiagnosticDescriptor InvalidHandler = new("PKGWY003", "Messaging gateway handler signature is invalid", "Handler '{0}' must be static and return Message with Message and MessageContext parameters", "PatternKit.Generators.Messaging", DiagnosticSeverity.Error, true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + "PatternKit.Generators.Messaging.GenerateMessagingGatewayAttribute", + 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.GenerateMessagingGatewayAttribute"); + 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 requestType = attribute.ConstructorArguments.Length > 0 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + var responseType = attribute.ConstructorArguments.Length > 1 ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol : null; + if (requestType is null || responseType is null) + return; + + var handlers = type.GetMembers().OfType().Where(static method => + method.GetAttributes().Any(static attr => attr.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.MessagingGatewayHandlerAttribute")).ToArray(); + if (handlers.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingHandler, node.Identifier.GetLocation(), type.Name)); + return; + } + + if (!IsHandler(handlers[0], requestType, responseType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidHandler, handlers[0].Locations.FirstOrDefault(), handlers[0].Name)); + return; + } + + var factoryName = GetNamedString(attribute, "FactoryName") ?? "Create"; + var gatewayName = GetNamedString(attribute, "GatewayName") ?? "messaging-gateway"; + context.AddSource($"{type.Name}.MessagingGateway.g.cs", SourceText.From(GenerateSource(type, requestType, responseType, handlers[0].Name, factoryName, gatewayName), Encoding.UTF8)); + } + + private static bool IsHandler(IMethodSymbol method, INamedTypeSymbol requestType, INamedTypeSymbol responseType) + => method.IsStatic && + IsMessageOf(method.ReturnType, responseType) && + method.Parameters.Length == 2 && + IsMessageOf(method.Parameters[0].Type, requestType) && + 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 requestType, INamedTypeSymbol responseType, string handler, string factoryName, string gatewayName) + { + 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.Gateways.MessagingGateway<") + .Append(requestType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)).Append(", ") + .Append(responseType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append("> ").Append(factoryName).AppendLine("("); + sb.Append(" global::PatternKit.Messaging.Channels.MessageChannel<") + .Append(requestType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .AppendLine("> requestChannel)"); + sb.Append(" => global::PatternKit.Messaging.Gateways.MessagingGateway<") + .Append(requestType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)).Append(", ") + .Append(responseType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append(">.Create(").Append(ToLiteral(gatewayName)).AppendLine(")"); + sb.AppendLine(" .SendTo(requestChannel)"); + sb.Append(" .Handle(").Append(handler).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/PaymentMessagingGatewayExampleTests.cs b/test/PatternKit.Examples.Tests/Messaging/PaymentMessagingGatewayExampleTests.cs new file mode 100644 index 00000000..a1166dde --- /dev/null +++ b/test/PatternKit.Examples.Tests/Messaging/PaymentMessagingGatewayExampleTests.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.Messaging; +using TinyBDD; + +namespace PatternKit.Examples.Tests.Messaging; + +public sealed class PaymentMessagingGatewayExampleTests +{ + [Scenario("FluentMessagingGateway AuthorizesPayment")] + [Fact] + public void FluentMessagingGateway_AuthorizesPayment() + { + var summary = PaymentMessagingGatewayExampleRunner.RunFluent(new("ORDER-100", 42.50m)); + + ScenarioExpect.True(summary.Completed); + ScenarioExpect.True(summary.Approved); + ScenarioExpect.Equal("AUTH-ORDER-100", summary.AuthorizationCode); + ScenarioExpect.Equal(1, summary.RequestCount); + } + + [Scenario("GeneratedMessagingGateway MatchesFluentGateway")] + [Fact] + public void GeneratedMessagingGateway_MatchesFluentGateway() + { + var generated = PaymentMessagingGatewayExampleRunner.RunGeneratedStatic(new("ORDER-100", 42.50m)); + var fluent = PaymentMessagingGatewayExampleRunner.RunFluent(new("ORDER-100", 42.50m)); + + ScenarioExpect.Equal(fluent.Completed, generated.Completed); + ScenarioExpect.Equal(fluent.Approved, generated.Approved); + ScenarioExpect.Equal(fluent.AuthorizationCode, generated.AuthorizationCode); + } + + [Scenario("ServiceCollection ImportsMessagingGatewayExample")] + [Fact] + public void ServiceCollection_ImportsMessagingGatewayExample() + { + var services = new ServiceCollection(); + services.AddPaymentMessagingGatewayDemo(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var service = provider.GetRequiredService(); + + var summary = service.Authorize(new("ORDER-100", 42.50m)); + + ScenarioExpect.True(summary.Completed); + ScenarioExpect.True(summary.Approved); + } + + [Scenario("AggregateServiceCollection ImportsMessagingGatewayExample")] + [Fact] + public void AggregateServiceCollection_ImportsMessagingGatewayExample() + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var example = provider.GetRequiredService(); + + var summary = example.Service.Authorize(new("ORDER-100", 42.50m)); + + ScenarioExpect.True(summary.Completed); + ScenarioExpect.True(summary.Approved); + } +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index ff2d03c4..1b3afcc8 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -42,6 +42,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Polling Consumer", "Event-Driven Consumer", "Channel Adapter", + "Messaging Gateway", "Message Envelope", "Message Translator", "Claim Check", @@ -128,7 +129,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(25, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); + ScenarioExpect.Equal(26, 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 52157fb7..a86c54bf 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -139,6 +139,8 @@ private enum TestTrigger { typeof(GenerateChannelAdapterAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(ChannelAdapterInboundAttribute), AttributeTargets.Method, false, false }, { typeof(ChannelAdapterOutboundAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateMessagingGatewayAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(MessagingGatewayHandlerAttribute), 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 }, @@ -768,6 +770,11 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf FactoryName = "BuildAdapter", AdapterName = "erp-orders" }; + var messagingGateway = new GenerateMessagingGatewayAttribute(typeof(string), typeof(int)) + { + FactoryName = "BuildGateway", + GatewayName = "payments" + }; var routingSlip = new GenerateRoutingSlipAttribute(typeof(string)) { FactoryName = "Build", @@ -940,6 +947,10 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Equal(typeof(int), channelAdapter.PayloadType); ScenarioExpect.Equal("BuildAdapter", channelAdapter.FactoryName); ScenarioExpect.Equal("erp-orders", channelAdapter.AdapterName); + ScenarioExpect.Equal(typeof(string), messagingGateway.RequestType); + ScenarioExpect.Equal(typeof(int), messagingGateway.ResponseType); + ScenarioExpect.Equal("BuildGateway", messagingGateway.FactoryName); + ScenarioExpect.Equal("payments", messagingGateway.GatewayName); ScenarioExpect.Equal(typeof(string), routingSlip.PayloadType); ScenarioExpect.Equal("Build", routingSlip.FactoryName); ScenarioExpect.Equal("BuildAsync", routingSlip.AsyncFactoryName); @@ -1061,6 +1072,9 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Throws(() => new GenerateChannelAdapterAttribute(typeof(string), null!)); ScenarioExpect.IsType(new ChannelAdapterInboundAttribute()); ScenarioExpect.IsType(new ChannelAdapterOutboundAttribute()); + ScenarioExpect.Throws(() => new GenerateMessagingGatewayAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateMessagingGatewayAttribute(typeof(string), null!)); + ScenarioExpect.IsType(new MessagingGatewayHandlerAttribute()); ScenarioExpect.Throws(() => new GenerateRoutingSlipAttribute(null!)); ScenarioExpect.Throws(() => new RoutingSlipStepAttribute("", 1)); ScenarioExpect.Throws(() => new GenerateSagaAttribute(null!)); diff --git a/test/PatternKit.Generators.Tests/MessagingGatewayGeneratorTests.cs b/test/PatternKit.Generators.Tests/MessagingGatewayGeneratorTests.cs new file mode 100644 index 00000000..de45dd50 --- /dev/null +++ b/test/PatternKit.Generators.Tests/MessagingGatewayGeneratorTests.cs @@ -0,0 +1,92 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.Messaging; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class MessagingGatewayGeneratorTests +{ + [Scenario("GeneratesMessagingGatewayFactory")] + [Fact] + public void GeneratesMessagingGatewayFactory() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + namespace MyApp; + public sealed record Request(string OrderId); + public sealed record Response(string OrderId); + [GenerateMessagingGateway(typeof(Request), typeof(Response), FactoryName = "Build", GatewayName = "payments")] + public static partial class PaymentGateway + { + [MessagingGatewayHandler] + private static Message Handle(Message request, MessageContext context) + => Message.Create(new Response(request.Payload.OrderId)); + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesMessagingGatewayFactory)); + _ = RoslynTestHelpers.Run(comp, new MessagingGatewayGenerator(), 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("MessagingGateway", text); + ScenarioExpect.Contains(".Handle(Handle)", text); + ScenarioExpect.True(updated.Emit(Stream.Null).Success); + } + + [Scenario("ReportsMessagingGatewayDiagnostics")] + [Theory] + [InlineData("public static class PaymentGateway { }", "PKGWY001")] + [InlineData("public static partial class PaymentGateway { }", "PKGWY002")] + public void ReportsMessagingGatewayDiagnostics(string declaration, string expected) + { + var source = $$""" + using PatternKit.Generators.Messaging; + namespace MyApp; + public sealed record Request(string OrderId); + public sealed record Response(string OrderId); + [GenerateMessagingGateway(typeof(Request), typeof(Response))] + {{declaration}} + """; + + var comp = CreateCompilation(source, nameof(ReportsMessagingGatewayDiagnostics) + expected); + _ = RoslynTestHelpers.Run(comp, new MessagingGatewayGenerator(), out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal(expected, diagnostic.Id); + } + + [Scenario("ReportsInvalidMessagingGatewayHandler")] + [Fact] + public void ReportsInvalidMessagingGatewayHandler() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + namespace MyApp; + public sealed record Request(string OrderId); + public sealed record Response(string OrderId); + [GenerateMessagingGateway(typeof(Request), typeof(Response))] + public static partial class PaymentGateway + { + [MessagingGatewayHandler] + private static string Handle(Message request, MessageContext context) => "bad"; + } + """; + + var comp = CreateCompilation(source, nameof(ReportsInvalidMessagingGatewayHandler)); + _ = RoslynTestHelpers.Run(comp, new MessagingGatewayGenerator(), out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKGWY003", diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: MetadataReference.CreateFromFile(typeof(global::PatternKit.Messaging.Gateways.MessagingGateway<,>).Assembly.Location)); +} diff --git a/test/PatternKit.Tests/Messaging/Gateways/MessagingGatewayTests.cs b/test/PatternKit.Tests/Messaging/Gateways/MessagingGatewayTests.cs new file mode 100644 index 00000000..513dd3a2 --- /dev/null +++ b/test/PatternKit.Tests/Messaging/Gateways/MessagingGatewayTests.cs @@ -0,0 +1,61 @@ +using PatternKit.Messaging; +using PatternKit.Messaging.Channels; +using PatternKit.Messaging.Gateways; +using TinyBDD; + +namespace PatternKit.Tests.Messaging.Gateways; + +public sealed class MessagingGatewayTests +{ + [Scenario("Invoke SendsRequestAndReturnsResponse")] + [Fact] + public void Invoke_SendsRequestAndReturnsResponse() + { + var channel = MessageChannel.Create("requests").Build(); + var gateway = MessagingGateway.Create("payments") + .SendTo(channel) + .Handle((request, _) => Message.Create(new(request.Payload.OrderId, true))) + .Build(); + + var result = gateway.Invoke(new("order-1", 42m)); + + ScenarioExpect.True(result.Completed); + ScenarioExpect.Equal("payments", result.GatewayName); + ScenarioExpect.Equal("order-1", result.Response!.Payload.OrderId); + ScenarioExpect.Equal(1, channel.Count); + } + + [Scenario("InvokeReturnsRejectedResultWhenRequestChannelRejects")] + [Fact] + public void InvokeReturnsRejectedResultWhenRequestChannelRejects() + { + var channel = MessageChannel.Create("requests").WithCapacity(1).Build(); + channel.Send(Message.Create(new("existing", 1m))); + var gateway = MessagingGateway.Create() + .SendTo(channel) + .Handle((request, _) => Message.Create(new(request.Payload.OrderId, true))) + .Build(); + + var result = gateway.Invoke(new("order-1", 42m)); + + ScenarioExpect.False(result.Completed); + ScenarioExpect.Null(result.Response); + ScenarioExpect.False(result.ChannelResult.Accepted); + } + + [Scenario("Builder RejectsInvalidConfiguration")] + [Fact] + public void Builder_RejectsInvalidConfiguration() + { + var channel = MessageChannel.Create().Build(); + + ScenarioExpect.Throws(() => MessagingGateway.Create("")); + ScenarioExpect.Throws(() => MessagingGateway.Create().SendTo(null!)); + ScenarioExpect.Throws(() => MessagingGateway.Create().Handle(null!)); + ScenarioExpect.Throws(() => MessagingGateway.Create().SendTo(channel).Build()); + } + + public sealed record Request(string OrderId, decimal Amount); + + public sealed record Response(string OrderId, bool Approved); +}