Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/examples/order-message-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Order Message Filter

The order message-filter example screens fulfillment commands before downstream processing. It demonstrates:

- a fluent `MessageFilter<OrderMessageFilterCommand>` 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`.
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]` |
Expand Down
15 changes: 15 additions & 0 deletions docs/generators/message-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Message Filter Generator

`[GenerateMessageFilter]` creates a typed `MessageFilter<TPayload>` 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<OrderCommand> message, MessageContext context)
=> message.Payload.CustomerTier == "trusted";
}
```

Rules must be static methods returning `bool` with `(Message<TPayload>, MessageContext)` parameters. Duplicate rule names or orders are reported at compile time.
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/guides/pattern-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Enterprise Integration | Claim Check | `ClaimCheck<TPayload>` | Claim Check generator |
| Enterprise Integration | Dead Letter Channel | `DeadLetterChannel<TPayload>` | Dead Letter Channel generator |
| Enterprise Integration | Content-Based Router | `ContentRouter<TPayload, TResult>` | Messaging generator |
| Enterprise Integration | Message Filter | `MessageFilter<TPayload>` | Message Filter generator |
| Enterprise Integration | Recipient List | `RecipientList<TPayload>` | Messaging generator |
| Enterprise Integration | Competing Consumers | `CompetingConsumerGroup<TMessage, TResult>` | Competing Consumers generator |
| Enterprise Integration | Pipes and Filters | `PipesAndFiltersPipeline<TContext>` | Pipes and Filters generator |
Expand Down
13 changes: 13 additions & 0 deletions docs/patterns/messaging/message-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Message Filter

Use `MessageFilter<TPayload>` 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<OrderCommand>.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.
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 125 additions & 0 deletions src/PatternKit.Core/Messaging/Routing/MessageFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
namespace PatternKit.Messaging.Routing;

/// <summary>
/// Message filter that accepts messages matching at least one named rule and rejects the rest.
/// </summary>
public sealed class MessageFilter<TPayload>
{
/// <summary>Predicate used to decide whether a message should pass through the filter.</summary>
public delegate bool FilterPredicate(Message<TPayload> 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);

/// <summary>Filters <paramref name="message"/> and returns whether it should continue downstream.</summary>
public MessageFilterResult<TPayload> Filter(Message<TPayload> 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<TPayload>.Accept(message, _name, rule.Name);
}

return MessageFilterResult<TPayload>.Reject(message, _name, _rejectionReason);
}

/// <summary>Creates a new message filter builder.</summary>
public static Builder Create(string name = "message-filter") => new(name);

/// <summary>Fluent builder for <see cref="MessageFilter{TPayload}"/>.</summary>
public sealed class Builder
{
private readonly string _name;
private readonly List<FilterRule> _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;
}

/// <summary>Adds a named rule that allows matching messages through the filter.</summary>
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;
}

/// <summary>Configures the reason returned when no allow rule matches.</summary>
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;
}

/// <summary>Builds an immutable message filter.</summary>
public MessageFilter<TPayload> Build()
{
if (_rules.Count == 0)
throw new InvalidOperationException("Message filter must have at least one allow rule.");

return new MessageFilter<TPayload>(_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; }
}
}

/// <summary>
/// Result returned by <see cref="MessageFilter{TPayload}"/>.
/// </summary>
public sealed class MessageFilterResult<TPayload>
{
private MessageFilterResult(Message<TPayload> message, string filterName, string? ruleName, bool accepted, string? rejectionReason)
=> (Message, FilterName, RuleName, Accepted, RejectionReason) = (message, filterName, ruleName, accepted, rejectionReason);

/// <summary>The original message evaluated by the filter.</summary>
public Message<TPayload> Message { get; }

/// <summary>The name of the filter that evaluated the message.</summary>
public string FilterName { get; }

/// <summary>The name of the allow rule that matched, or null when rejected.</summary>
public string? RuleName { get; }

/// <summary>True when the message should continue downstream.</summary>
public bool Accepted { get; }

/// <summary>Human-readable rejection reason when the message is rejected.</summary>
public string? RejectionReason { get; }

/// <summary>Creates an accepted result.</summary>
public static MessageFilterResult<TPayload> Accept(Message<TPayload> message, string filterName, string ruleName)
=> new(message, filterName, ruleName, true, null);

/// <summary>Creates a rejected result.</summary>
public static MessageFilterResult<TPayload> Reject(Message<TPayload> message, string filterName, string rejectionReason)
=> new(message, filterName, null, false, rejectionReason);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<OrderMessageFilterCommand> Filter, OrderMessageFilterService Service);
public sealed record FulfillmentCompetingConsumersExampleService(CompetingConsumerGroup<FulfillmentConsumerWork, FulfillmentConsumerResult> Group, FulfillmentCompetingConsumerService Service);
public sealed record FulfillmentPipesAndFiltersExampleService(PipesAndFiltersPipeline<FulfillmentPipelineContext> Pipeline, FulfillmentPipesAndFiltersService Service);
public sealed record PatternsShowcaseExample(ShowcaseFacade Facade);
Expand Down Expand Up @@ -197,6 +198,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s
.AddGeneratedDeadLetterChannelExample()
.AddGeneratedRecipientListExample()
.AddGeneratedSplitterAggregatorExample()
.AddOrderMessageFilterExample()
.AddFulfillmentCompetingConsumersExample()
.AddFulfillmentPipesAndFiltersExample()
.AddPatternsShowcaseExample()
Expand Down Expand Up @@ -482,6 +484,15 @@ public static IServiceCollection AddGeneratedSplitterAggregatorExample(this ISer
return services.RegisterExample<GeneratedSplitterAggregatorExample>("Generated Splitter and Aggregator", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection);
}

public static IServiceCollection AddOrderMessageFilterExample(this IServiceCollection services)
{
services.AddOrderMessageFilterDemo();
services.AddSingleton<OrderMessageFilterExampleService>(sp => new(
sp.GetRequiredService<MessageFilter<OrderMessageFilterCommand>>(),
sp.GetRequiredService<OrderMessageFilterService>()));
return services.RegisterExample<OrderMessageFilterExampleService>("Order Message Filter", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection);
}

public static IServiceCollection AddFulfillmentCompetingConsumersExample(this IServiceCollection services)
{
services.AddFulfillmentCompetingConsumersDemo();
Expand Down
76 changes: 76 additions & 0 deletions src/PatternKit.Examples/Messaging/OrderMessageFilterExample.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using Microsoft.Extensions.DependencyInjection;
using PatternKit.Generators.Messaging;
using PatternKit.Messaging;
using PatternKit.Messaging.Routing;

namespace PatternKit.Examples.Messaging;

/// <summary>Order intake command used by the message-filter example.</summary>
public sealed record OrderMessageFilterCommand(string OrderId, string CustomerTier, decimal Total, bool PaymentVerified);

/// <summary>Summary returned by the order message-filter example.</summary>
public sealed record OrderMessageFilterSummary(bool Accepted, string? RuleName, string? RejectionReason);

/// <summary>
/// Service that applies an importable message filter before an order moves into fulfillment.
/// </summary>
public sealed class OrderMessageFilterService(MessageFilter<OrderMessageFilterCommand> filter)
{
public OrderMessageFilterSummary Screen(OrderMessageFilterCommand command)
{
var result = filter.Filter(Message<OrderMessageFilterCommand>.Create(command));
return new OrderMessageFilterSummary(result.Accepted, result.RuleName, result.RejectionReason);
}
}

/// <summary>Fluent message-filter builder used by applications that do not enable generators.</summary>
public static class OrderMessageFilters
{
public static MessageFilter<OrderMessageFilterCommand> CreateFraudScreen()
=> MessageFilter<OrderMessageFilterCommand>.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();
}

/// <summary>Source-generated message-filter rules for order fraud screening.</summary>
[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<OrderMessageFilterCommand> message, MessageContext context)
=> message.Payload.CustomerTier == "trusted" && message.Payload.PaymentVerified;

[MessageFilterRule("verified-low-value", 20)]
private static bool IsVerifiedLowValue(Message<OrderMessageFilterCommand> message, MessageContext context)
=> message.Payload.PaymentVerified && message.Payload.Total <= 100m;
}

/// <summary>Runner that demonstrates both fluent and generated message-filter paths.</summary>
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<OrderMessageFilterCommand>.Create(command));
return new OrderMessageFilterSummary(result.Accepted, result.RuleName, result.RejectionReason);
}
}

/// <summary>DI helpers for importing the order message-filter example into standard .NET containers.</summary>
public static class OrderMessageFilterExampleServiceCollectionExtensions
{
public static IServiceCollection AddOrderMessageFilterDemo(this IServiceCollection services)
{
services.AddSingleton(_ => GeneratedOrderMessageFilter.Create());
services.AddSingleton<OrderMessageFilterService>();
services.AddSingleton<OrderMessageFilterExampleRunner>();
return services;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading