diff --git a/docs/examples/index.md b/docs/examples/index.md index 44589739..734e66df 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -75,6 +75,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **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). +* **Inventory Service Activator** + Shows fluent and source-generated message-to-service activation with an importable `IServiceCollection` extension. See [Inventory Service Activator](inventory-service-activator.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/inventory-service-activator.md b/docs/examples/inventory-service-activator.md new file mode 100644 index 00000000..0cdb962b --- /dev/null +++ b/docs/examples/inventory-service-activator.md @@ -0,0 +1,12 @@ +# Inventory Service Activator + +The inventory service activator example turns an inbound reservation message into a container-owned application service call. + +```csharp +services.AddInventoryServiceActivatorDemo(); + +var service = provider.GetRequiredService(); +var summary = service.Reserve(new InventoryReservationRequest("SKU-100", 5)); +``` + +The example includes fluent and source-generated construction, typed message activation, and `IServiceCollection` registration for existing .NET applications. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 3d7c24d4..407bd75d 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -61,6 +61,9 @@ - name: Payment Messaging Gateway href: payment-messaging-gateway.md +- name: Inventory Service Activator + href: inventory-service-activator.md + - name: Patterns Showcase — Integrated Order Processing href: patterns-showcase.md diff --git a/docs/generators/index.md b/docs/generators/index.md index 7cf742e8..e122fbcc 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -85,6 +85,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**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]` | +| [**Service Activator**](service-activator.md) | Message-to-service operation factories | `[GenerateServiceActivator]` | | [**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/service-activator.md b/docs/generators/service-activator.md new file mode 100644 index 00000000..01afe4d8 --- /dev/null +++ b/docs/generators/service-activator.md @@ -0,0 +1,21 @@ +# Service Activator Generator + +`[GenerateServiceActivator]` creates a typed `ServiceActivator` factory. + +```csharp +[GenerateServiceActivator(typeof(InventoryReservationRequest), typeof(InventoryReservationResult), FactoryName = "Create", ActivatorName = "inventory-reservation-activator")] +public static partial class InventoryActivator +{ + [ServiceActivatorHandler] + private static Message Reserve(Message request, MessageContext context) + => Message.Create(new(request.Payload.Sku, true, "allocated")); +} +``` + +The generated factory has no parameters, so the activator can be registered directly in `IServiceCollection` and injected into application services. + +Diagnostics: + +- `PKSVA001`: host type must be partial. +- `PKSVA002`: exactly one service activator handler is required. +- `PKSVA003`: service activator handler signature is invalid. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index a3730917..d3a9de0b 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -100,6 +100,9 @@ - name: Messaging Gateway href: messaging-gateway.md +- name: Service Activator + href: service-activator.md + - name: Message Translator href: message-translator.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index d51e5fb7..8fd7b83a 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -49,6 +49,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | 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 | Service Activator | `ServiceActivator` | Service Activator 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 6f1590c2..c9992b3a 100644 --- a/docs/patterns/messaging/README.md +++ b/docs/patterns/messaging/README.md @@ -50,6 +50,12 @@ Messaging gateways expose typed request/response methods while hiding message en [Learn More](messaging-gateway.md) +## Service Activator + +Service activators invoke application service operations from typed messages while preserving message context and response envelopes. + +[Learn More](service-activator.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/service-activator.md b/docs/patterns/messaging/service-activator.md new file mode 100644 index 00000000..dddaeaa8 --- /dev/null +++ b/docs/patterns/messaging/service-activator.md @@ -0,0 +1,16 @@ +# Service Activator + +Service Activator invokes an application service operation from a typed message and returns a typed response message. + +```csharp +var activator = ServiceActivator + .Create("inventory-reservation-activator") + .Handle((message, context) => Message.Create(result)) + .Build(); + +var response = activator.Activate(Message.Create(request)); +``` + +Use it when a message endpoint should hand work to a domain or application service while preserving message context and typed request/response contracts. The activator keeps handler validation explicit, making it suitable for container-owned services in Generic Host or ASP.NET Core applications. + +The source-generated path uses `[GenerateServiceActivator]` and `[ServiceActivatorHandler]`. Import the inventory example through `AddInventoryServiceActivatorDemo()` or `AddPatternKitExamples()`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index f1d7bb9e..9f703dd9 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -305,6 +305,8 @@ href: messaging/channel-adapter.md - name: Messaging Gateway href: messaging/messaging-gateway.md + - name: Service Activator + href: messaging/service-activator.md - name: Message Envelope and Context href: messaging/message-envelope.md - name: Message Translator diff --git a/src/PatternKit.Core/Messaging/Activation/ServiceActivator.cs b/src/PatternKit.Core/Messaging/Activation/ServiceActivator.cs new file mode 100644 index 00000000..d18f3695 --- /dev/null +++ b/src/PatternKit.Core/Messaging/Activation/ServiceActivator.cs @@ -0,0 +1,74 @@ +namespace PatternKit.Messaging.Activation; + +/// Activates an application service operation from a typed message. +public sealed class ServiceActivator +{ + public delegate Message ServiceHandler(Message request, MessageContext context); + + private readonly ServiceHandler _handler; + + private ServiceActivator(string name, ServiceHandler handler) + => (Name, _handler) = (name, handler); + + public string Name { get; } + + public ServiceActivatorResult Activate(Message request, MessageContext? context = null) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + var effectiveContext = context ?? MessageContext.From(request); + var response = _handler(request, effectiveContext); + if (response is null) + throw new InvalidOperationException("Service activator handler returned null."); + + return new(Name, request, response); + } + + public static Builder Create(string name = "service-activator") => new(name); + + public sealed class Builder + { + private readonly string _name; + private ServiceHandler? _handler; + + internal Builder(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Service activator name cannot be null, empty, or whitespace.", nameof(name)); + + _name = name; + } + + public Builder Handle(ServiceHandler handler) + { + _handler = handler ?? throw new ArgumentNullException(nameof(handler)); + return this; + } + + public ServiceActivator Build() + { + if (_handler is null) + throw new InvalidOperationException("Service activator requires a handler."); + + return new(_name, _handler); + } + } +} + +public sealed class ServiceActivatorResult +{ + public ServiceActivatorResult( + string activatorName, + Message request, + Message response) + => (ActivatorName, Request, Response) = (activatorName, request, response); + + public string ActivatorName { get; } + + public Message Request { get; } + + public Message Response { get; } + + public bool Completed => Response is not null; +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 4ad47d2d..a6758198 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.Activation; using PatternKit.Messaging.Gateways; using PatternKit.Messaging.Routing; using PatternKit.Messaging.Storage; @@ -132,6 +133,7 @@ public sealed record WarehousePollingConsumerExampleService(PollingConsumer Consumer, OrderEventDrivenConsumerService Service); public sealed record ErpChannelAdapterExampleService(ChannelAdapter Adapter, ErpChannelAdapterService Service); public sealed record PaymentMessagingGatewayExampleService(MessagingGateway Gateway, PaymentMessagingGatewayService Service); +public sealed record InventoryServiceActivatorExampleService(ServiceActivator Activator, InventoryServiceActivatorService Service); public sealed record GeneratedMessageEnvelopeExample(MessageEnvelopeExampleRunner Runner); public sealed record GeneratedMessageTranslatorExample(PartnerEventTranslatorExampleRunner Runner, PartnerOrderImportService Service); public sealed record GeneratedClaimCheckExample(LargeDocumentClaimCheckExampleRunner Runner, LargeDocumentWorkflow Workflow); @@ -215,6 +217,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddOrderEventDrivenConsumerExample() .AddErpChannelAdapterExample() .AddPaymentMessagingGatewayExample() + .AddInventoryServiceActivatorExample() .AddGeneratedMessageEnvelopeExample() .AddGeneratedMessageTranslatorExample() .AddGeneratedClaimCheckExample() @@ -510,6 +513,15 @@ public static IServiceCollection AddPaymentMessagingGatewayExample(this IService return services.RegisterExample("Payment Messaging Gateway", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddInventoryServiceActivatorExample(this IServiceCollection services) + { + services.AddInventoryServiceActivatorDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService())); + return services.RegisterExample("Inventory Service Activator", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddGeneratedMessageEnvelopeExample(this IServiceCollection services) { services.AddMessageEnvelopeExample(); diff --git a/src/PatternKit.Examples/Messaging/InventoryServiceActivatorExample.cs b/src/PatternKit.Examples/Messaging/InventoryServiceActivatorExample.cs new file mode 100644 index 00000000..91d8843e --- /dev/null +++ b/src/PatternKit.Examples/Messaging/InventoryServiceActivatorExample.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; +using PatternKit.Messaging.Activation; + +namespace PatternKit.Examples.Messaging; + +public sealed record InventoryReservationRequest(string Sku, int Quantity); + +public sealed record InventoryReservationResult(string Sku, bool Reserved, string Reason); + +public sealed record InventoryServiceActivatorSummary(bool Completed, bool Reserved, string Reason); + +public sealed class InventoryServiceActivatorService(ServiceActivator activator) +{ + public InventoryServiceActivatorSummary Reserve(InventoryReservationRequest request) + { + var result = activator.Activate(Message.Create(request)); + return new(result.Completed, result.Response.Payload.Reserved, result.Response.Payload.Reason); + } +} + +public static class InventoryServiceActivators +{ + public static ServiceActivator Create() + => ServiceActivator.Create("inventory-reservation-activator") + .Handle(Reserve) + .Build(); + + public static Message Reserve(Message request, MessageContext context) + { + var reserved = request.Payload.Quantity <= 25; + var reason = reserved ? "allocated" : "insufficient-stock"; + return Message.Create(new(request.Payload.Sku, reserved, reason)); + } +} + +[GenerateServiceActivator(typeof(InventoryReservationRequest), typeof(InventoryReservationResult), FactoryName = "Create", ActivatorName = "inventory-reservation-activator")] +public static partial class GeneratedInventoryServiceActivator +{ + [ServiceActivatorHandler] + private static Message Reserve(Message request, MessageContext context) + => InventoryServiceActivators.Reserve(request, context); +} + +public sealed class InventoryServiceActivatorExampleRunner(InventoryServiceActivatorService service) +{ + public InventoryServiceActivatorSummary RunGenerated(InventoryReservationRequest request) => service.Reserve(request); + + public static InventoryServiceActivatorSummary RunFluent(InventoryReservationRequest request) + => new InventoryServiceActivatorService(InventoryServiceActivators.Create()).Reserve(request); + + public static InventoryServiceActivatorSummary RunGeneratedStatic(InventoryReservationRequest request) + => new InventoryServiceActivatorService(GeneratedInventoryServiceActivator.Create()).Reserve(request); +} + +public static class InventoryServiceActivatorExampleServiceCollectionExtensions +{ + public static IServiceCollection AddInventoryServiceActivatorDemo(this IServiceCollection services) + { + services.AddSingleton(_ => GeneratedInventoryServiceActivator.Create()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index d57b161f..cd95348f 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -248,6 +248,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["MessagingGateway", "MessageChannel"], ["typed authorization gateway", "source-generated gateway factory", "DI composition"]), + Descriptor( + "Inventory Service Activator", + "src/PatternKit.Examples/Messaging/InventoryServiceActivatorExample.cs", + "test/PatternKit.Examples.Tests/Messaging/InventoryServiceActivatorExampleTests.cs", + "docs/examples/inventory-service-activator.md", + ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["ServiceActivator"], + ["message-to-service operation", "source-generated activator 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 663bc2ab..26a6154b 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -428,6 +428,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/Messaging/PaymentMessagingGatewayExampleTests.cs", ["fluent request-response facade", "generated gateway factory", "DI-importable payment authorization example"]), + Pattern("Service Activator", PatternFamily.EnterpriseIntegration, + "docs/patterns/messaging/service-activator.md", + "src/PatternKit.Core/Messaging/Activation/ServiceActivator.cs", + "test/PatternKit.Tests/Messaging/Activation/ServiceActivatorTests.cs", + "docs/generators/service-activator.md", + "src/PatternKit.Generators/Messaging/ServiceActivatorGenerator.cs", + "test/PatternKit.Generators.Tests/ServiceActivatorGeneratorTests.cs", + null, + "docs/examples/inventory-service-activator.md", + "src/PatternKit.Examples/Messaging/InventoryServiceActivatorExample.cs", + "test/PatternKit.Examples.Tests/Messaging/InventoryServiceActivatorExampleTests.cs", + ["fluent message-to-service activation", "generated activator factory", "DI-importable inventory reservation 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/ServiceActivatorAttributes.cs b/src/PatternKit.Generators.Abstractions/Messaging/ServiceActivatorAttributes.cs new file mode 100644 index 00000000..62ef2d9d --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Messaging/ServiceActivatorAttributes.cs @@ -0,0 +1,26 @@ +using System; + +namespace PatternKit.Generators.Messaging; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateServiceActivatorAttribute : Attribute +{ + public GenerateServiceActivatorAttribute(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 ActivatorName { get; set; } = "service-activator"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class ServiceActivatorHandlerAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 993c6245..00e1473e 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -328,3 +328,6 @@ PKCAD005 | PatternKit.Generators.Messaging | Error | Channel Adapter outbound tr 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. +PKSVA001 | PatternKit.Generators.Messaging | Error | Service Activator host type must be partial. +PKSVA002 | PatternKit.Generators.Messaging | Error | Service Activator must declare exactly one handler. +PKSVA003 | PatternKit.Generators.Messaging | Error | Service Activator handler signature is invalid. diff --git a/src/PatternKit.Generators/Messaging/ServiceActivatorGenerator.cs b/src/PatternKit.Generators/Messaging/ServiceActivatorGenerator.cs new file mode 100644 index 00000000..29a9fdba --- /dev/null +++ b/src/PatternKit.Generators/Messaging/ServiceActivatorGenerator.cs @@ -0,0 +1,108 @@ +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 ServiceActivatorGenerator : IIncrementalGenerator +{ + private static readonly DiagnosticDescriptor MustBePartial = new("PKSVA001", "Service activator type must be partial", "Type '{0}' is marked with [GenerateServiceActivator] but is not declared as partial", "PatternKit.Generators.Messaging", DiagnosticSeverity.Error, true); + private static readonly DiagnosticDescriptor MissingHandler = new("PKSVA002", "Service activator handler is missing", "Type '{0}' must declare exactly one [ServiceActivatorHandler] method", "PatternKit.Generators.Messaging", DiagnosticSeverity.Error, true); + private static readonly DiagnosticDescriptor InvalidHandler = new("PKSVA003", "Service activator 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.GenerateServiceActivatorAttribute", + 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.GenerateServiceActivatorAttribute"); + 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.ServiceActivatorHandlerAttribute")).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 activatorName = GetNamedString(attribute, "ActivatorName") ?? "service-activator"; + context.AddSource($"{type.Name}.ServiceActivator.g.cs", SourceText.From(GenerateSource(type, requestType, responseType, handlers[0].Name, factoryName, activatorName), 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 activatorName) + { + 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.Activation.ServiceActivator<") + .Append(requestType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)).Append(", ") + .Append(responseType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append("> ").Append(factoryName).AppendLine("()"); + sb.Append(" => global::PatternKit.Messaging.Activation.ServiceActivator<") + .Append(requestType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)).Append(", ") + .Append(responseType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append(">.Create(").Append(ToLiteral(activatorName)).AppendLine(")"); + 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/InventoryServiceActivatorExampleTests.cs b/test/PatternKit.Examples.Tests/Messaging/InventoryServiceActivatorExampleTests.cs new file mode 100644 index 00000000..ed4e751f --- /dev/null +++ b/test/PatternKit.Examples.Tests/Messaging/InventoryServiceActivatorExampleTests.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.Messaging; +using TinyBDD; + +namespace PatternKit.Examples.Tests.Messaging; + +public sealed class InventoryServiceActivatorExampleTests +{ + [Scenario("FluentServiceActivator ReservesInventory")] + [Fact] + public void FluentServiceActivator_ReservesInventory() + { + var summary = InventoryServiceActivatorExampleRunner.RunFluent(new("SKU-100", 5)); + + ScenarioExpect.True(summary.Completed); + ScenarioExpect.True(summary.Reserved); + ScenarioExpect.Equal("allocated", summary.Reason); + } + + [Scenario("GeneratedServiceActivator MatchesFluentActivator")] + [Fact] + public void GeneratedServiceActivator_MatchesFluentActivator() + { + var generated = InventoryServiceActivatorExampleRunner.RunGeneratedStatic(new("SKU-100", 5)); + var fluent = InventoryServiceActivatorExampleRunner.RunFluent(new("SKU-100", 5)); + + ScenarioExpect.Equal(fluent.Completed, generated.Completed); + ScenarioExpect.Equal(fluent.Reserved, generated.Reserved); + ScenarioExpect.Equal(fluent.Reason, generated.Reason); + } + + [Scenario("ServiceCollection ImportsServiceActivatorExample")] + [Fact] + public void ServiceCollection_ImportsServiceActivatorExample() + { + var services = new ServiceCollection(); + services.AddInventoryServiceActivatorDemo(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var service = provider.GetRequiredService(); + + var summary = service.Reserve(new("SKU-100", 5)); + + ScenarioExpect.True(summary.Completed); + ScenarioExpect.True(summary.Reserved); + } + + [Scenario("AggregateServiceCollection ImportsServiceActivatorExample")] + [Fact] + public void AggregateServiceCollection_ImportsServiceActivatorExample() + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var example = provider.GetRequiredService(); + + var summary = example.Service.Reserve(new("SKU-100", 5)); + + ScenarioExpect.True(summary.Completed); + ScenarioExpect.True(summary.Reserved); + } +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 1b3afcc8..553086e8 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -43,6 +43,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Event-Driven Consumer", "Channel Adapter", "Messaging Gateway", + "Service Activator", "Message Envelope", "Message Translator", "Claim Check", @@ -129,7 +130,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(26, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); + ScenarioExpect.Equal(27, 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 a86c54bf..51191f72 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -141,6 +141,8 @@ private enum TestTrigger { typeof(ChannelAdapterOutboundAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateMessagingGatewayAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(MessagingGatewayHandlerAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateServiceActivatorAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(ServiceActivatorHandlerAttribute), 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 }, @@ -775,6 +777,11 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf FactoryName = "BuildGateway", GatewayName = "payments" }; + var serviceActivator = new GenerateServiceActivatorAttribute(typeof(string), typeof(int)) + { + FactoryName = "BuildActivator", + ActivatorName = "inventory" + }; var routingSlip = new GenerateRoutingSlipAttribute(typeof(string)) { FactoryName = "Build", @@ -951,6 +958,10 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Equal(typeof(int), messagingGateway.ResponseType); ScenarioExpect.Equal("BuildGateway", messagingGateway.FactoryName); ScenarioExpect.Equal("payments", messagingGateway.GatewayName); + ScenarioExpect.Equal(typeof(string), serviceActivator.RequestType); + ScenarioExpect.Equal(typeof(int), serviceActivator.ResponseType); + ScenarioExpect.Equal("BuildActivator", serviceActivator.FactoryName); + ScenarioExpect.Equal("inventory", serviceActivator.ActivatorName); ScenarioExpect.Equal(typeof(string), routingSlip.PayloadType); ScenarioExpect.Equal("Build", routingSlip.FactoryName); ScenarioExpect.Equal("BuildAsync", routingSlip.AsyncFactoryName); @@ -1075,6 +1086,9 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Throws(() => new GenerateMessagingGatewayAttribute(null!, typeof(int))); ScenarioExpect.Throws(() => new GenerateMessagingGatewayAttribute(typeof(string), null!)); ScenarioExpect.IsType(new MessagingGatewayHandlerAttribute()); + ScenarioExpect.Throws(() => new GenerateServiceActivatorAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateServiceActivatorAttribute(typeof(string), null!)); + ScenarioExpect.IsType(new ServiceActivatorHandlerAttribute()); ScenarioExpect.Throws(() => new GenerateRoutingSlipAttribute(null!)); ScenarioExpect.Throws(() => new RoutingSlipStepAttribute("", 1)); ScenarioExpect.Throws(() => new GenerateSagaAttribute(null!)); diff --git a/test/PatternKit.Generators.Tests/ServiceActivatorGeneratorTests.cs b/test/PatternKit.Generators.Tests/ServiceActivatorGeneratorTests.cs new file mode 100644 index 00000000..902c2187 --- /dev/null +++ b/test/PatternKit.Generators.Tests/ServiceActivatorGeneratorTests.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 ServiceActivatorGeneratorTests +{ + [Scenario("GeneratesServiceActivatorFactory")] + [Fact] + public void GeneratesServiceActivatorFactory() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + namespace MyApp; + public sealed record Request(string Sku); + public sealed record Response(string Sku); + [GenerateServiceActivator(typeof(Request), typeof(Response), FactoryName = "Build", ActivatorName = "inventory")] + public static partial class InventoryActivator + { + [ServiceActivatorHandler] + private static Message Handle(Message request, MessageContext context) + => Message.Create(new Response(request.Payload.Sku)); + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesServiceActivatorFactory)); + _ = RoslynTestHelpers.Run(comp, new ServiceActivatorGenerator(), 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("ServiceActivator", text); + ScenarioExpect.Contains(".Handle(Handle)", text); + ScenarioExpect.True(updated.Emit(Stream.Null).Success); + } + + [Scenario("ReportsServiceActivatorDiagnostics")] + [Theory] + [InlineData("public static class InventoryActivator { }", "PKSVA001")] + [InlineData("public static partial class InventoryActivator { }", "PKSVA002")] + public void ReportsServiceActivatorDiagnostics(string declaration, string expected) + { + var source = $$""" + using PatternKit.Generators.Messaging; + namespace MyApp; + public sealed record Request(string Sku); + public sealed record Response(string Sku); + [GenerateServiceActivator(typeof(Request), typeof(Response))] + {{declaration}} + """; + + var comp = CreateCompilation(source, nameof(ReportsServiceActivatorDiagnostics) + expected); + _ = RoslynTestHelpers.Run(comp, new ServiceActivatorGenerator(), out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal(expected, diagnostic.Id); + } + + [Scenario("ReportsInvalidServiceActivatorHandler")] + [Fact] + public void ReportsInvalidServiceActivatorHandler() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + namespace MyApp; + public sealed record Request(string Sku); + public sealed record Response(string Sku); + [GenerateServiceActivator(typeof(Request), typeof(Response))] + public static partial class InventoryActivator + { + [ServiceActivatorHandler] + private static string Handle(Message request, MessageContext context) => "bad"; + } + """; + + var comp = CreateCompilation(source, nameof(ReportsInvalidServiceActivatorHandler)); + _ = RoslynTestHelpers.Run(comp, new ServiceActivatorGenerator(), out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKSVA003", diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: MetadataReference.CreateFromFile(typeof(global::PatternKit.Messaging.Activation.ServiceActivator<,>).Assembly.Location)); +} diff --git a/test/PatternKit.Tests/Messaging/Activation/ServiceActivatorTests.cs b/test/PatternKit.Tests/Messaging/Activation/ServiceActivatorTests.cs new file mode 100644 index 00000000..5f6349c6 --- /dev/null +++ b/test/PatternKit.Tests/Messaging/Activation/ServiceActivatorTests.cs @@ -0,0 +1,48 @@ +using PatternKit.Messaging; +using PatternKit.Messaging.Activation; +using TinyBDD; + +namespace PatternKit.Tests.Messaging.Activation; + +public sealed class ServiceActivatorTests +{ + [Scenario("Activate InvokesServiceHandler")] + [Fact] + public void Activate_InvokesServiceHandler() + { + var activator = ServiceActivator.Create("inventory") + .Handle((request, context) => Message.Create(new(request.Payload.Sku, request.Payload.Quantity <= 10))) + .Build(); + + var result = activator.Activate(Message.Create(new("SKU-100", 4))); + + ScenarioExpect.True(result.Completed); + ScenarioExpect.Equal("inventory", result.ActivatorName); + ScenarioExpect.Equal("SKU-100", result.Response.Payload.Sku); + ScenarioExpect.True(result.Response.Payload.Reserved); + } + + [Scenario("Activate RejectsInvalidInput")] + [Fact] + public void Activate_RejectsInvalidInput() + { + var activator = ServiceActivator.Create() + .Handle((_, _) => Message.Create(new("SKU-100", true))) + .Build(); + + ScenarioExpect.Throws(() => activator.Activate(null!)); + } + + [Scenario("Builder RejectsInvalidConfiguration")] + [Fact] + public void Builder_RejectsInvalidConfiguration() + { + ScenarioExpect.Throws(() => ServiceActivator.Create("")); + ScenarioExpect.Throws(() => ServiceActivator.Create().Handle(null!)); + ScenarioExpect.Throws(() => ServiceActivator.Create().Build()); + } + + public sealed record Request(string Sku, int Quantity); + + public sealed record Response(string Sku, bool Reserved); +}