diff --git a/README.md b/README.md index c20b5ff7..d5a85587 100644 --- a/README.md +++ b/README.md @@ -446,7 +446,7 @@ var cachedRemoteProxy = Proxy.Create(id => remoteProxy.Execute(id)) --- ## Patterns Table -PatternKit currently tracks 94 production-readiness patterns. Each catalog pattern is represented in tests, documentation, real-world examples, IoC integration, and the BenchmarkDotNet coverage matrix. +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. | Category | Count | Patterns | | --- | ---: | --- | @@ -454,7 +454,7 @@ PatternKit currently tracks 94 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 | 35 | 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 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 | 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 | | Messaging Reliability | 3 | Idempotent Receiver, Inbox, Outbox | | Structural | 7 | Adapter, Bridge, Composite, Decorator, Facade, Flyweight, Proxy | @@ -520,6 +520,8 @@ BenchmarkDotNet guidance is documented in [docs/guides/benchmarks.md](docs/guide | 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. | | Message Bus | Construction | 186.7 ns | 1,264 B | 160.0 ns | 904 B | Generated reduced construction time and allocation for topic bus topology setup. | | 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. | | 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/MessagingBridgeBenchmarks.cs b/benchmarks/PatternKit.Benchmarks/Messaging/MessagingBridgeBenchmarks.cs new file mode 100644 index 00000000..74596c89 --- /dev/null +++ b/benchmarks/PatternKit.Benchmarks/Messaging/MessagingBridgeBenchmarks.cs @@ -0,0 +1,57 @@ +using BenchmarkDotNet.Attributes; +using PatternKit.Examples.Messaging; +using PatternKit.Messaging.Bridges; +using PatternKit.Messaging.Channels; + +namespace PatternKit.Benchmarks.Messaging; + +[BenchmarkCategory("EnterpriseIntegration", "Messaging", "MessagingBridge")] +public class MessagingBridgeBenchmarks +{ + private static readonly PartnerBridgeOrder[] Orders = + [ + new("P-100", "accepted", 125m), + new("P-101", "paid", 250m) + ]; + + [Benchmark(Baseline = true, Description = "Fluent: create messaging bridge")] + [BenchmarkCategory("Fluent", "Construction")] + public MessagingBridge Fluent_CreateMessagingBridge() + { + var partnerOrders = MessageChannel.Create("partner-orders").Build(); + var commerceEvents = MessageChannel.Create("commerce-events").Build(); + var commerceBus = MessageBus.Create("commerce-bus") + .Route("accepted", commerceEvents) + .Route("paid", commerceEvents) + .Build(); + return PartnerOrderMessagingBridges.Create(partnerOrders, commerceBus); + } + + [Benchmark(Description = "Generated: create messaging bridge")] + [BenchmarkCategory("Generated", "Construction")] + public MessagingBridge Generated_CreateMessagingBridge() + { + var partnerOrders = MessageChannel.Create("partner-orders").Build(); + var commerceEvents = MessageChannel.Create("commerce-events").Build(); + var commerceBus = MessageBus.Create("commerce-bus") + .Route("accepted", commerceEvents) + .Route("paid", commerceEvents) + .Build(); + return GeneratedPartnerOrderMessagingBridge.Create() + .From(partnerOrders) + .To(commerceBus) + .PreserveHeaders(static order => new CommerceBridgeOrderEvent(order.PartnerOrderId, order.State, order.Amount)) + .SelectTopic(static message => message.Payload.State == "paid" ? "paid" : "accepted") + .Build(); + } + + [Benchmark(Description = "Fluent: bridge partner orders")] + [BenchmarkCategory("Fluent", "Execution")] + public PartnerOrderMessagingBridgeSummary Fluent_BridgePartnerOrders() + => PartnerOrderMessagingBridgeExampleRunner.RunFluent(Orders); + + [Benchmark(Description = "Generated: bridge partner orders")] + [BenchmarkCategory("Generated", "Execution")] + public PartnerOrderMessagingBridgeSummary Generated_BridgePartnerOrders() + => PartnerOrderMessagingBridgeExampleRunner.RunGeneratedStatic(Orders); +} diff --git a/docs/examples/partner-order-messaging-bridge.md b/docs/examples/partner-order-messaging-bridge.md new file mode 100644 index 00000000..a30da51c --- /dev/null +++ b/docs/examples/partner-order-messaging-bridge.md @@ -0,0 +1,11 @@ +# Partner Order Messaging Bridge + +This example models a partner order stream feeding an internal commerce bus. + +- Partner messages arrive on `MessageChannel`. +- `MessagingBridge` maps partner payloads into commerce events. +- Existing message headers are preserved, including correlation identifiers. +- The bridge selects `accepted` or `paid` target topics and publishes into `MessageBus`. +- `AddPartnerOrderMessagingBridgeDemo()` imports the generated bridge path through `IServiceCollection`. + +The example is implemented in `src/PatternKit.Examples/Messaging/PartnerOrderMessagingBridgeExample.cs` and validated by TinyBDD tests in `test/PatternKit.Examples.Tests/Messaging/PartnerOrderMessagingBridgeExampleTests.cs`. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index e04bc194..3f509c96 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -121,6 +121,9 @@ - name: Order Dynamic Router href: order-dynamic-router.md +- name: Partner Order Messaging Bridge + href: partner-order-messaging-bridge.md + - name: Order Wire Tap href: order-wire-tap.md diff --git a/docs/generators/index.md b/docs/generators/index.md index ea1e3c99..b1d8090f 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -83,6 +83,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]` | | [**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]` | | [**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/messaging-bridge.md b/docs/generators/messaging-bridge.md new file mode 100644 index 00000000..40057b29 --- /dev/null +++ b/docs/generators/messaging-bridge.md @@ -0,0 +1,30 @@ +# Messaging Bridge Generator + +`[GenerateMessagingBridge]` emits the typed bridge factory for a channel-to-bus integration boundary. + +```csharp +[GenerateMessagingBridge( + typeof(PartnerBridgeOrder), + typeof(CommerceBridgeOrderEvent), + FactoryName = "Create", + BridgeName = "partner-order-bridge")] +public static partial class GeneratedPartnerOrderMessagingBridge; +``` + +The generated factory returns a `MessagingBridge.Builder`, so application code still supplies concrete channels, target bus, translation, and topic selection through fluent composition: + +```csharp +var bridge = GeneratedPartnerOrderMessagingBridge.Create() + .From(partnerOrders) + .To(commerceBus) + .PreserveHeaders(order => new CommerceBridgeOrderEvent(order.PartnerOrderId, order.State, order.Amount)) + .SelectTopic(message => message.Payload.State) + .Build(); +``` + +Diagnostics: + +| ID | Meaning | +| --- | --- | +| `PKMBR001` | The host type must be `partial`. | +| `PKMBR002` | `FactoryName` and `BridgeName` must be non-empty. | diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index c753ac69..adf57a70 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -106,6 +106,9 @@ - name: Message Bus href: message-bus.md +- name: Messaging Bridge + href: messaging-bridge.md + - name: Channel Purger href: channel-purger.md diff --git a/docs/guides/benchmark-results.md b/docs/guides/benchmark-results.md index 4ba1cdfe..70109c88 100644 --- a/docs/guides/benchmark-results.md +++ b/docs/guides/benchmark-results.md @@ -67,6 +67,8 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 | 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. | | Message Bus | Construction | 186.7 ns | 1,264 B | 160.0 ns | 904 B | Generated reduced construction time and allocation for topic bus topology setup. | | 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. | | 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. | @@ -206,7 +208,7 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 ## Coverage Matrix Summary -The coverage matrix currently publishes 94 catalog patterns and 376 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 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. | Category | Patterns | Published route results | | --- | ---: | ---: | @@ -214,11 +216,11 @@ The coverage matrix currently publishes 94 catalog patterns and 376 pattern rout | Behavioral | 11 | 44 | | Cloud Architecture | 17 | 68 | | Creational | 5 | 20 | -| Enterprise Integration | 34 | 136 | +| Enterprise Integration | 36 | 144 | | Messaging Reliability | 3 | 12 | | Structural | 7 | 28 | -The generator matrix currently publishes 90 generator source route results. +The generator matrix currently publishes 91 generator source route results. ## Pattern Matrix Results @@ -286,6 +288,7 @@ The generator matrix currently publishes 90 generator source route results. | Enterprise Integration | Durable Subscriber | Covered | Covered | Covered | Covered | | 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 | 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 | @@ -375,6 +378,7 @@ The generator matrix currently publishes 90 generator source route results. | EventDrivenConsumerGenerator | `src/PatternKit.Generators/Messaging/EventDrivenConsumerGenerator.cs` | Covered | | MailboxGenerator | `src/PatternKit.Generators/Messaging/MailboxGenerator.cs` | Covered | | 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 | diff --git a/docs/guides/benchmarks.md b/docs/guides/benchmarks.md index b271592b..92987167 100644 --- a/docs/guides/benchmarks.md +++ b/docs/guides/benchmarks.md @@ -82,6 +82,8 @@ The following numbers were captured on Windows 11, Intel Core i9-14900K, .NET SD | Durable Subscriber | Execution | 711.81 ns | 3,912 B | 605.65 ns | 3,128 B | Generated reduced catch-up projection replay time and allocation in this microbenchmark. | | Dynamic Router | Construction | 213.4 ns | 1.29 KB | 214.0 ns | 1.29 KB | Fluent and generated route-table construction were effectively equivalent. | | 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. | | 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 2af035a1..34fd8fa9 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -62,6 +62,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Enterprise Integration | Content-Based Router | `ContentRouter` | Messaging generator | | 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 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/messaging-bridge.md b/docs/patterns/messaging/messaging-bridge.md new file mode 100644 index 00000000..172ac374 --- /dev/null +++ b/docs/patterns/messaging/messaging-bridge.md @@ -0,0 +1,19 @@ +# Messaging Bridge + +Messaging Bridge connects two independent message topologies through an explicit translation boundary. Use it when an external channel, partner bus, or legacy integration stream must feed an internal `MessageBus` without leaking transport-specific payloads into the target model. + +PatternKit's bridge reads from a `MessageChannel`, translates the full message envelope to `Message`, selects the target topic, and publishes into the target bus. The `PreserveHeaders` helper keeps message metadata such as correlation identifiers while mapping only the payload. + +```csharp +var bridge = MessagingBridge + .Create("partner-order-bridge") + .From(partnerOrders) + .To(commerceBus) + .PreserveHeaders(order => new CommerceBridgeOrderEvent(order.PartnerOrderId, order.State, order.Amount)) + .SelectTopic(message => message.Payload.State == "paid" ? "paid" : "accepted") + .Build(); + +var results = bridge.BridgeAll(); +``` + +Prefer this pattern when the important design decision is the boundary between topologies. If the payload type stays the same and only channel endpoints change, use Channel Adapter. If the main concern is payload normalization, use Message Translator or Canonical Data Model. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 658dcbce..57fe1db6 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -309,6 +309,10 @@ href: messaging/durable-subscriber.md - name: Dynamic Router href: messaging/dynamic-router.md + - name: Message Bus + href: messaging/message-bus.md + - name: Messaging Bridge + href: messaging/messaging-bridge.md - name: Channel Adapter href: messaging/channel-adapter.md - name: Messaging Gateway diff --git a/src/PatternKit.Core/Messaging/Bridges/MessagingBridge.cs b/src/PatternKit.Core/Messaging/Bridges/MessagingBridge.cs new file mode 100644 index 00000000..79ebaf11 --- /dev/null +++ b/src/PatternKit.Core/Messaging/Bridges/MessagingBridge.cs @@ -0,0 +1,160 @@ +using PatternKit.Messaging.Channels; + +namespace PatternKit.Messaging.Bridges; + +/// +/// Bridges messages from one channel topology into another bus while keeping translation explicit. +/// +public sealed class MessagingBridge +{ + private readonly MessageChannel _source; + private readonly MessageBus _target; + private readonly Func, Message> _translator; + private readonly Func, string> _topicSelector; + + private MessagingBridge( + string name, + MessageChannel source, + MessageBus target, + Func, Message> translator, + Func, string> topicSelector) + => (Name, _source, _target, _translator, _topicSelector) = (name, source, target, translator, topicSelector); + + public string Name { get; } + + public MessagingBridgeResult BridgeNext() + { + var received = _source.TryReceive(); + if (!received.Received || received.Message is null) + return MessagingBridgeResult.Empty(Name, _source.Name, _target.Name); + + var topic = _topicSelector(received.Message); + if (string.IsNullOrWhiteSpace(topic)) + throw new InvalidOperationException("Messaging bridge topic selector returned a null, empty, or whitespace topic."); + + var outbound = _translator(received.Message) ?? throw new InvalidOperationException("Messaging bridge translator returned null."); + var publish = _target.Publish(topic, outbound); + return MessagingBridgeResult.Published(Name, _source.Name, _target.Name, topic, outbound, publish); + } + + public IReadOnlyList> BridgeAll() + { + var results = new List>(); + while (true) + { + var result = BridgeNext(); + if (!result.Bridged) + return results; + + results.Add(result); + } + } + + public static Builder Create(string name = "messaging-bridge") => new(name); + + public sealed class Builder + { + private readonly string _name; + private MessageChannel? _source; + private MessageBus? _target; + private Func, Message>? _translator; + private Func, string>? _topicSelector; + + internal Builder(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Messaging bridge name cannot be null, empty, or whitespace.", nameof(name)); + + _name = name; + } + + public Builder From(MessageChannel source) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + return this; + } + + public Builder To(MessageBus target) + { + _target = target ?? throw new ArgumentNullException(nameof(target)); + return this; + } + + public Builder TranslateWith(Func, Message> translator) + { + _translator = translator ?? throw new ArgumentNullException(nameof(translator)); + return this; + } + + public Builder SelectTopic(Func, string> topicSelector) + { + _topicSelector = topicSelector ?? throw new ArgumentNullException(nameof(topicSelector)); + return this; + } + + public Builder PreserveHeaders(Func payloadTranslator) + { + if (payloadTranslator is null) + throw new ArgumentNullException(nameof(payloadTranslator)); + + _translator = message => message.WithPayload(payloadTranslator(message.Payload)); + return this; + } + + public MessagingBridge Build() + { + if (_source is null) + throw new InvalidOperationException("Messaging bridge source channel is required."); + if (_target is null) + throw new InvalidOperationException("Messaging bridge target bus is required."); + if (_translator is null) + throw new InvalidOperationException("Messaging bridge translator is required."); + if (_topicSelector is null) + throw new InvalidOperationException("Messaging bridge topic selector is required."); + + return new(_name, _source, _target, _translator, _topicSelector); + } + } +} + +public sealed class MessagingBridgeResult +{ + private MessagingBridgeResult( + string bridgeName, + string sourceChannelName, + string targetBusName, + bool bridged, + string? topic, + Message? message, + MessageBusPublishResult? publishResult) + => (BridgeName, SourceChannelName, TargetBusName, Bridged, Topic, Message, PublishResult) = + (bridgeName, sourceChannelName, targetBusName, bridged, topic, message, publishResult); + + public string BridgeName { get; } + + public string SourceChannelName { get; } + + public string TargetBusName { get; } + + public bool Bridged { get; } + + public string? Topic { get; } + + public Message? Message { get; } + + public MessageBusPublishResult? PublishResult { get; } + + public int AcceptedCount => PublishResult?.AcceptedCount ?? 0; + + public static MessagingBridgeResult Empty(string bridgeName, string sourceChannelName, string targetBusName) + => new(bridgeName, sourceChannelName, targetBusName, false, null, null, null); + + public static MessagingBridgeResult Published( + string bridgeName, + string sourceChannelName, + string targetBusName, + string topic, + Message message, + MessageBusPublishResult publishResult) + => new(bridgeName, sourceChannelName, targetBusName, true, topic, message, publishResult); +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index e96eec4d..2d74a113 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -80,6 +80,7 @@ using PatternKit.Messaging.Consumers; using PatternKit.Messaging.Adapters; using PatternKit.Messaging.Activation; +using PatternKit.Messaging.Bridges; using PatternKit.Messaging.Gateways; using PatternKit.Messaging.Routing; using PatternKit.Messaging.Storage; @@ -167,6 +168,7 @@ public sealed record OrderMessageStoreExampleService(MessageStore Subscriber, OrderDurableSubscriberService Service); public sealed record OrderDynamicRouterExampleService(DynamicRouter Router, FulfillmentRoutingService Service); public sealed record OrderMessageBusExampleService(MessageBus Bus, OrderMessageBusExampleRunner Runner); +public sealed record PartnerOrderMessagingBridgeExampleService(MessagingBridge Bridge, PartnerOrderMessagingBridgeExampleRunner 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); @@ -270,6 +272,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddOrderDurableSubscriberExample() .AddOrderDynamicRouterExample() .AddOrderMessageBusExample() + .AddPartnerOrderMessagingBridgeExample() .AddOrderWireTapExample() .AddFulfillmentControlBusExample() .AddSupplierQuoteScatterGatherExample() @@ -715,6 +718,15 @@ public static IServiceCollection AddOrderMessageBusExample(this IServiceCollecti return services.RegisterExample("Order Message Bus", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddPartnerOrderMessagingBridgeExample(this IServiceCollection services) + { + services.AddPartnerOrderMessagingBridgeDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService())); + return services.RegisterExample("Partner Order Messaging Bridge", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddOrderWireTapExample(this IServiceCollection services) { services.AddOrderWireTapDemo(); diff --git a/src/PatternKit.Examples/Messaging/PartnerOrderMessagingBridgeExample.cs b/src/PatternKit.Examples/Messaging/PartnerOrderMessagingBridgeExample.cs new file mode 100644 index 00000000..af533bc1 --- /dev/null +++ b/src/PatternKit.Examples/Messaging/PartnerOrderMessagingBridgeExample.cs @@ -0,0 +1,118 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; +using PatternKit.Messaging.Bridges; +using PatternKit.Messaging.Channels; + +namespace PatternKit.Examples.Messaging; + +public sealed record PartnerBridgeOrder(string PartnerOrderId, string State, decimal Amount); + +public sealed record CommerceBridgeOrderEvent(string OrderId, string Status, decimal Total); + +public sealed record PartnerOrderMessagingBridgeSummary(int BridgedCount, int CommerceEventCount, IReadOnlyList Topics, string? CorrelationId); + +public sealed record PartnerOrderMessagingBridgeChannels( + MessageChannel PartnerOrders, + MessageChannel CommerceEvents); + +public sealed class PartnerOrderMessagingBridgeService( + MessagingBridge bridge, + PartnerOrderMessagingBridgeChannels channels) +{ + public PartnerOrderMessagingBridgeSummary Import(IEnumerable orders) + { + foreach (var order in orders) + { + channels.PartnerOrders.Send(Message.Create(order) + .WithCorrelationId($"partner:{order.PartnerOrderId}") + .WithHeader("source-system", "partner")); + } + + var results = bridge.BridgeAll(); + var first = channels.CommerceEvents.TryReceive(); + var correlationId = first.Message?.Headers.CorrelationId; + if (first.Received && first.Message is not null) + channels.CommerceEvents.Send(first.Message); + + return new( + results.Count, + channels.CommerceEvents.Count, + results.Select(static result => result.Topic!).ToArray(), + correlationId); + } +} + +public static class PartnerOrderMessagingBridges +{ + public static MessagingBridge Create( + MessageChannel partnerOrders, + MessageBus commerceBus) + => MessagingBridge.Create("partner-order-bridge") + .From(partnerOrders) + .To(commerceBus) + .PreserveHeaders(static order => new CommerceBridgeOrderEvent(order.PartnerOrderId, order.State, order.Amount)) + .SelectTopic(static message => message.Payload.State == "paid" ? "paid" : "accepted") + .Build(); +} + +[GenerateMessagingBridge(typeof(PartnerBridgeOrder), typeof(CommerceBridgeOrderEvent), FactoryName = "Create", BridgeName = "partner-order-bridge")] +public static partial class GeneratedPartnerOrderMessagingBridge; + +public sealed class PartnerOrderMessagingBridgeExampleRunner(PartnerOrderMessagingBridgeService service) +{ + public PartnerOrderMessagingBridgeSummary RunGenerated(IEnumerable orders) + => service.Import(orders); + + public static PartnerOrderMessagingBridgeSummary RunFluent(IEnumerable orders) + { + var partnerOrders = MessageChannel.Create("partner-orders").Build(); + var commerceEvents = MessageChannel.Create("commerce-events").Build(); + var commerceBus = MessageBus.Create("commerce-bus") + .Route("accepted", commerceEvents) + .Route("paid", commerceEvents) + .Build(); + var bridge = PartnerOrderMessagingBridges.Create(partnerOrders, commerceBus); + return new PartnerOrderMessagingBridgeService(bridge, new(partnerOrders, commerceEvents)).Import(orders); + } + + public static PartnerOrderMessagingBridgeSummary RunGeneratedStatic(IEnumerable orders) + { + var partnerOrders = MessageChannel.Create("partner-orders").Build(); + var commerceEvents = MessageChannel.Create("commerce-events").Build(); + var commerceBus = MessageBus.Create("commerce-bus") + .Route("accepted", commerceEvents) + .Route("paid", commerceEvents) + .Build(); + var bridge = GeneratedPartnerOrderMessagingBridge.Create() + .From(partnerOrders) + .To(commerceBus) + .PreserveHeaders(static order => new CommerceBridgeOrderEvent(order.PartnerOrderId, order.State, order.Amount)) + .SelectTopic(static message => message.Payload.State == "paid" ? "paid" : "accepted") + .Build(); + return new PartnerOrderMessagingBridgeService(bridge, new(partnerOrders, commerceEvents)).Import(orders); + } +} + +public static class PartnerOrderMessagingBridgeServiceCollectionExtensions +{ + public static IServiceCollection AddPartnerOrderMessagingBridgeDemo(this IServiceCollection services) + { + services.AddSingleton(static _ => new PartnerOrderMessagingBridgeChannels( + MessageChannel.Create("partner-orders").Build(), + MessageChannel.Create("commerce-events").Build())); + services.AddSingleton(static sp => MessageBus.Create("commerce-bus") + .Route("accepted", sp.GetRequiredService().CommerceEvents) + .Route("paid", sp.GetRequiredService().CommerceEvents) + .Build()); + services.AddSingleton(static sp => GeneratedPartnerOrderMessagingBridge.Create() + .From(sp.GetRequiredService().PartnerOrders) + .To(sp.GetRequiredService>()) + .PreserveHeaders(static order => new CommerceBridgeOrderEvent(order.PartnerOrderId, order.State, order.Amount)) + .SelectTopic(static message => message.Payload.State == "paid" ? "paid" : "accepted") + .Build()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 8d80c469..d93fde72 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -408,6 +408,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["MessageBus", "MessageChannel"], ["topic bus", "source-generated topology", "DI composition"]), + Descriptor( + "Partner Order Messaging Bridge", + "src/PatternKit.Examples/Messaging/PartnerOrderMessagingBridgeExample.cs", + "test/PatternKit.Examples.Tests/Messaging/PartnerOrderMessagingBridgeExampleTests.cs", + "docs/examples/partner-order-messaging-bridge.md", + ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["MessagingBridge", "MessageBus", "MessageChannel"], + ["topology bridge", "header-preserving translation", "source-generated bridge 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 ffacb0d6..95a84049 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -636,6 +636,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/Messaging/OrderMessageBusExampleTests.cs", ["fluent topic bus", "generated bus topology", "DI-importable order event bus example"]), + Pattern("Messaging Bridge", PatternFamily.EnterpriseIntegration, + "docs/patterns/messaging/messaging-bridge.md", + "src/PatternKit.Core/Messaging/Bridges/MessagingBridge.cs", + "test/PatternKit.Tests/Messaging/Bridges/MessagingBridgeTests.cs", + "docs/generators/messaging-bridge.md", + "src/PatternKit.Generators/Messaging/MessagingBridgeGenerator.cs", + "test/PatternKit.Generators.Tests/MessagingBridgeGeneratorTests.cs", + null, + "docs/examples/partner-order-messaging-bridge.md", + "src/PatternKit.Examples/Messaging/PartnerOrderMessagingBridgeExample.cs", + "test/PatternKit.Examples.Tests/Messaging/PartnerOrderMessagingBridgeExampleTests.cs", + ["fluent topology bridge", "generated bridge factory", "DI-importable partner order bridge 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/MessagingBridgeAttributes.cs b/src/PatternKit.Generators.Abstractions/Messaging/MessagingBridgeAttributes.cs new file mode 100644 index 00000000..cc09c203 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Messaging/MessagingBridgeAttributes.cs @@ -0,0 +1,21 @@ +using System; + +namespace PatternKit.Generators.Messaging; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateMessagingBridgeAttribute : Attribute +{ + public GenerateMessagingBridgeAttribute(Type inboundType, Type outboundType) + { + InboundType = inboundType ?? throw new ArgumentNullException(nameof(inboundType)); + OutboundType = outboundType ?? throw new ArgumentNullException(nameof(outboundType)); + } + + public Type InboundType { get; } + + public Type OutboundType { get; } + + public string FactoryName { get; set; } = "Create"; + + public string BridgeName { get; set; } = "messaging-bridge"; +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 40569cf4..836ac883 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -20,6 +20,8 @@ PKCOM006 | PatternKit.Generators.Composer | Error | ComposerGenerator PKCOM007 | PatternKit.Generators.Composer | Error | ComposerGenerator 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 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/MessagingBridgeGenerator.cs b/src/PatternKit.Generators/Messaging/MessagingBridgeGenerator.cs new file mode 100644 index 00000000..87e7f055 --- /dev/null +++ b/src/PatternKit.Generators/Messaging/MessagingBridgeGenerator.cs @@ -0,0 +1,124 @@ +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 MessagingBridgeGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.Messaging.GenerateMessagingBridgeAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKMBR001", + "Messaging bridge type must be partial", + "Type '{0}' is marked with [GenerateMessagingBridge] but is not declared as partial", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidConfiguration = new( + "PKMBR002", + "Messaging bridge configuration is invalid", + "Messaging bridge '{0}' must have non-empty FactoryName and BridgeName values", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + AttributeName, + 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(static attribute => + attribute.AttributeClass?.ToDisplayString() == AttributeName); + 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 inboundType = attribute.ConstructorArguments.Length > 0 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + var outboundType = attribute.ConstructorArguments.Length > 1 ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol : null; + if (inboundType is null || outboundType is null) + return; + + var factoryName = GetNamedString(attribute, "FactoryName") ?? "Create"; + var bridgeName = GetNamedString(attribute, "BridgeName") ?? "messaging-bridge"; + if (string.IsNullOrWhiteSpace(factoryName) || string.IsNullOrWhiteSpace(bridgeName)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidConfiguration, node.Identifier.GetLocation(), type.Name)); + return; + } + + context.AddSource($"{type.Name}.MessagingBridge.g.cs", SourceText.From( + GenerateSource(type, inboundType, outboundType, factoryName, bridgeName), + Encoding.UTF8)); + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol inboundType, + INamedTypeSymbol outboundType, + string factoryName, + string bridgeName) + { + var inbound = inboundType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var outbound = outboundType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsAbstract && type.TypeKind == TypeKind.Class) + sb.Append("abstract "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Messaging.Bridges.MessagingBridge<").Append(inbound).Append(", ").Append(outbound).Append(">.Builder ").Append(factoryName).AppendLine("()"); + sb.Append(" => global::PatternKit.Messaging.Bridges.MessagingBridge<").Append(inbound).Append(", ").Append(outbound).Append(">.Create(").Append(ToLiteral(bridgeName)).AppendLine(");"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; + + 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/PartnerOrderMessagingBridgeExampleTests.cs b/test/PatternKit.Examples.Tests/Messaging/PartnerOrderMessagingBridgeExampleTests.cs new file mode 100644 index 00000000..58534d47 --- /dev/null +++ b/test/PatternKit.Examples.Tests/Messaging/PartnerOrderMessagingBridgeExampleTests.cs @@ -0,0 +1,77 @@ +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("Partner order messaging bridge example")] +public sealed class PartnerOrderMessagingBridgeExampleTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + private static readonly PartnerBridgeOrder[] Orders = + [ + new("P-100", "accepted", 125m), + new("P-101", "paid", 250m) + ]; + + [Scenario("Fluent messaging bridge imports partner orders into commerce topics")] + [Fact] + public Task Fluent_MessagingBridge_ImportsPartnerOrdersIntoCommerceTopics() + => Given("partner orders from an external topology", () => Orders) + .When("the fluent bridge imports them", PartnerOrderMessagingBridgeExampleRunner.RunFluent) + .Then("both orders are bridged", summary => + { + ScenarioExpect.Equal(2, summary.BridgedCount); + ScenarioExpect.Equal(2, summary.CommerceEventCount); + }) + .And("the bridge preserves correlation headers", summary => + ScenarioExpect.Equal("partner:P-100", summary.CorrelationId)) + .AssertPassed(); + + [Scenario("Generated messaging bridge matches fluent bridge behavior")] + [Fact] + public Task Generated_MessagingBridge_MatchesFluentBridgeBehavior() + => Given("partner orders from an external topology", () => Orders) + .When("the generated bridge imports them", PartnerOrderMessagingBridgeExampleRunner.RunGeneratedStatic) + .Then("topics are selected for accepted and paid orders", summary => + ScenarioExpect.Equal(["accepted", "paid"], summary.Topics)) + .AssertPassed(); + + [Scenario("Messaging bridge demo is importable through IServiceCollection")] + [Fact] + public Task MessagingBridge_Demo_IsImportableThroughIServiceCollection() + => Given("a service collection with the messaging bridge demo", () => + { + var services = new ServiceCollection(); + services.AddPartnerOrderMessagingBridgeDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("resolving and running the bridge", provider => + { + using (provider) + return provider.GetRequiredService().RunGenerated(Orders); + }) + .Then("orders are bridged through dependency injection", summary => + ScenarioExpect.Equal(2, summary.BridgedCount)) + .AssertPassed(); + + [Scenario("Aggregate example registrations include the messaging bridge demo")] + [Fact] + public Task Aggregate_ExampleRegistrations_IncludeMessagingBridgeDemo() + => Given("the aggregate PatternKit examples registration", () => + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("resolving the messaging bridge example", provider => + { + using (provider) + return provider.GetRequiredService().Runner.RunGenerated(Orders); + }) + .Then("the aggregate registration imports the bridge", summary => + ScenarioExpect.Equal(2, summary.BridgedCount)) + .AssertPassed(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs index 5b580fc9..7f7a0c0d 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("376 pattern route results", ctx.ResultsGuide)) + ScenarioExpect.Contains("380 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 00c73c7b..b23f694c 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -56,6 +56,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Durable Subscriber", "Dynamic Router", "Message Bus", + "Messaging Bridge", "Content-Based Router", "Message Filter", "Message Store", @@ -149,7 +150,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(35, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); + ScenarioExpect.Equal(36, 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 2af91308..b475cea5 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -160,6 +160,7 @@ private enum TestTrigger { typeof(GenerateMessageChannelAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GenerateMessageBusAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(MessageBusRouteAttribute), AttributeTargets.Method, true, false }, + { typeof(GenerateMessagingBridgeAttribute), 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 }, @@ -1147,6 +1148,11 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf BusName = "orders" }; var messageBusRoute = new MessageBusRouteAttribute("accepted"); + var messagingBridge = new GenerateMessagingBridgeAttribute(typeof(string), typeof(int)) + { + FactoryName = "BuildBridge", + BridgeName = "partner-commerce" + }; var channelPurger = new GenerateChannelPurgerAttribute(typeof(string)) { FactoryName = "BuildPurger", @@ -1488,6 +1494,12 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Equal("accepted", messageBusRoute.Topic); ScenarioExpect.Throws(() => new GenerateMessageBusAttribute(null!)); ScenarioExpect.Throws(() => new MessageBusRouteAttribute("")); + ScenarioExpect.Equal(typeof(string), messagingBridge.InboundType); + ScenarioExpect.Equal(typeof(int), messagingBridge.OutboundType); + ScenarioExpect.Equal("BuildBridge", messagingBridge.FactoryName); + ScenarioExpect.Equal("partner-commerce", messagingBridge.BridgeName); + ScenarioExpect.Throws(() => new GenerateMessagingBridgeAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateMessagingBridgeAttribute(typeof(string), null!)); ScenarioExpect.Throws(() => new GenerateChannelPurgerAttribute(null!)); ScenarioExpect.Throws(() => new GenerateInvalidMessageChannelAttribute(null!)); ScenarioExpect.Throws(() => new GeneratePollingConsumerAttribute(null!)); diff --git a/test/PatternKit.Generators.Tests/MessagingBridgeGeneratorTests.cs b/test/PatternKit.Generators.Tests/MessagingBridgeGeneratorTests.cs new file mode 100644 index 00000000..ccd844bf --- /dev/null +++ b/test/PatternKit.Generators.Tests/MessagingBridgeGeneratorTests.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("Messaging Bridge generator")] +public sealed partial class MessagingBridgeGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates messaging bridge factory")] + [Fact] + public Task Generates_MessagingBridge_Factory() + => Given("a partial host marked with GenerateMessagingBridge", () => """ + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record PartnerOrder(string Id); + public sealed record CommerceOrder(string Id); + + [GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder), FactoryName = "Build", BridgeName = "partner-commerce")] + public static partial class PartnerCommerceBridge; + """) + .When("the generator runs", source => + { + var comp = CreateCompilation(source, nameof(Generates_MessagingBridge_Factory)); + _ = RoslynTestHelpers.Run(comp, new MessagingBridgeGenerator(), out var run, out _); + return run.Results.Single().GeneratedSources.Single().SourceText.ToString(); + }) + .Then("the generated factory returns a configured builder", text => + { + ScenarioExpect.Contains("MessagingBridge.Builder Build()", text); + ScenarioExpect.Contains("MessagingBridge.Create(@\"partner-commerce\")", text); + }) + .AssertPassed(); + + [Scenario("Reports messaging bridge diagnostics")] + [Theory] + [InlineData("[GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder))] public static class PartnerCommerceBridge;", "PKMBR001")] + [InlineData("[GenerateMessagingBridge(typeof(PartnerOrder), typeof(CommerceOrder), BridgeName = \"\")] public static partial class PartnerCommerceBridge;", "PKMBR002")] + public Task Reports_MessagingBridge_Diagnostics(string declaration, string expected) + => Given("an invalid GenerateMessagingBridge declaration", () => $$""" + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record PartnerOrder(string Id); + public sealed record CommerceOrder(string Id); + + {{declaration}} + """) + .When("the generator runs", source => + { + var comp = CreateCompilation(source, nameof(Reports_MessagingBridge_Diagnostics) + expected); + _ = RoslynTestHelpers.Run(comp, new MessagingBridgeGenerator(), 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(GenerateMessagingBridgeAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(global::PatternKit.Messaging.Bridges.MessagingBridge<,>).Assembly.Location) + ]); +} diff --git a/test/PatternKit.Tests/Messaging/Bridges/MessagingBridgeTests.cs b/test/PatternKit.Tests/Messaging/Bridges/MessagingBridgeTests.cs new file mode 100644 index 00000000..7efdb989 --- /dev/null +++ b/test/PatternKit.Tests/Messaging/Bridges/MessagingBridgeTests.cs @@ -0,0 +1,138 @@ +using PatternKit.Messaging; +using PatternKit.Messaging.Bridges; +using PatternKit.Messaging.Channels; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Messaging.Bridges; + +[Feature("Messaging Bridge")] +public sealed class MessagingBridgeTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("BridgeNext translates one inbound message into the target bus")] + [Fact] + public Task BridgeNext_TranslatesOneInboundMessageIntoTheTargetBus() + => Given("a bridge between partner order imports and internal order events", CreateContext) + .When("one partner order is bridged", ctx => + { + ctx.Source.Send(Message.Create(new("P-100", "accepted", 125m)) + .WithCorrelationId("checkout:P-100") + .WithHeader("source-system", "partner")); + + return new { ctx.Internal, Result = ctx.Bridge.BridgeNext() }; + }) + .Then("the bridge publishes to the selected topic", ctx => + { + ScenarioExpect.True(ctx.Result.Bridged); + ScenarioExpect.Equal("accepted", ctx.Result.Topic); + ScenarioExpect.Equal(1, ctx.Result.AcceptedCount); + }) + .And("the translated message preserves headers", ctx => + { + var received = ctx.Internal.TryReceive(); + ScenarioExpect.True(received.Received); + ScenarioExpect.Equal("P-100", received.Message!.Payload.OrderId); + ScenarioExpect.Equal("checkout:P-100", received.Message.Headers.CorrelationId); + ScenarioExpect.Equal("partner", received.Message.Headers.GetString("source-system")); + }) + .AssertPassed(); + + [Scenario("BridgeAll drains active work until the source channel is empty")] + [Fact] + public Task BridgeAll_DrainsActiveWorkUntilTheSourceChannelIsEmpty() + => Given("a bridge with two inbound partner orders", CreateContext) + .When("all available messages are bridged", ctx => + { + ctx.Source.Send(Message.Create(new("P-100", "accepted", 125m))); + ctx.Source.Send(Message.Create(new("P-101", "paid", 250m))); + return new { ctx.Source, ctx.Internal, Results = ctx.Bridge.BridgeAll() }; + }) + .Then("both messages are published and the source is empty", ctx => + { + ScenarioExpect.Equal(2, ctx.Results.Count); + ScenarioExpect.Equal(0, ctx.Source.Count); + ScenarioExpect.Equal(2, ctx.Internal.Count); + }) + .And("topics are selected from the inbound payload", ctx => + ScenarioExpect.Equal(["accepted", "paid"], ctx.Results.Select(static result => result.Topic).ToArray())) + .AssertPassed(); + + [Scenario("BridgeNext returns an empty result when no source message exists")] + [Fact] + public Task BridgeNext_ReturnsEmptyResultWhenNoSourceMessageExists() + => Given("an empty bridge source channel", CreateContext) + .When("the bridge is asked for the next message", ctx => ctx.Bridge.BridgeNext()) + .Then("the result reports no bridged message", result => + { + ScenarioExpect.False(result.Bridged); + ScenarioExpect.Equal(0, result.AcceptedCount); + ScenarioExpect.Null(result.Message); + ScenarioExpect.Null(result.PublishResult); + }) + .AssertPassed(); + + [Scenario("Builder rejects invalid messaging bridge configuration")] + [Fact] + public Task Builder_RejectsInvalidMessagingBridgeConfiguration() + => Given("messaging bridge dependencies", CreateContext) + .When("building invalid bridge configurations", ctx => new Action[] + { + () => MessagingBridge.Create(""), + () => MessagingBridge.Create().From(null!), + () => MessagingBridge.Create().To(null!), + () => MessagingBridge.Create().TranslateWith(null!), + () => MessagingBridge.Create().SelectTopic(null!), + () => MessagingBridge.Create().PreserveHeaders(null!), + () => MessagingBridge.Create().From(ctx.Source).To(ctx.Target).Build(), + () => MessagingBridge.Create().From(ctx.Source).To(ctx.Target).PreserveHeaders(Map).Build(), + () => MessagingBridge.Create().From(ctx.Source).To(ctx.Target).SelectTopic(SelectTopic).Build() + }) + .Then("each invalid configuration fails explicitly", failures => + { + ScenarioExpect.Throws(failures[0]); + ScenarioExpect.Throws(failures[1]); + ScenarioExpect.Throws(failures[2]); + ScenarioExpect.Throws(failures[3]); + ScenarioExpect.Throws(failures[4]); + ScenarioExpect.Throws(failures[5]); + ScenarioExpect.Throws(failures[6]); + ScenarioExpect.Throws(failures[7]); + ScenarioExpect.Throws(failures[8]); + }) + .AssertPassed(); + + private static BridgeContext CreateContext() + { + var source = MessageChannel.Create("partner-orders").Build(); + var internalOrders = MessageChannel.Create("internal-orders").Build(); + var target = MessageBus.Create("commerce-bus") + .Route("accepted", internalOrders) + .Route("paid", internalOrders) + .Build(); + var bridge = MessagingBridge.Create("partner-commerce-bridge") + .From(source) + .To(target) + .PreserveHeaders(Map) + .SelectTopic(SelectTopic) + .Build(); + + return new(source, internalOrders, target, bridge); + } + + private static InternalOrderEvent Map(PartnerOrder order) + => new(order.PartnerOrderId, order.State, order.Amount); + + private static string SelectTopic(Message message) + => message.Payload.State; + + private sealed record BridgeContext( + MessageChannel Source, + MessageChannel Internal, + MessageBus Target, + MessagingBridge Bridge); + + private sealed record PartnerOrder(string PartnerOrderId, string State, decimal Amount); + + private sealed record InternalOrderEvent(string OrderId, string Status, decimal Total); +}