From f2d6221c9da6dab40aaaa530de630799f9eb8597 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 22 May 2026 01:37:09 -0500 Subject: [PATCH] feat: add event notification pattern --- docs/examples/index.md | 3 + docs/examples/order-event-notification.md | 12 + docs/examples/toc.yml | 3 + docs/generators/event-notification.md | 28 +++ docs/generators/index.md | 5 + docs/generators/toc.yml | 3 + docs/guides/pattern-coverage.md | 1 + docs/patterns/messaging/README.md | 6 + docs/patterns/messaging/event-notification.md | 19 ++ docs/patterns/toc.yml | 2 + .../EventNotification/EventNotification.cs | 154 ++++++++++++ ...rnKitExampleServiceCollectionExtensions.cs | 12 + .../OrderEventNotificationDemo.cs | 93 ++++++++ .../PatternKitExampleCatalog.cs | 8 + .../PatternKitPatternCatalog.cs | 13 ++ .../EventNotificationAttributes.cs | 34 +++ .../AnalyzerReleases.Unshipped.md | 4 + .../EventNotificationGenerator.cs | 220 ++++++++++++++++++ .../OrderEventNotificationDemoTests.cs | 47 ++++ .../PatternKitPatternCatalogTests.cs | 3 +- .../AbstractionsAttributeCoverageTests.cs | 30 +++ .../EventNotificationGeneratorTests.cs | 107 +++++++++ .../EventNotificationTests.cs | 68 ++++++ 23 files changed, 874 insertions(+), 1 deletion(-) create mode 100644 docs/examples/order-event-notification.md create mode 100644 docs/generators/event-notification.md create mode 100644 docs/patterns/messaging/event-notification.md create mode 100644 src/PatternKit.Core/EnterpriseIntegration/EventNotification/EventNotification.cs create mode 100644 src/PatternKit.Examples/EventNotificationDemo/OrderEventNotificationDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/EnterpriseIntegration/EventNotificationAttributes.cs create mode 100644 src/PatternKit.Generators/EventNotification/EventNotificationGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/EventNotificationDemo/OrderEventNotificationDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/EventNotificationGeneratorTests.cs create mode 100644 test/PatternKit.Tests/EnterpriseIntegration/EventNotification/EventNotificationTests.cs diff --git a/docs/examples/index.md b/docs/examples/index.md index 5ea83882..3e0cf119 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -93,6 +93,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Inventory Event-Carried State Transfer** Shows fluent and source-generated inventory projection events that carry enough state to update a local read model, with an importable `IServiceCollection` extension. See [Inventory Event-Carried State Transfer](inventory-event-carried-state-transfer.md). +* **Order Event Notification** + Shows fluent and source-generated compact order notifications with correlation metadata, dispatch rules, and an importable `IServiceCollection` extension. See [Order Event Notification](order-event-notification.md). + * **Generated Recipient List** Shows fluent and source-generated recipient-list fan-out side by side, with an importable `IServiceCollection` extension. See [Generated Recipient List](generated-recipient-list.md). diff --git a/docs/examples/order-event-notification.md b/docs/examples/order-event-notification.md new file mode 100644 index 00000000..3c3fa514 --- /dev/null +++ b/docs/examples/order-event-notification.md @@ -0,0 +1,12 @@ +# Order Event Notification + +The order event notification example publishes compact `OrderAcceptedNotificationEvent` notifications through an importable service. + +```csharp +services.AddOrderEventNotificationDemo(); + +var runner = provider.GetRequiredService(); +var summary = runner.RunGenerated(new OrderAcceptedNotificationEvent("O-100", "C-900", "web", true)); +``` + +The example includes fluent and source-generated construction plus an `IServiceCollection` extension for standard .NET hosts. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index d75e0ca2..f12319bc 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -85,6 +85,9 @@ - name: Inventory Event-Carried State Transfer href: inventory-event-carried-state-transfer.md +- name: Order Event Notification + href: order-event-notification.md + - name: Generated Claim Check href: generated-claim-check.md diff --git a/docs/generators/event-notification.md b/docs/generators/event-notification.md new file mode 100644 index 00000000..c30f9261 --- /dev/null +++ b/docs/generators/event-notification.md @@ -0,0 +1,28 @@ +# Event Notification Generator + +`[GenerateEventNotification]` creates a typed `EventNotification` factory from key, rule, correlation, and metadata methods. + +```csharp +[GenerateEventNotification(typeof(OrderAccepted), typeof(string), NotificationName = "order-accepted")] +public static partial class OrderAcceptedNotification +{ + [EventNotificationRule] + private static bool ShouldNotify(OrderAccepted evt) => evt.NotifySubscribers; + + [EventNotificationKey] + private static string Key(OrderAccepted evt) => evt.OrderId; + + [EventNotificationCorrelation] + private static string Correlation(OrderAccepted evt) => evt.CorrelationId; + + [EventNotificationMetadata("source")] + private static string Source(OrderAccepted evt) => evt.Source; +} +``` + +Diagnostics: + +- `PKEN001`: host type must be partial. +- `PKEN002`: exactly one key selector is required. +- `PKEN003`: selector, rule, correlation, or metadata signature is invalid. +- `PKEN004`: metadata names must be unique. diff --git a/docs/generators/index.md b/docs/generators/index.md index bf31c166..761e064b 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -90,6 +90,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Message Translator**](message-translator.md) | Partner and transport event normalization | `[GenerateMessageTranslator]` | | [**Canonical Data Model**](canonical-data-model.md) | Source-to-canonical contract normalization | `[GenerateCanonicalDataModel]` | | [**Event-Carried State Transfer**](event-carried-state-transfer.md) | State-rich event projection factories | `[GenerateEventCarriedStateTransfer]` | +| [**Event Notification**](event-notification.md) | Compact event notification factories | `[GenerateEventNotification]` | | [**Claim Check**](claim-check.md) | External payload storage references | `[GenerateClaimCheck]` | | [**Dead Letter Channel**](dead-letter-channel.md) | Failed-message capture and replay handoff | `[GenerateDeadLetterChannel]` | | [**Content Router**](messaging.md#generated-content-router) | Content-based message routing factories | `[GenerateContentRouter]` | @@ -200,6 +201,10 @@ public static partial class PartnerOrderTranslator { } [GenerateEventCarriedStateTransfer(typeof(InventoryAdjustedEvent), typeof(string), typeof(InventoryReadModel))] public static partial class InventoryStateTransfer { } +// Event notification - compact event signals +[GenerateEventNotification(typeof(OrderAccepted), typeof(string))] +public static partial class OrderAcceptedNotification { } + // Claim check - external payload storage reference [GenerateClaimCheck(typeof(LargeOrderDocument), StoreName = "document-archive")] public static partial class LargeDocumentClaims { } diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 713ca845..c5ce03e4 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -31,6 +31,9 @@ - name: Event-Carried State Transfer href: event-carried-state-transfer.md +- name: Event Notification + href: event-notification.md + - name: Chain href: chain.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index c42c4ff0..64efce68 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -54,6 +54,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Enterprise Integration | Message Translator | `MessageTranslator` | Message Translator generator | | Enterprise Integration | Canonical Data Model | `CanonicalDataModel` | Canonical Data Model generator | | Enterprise Integration | Event-Carried State Transfer | `EventCarriedStateTransfer` | Event-Carried State Transfer generator | +| Enterprise Integration | Event Notification | `EventNotification` | Event Notification generator | | Enterprise Integration | Claim Check | `ClaimCheck` | Claim Check generator | | Enterprise Integration | Dead Letter Channel | `DeadLetterChannel` | Dead Letter Channel generator | | Enterprise Integration | Content-Based Router | `ContentRouter` | Messaging generator | diff --git a/docs/patterns/messaging/README.md b/docs/patterns/messaging/README.md index fead6c18..b6ef4414 100644 --- a/docs/patterns/messaging/README.md +++ b/docs/patterns/messaging/README.md @@ -62,6 +62,12 @@ Event-carried state transfer publishes enough state in an event for subscribers [Learn More](event-carried-state-transfer.md) +## Event Notification + +Event notification publishes compact event signals with keys, correlation IDs, and metadata when subscribers do not need a full state payload. + +[Learn More](event-notification.md) + ## Idempotent Receiver, Inbox, and Outbox Idempotency and handoff helpers compose message handlers with pluggable stores, inbox boundaries, and outbox records without claiming broker durability or exactly-once delivery. diff --git a/docs/patterns/messaging/event-notification.md b/docs/patterns/messaging/event-notification.md new file mode 100644 index 00000000..9eca45ef --- /dev/null +++ b/docs/patterns/messaging/event-notification.md @@ -0,0 +1,19 @@ +# Event Notification + +Event Notification publishes a compact signal that something happened, usually with an identifier and correlation metadata rather than the full state payload. + +```csharp +var notification = EventNotification + .Create("order-accepted") + .When(evt => evt.NotifySubscribers) + .WithKey(evt => evt.OrderId) + .WithCorrelation(evt => evt.CorrelationId) + .WithMetadata("source", evt => evt.Source) + .Build(); + +var result = notification.Notify(orderAccepted); +``` + +Use it when subscribers can react to a lightweight event and only fetch more detail when their workflow requires it. The runtime path supports dispatch predicates, correlation IDs, metadata, and explicit skipped or failed results. + +The source-generated path uses `[GenerateEventNotification]`, `[EventNotificationKey]`, `[EventNotificationRule]`, `[EventNotificationCorrelation]`, and `[EventNotificationMetadata]`. Import the example through `AddOrderEventNotificationDemo()` or `AddPatternKitExamples()`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 3f363b0f..13e6ac0a 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -315,6 +315,8 @@ href: messaging/canonical-data-model.md - name: Event-Carried State Transfer href: messaging/event-carried-state-transfer.md + - name: Event Notification + href: messaging/event-notification.md - name: Claim Check href: messaging/claim-check.md - name: Dead Letter Channel diff --git a/src/PatternKit.Core/EnterpriseIntegration/EventNotification/EventNotification.cs b/src/PatternKit.Core/EnterpriseIntegration/EventNotification/EventNotification.cs new file mode 100644 index 00000000..b4f94a4d --- /dev/null +++ b/src/PatternKit.Core/EnterpriseIntegration/EventNotification/EventNotification.cs @@ -0,0 +1,154 @@ +namespace PatternKit.EnterpriseIntegration.EventNotification; + +public sealed class EventNotificationResult +{ + private EventNotificationResult( + string notificationName, + TKey? key, + string correlationId, + IReadOnlyDictionary metadata, + Exception? exception, + bool published, + bool skipped) + => (NotificationName, Key, CorrelationId, Metadata, Exception, Published, Skipped) = (notificationName, key, correlationId, metadata, exception, published, skipped); + + public string NotificationName { get; } + + public TKey? Key { get; } + + public string CorrelationId { get; } + + public IReadOnlyDictionary Metadata { get; } + + public Exception? Exception { get; } + + public bool Published { get; } + + public bool Skipped { get; } + + public bool Failed => !Published && !Skipped; + + public static EventNotificationResult Publish(string notificationName, TKey key, string correlationId, IReadOnlyDictionary metadata) + => new(notificationName, key, correlationId, metadata, null, true, false); + + public static EventNotificationResult Skip(string notificationName) + => new(notificationName, default, string.Empty, new Dictionary(), null, false, true); + + public static EventNotificationResult Failure(string notificationName, Exception exception) + => new(notificationName, default, string.Empty, new Dictionary(), exception ?? throw new ArgumentNullException(nameof(exception)), false, false); +} + +public sealed class EventNotification +{ + private readonly Func _predicate; + private readonly Func _keySelector; + private readonly Func? _correlationSelector; + private readonly IReadOnlyList _metadataSelectors; + + private EventNotification( + string name, + Func? predicate, + Func? keySelector, + Func? correlationSelector, + IReadOnlyList metadataSelectors) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Event notification name is required.", nameof(name)); + + Name = name; + _predicate = predicate ?? (_ => true); + _keySelector = keySelector ?? throw new InvalidOperationException("Event notification requires a key selector."); + _correlationSelector = correlationSelector; + _metadataSelectors = metadataSelectors ?? throw new ArgumentNullException(nameof(metadataSelectors)); + } + + public string Name { get; } + + public EventNotificationResult Notify(TEvent @event) + { + if (@event is null) + throw new ArgumentNullException(nameof(@event)); + + try + { + if (!_predicate(@event)) + return EventNotificationResult.Skip(Name); + + var key = _keySelector(@event); + if (key is null) + return EventNotificationResult.Failure(Name, new InvalidOperationException("Event notification key selector returned null.")); + + var correlationId = _correlationSelector?.Invoke(@event) ?? string.Empty; + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var selector in _metadataSelectors) + { + var value = selector.Select(@event); + if (!string.IsNullOrWhiteSpace(value)) + metadata[selector.Name] = value!; + } + + return EventNotificationResult.Publish(Name, key, correlationId, metadata); + } + catch (Exception ex) + { + return EventNotificationResult.Failure(Name, ex); + } + } + + public static Builder Create(string name = "event-notification") => new(name); + + public sealed class Builder + { + private readonly string _name; + private readonly List _metadataSelectors = []; + private Func? _predicate; + private Func? _keySelector; + private Func? _correlationSelector; + + internal Builder(string name) => _name = name; + + public Builder When(Func predicate) + { + _predicate = predicate ?? throw new ArgumentNullException(nameof(predicate)); + return this; + } + + public Builder WithKey(Func keySelector) + { + _keySelector = keySelector ?? throw new ArgumentNullException(nameof(keySelector)); + return this; + } + + public Builder WithCorrelation(Func correlationSelector) + { + _correlationSelector = correlationSelector ?? throw new ArgumentNullException(nameof(correlationSelector)); + return this; + } + + public Builder WithMetadata(string name, Func metadataSelector) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Event notification metadata name is required.", nameof(name)); + if (metadataSelector is null) + throw new ArgumentNullException(nameof(metadataSelector)); + if (_metadataSelectors.Any(selector => string.Equals(selector.Name, name, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException($"Event notification metadata '{name}' is already registered."); + + _metadataSelectors.Add(new(name, metadataSelector)); + return this; + } + + public EventNotification Build() + => new(_name, _predicate, _keySelector, _correlationSelector, _metadataSelectors.ToArray()); + } + + private sealed class MetadataSelector + { + public MetadataSelector(string name, Func select) + => (Name, Select) = (name, select); + + public string Name { get; } + + public Func Select { get; } + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 9331fcff..9b3ee948 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -33,6 +33,7 @@ using PatternKit.Examples.EnterpriseFeatureSlices; using PatternKit.Examples.EventSourcingDemo; using PatternKit.Examples.EventCarriedStateTransferDemo; +using PatternKit.Examples.EventNotificationDemo; using PatternKit.Examples.ExternalConfigurationStoreDemo; using PatternKit.Examples.FeatureToggleDemo; using PatternKit.Examples.FlyweightDemo; @@ -144,6 +145,7 @@ public sealed record GeneratedMessageEnvelopeExample(MessageEnvelopeExampleRunne public sealed record GeneratedMessageTranslatorExample(PartnerEventTranslatorExampleRunner Runner, PartnerOrderImportService Service); public sealed record CanonicalOrderDataModelExample(CanonicalOrderDemoRunner Runner, CanonicalOrderImportService Service); public sealed record InventoryEventCarriedStateTransferExample(InventoryEventCarriedStateTransferDemoRunner Runner, InventoryProjectionService Service); +public sealed record OrderEventNotificationExample(OrderEventNotificationDemoRunner Runner, OrderNotificationService Service); public sealed record GeneratedClaimCheckExample(LargeDocumentClaimCheckExampleRunner Runner, LargeDocumentWorkflow Workflow); public sealed record GeneratedDeadLetterChannelExample(FulfillmentDeadLetterChannelExampleRunner Runner, FulfillmentDeadLetterWorkflow Workflow); public sealed record GeneratedRecipientListExample(RecipientListGeneratorExampleRunner Runner); @@ -232,6 +234,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddGeneratedMessageTranslatorExample() .AddCanonicalOrderDataModelExample() .AddInventoryEventCarriedStateTransferExample() + .AddOrderEventNotificationExample() .AddGeneratedClaimCheckExample() .AddGeneratedDeadLetterChannelExample() .AddGeneratedRecipientListExample() @@ -570,6 +573,15 @@ public static IServiceCollection AddInventoryEventCarriedStateTransferExample(th return services.RegisterExample("Inventory Event-Carried State Transfer", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddOrderEventNotificationExample(this IServiceCollection services) + { + services.AddOrderEventNotificationDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("Order Event Notification", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddGeneratedClaimCheckExample(this IServiceCollection services) { services.AddLargeDocumentClaimCheckExample(); diff --git a/src/PatternKit.Examples/EventNotificationDemo/OrderEventNotificationDemo.cs b/src/PatternKit.Examples/EventNotificationDemo/OrderEventNotificationDemo.cs new file mode 100644 index 00000000..681b46e4 --- /dev/null +++ b/src/PatternKit.Examples/EventNotificationDemo/OrderEventNotificationDemo.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.EnterpriseIntegration.EventNotification; +using PatternKit.Generators.EventNotification; + +namespace PatternKit.Examples.EventNotificationDemo; + +public sealed record OrderAcceptedNotificationEvent(string OrderId, string CorrelationId, string Source, bool NotifySubscribers); + +public sealed record OrderNotificationSummary(string NotificationName, string OrderId, string CorrelationId, string Source); + +public interface IOrderNotificationPublisher +{ + void Publish(OrderNotificationSummary notification); + + IReadOnlyList Published { get; } +} + +public sealed class InMemoryOrderNotificationPublisher : IOrderNotificationPublisher +{ + private readonly List _published = []; + + public IReadOnlyList Published => _published; + + public void Publish(OrderNotificationSummary notification) => _published.Add(notification); +} + +public sealed class OrderNotificationService( + EventNotification notification, + IOrderNotificationPublisher publisher) +{ + public OrderNotificationSummary? Notify(OrderAcceptedNotificationEvent evt) + { + var result = notification.Notify(evt); + if (result.Skipped) + return null; + if (result.Failed) + throw new InvalidOperationException("Order notification could not be created.", result.Exception); + + var summary = new OrderNotificationSummary(result.NotificationName, result.Key!, result.CorrelationId, result.Metadata["source"]); + publisher.Publish(summary); + return summary; + } +} + +public static class OrderNotifications +{ + public static EventNotification CreateFluent() + => EventNotification.Create("order-accepted") + .When(static evt => evt.NotifySubscribers) + .WithKey(static evt => evt.OrderId) + .WithCorrelation(static evt => evt.CorrelationId) + .WithMetadata("source", static evt => evt.Source) + .Build(); +} + +[GenerateEventNotification(typeof(OrderAcceptedNotificationEvent), typeof(string), FactoryMethodName = "Create", NotificationName = "order-accepted")] +public static partial class GeneratedOrderAcceptedNotification +{ + [EventNotificationRule] + private static bool ShouldNotify(OrderAcceptedNotificationEvent evt) => evt.NotifySubscribers; + + [EventNotificationKey] + private static string Key(OrderAcceptedNotificationEvent evt) => evt.OrderId; + + [EventNotificationCorrelation] + private static string Correlation(OrderAcceptedNotificationEvent evt) => evt.CorrelationId; + + [EventNotificationMetadata("source")] + private static string Source(OrderAcceptedNotificationEvent evt) => evt.Source; +} + +public sealed class OrderEventNotificationDemoRunner(OrderNotificationService service) +{ + public OrderNotificationSummary? RunGenerated(OrderAcceptedNotificationEvent evt) => service.Notify(evt); + + public static OrderNotificationSummary? RunFluent() + { + var service = new OrderNotificationService(OrderNotifications.CreateFluent(), new InMemoryOrderNotificationPublisher()); + return service.Notify(new OrderAcceptedNotificationEvent("O-100", "C-900", "web", true)); + } +} + +public static class OrderEventNotificationServiceCollectionExtensions +{ + public static IServiceCollection AddOrderEventNotificationDemo(this IServiceCollection services) + { + services.AddSingleton(static _ => GeneratedOrderAcceptedNotification.Create()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index d32186fc..9851899e 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -312,6 +312,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["Event-Carried State Transfer"], ["inventory read-model projection", "source-generated state transfer", "DI composition"]), + Descriptor( + "Order Event Notification", + "src/PatternKit.Examples/EventNotificationDemo/OrderEventNotificationDemo.cs", + "test/PatternKit.Examples.Tests/EventNotificationDemo/OrderEventNotificationDemoTests.cs", + "docs/examples/order-event-notification.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["Event Notification"], + ["compact order notification", "source-generated notification", "DI composition"]), Descriptor( "Generated Claim Check", "src/PatternKit.Examples/Messaging/LargeDocumentClaimCheckExample.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 10a20350..fa82cd69 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -493,6 +493,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/EventCarriedStateTransferDemo/InventoryEventCarriedStateTransferDemoTests.cs", ["fluent state extraction", "generated state transfer factory", "DI-importable inventory projection example"]), + Pattern("Event Notification", PatternFamily.EnterpriseIntegration, + "docs/patterns/messaging/event-notification.md", + "src/PatternKit.Core/EnterpriseIntegration/EventNotification/EventNotification.cs", + "test/PatternKit.Tests/EnterpriseIntegration/EventNotification/EventNotificationTests.cs", + "docs/generators/event-notification.md", + "src/PatternKit.Generators/EventNotification/EventNotificationGenerator.cs", + "test/PatternKit.Generators.Tests/EventNotificationGeneratorTests.cs", + null, + "docs/examples/order-event-notification.md", + "src/PatternKit.Examples/EventNotificationDemo/OrderEventNotificationDemo.cs", + "test/PatternKit.Examples.Tests/EventNotificationDemo/OrderEventNotificationDemoTests.cs", + ["fluent compact notification", "generated notification factory", "DI-importable order notification example"]), + Pattern("Claim Check", PatternFamily.EnterpriseIntegration, "docs/patterns/messaging/claim-check.md", "src/PatternKit.Core/Messaging/Transformation/ClaimCheck.cs", diff --git a/src/PatternKit.Generators.Abstractions/EnterpriseIntegration/EventNotificationAttributes.cs b/src/PatternKit.Generators.Abstractions/EnterpriseIntegration/EventNotificationAttributes.cs new file mode 100644 index 00000000..5c1fd6a6 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/EnterpriseIntegration/EventNotificationAttributes.cs @@ -0,0 +1,34 @@ +namespace PatternKit.Generators.EventNotification; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateEventNotificationAttribute(Type eventType, Type keyType) : Attribute +{ + public Type EventType { get; } = eventType ?? throw new ArgumentNullException(nameof(eventType)); + + public Type KeyType { get; } = keyType ?? throw new ArgumentNullException(nameof(keyType)); + + public string FactoryMethodName { get; set; } = "Create"; + + public string NotificationName { get; set; } = "event-notification"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class EventNotificationKeyAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class EventNotificationCorrelationAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class EventNotificationRuleAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class EventNotificationMetadataAttribute(string name) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Metadata name is required.", nameof(name)) : name; +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index f1e4c843..d3f8abd2 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -337,6 +337,10 @@ PKCDM003 | PatternKit.Generators.CanonicalDataModel | Error | Canonical Data Mod PKECST001 | PatternKit.Generators.EventCarriedStateTransfer | Error | Event-Carried State Transfer host must be partial. PKECST002 | PatternKit.Generators.EventCarriedStateTransfer | Error | Event-Carried State Transfer methods are missing. PKECST003 | PatternKit.Generators.EventCarriedStateTransfer | Error | Event-Carried State Transfer method signature is invalid. +PKEN001 | PatternKit.Generators.EventNotification | Error | Event Notification host must be partial. +PKEN002 | PatternKit.Generators.EventNotification | Error | Event Notification key selector is missing. +PKEN003 | PatternKit.Generators.EventNotification | Error | Event Notification method signature is invalid. +PKEN004 | PatternKit.Generators.EventNotification | Error | Event Notification metadata is duplicated. PKGWY001 | PatternKit.Generators.Messaging | Error | Messaging Gateway host type must be partial. PKGWY002 | PatternKit.Generators.Messaging | Error | Messaging Gateway must declare exactly one handler. PKGWY003 | PatternKit.Generators.Messaging | Error | Messaging Gateway handler signature is invalid. diff --git a/src/PatternKit.Generators/EventNotification/EventNotificationGenerator.cs b/src/PatternKit.Generators/EventNotification/EventNotificationGenerator.cs new file mode 100644 index 00000000..ec870328 --- /dev/null +++ b/src/PatternKit.Generators/EventNotification/EventNotificationGenerator.cs @@ -0,0 +1,220 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.EventNotification; + +[Generator] +public sealed class EventNotificationGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.EventNotification.GenerateEventNotificationAttribute"; + private const string KeyAttributeName = "PatternKit.Generators.EventNotification.EventNotificationKeyAttribute"; + private const string CorrelationAttributeName = "PatternKit.Generators.EventNotification.EventNotificationCorrelationAttribute"; + private const string RuleAttributeName = "PatternKit.Generators.EventNotification.EventNotificationRuleAttribute"; + private const string MetadataAttributeName = "PatternKit.Generators.EventNotification.EventNotificationMetadataAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKEN001", "Event Notification host must be partial", + "Type '{0}' is marked with [GenerateEventNotification] but is not declared as partial", + "PatternKit.Generators.EventNotification", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingKey = new( + "PKEN002", "Event Notification key selector is missing", + "Event Notification type '{0}' must declare exactly one [EventNotificationKey] method", + "PatternKit.Generators.EventNotification", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidMember = new( + "PKEN003", "Event Notification method signature is invalid", + "Event Notification method '{0}' has an invalid static signature for the configured event or key type", + "PatternKit.Generators.EventNotification", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor DuplicateMetadata = new( + "PKEN004", "Event Notification metadata is duplicated", + "Event Notification metadata name '{0}' is duplicated", + "PatternKit.Generators.EventNotification", DiagnosticSeverity.Error, true); + + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + 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 a => a.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 eventType = attribute.ConstructorArguments.Length >= 1 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + var keyType = attribute.ConstructorArguments.Length >= 2 ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol : null; + if (eventType is null || keyType is null) + return; + + var keys = MembersWith(type, KeyAttributeName); + if (keys.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingKey, node.Identifier.GetLocation(), type.Name)); + return; + } + + var correlations = MembersWith(type, CorrelationAttributeName); + var rules = MembersWith(type, RuleAttributeName); + var metadata = MetadataMembers(type); + var duplicate = metadata.GroupBy(static item => item.Name, StringComparer.OrdinalIgnoreCase).FirstOrDefault(static group => group.Count() > 1); + if (duplicate is not null) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateMetadata, node.Identifier.GetLocation(), duplicate.Key)); + return; + } + + if (!IsSelector(keys[0], eventType, keyType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, keys[0].Locations.FirstOrDefault(), keys[0].Name)); + return; + } + + var invalidCorrelation = correlations.FirstOrDefault(method => !IsStringSelector(method, eventType)); + var invalidRule = rules.FirstOrDefault(method => !IsBoolSelector(method, eventType)); + var invalidMetadata = metadata.FirstOrDefault(item => !IsStringSelector(item.Method, eventType)); + if (invalidCorrelation is not null || invalidRule is not null || invalidMetadata is not null) + { + var invalid = invalidCorrelation ?? invalidRule ?? invalidMetadata!.Method; + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, invalid.Locations.FirstOrDefault(), invalid.Name)); + return; + } + + context.AddSource($"{type.Name}.EventNotification.g.cs", SourceText.From(GenerateSource( + type, + eventType, + keyType, + keys[0].Name, + correlations.FirstOrDefault()?.Name, + rules.FirstOrDefault()?.Name, + metadata, + GetNamedString(attribute, "FactoryMethodName") ?? "Create", + GetNamedString(attribute, "NotificationName") ?? "event-notification"), Encoding.UTF8)); + } + + private static IMethodSymbol[] MembersWith(INamedTypeSymbol type, string attributeName) + => type.GetMembers().OfType() + .Where(method => method.GetAttributes().Any(attr => attr.AttributeClass?.ToDisplayString() == attributeName)) + .ToArray(); + + private static MetadataMember[] MetadataMembers(INamedTypeSymbol type) + => type.GetMembers().OfType() + .Select(method => new + { + Method = method, + Attribute = method.GetAttributes().FirstOrDefault(attr => attr.AttributeClass?.ToDisplayString() == MetadataAttributeName) + }) + .Where(static item => item.Attribute is not null) + .Select(static item => new MetadataMember((string)item.Attribute!.ConstructorArguments[0].Value!, item.Method)) + .ToArray(); + + private static bool IsSelector(IMethodSymbol method, INamedTypeSymbol eventType, ITypeSymbol returnType) + => method.IsStatic && + SymbolEqualityComparer.Default.Equals(method.ReturnType, returnType) && + method.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, eventType); + + private static bool IsStringSelector(IMethodSymbol method, INamedTypeSymbol eventType) + => method.IsStatic && + method.ReturnType.SpecialType == SpecialType.System_String && + method.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, eventType); + + private static bool IsBoolSelector(IMethodSymbol method, INamedTypeSymbol eventType) + => method.IsStatic && + method.ReturnType.SpecialType == SpecialType.System_Boolean && + method.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, eventType); + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol eventType, + INamedTypeSymbol keyType, + string keySelectorName, + string? correlationSelectorName, + string? ruleName, + IReadOnlyList metadata, + string factoryMethodName, + string notificationName) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var eventTypeName = eventType.ToDisplayString(TypeFormat); + var keyTypeName = keyType.ToDisplayString(TypeFormat); + 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.EnterpriseIntegration.EventNotification.EventNotification<") + .Append(eventTypeName).Append(", ").Append(keyTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" return global::PatternKit.EnterpriseIntegration.EventNotification.EventNotification<") + .Append(eventTypeName).Append(", ").Append(keyTypeName).Append(">.Create(\"").Append(Escape(notificationName)).AppendLine("\")"); + if (!string.IsNullOrWhiteSpace(ruleName)) + sb.Append(" .When(").Append(ruleName).AppendLine(")"); + sb.Append(" .WithKey(").Append(keySelectorName).AppendLine(")"); + if (!string.IsNullOrWhiteSpace(correlationSelectorName)) + sb.Append(" .WithCorrelation(").Append(correlationSelectorName).AppendLine(")"); + foreach (var item in metadata) + sb.Append(" .WithMetadata(\"").Append(Escape(item.Name)).Append("\", ").Append(item.Method.Name).AppendLine(")"); + sb.AppendLine(" .Build();"); + sb.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 Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + 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 sealed record MetadataMember(string Name, IMethodSymbol Method); +} diff --git a/test/PatternKit.Examples.Tests/EventNotificationDemo/OrderEventNotificationDemoTests.cs b/test/PatternKit.Examples.Tests/EventNotificationDemo/OrderEventNotificationDemoTests.cs new file mode 100644 index 00000000..1ae89be2 --- /dev/null +++ b/test/PatternKit.Examples.Tests/EventNotificationDemo/OrderEventNotificationDemoTests.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.EventNotificationDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.EventNotificationDemo; + +[Feature("Order Event Notification example")] +public sealed class OrderEventNotificationDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent and generated paths publish compact order notifications")] + [Fact] + public Task Fluent_And_Generated_Paths_Publish_Compact_Order_Notifications() + => Given("an order accepted event", () => new OrderAcceptedNotificationEvent("O-100", "C-900", "web", true)) + .When("fluent and generated notifications handle the event", evt => new + { + Fluent = OrderEventNotificationDemoRunner.RunFluent(), + Generated = BuildServiceProvider().GetRequiredService().RunGenerated(evt) + }) + .Then("both paths publish compact notification metadata", result => + { + ScenarioExpect.Equal("O-100", result.Fluent!.OrderId); + ScenarioExpect.Equal("C-900", result.Generated!.CorrelationId); + ScenarioExpect.Equal("web", result.Generated.Source); + }) + .AssertPassed(); + + [Scenario("Order notification is importable through AddPatternKitExamples")] + [Fact] + public Task Order_Notification_Is_Importable_Through_AddPatternKitExamples() + => Given("the aggregate PatternKit example registration", () => new ServiceCollection().AddPatternKitExamples().BuildServiceProvider()) + .When("the order notification example is resolved", provider => provider.GetRequiredService()) + .Then("the runner and service are available through standard IoC", example => + { + var summary = example.Runner.RunGenerated(new OrderAcceptedNotificationEvent("O-200", "C-901", "mobile", true)); + ScenarioExpect.Equal("O-200", summary!.OrderId); + ScenarioExpect.NotNull(example.Service); + }) + .AssertPassed(); + + private static ServiceProvider BuildServiceProvider() + => new ServiceCollection() + .AddOrderEventNotificationDemo() + .BuildServiceProvider(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 00314b96..9b274118 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -48,6 +48,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Message Translator", "Canonical Data Model", "Event-Carried State Transfer", + "Event Notification", "Claim Check", "Dead Letter Channel", "Content-Based Router", @@ -134,7 +135,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(29, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); + ScenarioExpect.Equal(30, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(9, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); ScenarioExpect.Equal(15, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 9a5f17c7..017dc3f3 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -13,6 +13,7 @@ using PatternKit.Generators.Decorator; using PatternKit.Generators.DomainEvents; using PatternKit.Generators.EventCarriedStateTransfer; +using PatternKit.Generators.EventNotification; using PatternKit.Generators.EventSourcing; using PatternKit.Generators.Facade; using PatternKit.Generators.FeatureToggles; @@ -83,6 +84,11 @@ private enum TestTrigger { typeof(EventCarriedStateKeyAttribute), AttributeTargets.Method, false, false }, { typeof(EventCarriedStateVersionAttribute), AttributeTargets.Method, false, false }, { typeof(EventCarriedStateMapperAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateEventNotificationAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(EventNotificationKeyAttribute), AttributeTargets.Method, false, false }, + { typeof(EventNotificationCorrelationAttribute), AttributeTargets.Method, false, false }, + { typeof(EventNotificationRuleAttribute), AttributeTargets.Method, false, false }, + { typeof(EventNotificationMetadataAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateExternalConfigurationStoreAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(ExternalConfigurationLoaderAttribute), AttributeTargets.Method, false, false }, { typeof(ExternalConfigurationValidatorAttribute), AttributeTargets.Method, false, false }, @@ -444,6 +450,30 @@ public void EventCarriedStateTransfer_Attributes_Expose_Defaults_And_Configurati ScenarioExpect.IsType(new EventCarriedStateMapperAttribute()); } + [Scenario("Event Notification Attributes Expose Defaults And Configuration")] + [Fact] + public void EventNotification_Attributes_Expose_Defaults_And_Configuration() + { + var notification = new GenerateEventNotificationAttribute(typeof(string), typeof(Guid)) + { + FactoryMethodName = "BuildOrderAccepted", + NotificationName = "order-accepted" + }; + var metadata = new EventNotificationMetadataAttribute("source"); + + ScenarioExpect.Equal(typeof(string), notification.EventType); + ScenarioExpect.Equal(typeof(Guid), notification.KeyType); + ScenarioExpect.Equal("BuildOrderAccepted", notification.FactoryMethodName); + ScenarioExpect.Equal("order-accepted", notification.NotificationName); + ScenarioExpect.Equal("source", metadata.Name); + ScenarioExpect.Throws(() => new GenerateEventNotificationAttribute(null!, typeof(Guid))); + ScenarioExpect.Throws(() => new GenerateEventNotificationAttribute(typeof(string), null!)); + ScenarioExpect.Throws(() => new EventNotificationMetadataAttribute("")); + ScenarioExpect.IsType(new EventNotificationKeyAttribute()); + ScenarioExpect.IsType(new EventNotificationCorrelationAttribute()); + ScenarioExpect.IsType(new EventNotificationRuleAttribute()); + } + [Scenario("Priority Queue Attributes Expose Defaults And Configuration")] [Fact] public void PriorityQueue_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/EventNotificationGeneratorTests.cs b/test/PatternKit.Generators.Tests/EventNotificationGeneratorTests.cs new file mode 100644 index 00000000..79563cf2 --- /dev/null +++ b/test/PatternKit.Generators.Tests/EventNotificationGeneratorTests.cs @@ -0,0 +1,107 @@ +using Microsoft.CodeAnalysis; +using PatternKit.EnterpriseIntegration.EventNotification; +using PatternKit.Generators.EventNotification; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Event Notification generator")] +public sealed partial class EventNotificationGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates event notification factory")] + [Fact] + public Task Generates_Event_Notification_Factory() + => Given("an event notification declaration", () => Compile(""" + using PatternKit.Generators.EventNotification; + namespace Demo; + public sealed record OrderAccepted(string OrderId, string CorrelationId, string Source, bool NotifySubscribers); + [GenerateEventNotification(typeof(OrderAccepted), typeof(string), FactoryMethodName = "Build", NotificationName = "order-accepted")] + public static partial class OrderAcceptedNotification + { + [EventNotificationRule] + private static bool ShouldNotify(OrderAccepted evt) => evt.NotifySubscribers; + [EventNotificationKey] + private static string Key(OrderAccepted evt) => evt.OrderId; + [EventNotificationCorrelation] + private static string Correlation(OrderAccepted evt) => evt.CorrelationId; + [EventNotificationMetadata("source")] + private static string Source(OrderAccepted evt) => evt.Source; + } + """)) + .Then("the generated source creates the configured notification", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("Build()", source); + ScenarioExpect.Contains("EventNotification.Create(\"order-accepted\")", source); + ScenarioExpect.Contains(".When(ShouldNotify)", source); + ScenarioExpect.Contains(".WithKey(Key)", source); + ScenarioExpect.Contains(".WithCorrelation(Correlation)", source); + ScenarioExpect.Contains(".WithMetadata(\"source\", Source)", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid event notification declarations")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Event_Notification_Declarations() + => Given("invalid event notification declarations", () => new[] + { + Compile(""" + using PatternKit.Generators.EventNotification; + [GenerateEventNotification(typeof(string), typeof(string))] + public static class NotificationHost; + """), + Compile(""" + using PatternKit.Generators.EventNotification; + [GenerateEventNotification(typeof(string), typeof(string))] + public static partial class NotificationHost; + """), + Compile(""" + using PatternKit.Generators.EventNotification; + [GenerateEventNotification(typeof(string), typeof(string))] + public static partial class NotificationHost + { + [EventNotificationKey] + private static int Key(string value) => value.Length; + } + """), + Compile(""" + using PatternKit.Generators.EventNotification; + [GenerateEventNotification(typeof(string), typeof(string))] + public static partial class NotificationHost + { + [EventNotificationKey] + private static string Key(string value) => value; + [EventNotificationMetadata("source")] + private static string Source(string value) => value; + [EventNotificationMetadata("SOURCE")] + private static string OtherSource(string value) => value; + } + """) + }) + .Then("diagnostics identify invalid declarations", results => + { + ScenarioExpect.Contains(results[0].Diagnostics, diagnostic => diagnostic.Id == "PKEN001"); + ScenarioExpect.Contains(results[1].Diagnostics, diagnostic => diagnostic.Id == "PKEN002"); + ScenarioExpect.Contains(results[2].Diagnostics, diagnostic => diagnostic.Id == "PKEN003"); + ScenarioExpect.Contains(results[3].Diagnostics, diagnostic => diagnostic.Id == "PKEN004"); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "EventNotificationGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(EventNotification<,>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new EventNotificationGenerator(), out var run, out var updated); + var result = run.Results.Single(); + var emit = updated.Emit(Stream.Null); + return new(result.Diagnostics.ToArray(), result.GeneratedSources.Select(static source => source.SourceText.ToString()).ToArray(), emit.Success, emit.Diagnostics.Select(static diagnostic => diagnostic.ToString()).ToArray()); + } + + private sealed record GeneratorResult(IReadOnlyList Diagnostics, IReadOnlyList GeneratedSources, bool EmitSuccess, IReadOnlyList EmitDiagnostics); +} diff --git a/test/PatternKit.Tests/EnterpriseIntegration/EventNotification/EventNotificationTests.cs b/test/PatternKit.Tests/EnterpriseIntegration/EventNotification/EventNotificationTests.cs new file mode 100644 index 00000000..1ef1eb1b --- /dev/null +++ b/test/PatternKit.Tests/EnterpriseIntegration/EventNotification/EventNotificationTests.cs @@ -0,0 +1,68 @@ +using PatternKit.EnterpriseIntegration.EventNotification; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.EnterpriseIntegration.EventNotification; + +[Feature("Event Notification")] +public sealed class EventNotificationTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Event notification publishes compact metadata")] + [Fact] + public Task Event_Notification_Publishes_Compact_Metadata() + => Given("an order event notification", CreateNotification) + .When("an accepted order event is notified", notification => notification.Notify(new OrderAccepted("O-100", "C-900", "web", true))) + .Then("subscribers receive the key correlation and compact metadata", result => + { + ScenarioExpect.True(result.Published); + ScenarioExpect.Equal("order-accepted", result.NotificationName); + ScenarioExpect.Equal("O-100", result.Key); + ScenarioExpect.Equal("C-900", result.CorrelationId); + ScenarioExpect.Equal("web", result.Metadata["source"]); + }) + .AssertPassed(); + + [Scenario("Event notification can skip events before publishing")] + [Fact] + public Task Event_Notification_Can_Skip_Events_Before_Publishing() + => Given("an order notification with a dispatch rule", CreateNotification) + .When("an event does not satisfy the rule", notification => notification.Notify(new OrderAccepted("O-100", "C-900", "web", false))) + .Then("the notification is skipped without failure", result => + { + ScenarioExpect.True(result.Skipped); + ScenarioExpect.False(result.Failed); + }) + .AssertPassed(); + + [Scenario("Event notification validates configuration")] + [Fact] + public Task Event_Notification_Validates_Configuration() + => Given("invalid notification configuration", () => true) + .Then("invalid names are rejected", _ => + ScenarioExpect.Throws(() => EventNotification.Create("") + .WithKey(static evt => evt.OrderId) + .Build())) + .And("missing key selectors are rejected", _ => + ScenarioExpect.Throws(() => EventNotification.Create().Build())) + .And("null callbacks are rejected", _ => + ScenarioExpect.Throws(() => EventNotification.Create().WithKey(null!))) + .And("duplicate metadata names are rejected", _ => + ScenarioExpect.Throws(() => EventNotification.Create() + .WithKey(static evt => evt.OrderId) + .WithMetadata("source", static evt => evt.Source) + .WithMetadata("SOURCE", static evt => evt.Source))) + .And("null events are rejected", _ => + ScenarioExpect.Throws(() => CreateNotification().Notify(null!))) + .AssertPassed(); + + private static EventNotification CreateNotification() + => EventNotification.Create("order-accepted") + .When(static evt => evt.NotifySubscribers) + .WithKey(static evt => evt.OrderId) + .WithCorrelation(static evt => evt.CorrelationId) + .WithMetadata("source", static evt => evt.Source) + .Build(); + + private sealed record OrderAccepted(string OrderId, string CorrelationId, string Source, bool NotifySubscribers); +}