diff --git a/README.md b/README.md index d5a85587..0a03123b 100644 --- a/README.md +++ b/README.md @@ -446,7 +446,7 @@ var cachedRemoteProxy = Proxy.Create(id => remoteProxy.Execute(id)) --- ## Patterns Table -PatternKit currently tracks 95 production-readiness patterns. Each catalog pattern is represented in tests, documentation, real-world examples, IoC integration, and the BenchmarkDotNet coverage matrix. +PatternKit currently tracks 96 production-readiness patterns. Each catalog pattern is represented in tests, documentation, real-world examples, IoC integration, and the BenchmarkDotNet coverage matrix. | Category | Count | Patterns | | --- | ---: | --- | @@ -454,7 +454,7 @@ PatternKit currently tracks 95 production-readiness patterns. Each catalog patte | Behavioral | 11 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 17 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig | | Creational | 5 | Abstract Factory, Builder, Factory Method, Prototype, Singleton | -| Enterprise Integration | 36 | Aggregator, Canonical Data Model, Channel Adapter, Channel Purger, Claim Check, Competing Consumers, Content-Based Router, Control Bus, Dead Letter Channel, Durable Subscriber, Dynamic Router, Event Notification, Event-Carried State Transfer, Event-Driven Consumer, Invalid Message Channel, Mailbox, Message Bus, Message Channel, Message Envelope, Message Filter, Message Store, Message Translator, Messaging Bridge, Messaging Gateway, Pipes and Filters, Polling Consumer, Publish-Subscribe, Recipient List, Request-Reply, Resequencer, Routing Slip, Saga / Process Manager, Scatter-Gather, Service Activator, Splitter, Wire Tap | +| Enterprise Integration | 37 | Aggregator, Canonical Data Model, Channel Adapter, Channel Purger, Claim Check, Competing Consumers, Content-Based Router, Control Bus, Dead Letter Channel, Durable Subscriber, Dynamic Router, Event Notification, Event-Carried State Transfer, Event-Driven Consumer, Invalid Message Channel, Mailbox, Message Bus, Message Channel, Message Envelope, Message Filter, Message History, Message Store, Message Translator, Messaging Bridge, Messaging Gateway, Pipes and Filters, Polling Consumer, Publish-Subscribe, Recipient List, Request-Reply, Resequencer, Routing Slip, Saga / Process Manager, Scatter-Gather, Service Activator, Splitter, Wire Tap | | Messaging Reliability | 3 | Idempotent Receiver, Inbox, Outbox | | Structural | 7 | Adapter, Bridge, Composite, Decorator, Facade, Flyweight, Proxy | @@ -522,6 +522,8 @@ BenchmarkDotNet guidance is documented in [docs/guides/benchmarks.md](docs/guide | Message Bus | Execution | 503.8 ns | 3,192 B | 587.7 ns | 3,232 B | Fluent was faster and allocated slightly less for publishing order events to topic subscribers. | | Messaging Bridge | Construction | 184.0 ns | 1,328 B | 185.5 ns | 1,328 B | Same allocation; fluent and generated bridge construction were effectively equivalent. | | Messaging Bridge | Execution | 666.8 ns | 3,912 B | 670.8 ns | 3,912 B | Same allocation; fluent was slightly faster for partner order imports. | +| Message History | Construction | 6.843 ns | 56 B | 6.075 ns | 56 B | Same allocation; generated was slightly faster for history recorder construction. | +| Message History | Execution | 275.298 ns | 1,400 B | 285.765 ns | 1,400 B | Same allocation; fluent was slightly faster for order history recording. | | Decorator | Construction | 34.293 ns | 264 B | 17.669 ns | 168 B | Generated decorator composition was faster and allocated less. | | Decorator | Execution | 60.765 ns | 384 B | 35.551 ns | 304 B | Generated decorator execution was faster and allocated less for decorated storage reads. | | Domain Event | Construction | 199.5 ns | 1.34 KB | 157.6 ns | 1.04 KB | Generated reduced construction time and allocation in this microbenchmark. | diff --git a/benchmarks/PatternKit.Benchmarks/Messaging/MessageHistoryBenchmarks.cs b/benchmarks/PatternKit.Benchmarks/Messaging/MessageHistoryBenchmarks.cs new file mode 100644 index 00000000..9e7f904f --- /dev/null +++ b/benchmarks/PatternKit.Benchmarks/Messaging/MessageHistoryBenchmarks.cs @@ -0,0 +1,33 @@ +using BenchmarkDotNet.Attributes; +using PatternKit.Examples.Messaging; +using PatternKit.Messaging.Diagnostics; + +namespace PatternKit.Benchmarks.Messaging; + +[BenchmarkCategory("EnterpriseIntegration", "Messaging", "MessageHistory")] +public class MessageHistoryBenchmarks +{ + private static readonly HistoryOrder Order = new("O-100", 125m, "web"); + + [Benchmark(Baseline = true, Description = "Fluent: create message history")] + [BenchmarkCategory("Fluent", "Construction")] + public MessageHistory Fluent_CreateMessageHistory() + => OrderMessageHistories.CreateReceived(); + + [Benchmark(Description = "Generated: create message history")] + [BenchmarkCategory("Generated", "Construction")] + public MessageHistory Generated_CreateMessageHistory() + => GeneratedOrderReceivedHistory.Create() + .Details(static message => message.Payload.Channel) + .Build(); + + [Benchmark(Description = "Fluent: record order message history")] + [BenchmarkCategory("Fluent", "Execution")] + public OrderMessageHistorySummary Fluent_RecordOrderHistory() + => OrderMessageHistoryExampleRunner.RunFluent(Order); + + [Benchmark(Description = "Generated: record order message history")] + [BenchmarkCategory("Generated", "Execution")] + public OrderMessageHistorySummary Generated_RecordOrderHistory() + => OrderMessageHistoryExampleRunner.RunGeneratedStatic(Order); +} diff --git a/docs/examples/order-message-history.md b/docs/examples/order-message-history.md new file mode 100644 index 00000000..6f43024f --- /dev/null +++ b/docs/examples/order-message-history.md @@ -0,0 +1,11 @@ +# Order Message History + +This example models an order moving through a checkout API and fulfillment router. + +- `MessageHistory` appends handling steps to the message envelope. +- The fluent path builds the same recorders by hand. +- The generated path uses `[GenerateMessageHistory]` for stable component factories. +- Correlation metadata remains on the message while history entries accumulate. +- `AddOrderMessageHistoryDemo()` imports the generated path through `IServiceCollection`. + +The example is implemented in `src/PatternKit.Examples/Messaging/OrderMessageHistoryExample.cs` and validated by TinyBDD tests in `test/PatternKit.Examples.Tests/Messaging/OrderMessageHistoryExampleTests.cs`. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 3f509c96..3c4974b0 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -124,6 +124,9 @@ - name: Partner Order Messaging Bridge href: partner-order-messaging-bridge.md +- name: Order Message History + href: order-message-history.md + - name: Order Wire Tap href: order-wire-tap.md diff --git a/docs/generators/index.md b/docs/generators/index.md index b1d8090f..4fe8b21c 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -84,6 +84,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Message Channel**](message-channel.md) | Typed channel factories for in-process message queues | `[GenerateMessageChannel]` | | [**Message Bus**](message-bus.md) | Topic bus topology factories over message channels | `[GenerateMessageBus]` | | [**Messaging Bridge**](messaging-bridge.md) | Channel-to-bus bridge factories for topology boundaries | `[GenerateMessagingBridge]` | +| [**Message History**](message-history.md) | Message handling history factories for auditable envelopes | `[GenerateMessageHistory]` | | [**Channel Purger**](channel-purger.md) | Channel maintenance purger factories for in-process message queues | `[GenerateChannelPurger]` | | [**Invalid Message Channel**](invalid-message-channel.md) | Invalid-message channel builder factories for validation boundaries | `[GenerateInvalidMessageChannel]` | | [**Polling Consumer**](polling-consumer.md) | Pull-based message consumer factories | `[GeneratePollingConsumer]` | diff --git a/docs/generators/message-history.md b/docs/generators/message-history.md new file mode 100644 index 00000000..3ac04e59 --- /dev/null +++ b/docs/generators/message-history.md @@ -0,0 +1,27 @@ +# Message History Generator + +`[GenerateMessageHistory]` emits a typed message history factory. + +```csharp +[GenerateMessageHistory( + typeof(HistoryOrder), + "checkout-api", + FactoryName = "Create", + Action = "received")] +public static partial class GeneratedOrderReceivedHistory; +``` + +The generated factory returns a `MessageHistory.Builder`, so application code can still add runtime-only details or a deterministic test clock: + +```csharp +var history = GeneratedOrderReceivedHistory.Create() + .Details(message => message.Payload.Channel) + .Build(); +``` + +Diagnostics: + +| ID | Meaning | +| --- | --- | +| `PKMH001` | The host type must be `partial`. | +| `PKMH002` | Factory, component, action, and header names must be non-empty. | diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index adf57a70..4340eb90 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -109,6 +109,9 @@ - name: Messaging Bridge href: messaging-bridge.md +- name: Message History + href: message-history.md + - name: Channel Purger href: channel-purger.md diff --git a/docs/guides/benchmark-results.md b/docs/guides/benchmark-results.md index 70109c88..851556ef 100644 --- a/docs/guides/benchmark-results.md +++ b/docs/guides/benchmark-results.md @@ -69,6 +69,8 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 | Message Bus | Execution | 503.8 ns | 3,192 B | 587.7 ns | 3,232 B | Fluent was faster and allocated slightly less for publishing order events to topic subscribers. | | Messaging Bridge | Construction | 184.0 ns | 1,328 B | 185.5 ns | 1,328 B | Same allocation; fluent and generated bridge construction were effectively equivalent. | | Messaging Bridge | Execution | 666.8 ns | 3,912 B | 670.8 ns | 3,912 B | Same allocation; fluent was slightly faster for partner order imports. | +| Message History | Construction | 6.843 ns | 56 B | 6.075 ns | 56 B | Same allocation; generated was slightly faster for history recorder construction. | +| Message History | Execution | 275.298 ns | 1,400 B | 285.765 ns | 1,400 B | Same allocation; fluent was slightly faster for order history recording. | | Decorator | Construction | 34.293 ns | 264 B | 17.669 ns | 168 B | Generated decorator composition was faster and allocated less. | | Decorator | Execution | 60.765 ns | 384 B | 35.551 ns | 304 B | Generated decorator execution was faster and allocated less for decorated storage reads. | | Domain Event | Construction | 199.5 ns | 1.34 KB | 157.6 ns | 1.04 KB | Generated reduced construction time and allocation in this microbenchmark. | @@ -208,7 +210,7 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 ## Coverage Matrix Summary -The coverage matrix currently publishes 95 catalog patterns and 380 pattern route results. Each pattern has four BenchmarkDotNet routes: fluent construction, fluent execution, source-generated construction, and source-generated execution. +The coverage matrix currently publishes 96 catalog patterns and 384 pattern route results. Each pattern has four BenchmarkDotNet routes: fluent construction, fluent execution, source-generated construction, and source-generated execution. | Category | Patterns | Published route results | | --- | ---: | ---: | @@ -216,11 +218,11 @@ The coverage matrix currently publishes 95 catalog patterns and 380 pattern rout | Behavioral | 11 | 44 | | Cloud Architecture | 17 | 68 | | Creational | 5 | 20 | -| Enterprise Integration | 36 | 144 | +| Enterprise Integration | 37 | 148 | | Messaging Reliability | 3 | 12 | | Structural | 7 | 28 | -The generator matrix currently publishes 91 generator source route results. +The generator matrix currently publishes 92 generator source route results. ## Pattern Matrix Results @@ -289,6 +291,7 @@ The generator matrix currently publishes 91 generator source route results. | Enterprise Integration | Dynamic Router | Covered | Covered | Covered | Covered | | Enterprise Integration | Message Bus | Covered | Covered | Covered | Covered | | Enterprise Integration | Messaging Bridge | Covered | Covered | Covered | Covered | +| Enterprise Integration | Message History | Covered | Covered | Covered | Covered | | Enterprise Integration | Event Notification | Covered | Covered | Covered | Covered | | Enterprise Integration | Event-Carried State Transfer | Covered | Covered | Covered | Covered | | Enterprise Integration | Event-Driven Consumer | Covered | Covered | Covered | Covered | @@ -380,9 +383,10 @@ The generator matrix currently publishes 91 generator source route results. | MessageBusGenerator | `src/PatternKit.Generators/Messaging/MessageBusGenerator.cs` | Covered | | MessagingBridgeGenerator | `src/PatternKit.Generators/Messaging/MessagingBridgeGenerator.cs` | Covered | | MessageChannelGenerator | `src/PatternKit.Generators/Messaging/MessageChannelGenerator.cs` | Covered | -| MessageEnvelopeGenerator | `src/PatternKit.Generators/Messaging/MessageEnvelopeGenerator.cs` | Covered | -| MessageFilterGenerator | `src/PatternKit.Generators/Messaging/MessageFilterGenerator.cs` | Covered | -| MessageStoreGenerator | `src/PatternKit.Generators/Messaging/MessageStoreGenerator.cs` | Covered | +| MessageEnvelopeGenerator | `src/PatternKit.Generators/Messaging/MessageEnvelopeGenerator.cs` | Covered | +| MessageFilterGenerator | `src/PatternKit.Generators/Messaging/MessageFilterGenerator.cs` | Covered | +| MessageHistoryGenerator | `src/PatternKit.Generators/Messaging/MessageHistoryGenerator.cs` | Covered | +| MessageStoreGenerator | `src/PatternKit.Generators/Messaging/MessageStoreGenerator.cs` | Covered | | MessageTranslatorGenerator | `src/PatternKit.Generators/Messaging/MessageTranslatorGenerator.cs` | Covered | | MessagingGatewayGenerator | `src/PatternKit.Generators/Messaging/MessagingGatewayGenerator.cs` | Covered | | PipesAndFiltersPipelineGenerator | `src/PatternKit.Generators/Messaging/PipesAndFiltersPipelineGenerator.cs` | Covered | diff --git a/docs/guides/benchmarks.md b/docs/guides/benchmarks.md index 92987167..d954efb1 100644 --- a/docs/guides/benchmarks.md +++ b/docs/guides/benchmarks.md @@ -84,6 +84,8 @@ The following numbers were captured on Windows 11, Intel Core i9-14900K, .NET SD | Dynamic Router | Execution | 406.7 ns | 2.83 KB | 415.2 ns | 2.83 KB | Same allocation; fluent was slightly faster for fulfillment route-table dispatch. | | Messaging Bridge | Construction | 184.0 ns | 1,328 B | 185.5 ns | 1,328 B | Same allocation; fluent and generated bridge construction were effectively equivalent. | | Messaging Bridge | Execution | 666.8 ns | 3,912 B | 670.8 ns | 3,912 B | Same allocation; fluent was slightly faster for partner order imports. | +| Message History | Construction | 6.843 ns | 56 B | 6.075 ns | 56 B | Same allocation; generated was slightly faster for history recorder construction. | +| Message History | Execution | 275.298 ns | 1,400 B | 285.765 ns | 1,400 B | Same allocation; fluent was slightly faster for order history recording. | | Decorator | Construction | 34.293 ns | 264 B | 17.669 ns | 168 B | Generated decorator composition was faster and allocated less. | | Decorator | Execution | 60.765 ns | 384 B | 35.551 ns | 304 B | Generated decorator execution was faster and allocated less for decorated storage reads. | | Domain Event | Construction | 199.5 ns | 1.34 KB | 157.6 ns | 1.04 KB | Generated reduced construction time and allocation in this microbenchmark. | diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 34fd8fa9..65d54db0 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -63,6 +63,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Enterprise Integration | Dynamic Router | `DynamicRouter` | Dynamic Router generator | | Enterprise Integration | Message Bus | `MessageBus` | Message Bus generator | | Enterprise Integration | Messaging Bridge | `MessagingBridge` | Messaging Bridge generator | +| Enterprise Integration | Message History | `MessageHistory` | Message History generator | | Enterprise Integration | Message Filter | `MessageFilter` | Message Filter generator | | Enterprise Integration | Message Store | `MessageStore` | Message Store generator | | Enterprise Integration | Wire Tap | `WireTap` | Wire Tap generator | diff --git a/docs/patterns/messaging/message-history.md b/docs/patterns/messaging/message-history.md new file mode 100644 index 00000000..9761ce50 --- /dev/null +++ b/docs/patterns/messaging/message-history.md @@ -0,0 +1,18 @@ +# Message History + +Message History records the components and operations that handled a message while preserving the original payload and metadata. Use it when production support, audit trails, or cross-system troubleshooting need to answer where a message has been. + +PatternKit stores immutable `MessageHistoryEntry` values in a message header. Each recorder appends one hop: + +```csharp +var received = MessageHistory + .Create("checkout-api") + .Action("received") + .Details(message => message.Payload.Channel) + .Build(); + +var result = received.Record(message); +var history = MessageHistory.Read(result); +``` + +Use Message History for observability that must travel with the message envelope. Use Wire Tap when the observation should be copied out-of-band, and use Audit Log when the record is a domain or compliance event rather than transport metadata. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 57fe1db6..33225199 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -313,6 +313,8 @@ href: messaging/message-bus.md - name: Messaging Bridge href: messaging/messaging-bridge.md + - name: Message History + href: messaging/message-history.md - name: Channel Adapter href: messaging/channel-adapter.md - name: Messaging Gateway diff --git a/src/PatternKit.Core/Messaging/Diagnostics/MessageHistory.cs b/src/PatternKit.Core/Messaging/Diagnostics/MessageHistory.cs new file mode 100644 index 00000000..7fedc502 --- /dev/null +++ b/src/PatternKit.Core/Messaging/Diagnostics/MessageHistory.cs @@ -0,0 +1,132 @@ +namespace PatternKit.Messaging.Diagnostics; + +/// +/// Records the systems, components, or operations that handled a message. +/// +public sealed class MessageHistory +{ + public const string DefaultHeaderName = "Message-History"; + + private readonly string _component; + private readonly string _action; + private readonly string _headerName; + private readonly Func _clock; + private readonly Func, string?>? _details; + + private MessageHistory( + string component, + string action, + string headerName, + Func clock, + Func, string?>? details) + => (_component, _action, _headerName, _clock, _details) = (component, action, headerName, clock, details); + + /// Appends this history step to the supplied message. + public Message Record(Message message) + { + if (message is null) + throw new ArgumentNullException(nameof(message)); + + var entries = Read(message, _headerName); + var next = entries.Concat([ + new MessageHistoryEntry(_component, _action, _clock(), _details?.Invoke(message)) + ]).ToArray(); + + return message.WithHeader(_headerName, next); + } + + /// Reads message history entries from the default header. + public static IReadOnlyList Read(Message message) + => Read(message, DefaultHeaderName); + + /// Reads message history entries from the configured header. + public static IReadOnlyList Read(Message message, string headerName) + { + if (message is null) + throw new ArgumentNullException(nameof(message)); + if (string.IsNullOrWhiteSpace(headerName)) + throw new ArgumentException("Message history header name cannot be null, empty, or whitespace.", nameof(headerName)); + + return message.Headers.TryGet>(headerName, out var list) && list is not null + ? list + : []; + } + + /// Creates a message history builder. + public static Builder Create(string component) => new(component); + + /// Fluent builder for . + public sealed class Builder + { + private readonly string _component; + private string _action = "handled"; + private string _headerName = DefaultHeaderName; + private Func _clock = static () => DateTimeOffset.UtcNow; + private Func, string?>? _details; + + internal Builder(string component) + { + if (string.IsNullOrWhiteSpace(component)) + throw new ArgumentException("Message history component cannot be null, empty, or whitespace.", nameof(component)); + + _component = component; + } + + /// Sets the logical action this component performed. + public Builder Action(string action) + { + if (string.IsNullOrWhiteSpace(action)) + throw new ArgumentException("Message history action cannot be null, empty, or whitespace.", nameof(action)); + + _action = action; + return this; + } + + /// Sets the header used to store history entries. + public Builder Header(string headerName) + { + if (string.IsNullOrWhiteSpace(headerName)) + throw new ArgumentException("Message history header name cannot be null, empty, or whitespace.", nameof(headerName)); + + _headerName = headerName; + return this; + } + + /// Sets a deterministic clock, useful for tests and replayable workflows. + public Builder Clock(Func clock) + { + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + return this; + } + + /// Adds per-message details to each recorded history entry. + public Builder Details(Func, string?> details) + { + _details = details ?? throw new ArgumentNullException(nameof(details)); + return this; + } + + /// Builds an immutable message history recorder. + public MessageHistory Build() + => new(_component, _action, _headerName, _clock, _details); + } +} + +/// One message history hop. +public sealed class MessageHistoryEntry +{ + public MessageHistoryEntry(string component, string action, DateTimeOffset timestamp, string? details) + => (Component, Action, Timestamp, Details) = (component, action, timestamp, details); + + /// The component that handled the message. + public string Component { get; } + + /// The action performed by the component. + public string Action { get; } + + /// The time the action was recorded. + public DateTimeOffset Timestamp { get; } + + /// Optional component-specific details. + public string? Details { get; } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 2d74a113..2e592cce 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -78,6 +78,7 @@ using PatternKit.Examples.VisitorDemo; using PatternKit.Messaging.Channels; using PatternKit.Messaging.Consumers; +using PatternKit.Messaging.Diagnostics; using PatternKit.Messaging.Adapters; using PatternKit.Messaging.Activation; using PatternKit.Messaging.Bridges; @@ -169,6 +170,7 @@ public sealed record OrderDurableSubscriberExampleService(DurableSubscriber Router, FulfillmentRoutingService Service); public sealed record OrderMessageBusExampleService(MessageBus Bus, OrderMessageBusExampleRunner Runner); public sealed record PartnerOrderMessagingBridgeExampleService(MessagingBridge Bridge, PartnerOrderMessagingBridgeExampleRunner Runner); +public sealed record OrderMessageHistoryExampleService(MessageHistory Received, MessageHistory Routed, OrderMessageHistoryExampleRunner Runner); public sealed record OrderWireTapExampleService(WireTap Tap, OrderWireTapService Service); public sealed record FulfillmentControlBusExampleService(ControlBus Bus, FulfillmentControlBusService Service); public sealed record SupplierQuoteScatterGatherExampleService(ScatterGather ScatterGather, SupplierQuoteService Service); @@ -273,6 +275,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddOrderDynamicRouterExample() .AddOrderMessageBusExample() .AddPartnerOrderMessagingBridgeExample() + .AddOrderMessageHistoryExample() .AddOrderWireTapExample() .AddFulfillmentControlBusExample() .AddSupplierQuoteScatterGatherExample() @@ -727,6 +730,16 @@ public static IServiceCollection AddPartnerOrderMessagingBridgeExample(this ISer return services.RegisterExample("Partner Order Messaging Bridge", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddOrderMessageHistoryExample(this IServiceCollection services) + { + services.AddOrderMessageHistoryDemo(); + services.AddSingleton(sp => new( + sp.GetServices>().ElementAt(0), + sp.GetServices>().ElementAt(1), + sp.GetRequiredService())); + return services.RegisterExample("Order Message History", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddOrderWireTapExample(this IServiceCollection services) { services.AddOrderWireTapDemo(); diff --git a/src/PatternKit.Examples/Messaging/OrderMessageHistoryExample.cs b/src/PatternKit.Examples/Messaging/OrderMessageHistoryExample.cs new file mode 100644 index 00000000..66eae5a2 --- /dev/null +++ b/src/PatternKit.Examples/Messaging/OrderMessageHistoryExample.cs @@ -0,0 +1,82 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; +using PatternKit.Messaging.Diagnostics; + +namespace PatternKit.Examples.Messaging; + +public sealed record HistoryOrder(string Id, decimal Total, string Channel); + +public sealed record OrderMessageHistorySummary(string OrderId, int HistoryCount, IReadOnlyList Components, string? CorrelationId); + +public sealed class OrderMessageHistoryService(MessageHistory received, MessageHistory routed) +{ + public OrderMessageHistorySummary Capture(HistoryOrder order) + { + var message = Message.Create(order).WithCorrelationId($"order:{order.Id}"); + var result = routed.Record(received.Record(message)); + var history = MessageHistory.Read(result); + + return new( + result.Payload.Id, + history.Count, + history.Select(static entry => entry.Component).ToArray(), + result.Headers.CorrelationId); + } +} + +public static class OrderMessageHistories +{ + public static MessageHistory CreateReceived() + => MessageHistory.Create("checkout-api") + .Action("received") + .Details(static message => message.Payload.Channel) + .Build(); + + public static MessageHistory CreateRouted() + => MessageHistory.Create("fulfillment-router") + .Action("routed") + .Build(); +} + +[GenerateMessageHistory(typeof(HistoryOrder), "checkout-api", FactoryName = "Create", Action = "received")] +public static partial class GeneratedOrderReceivedHistory; + +[GenerateMessageHistory(typeof(HistoryOrder), "fulfillment-router", FactoryName = "Create", Action = "routed")] +public static partial class GeneratedOrderRoutedHistory; + +public sealed class OrderMessageHistoryExampleRunner(OrderMessageHistoryService service) +{ + public OrderMessageHistorySummary RunGenerated(HistoryOrder order) + => service.Capture(order); + + public static OrderMessageHistorySummary RunFluent(HistoryOrder order) + => new OrderMessageHistoryService( + OrderMessageHistories.CreateReceived(), + OrderMessageHistories.CreateRouted()).Capture(order); + + public static OrderMessageHistorySummary RunGeneratedStatic(HistoryOrder order) + => new OrderMessageHistoryService( + GeneratedOrderReceivedHistory.Create() + .Details(static message => message.Payload.Channel) + .Build(), + GeneratedOrderRoutedHistory.Create().Build()).Capture(order); +} + +public static class OrderMessageHistoryServiceCollectionExtensions +{ + public static IServiceCollection AddOrderMessageHistoryDemo(this IServiceCollection services) + { + services.AddSingleton(static _ => GeneratedOrderReceivedHistory.Create() + .Details(static message => message.Payload.Channel) + .Build()); + services.AddSingleton(static _ => GeneratedOrderRoutedHistory.Create().Build()); + services.AddSingleton(static sp => + { + var histories = sp.GetServices>().ToArray(); + return new OrderMessageHistoryService(histories[0], histories[1]); + }); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index d93fde72..3f2cb191 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -416,6 +416,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["MessagingBridge", "MessageBus", "MessageChannel"], ["topology bridge", "header-preserving translation", "source-generated bridge factory", "DI composition"]), + Descriptor( + "Order Message History", + "src/PatternKit.Examples/Messaging/OrderMessageHistoryExample.cs", + "test/PatternKit.Examples.Tests/Messaging/OrderMessageHistoryExampleTests.cs", + "docs/examples/order-message-history.md", + ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["MessageHistory"], + ["message envelope audit trail", "source-generated history factory", "DI composition"]), Descriptor( "Order Wire Tap", "src/PatternKit.Examples/Messaging/OrderWireTapExample.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 95a84049..ca7c60d7 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -649,6 +649,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/Messaging/PartnerOrderMessagingBridgeExampleTests.cs", ["fluent topology bridge", "generated bridge factory", "DI-importable partner order bridge example"]), + Pattern("Message History", PatternFamily.EnterpriseIntegration, + "docs/patterns/messaging/message-history.md", + "src/PatternKit.Core/Messaging/Diagnostics/MessageHistory.cs", + "test/PatternKit.Tests/Messaging/Diagnostics/MessageHistoryTests.cs", + "docs/generators/message-history.md", + "src/PatternKit.Generators/Messaging/MessageHistoryGenerator.cs", + "test/PatternKit.Generators.Tests/MessageHistoryGeneratorTests.cs", + null, + "docs/examples/order-message-history.md", + "src/PatternKit.Examples/Messaging/OrderMessageHistoryExample.cs", + "test/PatternKit.Examples.Tests/Messaging/OrderMessageHistoryExampleTests.cs", + ["fluent envelope history recorder", "generated history factory", "DI-importable order observability example"]), + Pattern("Wire Tap", PatternFamily.EnterpriseIntegration, "docs/patterns/messaging/wire-tap.md", "src/PatternKit.Core/Messaging/Routing/WireTap.cs", diff --git a/src/PatternKit.Generators.Abstractions/Messaging/MessageHistoryAttributes.cs b/src/PatternKit.Generators.Abstractions/Messaging/MessageHistoryAttributes.cs new file mode 100644 index 00000000..c24d4814 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Messaging/MessageHistoryAttributes.cs @@ -0,0 +1,26 @@ +using System; + +namespace PatternKit.Generators.Messaging; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateMessageHistoryAttribute : Attribute +{ + public GenerateMessageHistoryAttribute(Type payloadType, string component) + { + PayloadType = payloadType ?? throw new ArgumentNullException(nameof(payloadType)); + if (string.IsNullOrWhiteSpace(component)) + throw new ArgumentException("Message history component cannot be null, empty, or whitespace.", nameof(component)); + + Component = component; + } + + public Type PayloadType { get; } + + public string Component { get; } + + public string FactoryName { get; set; } = "Create"; + + public string Action { get; set; } = "handled"; + + public string HeaderName { get; set; } = "Message-History"; +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 836ac883..92bb5602 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -22,6 +22,8 @@ PKCOM008 | PatternKit.Generators.Composer | Error | ComposerGenerator PKCOM009 | PatternKit.Generators.Composer | Warning | ComposerGenerator PKMBR001 | PatternKit.Generators.Messaging | Error | Messaging bridge host must be partial PKMBR002 | PatternKit.Generators.Messaging | Error | Messaging bridge configuration is invalid +PKMH001 | PatternKit.Generators.Messaging | Error | Message history host must be partial +PKMH002 | PatternKit.Generators.Messaging | Error | Message history configuration is invalid PKFAC001 | PatternKit.Generators.Facade | Error | Diagnostics PKFAC002 | PatternKit.Generators.Facade | Error | Diagnostics PKFAC003 | PatternKit.Generators.Facade | Warning | Diagnostics diff --git a/src/PatternKit.Generators/Messaging/MessageHistoryGenerator.cs b/src/PatternKit.Generators/Messaging/MessageHistoryGenerator.cs new file mode 100644 index 00000000..7f3f155d --- /dev/null +++ b/src/PatternKit.Generators/Messaging/MessageHistoryGenerator.cs @@ -0,0 +1,121 @@ +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 MessageHistoryGenerator : IIncrementalGenerator +{ + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKMH001", + "Message history type must be partial", + "Type '{0}' is marked with [GenerateMessageHistory] but is not declared as partial", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidConfiguration = new( + "PKMH002", + "Message history configuration is invalid", + "Type '{0}' has invalid message history configuration: {1}", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + "PatternKit.Generators.Messaging.GenerateMessageHistoryAttribute", + 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.GenerateMessageHistoryAttribute"); + if (attr is null) + return; + + 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 >= 1 + ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol + : null; + var component = attribute.ConstructorArguments.Length >= 2 + ? attribute.ConstructorArguments[1].Value as string + : null; + var factoryName = GetNamedString(attribute, "FactoryName") ?? "Create"; + var action = GetNamedString(attribute, "Action") ?? "handled"; + var headerName = GetNamedString(attribute, "HeaderName") ?? "Message-History"; + + if (payloadType is null) + return; + if (string.IsNullOrWhiteSpace(component) || string.IsNullOrWhiteSpace(factoryName) || + string.IsNullOrWhiteSpace(action) || string.IsNullOrWhiteSpace(headerName)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidConfiguration, node.Identifier.GetLocation(), type.Name, "names cannot be null, empty, or whitespace")); + return; + } + + context.AddSource($"{type.Name}.MessageHistory.g.cs", SourceText.From( + GenerateSource(type, payloadType, component!, factoryName, action, headerName), + Encoding.UTF8)); + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol payloadType, + string component, + string factoryName, + string action, + string headerName) + { + 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.Diagnostics.MessageHistory<") + .Append(payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append(">.Builder ") + .Append(factoryName) + .AppendLine("()"); + sb.Append(" => global::PatternKit.Messaging.Diagnostics.MessageHistory<") + .Append(payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append(">.Create(") + .Append(ToLiteral(component)) + .AppendLine(")"); + sb.Append(" .Action(").Append(ToLiteral(action)).AppendLine(")"); + sb.Append(" .Header(").Append(ToLiteral(headerName)).AppendLine(");"); + 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/OrderMessageHistoryExampleTests.cs b/test/PatternKit.Examples.Tests/Messaging/OrderMessageHistoryExampleTests.cs new file mode 100644 index 00000000..86cea5f5 --- /dev/null +++ b/test/PatternKit.Examples.Tests/Messaging/OrderMessageHistoryExampleTests.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.Messaging; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.Messaging; + +[Feature("Order message history example")] +public sealed class OrderMessageHistoryExampleTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent message history captures order handling steps")] + [Fact] + public Task Fluent_MessageHistory_Captures_Order_Handling_Steps() + => Given("an order imported through the fluent history path", () => new HistoryOrder("O-100", 125m, "web")) + .When("the example runs", OrderMessageHistoryExampleRunner.RunFluent) + .Then("both production components are recorded", result => + { + ScenarioExpect.Equal("O-100", result.OrderId); + ScenarioExpect.Equal(2, result.HistoryCount); + ScenarioExpect.Equal(["checkout-api", "fulfillment-router"], result.Components); + }) + .And("the original correlation id is preserved", result => + ScenarioExpect.Equal("order:O-100", result.CorrelationId)) + .AssertPassed(); + + [Scenario("Generated message history matches fluent behavior")] + [Fact] + public Task Generated_MessageHistory_Matches_Fluent_Behavior() + => Given("an order imported through the generated history path", () => new HistoryOrder("O-101", 250m, "store")) + .When("the example runs", OrderMessageHistoryExampleRunner.RunGeneratedStatic) + .Then("both production components are recorded", result => + ScenarioExpect.Equal(["checkout-api", "fulfillment-router"], result.Components)) + .And("the history count matches the fluent path", result => + ScenarioExpect.Equal(2, result.HistoryCount)) + .AssertPassed(); + + [Scenario("Message history example is importable through IServiceCollection")] + [Fact] + public Task MessageHistory_Example_Is_Importable_Through_IServiceCollection() + => Given("a service collection with the message history demo", () => + { + var services = new ServiceCollection(); + services.AddOrderMessageHistoryDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("the runner is resolved and executed", provider => + { + using (provider) + return provider.GetRequiredService() + .RunGenerated(new("O-102", 75m, "mobile")); + }) + .Then("the generated DI registrations capture history", result => + ScenarioExpect.Equal(["checkout-api", "fulfillment-router"], result.Components)) + .AssertPassed(); + + [Scenario("Message history is included in aggregate PatternKit examples")] + [Fact] + public Task MessageHistory_Is_Included_In_Aggregate_PatternKit_Examples() + => Given("the aggregate PatternKit examples registration", () => + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("the message history runner is resolved", provider => + { + using (provider) + return provider.GetRequiredService(); + }) + .Then("the runner is available", runner => + ScenarioExpect.NotNull(runner)) + .AssertPassed(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs index 7f7a0c0d..a524c911 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs @@ -88,7 +88,7 @@ public Task Published_Benchmark_Results_Include_Every_Catalog_Pattern() .Then("every catalog pattern appears in the benchmark results matrix", ctx => ScenarioExpect.Empty(ctx.MissingPatterns)) .And("the guide publishes the route result total", ctx => - ScenarioExpect.Contains("380 pattern route results", ctx.ResultsGuide)) + ScenarioExpect.Contains("384 pattern route results", ctx.ResultsGuide)) .AssertPassed(); [Scenario("Published benchmark results include every generator source")] diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index b23f694c..4e70c635 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -57,6 +57,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Dynamic Router", "Message Bus", "Messaging Bridge", + "Message History", "Content-Based Router", "Message Filter", "Message Store", @@ -150,7 +151,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(36, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); + ScenarioExpect.Equal(37, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(17, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); ScenarioExpect.Equal(16, 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 b475cea5..38e229e4 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -161,6 +161,7 @@ private enum TestTrigger { typeof(GenerateMessageBusAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(MessageBusRouteAttribute), AttributeTargets.Method, true, false }, { typeof(GenerateMessagingBridgeAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(GenerateMessageHistoryAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GenerateChannelPurgerAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GenerateInvalidMessageChannelAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GeneratePollingConsumerAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, @@ -1153,6 +1154,12 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf FactoryName = "BuildBridge", BridgeName = "partner-commerce" }; + var messageHistory = new GenerateMessageHistoryAttribute(typeof(string), "checkout-api") + { + FactoryName = "BuildHistory", + Action = "received", + HeaderName = "X-History" + }; var channelPurger = new GenerateChannelPurgerAttribute(typeof(string)) { FactoryName = "BuildPurger", @@ -1500,6 +1507,13 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Equal("partner-commerce", messagingBridge.BridgeName); ScenarioExpect.Throws(() => new GenerateMessagingBridgeAttribute(null!, typeof(int))); ScenarioExpect.Throws(() => new GenerateMessagingBridgeAttribute(typeof(string), null!)); + ScenarioExpect.Equal(typeof(string), messageHistory.PayloadType); + ScenarioExpect.Equal("checkout-api", messageHistory.Component); + ScenarioExpect.Equal("BuildHistory", messageHistory.FactoryName); + ScenarioExpect.Equal("received", messageHistory.Action); + ScenarioExpect.Equal("X-History", messageHistory.HeaderName); + ScenarioExpect.Throws(() => new GenerateMessageHistoryAttribute(null!, "api")); + ScenarioExpect.Throws(() => new GenerateMessageHistoryAttribute(typeof(string), "")); ScenarioExpect.Throws(() => new GenerateChannelPurgerAttribute(null!)); ScenarioExpect.Throws(() => new GenerateInvalidMessageChannelAttribute(null!)); ScenarioExpect.Throws(() => new GeneratePollingConsumerAttribute(null!)); diff --git a/test/PatternKit.Generators.Tests/MessageHistoryGeneratorTests.cs b/test/PatternKit.Generators.Tests/MessageHistoryGeneratorTests.cs new file mode 100644 index 00000000..cac444e9 --- /dev/null +++ b/test/PatternKit.Generators.Tests/MessageHistoryGeneratorTests.cs @@ -0,0 +1,71 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Generators.Messaging; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Message History generator")] +public sealed partial class MessageHistoryGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates message history factory")] + [Fact] + public Task Generates_MessageHistory_Factory() + => Given("a partial host marked with GenerateMessageHistory", () => """ + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record Order(string Id); + + [GenerateMessageHistory(typeof(Order), "checkout-api", FactoryName = "Build", Action = "received", HeaderName = "X-History")] + public static partial class CheckoutHistory; + """) + .When("the generator runs", source => + { + var comp = CreateCompilation(source, nameof(Generates_MessageHistory_Factory)); + _ = RoslynTestHelpers.Run(comp, new MessageHistoryGenerator(), out var run, out _); + return run.Results.Single().GeneratedSources.Single().SourceText.ToString(); + }) + .Then("the generated factory returns a configured builder", text => + { + ScenarioExpect.Contains("MessageHistory.Builder Build()", text); + ScenarioExpect.Contains("MessageHistory.Create(@\"checkout-api\")", text); + ScenarioExpect.Contains(".Action(@\"received\")", text); + ScenarioExpect.Contains(".Header(@\"X-History\")", text); + }) + .AssertPassed(); + + [Scenario("Reports message history diagnostics")] + [Theory] + [InlineData("[GenerateMessageHistory(typeof(Order), \"api\")] public static class CheckoutHistory;", "PKMH001")] + [InlineData("[GenerateMessageHistory(typeof(Order), \"api\", FactoryName = \"\")] public static partial class CheckoutHistory;", "PKMH002")] + public Task Reports_MessageHistory_Diagnostics(string declaration, string expected) + => Given("an invalid GenerateMessageHistory declaration", () => $$""" + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record Order(string Id); + + {{declaration}} + """) + .When("the generator runs", source => + { + var comp = CreateCompilation(source, nameof(Reports_MessageHistory_Diagnostics) + expected); + _ = RoslynTestHelpers.Run(comp, new MessageHistoryGenerator(), out var run, out _); + return run.Diagnostics.Select(static d => d.Id).ToArray(); + }) + .Then("the expected diagnostic is reported", ids => + ScenarioExpect.Contains(expected, ids)) + .AssertPassed(); + + private static Compilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation(source, assemblyName, + extra: + [ + MetadataReference.CreateFromFile(typeof(GenerateMessageHistoryAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(global::PatternKit.Messaging.Diagnostics.MessageHistory<>).Assembly.Location) + ]); +} diff --git a/test/PatternKit.Tests/Messaging/Diagnostics/MessageHistoryTests.cs b/test/PatternKit.Tests/Messaging/Diagnostics/MessageHistoryTests.cs new file mode 100644 index 00000000..1f80d6a3 --- /dev/null +++ b/test/PatternKit.Tests/Messaging/Diagnostics/MessageHistoryTests.cs @@ -0,0 +1,80 @@ +using PatternKit.Messaging; +using PatternKit.Messaging.Diagnostics; +using TinyBDD; + +namespace PatternKit.Tests.Messaging.Diagnostics; + +public sealed class MessageHistoryTests +{ + private static readonly DateTimeOffset FixedTime = new(2026, 5, 27, 0, 0, 0, TimeSpan.Zero); + + [Scenario("Recorder appends history entries without changing the payload")] + [Fact] + public void Recorder_Appends_History_Entries_Without_Changing_The_Payload() + { + var message = Message.Create(new("O-100", 125m)).WithCorrelationId("corr-1"); + var received = MessageHistory.Create("checkout-api") + .Action("received") + .Clock(static () => FixedTime) + .Details(static message => message.Payload.Id) + .Build(); + var routed = MessageHistory.Create("fulfillment-router") + .Action("routed") + .Clock(static () => FixedTime.AddSeconds(1)) + .Build(); + + var result = routed.Record(received.Record(message)); + var entries = MessageHistory.Read(result); + + ScenarioExpect.Equal("O-100", result.Payload.Id); + ScenarioExpect.Equal("corr-1", result.Headers.CorrelationId); + ScenarioExpect.Equal(2, entries.Count); + ScenarioExpect.Equal("checkout-api", entries[0].Component); + ScenarioExpect.Equal("received", entries[0].Action); + ScenarioExpect.Equal("O-100", entries[0].Details); + ScenarioExpect.Equal("fulfillment-router", entries[1].Component); + ScenarioExpect.Equal(FixedTime.AddSeconds(1), entries[1].Timestamp); + } + + [Scenario("Recorder supports custom history headers")] + [Fact] + public void Recorder_Supports_Custom_History_Headers() + { + var recorder = MessageHistory.Create("partner-import") + .Header("X-Audit-History") + .Clock(static () => FixedTime) + .Build(); + + var result = recorder.Record(Message.Create(new("O-101", 90m))); + + ScenarioExpect.Empty(MessageHistory.Read(result)); + ScenarioExpect.Single(MessageHistory.Read(result, "X-Audit-History")); + } + + [Scenario("Reader returns empty history when no entries exist")] + [Fact] + public void Reader_Returns_Empty_History_When_No_Entries_Exist() + { + var message = Message.Create(new("O-102", 45m)); + + var entries = MessageHistory.Read(message); + + ScenarioExpect.Empty(entries); + } + + [Scenario("Builder rejects invalid message history configuration")] + [Fact] + public void Builder_Rejects_Invalid_Message_History_Configuration() + { + ScenarioExpect.Throws(() => MessageHistory.Create("")); + ScenarioExpect.Throws(() => MessageHistory.Create("api").Action("")); + ScenarioExpect.Throws(() => MessageHistory.Create("api").Header("")); + ScenarioExpect.Throws(() => MessageHistory.Create("api").Clock(null!)); + ScenarioExpect.Throws(() => MessageHistory.Create("api").Details(null!)); + ScenarioExpect.Throws(() => MessageHistory.Create("api").Build().Record(null!)); + ScenarioExpect.Throws(() => MessageHistory.Read(null!)); + ScenarioExpect.Throws(() => MessageHistory.Read(Message.Create(new("O-1", 1m)), "")); + } + + private sealed record Order(string Id, decimal Total); +}