diff --git a/docs/examples/index.md b/docs/examples/index.md index 81e24fc..b06c6b8 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -66,6 +66,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Enterprise Messaging Workflow Suite** End-to-end messaging examples for envelopes, content routing, recipient lists, splitters, aggregators, routing slips, sagas, mailboxes, idempotent receivers, inboxes, outboxes, and generated messaging factories. See [Enterprise Messaging Workflow Suite](enterprise-messaging-workflows.md). +* **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). + * **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/order-event-driven-consumer.md b/docs/examples/order-event-driven-consumer.md new file mode 100644 index 0000000..606ab91 --- /dev/null +++ b/docs/examples/order-event-driven-consumer.md @@ -0,0 +1,12 @@ +# Order Event-Driven Consumer + +The order event-driven consumer example handles pushed order-accepted events and writes an audit entry. + +```csharp +services.AddOrderEventDrivenConsumerDemo(); + +var service = provider.GetRequiredService(); +var summary = service.Accept(new OrderAcceptedEvent("ORDER-100", 42.50m)); +``` + +The example includes fluent and source-generated construction, a push-based service boundary, and `IServiceCollection` registration for existing .NET applications. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 3cb1e60..3269cb2 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -52,6 +52,9 @@ - name: Warehouse Polling Consumer href: warehouse-polling-consumer.md +- name: Order Event-Driven Consumer + href: order-event-driven-consumer.md + - name: Patterns Showcase — Integrated Order Processing href: patterns-showcase.md diff --git a/docs/generators/event-driven-consumer.md b/docs/generators/event-driven-consumer.md new file mode 100644 index 0000000..383823b --- /dev/null +++ b/docs/generators/event-driven-consumer.md @@ -0,0 +1,21 @@ +# Event-Driven Consumer Generator + +`[GenerateEventDrivenConsumer]` creates a typed `EventDrivenConsumer` factory. + +```csharp +[GenerateEventDrivenConsumer(typeof(OrderAcceptedEvent), FactoryName = "Create", ConsumerName = "order-accepted-consumer")] +public static partial class OrderAcceptedConsumer +{ + [EventDrivenConsumerHandler("audit")] + private static EventDrivenConsumerHandlerResult Audit(Message message, MessageContext context) + => EventDrivenConsumerHandlerResult.Success("audit"); +} +``` + +Handler methods must be static, return `EventDrivenConsumerHandlerResult`, and accept `Message` plus `MessageContext`. + +Diagnostics: + +- `PKEVT001`: host type must be partial. +- `PKEVT002`: at least one event-driven consumer handler is required. +- `PKEVT003`: event-driven consumer handler signature is invalid. diff --git a/docs/generators/index.md b/docs/generators/index.md index c550c74..a74be52 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -82,6 +82,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Dispatcher**](dispatcher.md) | Mediator pattern with commands, notifications, and streams | `[GenerateDispatcher]` | | [**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]` | | [**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 78bd9e5..b753416 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -91,6 +91,9 @@ - name: Polling Consumer href: polling-consumer.md +- name: Event-Driven Consumer + href: event-driven-consumer.md + - name: Message Translator href: message-translator.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 784170c..be0660f 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -46,6 +46,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 | 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 b53df08..7716b09 100644 --- a/docs/patterns/messaging/README.md +++ b/docs/patterns/messaging/README.md @@ -32,6 +32,12 @@ Bounded or unbounded in-process inboxes serialize async message handling through [Learn More](mailbox.md) +## Event-Driven Consumer + +Push-based consumers handle messages when a broker callback, background service, webhook, in-memory bus, or application event source delivers them. + +[Learn More](event-driven-consumer.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/event-driven-consumer.md b/docs/patterns/messaging/event-driven-consumer.md new file mode 100644 index 0000000..e1183e6 --- /dev/null +++ b/docs/patterns/messaging/event-driven-consumer.md @@ -0,0 +1,20 @@ +# Event-Driven Consumer + +Event-Driven Consumer reacts when application code delivers a message to the consumer. + +```csharp +var consumer = EventDrivenConsumer + .Create("order-accepted-consumer") + .Handle("audit", (message, context) => + { + audit.Append(message.Payload.OrderId); + return EventDrivenConsumerHandlerResult.Success("audit"); + }) + .Build(); + +var result = consumer.Accept(Message.Create(orderAccepted)); +``` + +Use it when the message arrival cadence is controlled by a broker callback, background service, webhook, in-memory bus, or application event source. The runtime path records handler failures and can either stop on the first failure or continue invoking remaining handlers. + +The source-generated path uses `[GenerateEventDrivenConsumer]` and `[EventDrivenConsumerHandler]`. Import the order event example through `AddOrderEventDrivenConsumerDemo()` or `AddPatternKitExamples()`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 9b74802..339761e 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -299,6 +299,8 @@ href: messaging/message-channel.md - name: Polling Consumer href: messaging/polling-consumer.md + - name: Event-Driven Consumer + href: messaging/event-driven-consumer.md - name: Message Envelope and Context href: messaging/message-envelope.md - name: Message Translator diff --git a/src/PatternKit.Core/Messaging/Consumers/EventDrivenConsumer.cs b/src/PatternKit.Core/Messaging/Consumers/EventDrivenConsumer.cs new file mode 100644 index 0000000..2108fec --- /dev/null +++ b/src/PatternKit.Core/Messaging/Consumers/EventDrivenConsumer.cs @@ -0,0 +1,168 @@ +namespace PatternKit.Messaging.Consumers; + +/// Push-based consumer that handles messages when application code delivers them. +public sealed class EventDrivenConsumer +{ + public delegate EventDrivenConsumerHandlerResult Handler(Message message, MessageContext context); + + private readonly IReadOnlyList _handlers; + private readonly EventDrivenConsumerErrorPolicy _errorPolicy; + + private EventDrivenConsumer(string name, IReadOnlyList handlers, EventDrivenConsumerErrorPolicy errorPolicy) + => (Name, _handlers, _errorPolicy) = (name, handlers, errorPolicy); + + public string Name { get; } + + public EventDrivenConsumerResult Accept(Message message, MessageContext? context = null) + { + if (message is null) + throw new ArgumentNullException(nameof(message)); + + var effectiveContext = context ?? MessageContext.From(message); + var failures = new List(); + var invoked = 0; + + foreach (var registration in _handlers) + { + invoked++; + EventDrivenConsumerHandlerResult result; + try + { + result = registration.Handler(message, effectiveContext); + } + catch (Exception ex) + { + result = EventDrivenConsumerHandlerResult.Failure(registration.Name, ex.Message, ex); + } + + if (!result.Succeeded) + { + failures.Add(result); + if (_errorPolicy == EventDrivenConsumerErrorPolicy.StopOnFirstFailure) + break; + } + } + + return new EventDrivenConsumerResult(Name, message, invoked, failures.AsReadOnly()); + } + + public static Builder Create(string name = "event-driven-consumer") => new(name); + + public sealed class Builder + { + private readonly string _name; + private readonly List _handlers = new(); + private EventDrivenConsumerErrorPolicy _errorPolicy = EventDrivenConsumerErrorPolicy.StopOnFirstFailure; + + internal Builder(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Event-driven consumer name cannot be null, empty, or whitespace.", nameof(name)); + + _name = name; + } + + public Builder Handle(string handlerName, Handler handler) + { + if (string.IsNullOrWhiteSpace(handlerName)) + throw new ArgumentException("Handler name cannot be null, empty, or whitespace.", nameof(handlerName)); + + _handlers.Add(new HandlerRegistration(handlerName, handler ?? throw new ArgumentNullException(nameof(handler)))); + return this; + } + + public Builder Handle(string handlerName, Action, MessageContext> handler) + { + if (handler is null) + throw new ArgumentNullException(nameof(handler)); + + return Handle(handlerName, (message, context) => + { + handler(message, context); + return EventDrivenConsumerHandlerResult.Success(handlerName); + }); + } + + public Builder OnError(EventDrivenConsumerErrorPolicy policy) + { + _errorPolicy = policy; + return this; + } + + public EventDrivenConsumer Build() + { + if (_handlers.Count == 0) + throw new InvalidOperationException("Event-driven consumer requires at least one handler."); + + return new(_name, _handlers.ToArray(), _errorPolicy); + } + } + + private sealed class HandlerRegistration + { + public HandlerRegistration(string name, Handler handler) + => (Name, Handler) = (name, handler); + + public string Name { get; } + + public Handler Handler { get; } + } +} + +public enum EventDrivenConsumerErrorPolicy +{ + StopOnFirstFailure, + Continue +} + +public sealed class EventDrivenConsumerHandlerResult +{ + private EventDrivenConsumerHandlerResult(string handlerName, bool succeeded, string? reason, Exception? exception) + => (HandlerName, Succeeded, Reason, Exception) = (handlerName, succeeded, reason, exception); + + public string HandlerName { get; } + + public bool Succeeded { get; } + + public string? Reason { get; } + + public Exception? Exception { get; } + + public static EventDrivenConsumerHandlerResult Success(string handlerName) + { + if (string.IsNullOrWhiteSpace(handlerName)) + throw new ArgumentException("Handler name cannot be null, empty, or whitespace.", nameof(handlerName)); + + return new(handlerName, true, null, null); + } + + public static EventDrivenConsumerHandlerResult Failure(string handlerName, string reason, Exception? exception = null) + { + if (string.IsNullOrWhiteSpace(handlerName)) + throw new ArgumentException("Handler name cannot be null, empty, or whitespace.", nameof(handlerName)); + if (string.IsNullOrWhiteSpace(reason)) + throw new ArgumentException("Failure reason cannot be null, empty, or whitespace.", nameof(reason)); + + return new(handlerName, false, reason, exception); + } +} + +public sealed class EventDrivenConsumerResult +{ + internal EventDrivenConsumerResult( + string consumerName, + Message message, + int handlerCount, + IReadOnlyList failures) + => (ConsumerName, Message, HandlerCount, Failures) = (consumerName, message, handlerCount, failures); + + public string ConsumerName { get; } + + public Message Message { get; } + + public int HandlerCount { get; } + + public IReadOnlyList Failures { get; } + + public bool Accepted => Failures.Count == 0; +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index cb2b80b..eb856b1 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -127,6 +127,7 @@ public sealed record EventProcessingVisitorExample(Func RunAsync); 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 GeneratedMessageEnvelopeExample(MessageEnvelopeExampleRunner Runner); public sealed record GeneratedMessageTranslatorExample(PartnerEventTranslatorExampleRunner Runner, PartnerOrderImportService Service); public sealed record GeneratedClaimCheckExample(LargeDocumentClaimCheckExampleRunner Runner, LargeDocumentWorkflow Workflow); @@ -207,6 +208,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddMessageRouterVisitorExample() .AddInventoryMessageChannelExample() .AddWarehousePollingConsumerExample() + .AddOrderEventDrivenConsumerExample() .AddGeneratedMessageEnvelopeExample() .AddGeneratedMessageTranslatorExample() .AddGeneratedClaimCheckExample() @@ -475,6 +477,15 @@ public static IServiceCollection AddWarehousePollingConsumerExample(this IServic return services.RegisterExample("Warehouse Polling Consumer", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddOrderEventDrivenConsumerExample(this IServiceCollection services) + { + services.AddOrderEventDrivenConsumerDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService())); + return services.RegisterExample("Order Event-Driven Consumer", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddGeneratedMessageEnvelopeExample(this IServiceCollection services) { services.AddMessageEnvelopeExample(); diff --git a/src/PatternKit.Examples/Messaging/OrderEventDrivenConsumerExample.cs b/src/PatternKit.Examples/Messaging/OrderEventDrivenConsumerExample.cs new file mode 100644 index 0000000..1205e65 --- /dev/null +++ b/src/PatternKit.Examples/Messaging/OrderEventDrivenConsumerExample.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; +using PatternKit.Messaging.Consumers; +using System.Globalization; + +namespace PatternKit.Examples.Messaging; + +public sealed record OrderAcceptedEvent(string OrderId, decimal Total); + +public sealed record OrderEventDrivenSummary(bool Accepted, int HandlerCount, IReadOnlyList AuditEntries); + +public sealed class OrderEventDrivenAuditSink +{ + private readonly List _entries = new(); + + public IReadOnlyList Entries => _entries.AsReadOnly(); + + public void Append(string entry) => _entries.Add(entry); + + public void Clear() => _entries.Clear(); +} + +public sealed class OrderEventDrivenConsumerService(EventDrivenConsumer consumer, OrderEventDrivenAuditSink sink) +{ + public OrderEventDrivenSummary Accept(OrderAcceptedEvent accepted) + { + var result = consumer.Accept(Message.Create(accepted)); + return new(result.Accepted, result.HandlerCount, sink.Entries); + } +} + +public static class OrderEventDrivenConsumers +{ + public static EventDrivenConsumer Create(OrderEventDrivenAuditSink sink) + => EventDrivenConsumer.Create("order-accepted-consumer") + .Handle("audit", (message, _) => + { + sink.Append(ToAuditEntry(message.Payload)); + return EventDrivenConsumerHandlerResult.Success("audit"); + }) + .Build(); + + public static string ToAuditEntry(OrderAcceptedEvent accepted) + => $"accepted:{accepted.OrderId}:{accepted.Total.ToString("0.00", CultureInfo.InvariantCulture)}"; +} + +[GenerateEventDrivenConsumer(typeof(OrderAcceptedEvent), FactoryName = "Create", ConsumerName = "order-accepted-consumer")] +public static partial class GeneratedOrderEventDrivenConsumer +{ + private static readonly OrderEventDrivenAuditSink Sink = new(); + + public static IReadOnlyList Entries => Sink.Entries; + + public static void Reset() => Sink.Clear(); + + [EventDrivenConsumerHandler("audit")] + private static EventDrivenConsumerHandlerResult Audit(Message message, MessageContext context) + { + Sink.Append(OrderEventDrivenConsumers.ToAuditEntry(message.Payload)); + return EventDrivenConsumerHandlerResult.Success("audit"); + } +} + +public sealed class OrderEventDrivenConsumerExampleRunner(OrderEventDrivenConsumerService service) +{ + public OrderEventDrivenSummary RunGenerated(OrderAcceptedEvent accepted) => service.Accept(accepted); + + public static OrderEventDrivenSummary RunGeneratedStatic(OrderAcceptedEvent accepted) + { + GeneratedOrderEventDrivenConsumer.Reset(); + var result = GeneratedOrderEventDrivenConsumer.Create().Accept(Message.Create(accepted)); + return new(result.Accepted, result.HandlerCount, GeneratedOrderEventDrivenConsumer.Entries); + } + + public static OrderEventDrivenSummary RunFluent(OrderAcceptedEvent accepted) + { + var sink = new OrderEventDrivenAuditSink(); + return new OrderEventDrivenConsumerService(OrderEventDrivenConsumers.Create(sink), sink).Accept(accepted); + } +} + +public static class OrderEventDrivenConsumerExampleServiceCollectionExtensions +{ + public static IServiceCollection AddOrderEventDrivenConsumerDemo(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(sp => OrderEventDrivenConsumers.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 25e35b6..bfbb6c2 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -224,6 +224,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["PollingConsumer", "MessageChannel"], ["pull-based replenishment workflow", "source-generated polling consumer factory", "DI composition"]), + Descriptor( + "Order Event-Driven Consumer", + "src/PatternKit.Examples/Messaging/OrderEventDrivenConsumerExample.cs", + "test/PatternKit.Examples.Tests/Messaging/OrderEventDrivenConsumerExampleTests.cs", + "docs/examples/order-event-driven-consumer.md", + ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["EventDrivenConsumer"], + ["push-based order workflow", "source-generated event handler 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 aba0b86..83f6f6c 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -389,6 +389,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/Messaging/WarehousePollingConsumerExampleTests.cs", ["fluent pull consumer", "generated polling source factory", "DI-importable warehouse replenishment example"]), + Pattern("Event-Driven Consumer", PatternFamily.EnterpriseIntegration, + "docs/patterns/messaging/event-driven-consumer.md", + "src/PatternKit.Core/Messaging/Consumers/EventDrivenConsumer.cs", + "test/PatternKit.Tests/Messaging/Consumers/EventDrivenConsumerTests.cs", + "docs/generators/event-driven-consumer.md", + "src/PatternKit.Generators/Messaging/EventDrivenConsumerGenerator.cs", + "test/PatternKit.Generators.Tests/EventDrivenConsumerGeneratorTests.cs", + null, + "docs/examples/order-event-driven-consumer.md", + "src/PatternKit.Examples/Messaging/OrderEventDrivenConsumerExample.cs", + "test/PatternKit.Examples.Tests/Messaging/OrderEventDrivenConsumerExampleTests.cs", + ["fluent push consumer", "generated event handler factory", "DI-importable order event 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/EventDrivenConsumerAttributes.cs b/src/PatternKit.Generators.Abstractions/Messaging/EventDrivenConsumerAttributes.cs new file mode 100644 index 0000000..b57c12b --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Messaging/EventDrivenConsumerAttributes.cs @@ -0,0 +1,30 @@ +using System; + +namespace PatternKit.Generators.Messaging; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateEventDrivenConsumerAttribute : Attribute +{ + public GenerateEventDrivenConsumerAttribute(Type payloadType) + => PayloadType = payloadType ?? throw new ArgumentNullException(nameof(payloadType)); + + public Type PayloadType { get; } + + public string FactoryName { get; set; } = "Create"; + + public string ConsumerName { get; set; } = "event-driven-consumer"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class EventDrivenConsumerHandlerAttribute : Attribute +{ + public EventDrivenConsumerHandlerAttribute(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Handler name cannot be null, empty, or whitespace.", nameof(name)); + + Name = name; + } + + public string Name { get; } +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 233e721..22dd67e 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -317,3 +317,6 @@ PKCHN002 | PatternKit.Generators.Messaging | Error | Message Channel capacity is PKPOLL001 | PatternKit.Generators.Messaging | Error | Polling Consumer host type must be partial. PKPOLL002 | PatternKit.Generators.Messaging | Error | Polling Consumer must declare exactly one source. PKPOLL003 | PatternKit.Generators.Messaging | Error | Polling Consumer source signature is invalid. +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. diff --git a/src/PatternKit.Generators/Messaging/EventDrivenConsumerGenerator.cs b/src/PatternKit.Generators/Messaging/EventDrivenConsumerGenerator.cs new file mode 100644 index 0000000..fdd806e --- /dev/null +++ b/src/PatternKit.Generators/Messaging/EventDrivenConsumerGenerator.cs @@ -0,0 +1,120 @@ +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 EventDrivenConsumerGenerator : IIncrementalGenerator +{ + private static readonly DiagnosticDescriptor MustBePartial = new("PKEVT001", "Event-driven consumer type must be partial", "Type '{0}' is marked with [GenerateEventDrivenConsumer] but is not declared as partial", "PatternKit.Generators.Messaging", DiagnosticSeverity.Error, true); + private static readonly DiagnosticDescriptor MissingHandler = new("PKEVT002", "Event-driven consumer handler is missing", "Type '{0}' must declare at least one [EventDrivenConsumerHandler] method", "PatternKit.Generators.Messaging", DiagnosticSeverity.Error, true); + private static readonly DiagnosticDescriptor InvalidHandler = new("PKEVT003", "Event-driven consumer handler signature is invalid", "Handler '{0}' must be static and return EventDrivenConsumerHandlerResult with Message and MessageContext parameters", "PatternKit.Generators.Messaging", DiagnosticSeverity.Error, true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + "PatternKit.Generators.Messaging.GenerateEventDrivenConsumerAttribute", + 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.GenerateEventDrivenConsumerAttribute"); + 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 payloadType = attribute.ConstructorArguments.Length > 0 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + if (payloadType is null) + return; + + var handlers = type.GetMembers().OfType().Where(static method => + method.GetAttributes().Any(static attr => attr.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.EventDrivenConsumerHandlerAttribute")).ToArray(); + if (handlers.Length == 0) + { + context.ReportDiagnostic(Diagnostic.Create(MissingHandler, node.Identifier.GetLocation(), type.Name)); + return; + } + + foreach (var handler in handlers) + { + if (!IsHandler(handler, payloadType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidHandler, handler.Locations.FirstOrDefault(), handler.Name)); + return; + } + } + + var factoryName = GetNamedString(attribute, "FactoryName") ?? "Create"; + var consumerName = GetNamedString(attribute, "ConsumerName") ?? "event-driven-consumer"; + + context.AddSource($"{type.Name}.EventDrivenConsumer.g.cs", SourceText.From(GenerateSource(type, payloadType, handlers, factoryName, consumerName), Encoding.UTF8)); + } + + private static bool IsHandler(IMethodSymbol method, INamedTypeSymbol payloadType) + => method.IsStatic && + method.ReturnType.ToDisplayString() == "PatternKit.Messaging.Consumers.EventDrivenConsumerHandlerResult" && + 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 payloadType, IMethodSymbol[] handlers, string factoryName, string consumerName) + { + 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.Consumers.EventDrivenConsumer<") + .Append(payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append("> ").Append(factoryName).AppendLine("()"); + sb.Append(" => global::PatternKit.Messaging.Consumers.EventDrivenConsumer<") + .Append(payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append(">.Create(").Append(ToLiteral(consumerName)).AppendLine(")"); + foreach (var handler in handlers) + { + var handlerName = GetHandlerName(handler); + sb.Append(" .Handle(").Append(ToLiteral(handlerName)).Append(", ").Append(handler.Name).AppendLine(")"); + } + + sb.AppendLine(" .Build();"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string GetHandlerName(IMethodSymbol handler) + { + var attr = handler.GetAttributes().First(a => a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.EventDrivenConsumerHandlerAttribute"); + return attr.ConstructorArguments.Length > 0 ? (string?)attr.ConstructorArguments[0].Value ?? handler.Name : handler.Name; + } + + 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/OrderEventDrivenConsumerExampleTests.cs b/test/PatternKit.Examples.Tests/Messaging/OrderEventDrivenConsumerExampleTests.cs new file mode 100644 index 0000000..d7c1f78 --- /dev/null +++ b/test/PatternKit.Examples.Tests/Messaging/OrderEventDrivenConsumerExampleTests.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 OrderEventDrivenConsumerExampleTests +{ + [Scenario("FluentEventDrivenConsumer AcceptsOrderEvent")] + [Fact] + public void FluentEventDrivenConsumer_AcceptsOrderEvent() + { + var summary = OrderEventDrivenConsumerExampleRunner.RunFluent(new("order-1", 42.50m)); + + ScenarioExpect.True(summary.Accepted); + ScenarioExpect.Equal(1, summary.HandlerCount); + ScenarioExpect.Equal("accepted:order-1:42.50", ScenarioExpect.Single(summary.AuditEntries)); + } + + [Scenario("GeneratedEventDrivenConsumer MatchesFluentConsumer")] + [Fact] + public void GeneratedEventDrivenConsumer_MatchesFluentConsumer() + { + var generated = OrderEventDrivenConsumerExampleRunner.RunGeneratedStatic(new("order-1", 42.50m)); + var fluent = OrderEventDrivenConsumerExampleRunner.RunFluent(new("order-1", 42.50m)); + + ScenarioExpect.Equal(fluent.Accepted, generated.Accepted); + ScenarioExpect.Equal(fluent.HandlerCount, generated.HandlerCount); + ScenarioExpect.Equal(fluent.AuditEntries, generated.AuditEntries); + } + + [Scenario("ServiceCollection ImportsEventDrivenConsumerExample")] + [Fact] + public void ServiceCollection_ImportsEventDrivenConsumerExample() + { + var services = new ServiceCollection(); + services.AddOrderEventDrivenConsumerDemo(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var service = provider.GetRequiredService(); + + var summary = service.Accept(new("order-1", 42.50m)); + + ScenarioExpect.True(summary.Accepted); + ScenarioExpect.Equal("accepted:order-1:42.50", ScenarioExpect.Single(summary.AuditEntries)); + } + + [Scenario("AggregateServiceCollection ImportsEventDrivenConsumerExample")] + [Fact] + public void AggregateServiceCollection_ImportsEventDrivenConsumerExample() + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var example = provider.GetRequiredService(); + + var summary = example.Service.Accept(new("order-1", 42.50m)); + + ScenarioExpect.True(summary.Accepted); + ScenarioExpect.Equal("accepted:order-1:42.50", ScenarioExpect.Single(summary.AuditEntries)); + } +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index e37ae7a..ce46869 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -40,6 +40,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti [ "Message Channel", "Polling Consumer", + "Event-Driven Consumer", "Message Envelope", "Message Translator", "Claim Check", @@ -126,7 +127,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(23, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); + ScenarioExpect.Equal(24, 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 20f652d..9f24747 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -134,6 +134,8 @@ private enum TestTrigger { typeof(GenerateMessageChannelAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GeneratePollingConsumerAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(PollingConsumerSourceAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateEventDrivenConsumerAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(EventDrivenConsumerHandlerAttribute), 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 }, @@ -752,6 +754,12 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf FactoryName = "BuildPoller", ConsumerName = "inventory-poller" }; + var eventDrivenConsumer = new GenerateEventDrivenConsumerAttribute(typeof(string)) + { + FactoryName = "BuildConsumer", + ConsumerName = "order-events" + }; + var eventDrivenHandler = new EventDrivenConsumerHandlerAttribute("audit"); var routingSlip = new GenerateRoutingSlipAttribute(typeof(string)) { FactoryName = "Build", @@ -916,6 +924,10 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Equal(typeof(string), pollingConsumer.PayloadType); ScenarioExpect.Equal("BuildPoller", pollingConsumer.FactoryName); ScenarioExpect.Equal("inventory-poller", pollingConsumer.ConsumerName); + ScenarioExpect.Equal(typeof(string), eventDrivenConsumer.PayloadType); + ScenarioExpect.Equal("BuildConsumer", eventDrivenConsumer.FactoryName); + ScenarioExpect.Equal("order-events", eventDrivenConsumer.ConsumerName); + ScenarioExpect.Equal("audit", eventDrivenHandler.Name); ScenarioExpect.Equal(typeof(string), routingSlip.PayloadType); ScenarioExpect.Equal("Build", routingSlip.FactoryName); ScenarioExpect.Equal("BuildAsync", routingSlip.AsyncFactoryName); @@ -1031,6 +1043,8 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Throws(() => new GenerateMessageChannelAttribute(null!)); ScenarioExpect.Throws(() => new GeneratePollingConsumerAttribute(null!)); ScenarioExpect.IsType(new PollingConsumerSourceAttribute()); + ScenarioExpect.Throws(() => new GenerateEventDrivenConsumerAttribute(null!)); + ScenarioExpect.Throws(() => new EventDrivenConsumerHandlerAttribute("")); ScenarioExpect.Throws(() => new GenerateRoutingSlipAttribute(null!)); ScenarioExpect.Throws(() => new RoutingSlipStepAttribute("", 1)); ScenarioExpect.Throws(() => new GenerateSagaAttribute(null!)); diff --git a/test/PatternKit.Generators.Tests/EventDrivenConsumerGeneratorTests.cs b/test/PatternKit.Generators.Tests/EventDrivenConsumerGeneratorTests.cs new file mode 100644 index 0000000..c0a478d --- /dev/null +++ b/test/PatternKit.Generators.Tests/EventDrivenConsumerGeneratorTests.cs @@ -0,0 +1,90 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.Messaging; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class EventDrivenConsumerGeneratorTests +{ + [Scenario("GeneratesEventDrivenConsumerFactory")] + [Fact] + public void GeneratesEventDrivenConsumerFactory() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + using PatternKit.Messaging.Consumers; + namespace MyApp; + public sealed record Command(string Sku); + [GenerateEventDrivenConsumer(typeof(Command), FactoryName = "Build", ConsumerName = "inventory-events")] + public static partial class InventoryEventDrivenConsumer + { + [EventDrivenConsumerHandler("audit")] + private static EventDrivenConsumerHandlerResult Audit(Message message, MessageContext context) + => EventDrivenConsumerHandlerResult.Success("audit"); + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesEventDrivenConsumerFactory)); + _ = RoslynTestHelpers.Run(comp, new EventDrivenConsumerGenerator(), 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("EventDrivenConsumer", text); + ScenarioExpect.Contains(".Handle(@\"audit\", Audit)", text); + ScenarioExpect.True(updated.Emit(Stream.Null).Success); + } + + [Scenario("ReportsEventDrivenConsumerDiagnostics")] + [Theory] + [InlineData("public static class InventoryEventDrivenConsumer { }", "PKEVT001")] + [InlineData("public static partial class InventoryEventDrivenConsumer { }", "PKEVT002")] + public void ReportsEventDrivenConsumerDiagnostics(string declaration, string expected) + { + var source = $$""" + using PatternKit.Generators.Messaging; + namespace MyApp; + public sealed record Command(string Sku); + [GenerateEventDrivenConsumer(typeof(Command))] + {{declaration}} + """; + + var comp = CreateCompilation(source, nameof(ReportsEventDrivenConsumerDiagnostics) + expected); + _ = RoslynTestHelpers.Run(comp, new EventDrivenConsumerGenerator(), out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal(expected, diagnostic.Id); + } + + [Scenario("ReportsInvalidEventDrivenConsumerHandler")] + [Fact] + public void ReportsInvalidEventDrivenConsumerHandler() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + namespace MyApp; + public sealed record Command(string Sku); + [GenerateEventDrivenConsumer(typeof(Command))] + public static partial class InventoryEventDrivenConsumer + { + [EventDrivenConsumerHandler("audit")] + private static string Audit(Message message, MessageContext context) => "bad"; + } + """; + + var comp = CreateCompilation(source, nameof(ReportsInvalidEventDrivenConsumerHandler)); + _ = RoslynTestHelpers.Run(comp, new EventDrivenConsumerGenerator(), out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKEVT003", diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: MetadataReference.CreateFromFile(typeof(global::PatternKit.Messaging.Consumers.EventDrivenConsumer<>).Assembly.Location)); +} diff --git a/test/PatternKit.Tests/Messaging/Consumers/EventDrivenConsumerTests.cs b/test/PatternKit.Tests/Messaging/Consumers/EventDrivenConsumerTests.cs new file mode 100644 index 0000000..2439984 --- /dev/null +++ b/test/PatternKit.Tests/Messaging/Consumers/EventDrivenConsumerTests.cs @@ -0,0 +1,94 @@ +using PatternKit.Messaging; +using PatternKit.Messaging.Consumers; +using TinyBDD; + +namespace PatternKit.Tests.Messaging.Consumers; + +public sealed class EventDrivenConsumerTests +{ + [Scenario("Accept InvokesRegisteredHandlers")] + [Fact] + public void Accept_InvokesRegisteredHandlers() + { + var handled = new List(); + var consumer = EventDrivenConsumer.Create("inventory-events") + .Handle("audit", (message, _) => + { + handled.Add(message.Payload.Sku); + return EventDrivenConsumerHandlerResult.Success("audit"); + }) + .Build(); + + var result = consumer.Accept(Message.Create(new("sku-1", 3))); + + ScenarioExpect.True(result.Accepted); + ScenarioExpect.Equal("inventory-events", result.ConsumerName); + ScenarioExpect.Equal(1, result.HandlerCount); + ScenarioExpect.Equal("sku-1", ScenarioExpect.Single(handled)); + } + + [Scenario("Accept PassesMessageContextToHandlers")] + [Fact] + public void Accept_PassesMessageContextToHandlers() + { + MessageContext? captured = null; + var context = MessageContext.Empty.WithItem("tenant", "north"); + var consumer = EventDrivenConsumer.Create() + .Handle("capture", (message, handlerContext) => + { + captured = handlerContext; + return EventDrivenConsumerHandlerResult.Success("capture"); + }) + .Build(); + + _ = consumer.Accept(Message.Create(new("sku-1", 3)), context); + + ScenarioExpect.Same(context, captured); + } + + [Scenario("Accept ReportsFailuresAndHonorsErrorPolicy")] + [Fact] + public void Accept_ReportsFailuresAndHonorsErrorPolicy() + { + var handled = new List(); + var consumer = EventDrivenConsumer.Create() + .Handle("reject", (message, _) => EventDrivenConsumerHandlerResult.Failure("reject", "not ready")) + .Handle("audit", (message, _) => + { + handled.Add(message.Payload.Sku); + return EventDrivenConsumerHandlerResult.Success("audit"); + }) + .Build(); + var continuing = EventDrivenConsumer.Create() + .OnError(EventDrivenConsumerErrorPolicy.Continue) + .Handle("reject", (message, _) => EventDrivenConsumerHandlerResult.Failure("reject", "not ready")) + .Handle("audit", (message, _) => + { + handled.Add(message.Payload.Sku); + return EventDrivenConsumerHandlerResult.Success("audit"); + }) + .Build(); + + var stopped = consumer.Accept(Message.Create(new("sku-1", 3))); + var continued = continuing.Accept(Message.Create(new("sku-2", 4))); + + ScenarioExpect.False(stopped.Accepted); + ScenarioExpect.Equal(1, stopped.HandlerCount); + ScenarioExpect.Equal("reject", ScenarioExpect.Single(stopped.Failures).HandlerName); + ScenarioExpect.False(continued.Accepted); + ScenarioExpect.Equal(2, continued.HandlerCount); + ScenarioExpect.Equal("sku-2", ScenarioExpect.Single(handled)); + } + + [Scenario("Builder RejectsInvalidConfiguration")] + [Fact] + public void Builder_RejectsInvalidConfiguration() + { + ScenarioExpect.Throws(() => EventDrivenConsumer.Create("")); + ScenarioExpect.Throws(() => EventDrivenConsumer.Create().Handle("", (_, _) => EventDrivenConsumerHandlerResult.Success("handler"))); + ScenarioExpect.Throws(() => EventDrivenConsumer.Create().Handle("handler", (EventDrivenConsumer.Handler)null!)); + ScenarioExpect.Throws(() => EventDrivenConsumer.Create().Build()); + } + + public sealed record Command(string Sku, int Quantity); +}