From c6dc23844ec2fc41c5b7583cbad7b19c3d254aff Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Thu, 21 May 2026 15:43:11 -0500 Subject: [PATCH] feat: add message filter pattern support --- docs/examples/order-message-filter.md | 10 + docs/examples/toc.yml | 3 + docs/generators/index.md | 1 + docs/generators/message-filter.md | 15 ++ docs/generators/toc.yml | 3 + docs/guides/pattern-coverage.md | 1 + docs/patterns/messaging/message-filter.md | 13 + docs/patterns/toc.yml | 2 + .../Messaging/Routing/MessageFilter.cs | 125 ++++++++++ ...rnKitExampleServiceCollectionExtensions.cs | 11 + .../Messaging/OrderMessageFilterExample.cs | 76 ++++++ .../PatternKitExampleCatalog.cs | 8 + .../PatternKitPatternCatalog.cs | 13 + .../Messaging/MessageFilterAttributes.cs | 49 ++++ .../AnalyzerReleases.Unshipped.md | 4 + .../Messaging/MessageFilterGenerator.cs | 234 ++++++++++++++++++ ...tternKitExampleDependencyInjectionTests.cs | 2 + .../OrderMessageFilterExampleTests.cs | 79 ++++++ .../PatternKitPatternCatalogTests.cs | 3 +- .../AbstractionsAttributeCoverageTests.cs | 17 ++ .../MessageFilterGeneratorTests.cs | 173 +++++++++++++ .../Messaging/Routing/MessageFilterTests.cs | 81 ++++++ 22 files changed, 922 insertions(+), 1 deletion(-) create mode 100644 docs/examples/order-message-filter.md create mode 100644 docs/generators/message-filter.md create mode 100644 docs/patterns/messaging/message-filter.md create mode 100644 src/PatternKit.Core/Messaging/Routing/MessageFilter.cs create mode 100644 src/PatternKit.Examples/Messaging/OrderMessageFilterExample.cs create mode 100644 src/PatternKit.Generators.Abstractions/Messaging/MessageFilterAttributes.cs create mode 100644 src/PatternKit.Generators/Messaging/MessageFilterGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/Messaging/OrderMessageFilterExampleTests.cs create mode 100644 test/PatternKit.Generators.Tests/MessageFilterGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Messaging/Routing/MessageFilterTests.cs diff --git a/docs/examples/order-message-filter.md b/docs/examples/order-message-filter.md new file mode 100644 index 00000000..f084d6e1 --- /dev/null +++ b/docs/examples/order-message-filter.md @@ -0,0 +1,10 @@ +# Order Message Filter + +The order message-filter example screens fulfillment commands before downstream processing. It demonstrates: + +- a fluent `MessageFilter` for non-generator consumers +- a `[GenerateMessageFilter]` source-generated factory +- `IServiceCollection` registration through `AddOrderMessageFilterDemo()` +- aggregate import through `AddPatternKitExamples()` + +The example is implemented in `src/PatternKit.Examples/Messaging/OrderMessageFilterExample.cs` and covered by TinyBDD tests in `test/PatternKit.Examples.Tests/Messaging/OrderMessageFilterExampleTests.cs`. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index c8b3f4fc..22e90cb2 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -73,6 +73,9 @@ - name: Generated Splitter and Aggregator href: generated-splitter-aggregator.md +- name: Order Message Filter + href: order-message-filter.md + - name: Fulfillment Competing Consumers href: fulfillment-competing-consumers.md diff --git a/docs/generators/index.md b/docs/generators/index.md index bfb4829b..c1ef205a 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -85,6 +85,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**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]` | +| [**Message Filter**](message-filter.md) | Named allow-rule filters for message consumers | `[GenerateMessageFilter]` | | [**Recipient List**](messaging.md#generated-recipient-list) | Recipient fan-out factories | `[GenerateRecipientList]` | | [**Splitter / Aggregator**](messaging.md#generated-splitter-and-aggregator) | Split/rejoin message routing factories | `[GenerateSplitter]` / `[GenerateAggregator]` | | [**Routing Slip**](messaging.md#generated-routing-slip) | Ordered message itinerary factories | `[GenerateRoutingSlip]` | diff --git a/docs/generators/message-filter.md b/docs/generators/message-filter.md new file mode 100644 index 00000000..ab12b2e6 --- /dev/null +++ b/docs/generators/message-filter.md @@ -0,0 +1,15 @@ +# Message Filter Generator + +`[GenerateMessageFilter]` creates a typed `MessageFilter` factory from ordered static predicate methods. + +```csharp +[GenerateMessageFilter(typeof(OrderCommand), FactoryName = "Create", FilterName = "order-fraud-screen")] +public static partial class GeneratedOrderFilter +{ + [MessageFilterRule("trusted-customer", 10)] + private static bool IsTrusted(Message message, MessageContext context) + => message.Payload.CustomerTier == "trusted"; +} +``` + +Rules must be static methods returning `bool` with `(Message, MessageContext)` parameters. Duplicate rule names or orders are reported at compile time. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 0b5a8066..9d39e822 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -91,6 +91,9 @@ - name: Claim Check href: claim-check.md +- name: Message Filter + href: message-filter.md + - name: Competing Consumers href: competing-consumers.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 041b5bf5..ad8248bc 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -49,6 +49,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Enterprise Integration | Claim Check | `ClaimCheck` | Claim Check generator | | Enterprise Integration | Dead Letter Channel | `DeadLetterChannel` | Dead Letter Channel generator | | Enterprise Integration | Content-Based Router | `ContentRouter` | Messaging generator | +| Enterprise Integration | Message Filter | `MessageFilter` | Message Filter generator | | Enterprise Integration | Recipient List | `RecipientList` | Messaging generator | | Enterprise Integration | Competing Consumers | `CompetingConsumerGroup` | Competing Consumers generator | | Enterprise Integration | Pipes and Filters | `PipesAndFiltersPipeline` | Pipes and Filters generator | diff --git a/docs/patterns/messaging/message-filter.md b/docs/patterns/messaging/message-filter.md new file mode 100644 index 00000000..8af5c90a --- /dev/null +++ b/docs/patterns/messaging/message-filter.md @@ -0,0 +1,13 @@ +# Message Filter + +Use `MessageFilter` when a consumer should only receive messages that satisfy explicit allow rules. The fluent API keeps filtering logic named and testable, while the result exposes the matched rule or rejection reason for observability. + +```csharp +var filter = MessageFilter.Create("order-fraud-screen") + .AllowWhen("trusted-customer", (message, _) => message.Payload.CustomerTier == "trusted") + .AllowWhen("verified-low-value", (message, _) => message.Payload.Total <= 100m) + .RejectUnmatched("Order requires fraud review before fulfillment.") + .Build(); +``` + +For generated factories, annotate a partial type with `[GenerateMessageFilter]` and mark static predicates with `[MessageFilterRule]`. Import production examples through `AddOrderMessageFilterDemo()` or the aggregate `AddPatternKitExamples()` registration. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 2d455f30..50c99fa2 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -303,6 +303,8 @@ href: messaging/claim-check.md - name: Dead Letter Channel href: messaging/dead-letter-channel.md + - name: Message Filter + href: messaging/message-filter.md - name: Enterprise Message Routing href: messaging/message-routing.md - name: Competing Consumers diff --git a/src/PatternKit.Core/Messaging/Routing/MessageFilter.cs b/src/PatternKit.Core/Messaging/Routing/MessageFilter.cs new file mode 100644 index 00000000..4c35a024 --- /dev/null +++ b/src/PatternKit.Core/Messaging/Routing/MessageFilter.cs @@ -0,0 +1,125 @@ +namespace PatternKit.Messaging.Routing; + +/// +/// Message filter that accepts messages matching at least one named rule and rejects the rest. +/// +public sealed class MessageFilter +{ + /// Predicate used to decide whether a message should pass through the filter. + public delegate bool FilterPredicate(Message message, MessageContext context); + + private readonly string _name; + private readonly FilterRule[] _rules; + private readonly string _rejectionReason; + + private MessageFilter(string name, FilterRule[] rules, string rejectionReason) + => (_name, _rules, _rejectionReason) = (name, rules, rejectionReason); + + /// Filters and returns whether it should continue downstream. + public MessageFilterResult Filter(Message message, MessageContext? context = null) + { + if (message is null) + throw new ArgumentNullException(nameof(message)); + + var effectiveContext = context ?? MessageContext.From(message); + foreach (var rule in _rules) + { + if (rule.Predicate(message, effectiveContext)) + return MessageFilterResult.Accept(message, _name, rule.Name); + } + + return MessageFilterResult.Reject(message, _name, _rejectionReason); + } + + /// Creates a new message filter builder. + public static Builder Create(string name = "message-filter") => new(name); + + /// Fluent builder for . + public sealed class Builder + { + private readonly string _name; + private readonly List _rules = new(4); + private string _rejectionReason = "Message did not match any allow rule."; + + internal Builder(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Message filter name cannot be null, empty, or whitespace.", nameof(name)); + + _name = name; + } + + /// Adds a named rule that allows matching messages through the filter. + public Builder AllowWhen(string name, FilterPredicate predicate) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Message filter rule name cannot be null, empty, or whitespace.", nameof(name)); + if (predicate is null) + throw new ArgumentNullException(nameof(predicate)); + + _rules.Add(new FilterRule(name, predicate)); + return this; + } + + /// Configures the reason returned when no allow rule matches. + public Builder RejectUnmatched(string reason) + { + if (string.IsNullOrWhiteSpace(reason)) + throw new ArgumentException("Message filter rejection reason cannot be null, empty, or whitespace.", nameof(reason)); + + _rejectionReason = reason; + return this; + } + + /// Builds an immutable message filter. + public MessageFilter Build() + { + if (_rules.Count == 0) + throw new InvalidOperationException("Message filter must have at least one allow rule."); + + return new MessageFilter(_name, _rules.ToArray(), _rejectionReason); + } + } + + private sealed class FilterRule + { + public FilterRule(string name, FilterPredicate predicate) + => (Name, Predicate) = (name, predicate); + + public string Name { get; } + + public FilterPredicate Predicate { get; } + } +} + +/// +/// Result returned by . +/// +public sealed class MessageFilterResult +{ + private MessageFilterResult(Message message, string filterName, string? ruleName, bool accepted, string? rejectionReason) + => (Message, FilterName, RuleName, Accepted, RejectionReason) = (message, filterName, ruleName, accepted, rejectionReason); + + /// The original message evaluated by the filter. + public Message Message { get; } + + /// The name of the filter that evaluated the message. + public string FilterName { get; } + + /// The name of the allow rule that matched, or null when rejected. + public string? RuleName { get; } + + /// True when the message should continue downstream. + public bool Accepted { get; } + + /// Human-readable rejection reason when the message is rejected. + public string? RejectionReason { get; } + + /// Creates an accepted result. + public static MessageFilterResult Accept(Message message, string filterName, string ruleName) + => new(message, filterName, ruleName, true, null); + + /// Creates a rejected result. + public static MessageFilterResult Reject(Message message, string filterName, string rejectionReason) + => new(message, filterName, null, false, rejectionReason); +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 6484d742..caa755f5 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -126,6 +126,7 @@ public sealed record GeneratedClaimCheckExample(LargeDocumentClaimCheckExampleRu public sealed record GeneratedDeadLetterChannelExample(FulfillmentDeadLetterChannelExampleRunner Runner, FulfillmentDeadLetterWorkflow Workflow); public sealed record GeneratedRecipientListExample(RecipientListGeneratorExampleRunner Runner); public sealed record GeneratedSplitterAggregatorExample(MessageRoutingExampleRunner Runner); +public sealed record OrderMessageFilterExampleService(MessageFilter Filter, OrderMessageFilterService Service); public sealed record FulfillmentCompetingConsumersExampleService(CompetingConsumerGroup Group, FulfillmentCompetingConsumerService Service); public sealed record FulfillmentPipesAndFiltersExampleService(PipesAndFiltersPipeline Pipeline, FulfillmentPipesAndFiltersService Service); public sealed record PatternsShowcaseExample(ShowcaseFacade Facade); @@ -197,6 +198,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddGeneratedDeadLetterChannelExample() .AddGeneratedRecipientListExample() .AddGeneratedSplitterAggregatorExample() + .AddOrderMessageFilterExample() .AddFulfillmentCompetingConsumersExample() .AddFulfillmentPipesAndFiltersExample() .AddPatternsShowcaseExample() @@ -482,6 +484,15 @@ public static IServiceCollection AddGeneratedSplitterAggregatorExample(this ISer return services.RegisterExample("Generated Splitter and Aggregator", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddOrderMessageFilterExample(this IServiceCollection services) + { + services.AddOrderMessageFilterDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService())); + return services.RegisterExample("Order Message Filter", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); + } + public static IServiceCollection AddFulfillmentCompetingConsumersExample(this IServiceCollection services) { services.AddFulfillmentCompetingConsumersDemo(); diff --git a/src/PatternKit.Examples/Messaging/OrderMessageFilterExample.cs b/src/PatternKit.Examples/Messaging/OrderMessageFilterExample.cs new file mode 100644 index 00000000..d6ba1ccc --- /dev/null +++ b/src/PatternKit.Examples/Messaging/OrderMessageFilterExample.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; +using PatternKit.Messaging.Routing; + +namespace PatternKit.Examples.Messaging; + +/// Order intake command used by the message-filter example. +public sealed record OrderMessageFilterCommand(string OrderId, string CustomerTier, decimal Total, bool PaymentVerified); + +/// Summary returned by the order message-filter example. +public sealed record OrderMessageFilterSummary(bool Accepted, string? RuleName, string? RejectionReason); + +/// +/// Service that applies an importable message filter before an order moves into fulfillment. +/// +public sealed class OrderMessageFilterService(MessageFilter filter) +{ + public OrderMessageFilterSummary Screen(OrderMessageFilterCommand command) + { + var result = filter.Filter(Message.Create(command)); + return new OrderMessageFilterSummary(result.Accepted, result.RuleName, result.RejectionReason); + } +} + +/// Fluent message-filter builder used by applications that do not enable generators. +public static class OrderMessageFilters +{ + public static MessageFilter CreateFraudScreen() + => MessageFilter.Create("order-fraud-screen") + .AllowWhen("trusted-customer", static (m, _) => m.Payload.CustomerTier == "trusted" && m.Payload.PaymentVerified) + .AllowWhen("verified-low-value", static (m, _) => m.Payload.PaymentVerified && m.Payload.Total <= 100m) + .RejectUnmatched("Order requires fraud review before fulfillment.") + .Build(); +} + +/// Source-generated message-filter rules for order fraud screening. +[GenerateMessageFilter( + typeof(OrderMessageFilterCommand), + FactoryName = "Create", + FilterName = "order-fraud-screen", + RejectionReason = "Order requires fraud review before fulfillment.")] +public static partial class GeneratedOrderMessageFilter +{ + [MessageFilterRule("trusted-customer", 10)] + private static bool IsTrustedCustomer(Message message, MessageContext context) + => message.Payload.CustomerTier == "trusted" && message.Payload.PaymentVerified; + + [MessageFilterRule("verified-low-value", 20)] + private static bool IsVerifiedLowValue(Message message, MessageContext context) + => message.Payload.PaymentVerified && message.Payload.Total <= 100m; +} + +/// Runner that demonstrates both fluent and generated message-filter paths. +public sealed class OrderMessageFilterExampleRunner(OrderMessageFilterService service) +{ + public OrderMessageFilterSummary RunGenerated(OrderMessageFilterCommand command) => service.Screen(command); + + public static OrderMessageFilterSummary RunFluent(OrderMessageFilterCommand command) + { + var result = OrderMessageFilters.CreateFraudScreen().Filter(Message.Create(command)); + return new OrderMessageFilterSummary(result.Accepted, result.RuleName, result.RejectionReason); + } +} + +/// DI helpers for importing the order message-filter example into standard .NET containers. +public static class OrderMessageFilterExampleServiceCollectionExtensions +{ + public static IServiceCollection AddOrderMessageFilterDemo(this IServiceCollection services) + { + services.AddSingleton(_ => GeneratedOrderMessageFilter.Create()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index d883eaf7..1fa57341 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -280,6 +280,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, ["Splitter", "Aggregator"], ["split/rejoin routing", "source-generated factories", "DI composition"]), + Descriptor( + "Order Message Filter", + "src/PatternKit.Examples/Messaging/OrderMessageFilterExample.cs", + "test/PatternKit.Examples.Tests/Messaging/OrderMessageFilterExampleTests.cs", + "docs/examples/order-message-filter.md", + ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, + ["MessageFilter"], + ["fraud-screening allow rules", "source-generated filter", "DI composition"]), Descriptor( "Fulfillment Competing Consumers", "src/PatternKit.Examples/Messaging/FulfillmentCompetingConsumersExample.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 5ddacf5b..6923083c 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -428,6 +428,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/Messaging/ContentRouterGeneratorExampleTests.cs", ["fluent content router", "generated content router", "message routing example"]), + Pattern("Message Filter", PatternFamily.EnterpriseIntegration, + "docs/patterns/messaging/message-filter.md", + "src/PatternKit.Core/Messaging/Routing/MessageFilter.cs", + "test/PatternKit.Tests/Messaging/Routing/MessageFilterTests.cs", + "docs/generators/message-filter.md", + "src/PatternKit.Generators/Messaging/MessageFilterGenerator.cs", + "test/PatternKit.Generators.Tests/MessageFilterGeneratorTests.cs", + null, + "docs/examples/order-message-filter.md", + "src/PatternKit.Examples/Messaging/OrderMessageFilterExample.cs", + "test/PatternKit.Examples.Tests/Messaging/OrderMessageFilterExampleTests.cs", + ["fluent message filter", "generated allow-rule filter", "DI-importable order fraud-screening example"]), + Pattern("Recipient List", PatternFamily.EnterpriseIntegration, "docs/patterns/messaging/message-routing.md", "src/PatternKit.Core/Messaging/Routing/RecipientList.cs", diff --git a/src/PatternKit.Generators.Abstractions/Messaging/MessageFilterAttributes.cs b/src/PatternKit.Generators.Abstractions/Messaging/MessageFilterAttributes.cs new file mode 100644 index 00000000..2b2789dc --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Messaging/MessageFilterAttributes.cs @@ -0,0 +1,49 @@ +using System; + +namespace PatternKit.Generators.Messaging; + +/// +/// Generates a typed message-filter factory for a partial class or struct. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateMessageFilterAttribute : Attribute +{ + /// Creates a message-filter generator attribute. + public GenerateMessageFilterAttribute(Type payloadType) + => PayloadType = payloadType ?? throw new ArgumentNullException(nameof(payloadType)); + + /// Payload type filtered by the generated message filter. + public Type PayloadType { get; } + + /// Name of the generated factory method. + public string FactoryName { get; set; } = "Create"; + + /// Name assigned to the generated message filter. + public string FilterName { get; set; } = "message-filter"; + + /// Reason returned when no allow rule matches. + public string RejectionReason { get; set; } = "Message did not match any allow rule."; +} + +/// +/// Marks a static predicate as an allow rule in a generated message filter. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class MessageFilterRuleAttribute : Attribute +{ + /// Creates a message-filter allow rule attribute. + public MessageFilterRuleAttribute(string name, int order) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Message filter rule name cannot be null, empty, or whitespace.", nameof(name)); + + Name = name; + Order = order; + } + + /// Rule name used for generated metadata and diagnostics. + public string Name { get; } + + /// Rule order in the generated message filter. + public int Order { get; } +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 919edeba..dc043be3 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -284,3 +284,7 @@ PKSA006 | PatternKit.Generators.Messaging | Error | Generated aggregator duplica PKCNS001 | PatternKit.Generators.Messaging | Error | Competing Consumers host must be partial. PKCNS002 | PatternKit.Generators.Messaging | Error | Competing Consumers configuration is invalid. PKPF001 | PatternKit.Generators.Messaging | Error | Pipes and Filters host must be partial. +PKMF001 | PatternKit.Generators.Messaging | Error | Message filter host type must be partial. +PKMF002 | PatternKit.Generators.Messaging | Error | Message filter must declare at least one rule. +PKMF003 | PatternKit.Generators.Messaging | Error | Message filter rule method signature is invalid. +PKMF004 | PatternKit.Generators.Messaging | Error | Message filter rule name or order is duplicated. diff --git a/src/PatternKit.Generators/Messaging/MessageFilterGenerator.cs b/src/PatternKit.Generators/Messaging/MessageFilterGenerator.cs new file mode 100644 index 00000000..24e9d2c3 --- /dev/null +++ b/src/PatternKit.Generators/Messaging/MessageFilterGenerator.cs @@ -0,0 +1,234 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.Messaging; + +[Generator] +public sealed class MessageFilterGenerator : IIncrementalGenerator +{ + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKMF001", + "Message filter type must be partial", + "Type '{0}' is marked with [GenerateMessageFilter] but is not declared as partial", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingRules = new( + "PKMF002", + "Message filter has no rules", + "Type '{0}' is marked with [GenerateMessageFilter] but does not declare any [MessageFilterRule] methods", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidRule = new( + "PKMF003", + "Message filter rule signature is invalid", + "Message filter rule '{0}' must be static and return bool with Message and MessageContext parameters", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor DuplicateRule = new( + "PKMF004", + "Message filter rule name or order is duplicated", + "Message filter rule '{0}' duplicates another rule name or order in '{1}'", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + "PatternKit.Generators.Messaging.GenerateMessageFilterAttribute", + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.GenerateMessageFilterAttribute"); + if (attr is null) + return; + + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate( + SourceProductionContext context, + INamedTypeSymbol type, + TypeDeclarationSyntax node, + AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var payloadType = attribute.ConstructorArguments.Length >= 1 + ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol + : null; + if (payloadType is null) + return; + + var hasRuleAttributes = type.GetMembers().OfType().Any(static method => + method.GetAttributes().Any(static attr => + attr.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.MessageFilterRuleAttribute")); + var rules = GetRules(type, payloadType, context); + if (rules.Length == 0) + { + if (!hasRuleAttributes) + context.ReportDiagnostic(Diagnostic.Create(MissingRules, node.Identifier.GetLocation(), type.Name)); + return; + } + + if (HasDuplicates(rules, out var duplicate)) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateRule, duplicate.Location, duplicate.Name, type.Name)); + return; + } + + var factoryName = GetNamedString(attribute, "FactoryName") ?? "Create"; + var filterName = GetNamedString(attribute, "FilterName") ?? "message-filter"; + var rejectionReason = GetNamedString(attribute, "RejectionReason") ?? "Message did not match any allow rule."; + var ordered = rules.OrderBy(static rule => rule.Order).ThenBy(static rule => rule.Name).ToArray(); + + context.AddSource($"{type.Name}.MessageFilter.g.cs", SourceText.From( + GenerateSource(type, payloadType, ordered, factoryName, filterName, rejectionReason), + Encoding.UTF8)); + } + + private static ImmutableArray GetRules( + INamedTypeSymbol type, + INamedTypeSymbol payloadType, + SourceProductionContext context) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var method in type.GetMembers().OfType()) + { + var attr = method.GetAttributes().FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.MessageFilterRuleAttribute"); + if (attr is null) + continue; + + if (!TryGetRule(method, payloadType, attr, out var rule)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidRule, method.Locations.FirstOrDefault(), method.Name)); + continue; + } + + builder.Add(rule); + } + + return builder.ToImmutable(); + } + + private static bool TryGetRule( + IMethodSymbol method, + INamedTypeSymbol payloadType, + AttributeData attribute, + out Rule rule) + { + rule = default; + if (!IsRule(method, payloadType) || attribute.ConstructorArguments.Length != 2) + return false; + + var name = attribute.ConstructorArguments[0].Value as string; + var order = attribute.ConstructorArguments[1].Value as int? ?? 0; + if (string.IsNullOrWhiteSpace(name)) + return false; + + rule = new Rule(name!, order, method.Name, method.Locations.FirstOrDefault()); + return true; + } + + private static bool IsRule(IMethodSymbol method, INamedTypeSymbol payloadType) + => method.IsStatic && + method.ReturnType.SpecialType == SpecialType.System_Boolean && + method.Parameters.Length == 2 && + IsMessageOfPayload(method.Parameters[0].Type, payloadType) && + method.Parameters[1].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext"; + + private static bool IsMessageOfPayload(ITypeSymbol type, INamedTypeSymbol payloadType) + => type is INamedTypeSymbol named && + named.ConstructedFrom.ToDisplayString() == "PatternKit.Messaging.Message" && + SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], payloadType); + + private static bool HasDuplicates(IReadOnlyList rules, out Rule duplicate) + { + var names = new HashSet(System.StringComparer.Ordinal); + var orders = new HashSet(); + foreach (var rule in rules) + { + if (!names.Add(rule.Name) || !orders.Add(rule.Order)) + { + duplicate = rule; + return true; + } + } + + duplicate = default; + return false; + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol payloadType, + IReadOnlyList rules, + string factoryName, + string filterName, + string rejectionReason) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append("partial ").Append(GetKind(type)).Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Messaging.Routing.MessageFilter<") + .Append(payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append("> ") + .Append(factoryName) + .AppendLine("()"); + sb.Append(" => global::PatternKit.Messaging.Routing.MessageFilter<") + .Append(payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append(">.Create(") + .Append(ToLiteral(filterName)) + .AppendLine(")"); + + foreach (var rule in rules) + sb.Append(" .AllowWhen(").Append(ToLiteral(rule.Name)).Append(", ").Append(rule.MethodName).AppendLine(")"); + + sb.Append(" .RejectUnmatched(").Append(ToLiteral(rejectionReason)).AppendLine(")"); + sb.AppendLine(" .Build();"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string GetKind(INamedTypeSymbol type) + => type.TypeKind == TypeKind.Struct ? "struct" : "class"; + + 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("\"", "\"\"") + "\""; + + private readonly record struct Rule(string Name, int Order, string MethodName, Location? Location); +} diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index 81eb3ec5..e937f37b 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -102,6 +102,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var generatedRecipients = provider.GetRequiredService(); var competingConsumers = provider.GetRequiredService(); var pipesAndFilters = provider.GetRequiredService(); + var messageFilter = provider.GetRequiredService(); var generatedTranslator = provider.GetRequiredService(); var generatedClaimCheck = provider.GetRequiredService(); var generatedDeadLetters = provider.GetRequiredService(); @@ -188,6 +189,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("generated recipient list delivers billing and audit recipients", generatedRecipientList.DeliveredRecipients.Count == 2), ("generated competing consumers dispatch fulfillment work", competingConsumers.Service.DispatchAsync(new FulfillmentConsumerWork("ORDER-CC", "central")).GetAwaiter().GetResult().Accepted), ("generated pipes and filters publish fulfillment work", pipesAndFilters.Service.ProcessAsync("ORDER-PF").GetAwaiter().GetResult().Value.Published), + ("generated message filter screens trusted orders", messageFilter.Service.Screen(new("ORDER-MF", "trusted", 250m, true)).Accepted), ("message envelope example tracks first attempt", envelope.Run().Attempt == 1), ("CQRS fluent path matches command writes to query reads", cqrsFluent.QueryMatchedCommand), ("CQRS generated path matches command writes to query reads", cqrsGenerated.QueryMatchedCommand), diff --git a/test/PatternKit.Examples.Tests/Messaging/OrderMessageFilterExampleTests.cs b/test/PatternKit.Examples.Tests/Messaging/OrderMessageFilterExampleTests.cs new file mode 100644 index 00000000..721976ab --- /dev/null +++ b/test/PatternKit.Examples.Tests/Messaging/OrderMessageFilterExampleTests.cs @@ -0,0 +1,79 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.Messaging; +using PatternKit.Messaging; +using PatternKit.Messaging.Routing; +using TinyBDD; + +namespace PatternKit.Examples.Tests.Messaging; + +public sealed class OrderMessageFilterExampleTests +{ + [Scenario("FluentMessageFilter AcceptsTrustedOrLowValueOrders")] + [Theory] + [InlineData("trusted", 250, true, true, "trusted-customer")] + [InlineData("guest", 75, true, true, "verified-low-value")] + [InlineData("guest", 250, true, false, null)] + [InlineData("trusted", 250, false, false, null)] + public void FluentMessageFilter_AcceptsTrustedOrLowValueOrders( + string tier, + decimal total, + bool paymentVerified, + bool expectedAccepted, + string? expectedRule) + { + var summary = OrderMessageFilterExampleRunner.RunFluent(new("order-1", tier, total, paymentVerified)); + + ScenarioExpect.Equal(expectedAccepted, summary.Accepted); + ScenarioExpect.Equal(expectedRule, summary.RuleName); + ScenarioExpect.Equal(expectedAccepted ? null : "Order requires fraud review before fulfillment.", summary.RejectionReason); + } + + [Scenario("GeneratedMessageFilter MatchesFluentFilterBehavior")] + [Fact] + public void GeneratedMessageFilter_MatchesFluentFilterBehavior() + { + var command = new OrderMessageFilterCommand("order-1", "guest", 75m, true); + + var fluent = OrderMessageFilterExampleRunner.RunFluent(command); + var generated = GeneratedOrderMessageFilter.Create().Filter(Message.Create(command)); + + ScenarioExpect.True(generated.Accepted); + ScenarioExpect.Equal(fluent.RuleName, generated.RuleName); + } + + [Scenario("ServiceCollection ImportsMessageFilterExample")] + [Fact] + public void ServiceCollection_ImportsMessageFilterExample() + { + var services = new ServiceCollection(); + services.AddOrderMessageFilterDemo(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var filter = provider.GetRequiredService>(); + var runner = provider.GetRequiredService(); + + var direct = filter.Filter(Message.Create(new("order-1", "trusted", 250m, true))); + var summary = runner.RunGenerated(new("order-2", "guest", 250m, true)); + + ScenarioExpect.True(direct.Accepted); + ScenarioExpect.False(summary.Accepted); + ScenarioExpect.Equal("Order requires fraud review before fulfillment.", summary.RejectionReason); + } + + [Scenario("AggregateServiceCollection ImportsMessageFilterExample")] + [Fact] + public void AggregateServiceCollection_ImportsMessageFilterExample() + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var example = provider.GetRequiredService(); + + var summary = example.Service.Screen(new("order-1", "trusted", 250m, true)); + + ScenarioExpect.True(summary.Accepted); + ScenarioExpect.Equal("trusted-customer", summary.RuleName); + } +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index c00bf2c2..4ccc2e03 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -43,6 +43,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Claim Check", "Dead Letter Channel", "Content-Based Router", + "Message Filter", "Recipient List", "Competing Consumers", "Pipes and Filters", @@ -117,7 +118,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(15, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); + ScenarioExpect.Equal(16, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(6, 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 43cf089b..78564c3e 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -137,6 +137,8 @@ private enum TestTrigger { typeof(GenerateContentRouterAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(ContentRouteAttribute), AttributeTargets.Method, false, false }, { typeof(ContentRouteDefaultAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateMessageFilterAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(MessageFilterRuleAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateClaimCheckAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(ClaimCheckStoreFactoryAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateDeadLetterChannelAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, @@ -720,6 +722,13 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf FactoryName = "BuildRouter" }; var route = new ContentRouteAttribute("priority", 4, "IsPriority"); + var messageFilter = new GenerateMessageFilterAttribute(typeof(string)) + { + FactoryName = "BuildFilter", + FilterName = "orders", + RejectionReason = "manual review" + }; + var messageFilterRule = new MessageFilterRuleAttribute("trusted", 9); var claimCheck = new GenerateClaimCheckAttribute(typeof(string)) { FactoryName = "BuildClaimCheck", @@ -839,6 +848,12 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Equal("priority", route.Name); ScenarioExpect.Equal(4, route.Order); ScenarioExpect.Equal("IsPriority", route.PredicateMethodName); + ScenarioExpect.Equal(typeof(string), messageFilter.PayloadType); + ScenarioExpect.Equal("BuildFilter", messageFilter.FactoryName); + ScenarioExpect.Equal("orders", messageFilter.FilterName); + ScenarioExpect.Equal("manual review", messageFilter.RejectionReason); + ScenarioExpect.Equal("trusted", messageFilterRule.Name); + ScenarioExpect.Equal(9, messageFilterRule.Order); ScenarioExpect.Equal(typeof(string), claimCheck.PayloadType); ScenarioExpect.Equal("BuildClaimCheck", claimCheck.FactoryName); ScenarioExpect.Equal("documents", claimCheck.ClaimCheckName); @@ -911,6 +926,8 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Throws(() => new GenerateContentRouterAttribute(typeof(string), null!)); ScenarioExpect.Throws(() => new ContentRouteAttribute("", 1, "Predicate")); ScenarioExpect.Throws(() => new ContentRouteAttribute("name", 1, "")); + ScenarioExpect.Throws(() => new GenerateMessageFilterAttribute(null!)); + ScenarioExpect.Throws(() => new MessageFilterRuleAttribute("", 1)); ScenarioExpect.Throws(() => new GenerateClaimCheckAttribute(null!)); ScenarioExpect.IsType(new ClaimCheckStoreFactoryAttribute()); ScenarioExpect.Throws(() => new GenerateDeadLetterChannelAttribute(null!)); diff --git a/test/PatternKit.Generators.Tests/MessageFilterGeneratorTests.cs b/test/PatternKit.Generators.Tests/MessageFilterGeneratorTests.cs new file mode 100644 index 00000000..5ea2db35 --- /dev/null +++ b/test/PatternKit.Generators.Tests/MessageFilterGeneratorTests.cs @@ -0,0 +1,173 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.Messaging; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class MessageFilterGeneratorTests +{ + [Scenario("GeneratesMessageFilterFactory")] + [Fact] + public void GeneratesMessageFilterFactory() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + + namespace MyApp; + + public sealed record Order(string Channel, decimal Total); + + [GenerateMessageFilter(typeof(Order), FactoryName = "Build", FilterName = "orders", RejectionReason = "manual review")] + public static partial class OrderFilter + { + [MessageFilterRule("low-value", 20)] + private static bool IsLowValue(Message message, MessageContext context) + => message.Payload.Total < 100m; + + [MessageFilterRule("trusted-channel", 10)] + private static bool IsTrustedChannel(Message message, MessageContext context) + => message.Payload.Channel == "trusted"; + } + + public static class Demo + { + public static bool Run() + => OrderFilter.Build().Filter(Message.Create(new Order("trusted", 250m))).Accepted; + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesMessageFilterFactory)); + var gen = new MessageFilterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + ScenarioExpect.Equal("OrderFilter.MessageFilter.g.cs", generated.HintName); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("MessageFilter", text); + ScenarioExpect.Contains(".AllowWhen(@\"trusted-channel\", IsTrustedChannel)", text); + ScenarioExpect.Contains(".AllowWhen(@\"low-value\", IsLowValue)", text); + ScenarioExpect.True(text.IndexOf("trusted-channel", StringComparison.Ordinal) < text.IndexOf("low-value", StringComparison.Ordinal)); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("ReportsDiagnosticForNonPartialFilter")] + [Fact] + public void ReportsDiagnosticForNonPartialFilter() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + + namespace MyApp; + + public sealed record Order(string Channel); + + [GenerateMessageFilter(typeof(Order))] + public static class OrderFilter + { + [MessageFilterRule("trusted", 10)] + private static bool Trusted(Message message, MessageContext context) => true; + } + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForNonPartialFilter)); + var gen = new MessageFilterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKMF001", diagnostic.Id); + } + + [Scenario("ReportsDiagnosticForMissingRules")] + [Fact] + public void ReportsDiagnosticForMissingRules() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record Order(string Channel); + + [GenerateMessageFilter(typeof(Order))] + public static partial class OrderFilter; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForMissingRules)); + var gen = new MessageFilterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKMF002", diagnostic.Id); + } + + [Scenario("ReportsDiagnosticForInvalidRuleSignature")] + [Fact] + public void ReportsDiagnosticForInvalidRuleSignature() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + + namespace MyApp; + + public sealed record Order(string Channel); + + [GenerateMessageFilter(typeof(Order))] + public static partial class OrderFilter + { + [MessageFilterRule("trusted", 10)] + private static string Trusted(Message message, MessageContext context) => "yes"; + } + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForInvalidRuleSignature)); + var gen = new MessageFilterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKMF003", diagnostic.Id); + } + + [Scenario("ReportsDiagnosticForDuplicateRuleNameOrOrder")] + [Fact] + public void ReportsDiagnosticForDuplicateRuleNameOrOrder() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + + namespace MyApp; + + public sealed record Order(string Channel); + + [GenerateMessageFilter(typeof(Order))] + public static partial class OrderFilter + { + [MessageFilterRule("trusted", 10)] + private static bool Trusted(Message message, MessageContext context) => true; + + [MessageFilterRule("guest", 10)] + private static bool Guest(Message message, MessageContext context) => true; + } + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForDuplicateRuleNameOrOrder)); + var gen = new MessageFilterGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKMF004", diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: MetadataReference.CreateFromFile(typeof(PatternKit.Messaging.Message<>).Assembly.Location)); +} diff --git a/test/PatternKit.Tests/Messaging/Routing/MessageFilterTests.cs b/test/PatternKit.Tests/Messaging/Routing/MessageFilterTests.cs new file mode 100644 index 00000000..4beaae0a --- /dev/null +++ b/test/PatternKit.Tests/Messaging/Routing/MessageFilterTests.cs @@ -0,0 +1,81 @@ +using PatternKit.Messaging; +using PatternKit.Messaging.Routing; +using TinyBDD; + +namespace PatternKit.Tests.Messaging.Routing; + +public sealed class MessageFilterTests +{ + [Scenario("Filter AcceptsFirstMatchingAllowRule")] + [Fact] + public void Filter_AcceptsFirstMatchingAllowRule() + { + var filter = MessageFilter.Create("fraud-screen") + .AllowWhen("trusted-customer", static (m, _) => m.Payload.CustomerTier == "trusted") + .AllowWhen("low-value", static (m, _) => m.Payload.Total < 100m) + .Build(); + + var result = filter.Filter(Message.Create(new Order("o-1", "trusted", 250m))); + + ScenarioExpect.True(result.Accepted); + ScenarioExpect.Equal("fraud-screen", result.FilterName); + ScenarioExpect.Equal("trusted-customer", result.RuleName); + ScenarioExpect.Null(result.RejectionReason); + ScenarioExpect.Equal("o-1", result.Message.Payload.Id); + } + + [Scenario("Filter RejectsUnmatchedMessagesWithConfiguredReason")] + [Fact] + public void Filter_RejectsUnmatchedMessagesWithConfiguredReason() + { + var filter = MessageFilter.Create() + .AllowWhen("low-value", static (m, _) => m.Payload.Total < 100m) + .RejectUnmatched("Manual fraud review required.") + .Build(); + + var result = filter.Filter(Message.Create(new Order("o-1", "guest", 250m))); + + ScenarioExpect.False(result.Accepted); + ScenarioExpect.Null(result.RuleName); + ScenarioExpect.Equal("Manual fraud review required.", result.RejectionReason); + } + + [Scenario("Filter PassesContextToAllowRules")] + [Fact] + public void Filter_PassesContextToAllowRules() + { + var filter = MessageFilter.Create() + .AllowWhen("tenant-allow-list", static (_, ctx) => ctx.Headers.CorrelationId == "tenant-a") + .Build(); + var context = new MessageContext(MessageHeaders.Empty.WithCorrelationId("tenant-a")); + + var result = filter.Filter(Message.Create(new Order("o-1", "guest", 250m)), context); + + ScenarioExpect.True(result.Accepted); + ScenarioExpect.Equal("tenant-allow-list", result.RuleName); + } + + [Scenario("Builder RejectsInvalidConfiguration")] + [Fact] + public void Builder_RejectsInvalidConfiguration() + { + ScenarioExpect.Throws(() => MessageFilter.Create("")); + ScenarioExpect.Throws(() => MessageFilter.Create().AllowWhen("", static (_, _) => true)); + ScenarioExpect.Throws(() => MessageFilter.Create().AllowWhen("valid", null!)); + ScenarioExpect.Throws(() => MessageFilter.Create().RejectUnmatched("")); + ScenarioExpect.Throws(() => MessageFilter.Create().Build()); + } + + [Scenario("Filter RejectsNullMessage")] + [Fact] + public void Filter_RejectsNullMessage() + { + var filter = MessageFilter.Create() + .AllowWhen("all", static (_, _) => true) + .Build(); + + ScenarioExpect.Throws(() => filter.Filter(null!)); + } + + private sealed record Order(string Id, string CustomerTier, decimal Total); +}