From 873a3459a86df36c76291de374559d278d9d4145 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Thu, 21 May 2026 20:50:24 -0500 Subject: [PATCH] feat: add polling consumer pattern support --- docs/examples/toc.yml | 3 + docs/examples/warehouse-polling-consumer.md | 13 +++ docs/generators/index.md | 1 + docs/generators/polling-consumer.md | 20 ++++ docs/generators/toc.yml | 3 + docs/guides/pattern-coverage.md | 1 + docs/patterns/messaging/polling-consumer.md | 16 +++ docs/patterns/toc.yml | 2 + .../Messaging/Consumers/PollingConsumer.cs | 69 ++++++++++++ ...rnKitExampleServiceCollectionExtensions.cs | 12 ++ .../WarehousePollingConsumerExample.cs | 62 ++++++++++ .../PatternKitExampleCatalog.cs | 8 ++ .../PatternKitPatternCatalog.cs | 13 +++ .../Messaging/PollingConsumerAttributes.cs | 21 ++++ .../AnalyzerReleases.Unshipped.md | 3 + .../Messaging/PollingConsumerGenerator.cs | 106 ++++++++++++++++++ .../WarehousePollingConsumerExampleTests.cs | 64 +++++++++++ .../PatternKitPatternCatalogTests.cs | 3 +- .../AbstractionsAttributeCoverageTests.cs | 12 ++ .../PollingConsumerGeneratorTests.cs | 88 +++++++++++++++ .../Consumers/PollingConsumerTests.cs | 58 ++++++++++ 21 files changed, 577 insertions(+), 1 deletion(-) create mode 100644 docs/examples/warehouse-polling-consumer.md create mode 100644 docs/generators/polling-consumer.md create mode 100644 docs/patterns/messaging/polling-consumer.md create mode 100644 src/PatternKit.Core/Messaging/Consumers/PollingConsumer.cs create mode 100644 src/PatternKit.Examples/Messaging/WarehousePollingConsumerExample.cs create mode 100644 src/PatternKit.Generators.Abstractions/Messaging/PollingConsumerAttributes.cs create mode 100644 src/PatternKit.Generators/Messaging/PollingConsumerGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/Messaging/WarehousePollingConsumerExampleTests.cs create mode 100644 test/PatternKit.Generators.Tests/PollingConsumerGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Messaging/Consumers/PollingConsumerTests.cs diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 5ac200b7..3cb1e601 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -49,6 +49,9 @@ - name: Inventory Message Channel href: inventory-message-channel.md +- name: Warehouse Polling Consumer + href: warehouse-polling-consumer.md + - name: Patterns Showcase — Integrated Order Processing href: patterns-showcase.md diff --git a/docs/examples/warehouse-polling-consumer.md b/docs/examples/warehouse-polling-consumer.md new file mode 100644 index 00000000..f8a48f64 --- /dev/null +++ b/docs/examples/warehouse-polling-consumer.md @@ -0,0 +1,13 @@ +# Warehouse Polling Consumer + +The warehouse polling consumer example explicitly pulls replenishment requests from a message source. + +```csharp +services.AddWarehousePollingConsumerDemo(); + +GeneratedWarehousePollingConsumer.Enqueue(new ReplenishmentRequest("SKU-100", 4)); +var service = provider.GetRequiredService(); +var summary = service.Poll(); +``` + +The example includes fluent and source-generated construction, a pull-based service boundary, and `IServiceCollection` registration for existing .NET applications. diff --git a/docs/generators/index.md b/docs/generators/index.md index 106b0edf..c550c743 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -81,6 +81,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato |---|---|---| | [**Dispatcher**](dispatcher.md) | Mediator pattern with commands, notifications, and streams | `[GenerateDispatcher]` | | [**Message Channel**](message-channel.md) | Typed channel factories for in-process message queues | `[GenerateMessageChannel]` | +| [**Polling Consumer**](polling-consumer.md) | Pull-based message consumer factories | `[GeneratePollingConsumer]` | | [**Message Envelope**](messaging.md#generated-message-envelope) | Required message metadata contracts | `[GenerateMessageEnvelope]` | | [**Message Translator**](message-translator.md) | Partner and transport event normalization | `[GenerateMessageTranslator]` | | [**Claim Check**](claim-check.md) | External payload storage references | `[GenerateClaimCheck]` | diff --git a/docs/generators/polling-consumer.md b/docs/generators/polling-consumer.md new file mode 100644 index 00000000..d1e64cdb --- /dev/null +++ b/docs/generators/polling-consumer.md @@ -0,0 +1,20 @@ +# Polling Consumer Generator + +`[GeneratePollingConsumer]` creates a typed `PollingConsumer` factory. + +```csharp +[GeneratePollingConsumer(typeof(ReplenishmentRequest), FactoryName = "Create", ConsumerName = "warehouse-replenishment-poller")] +public static partial class WarehousePoller +{ + [PollingConsumerSource] + private static Message? Poll(MessageContext context) => TryReadNext(); +} +``` + +The source method must be static, return `Message?`, and accept a `MessageContext`. + +Diagnostics: + +- `PKPOLL001`: host type must be partial. +- `PKPOLL002`: exactly one polling source is required. +- `PKPOLL003`: polling source signature is invalid. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 2439cc14..78bd9e55 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -88,6 +88,9 @@ - name: Message Channel href: message-channel.md +- name: Polling Consumer + href: polling-consumer.md + - name: Message Translator href: message-translator.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 3d29d379..784170c3 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -45,6 +45,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Family | Pattern | Fluent/runtime path | Source-generated path | | --- | --- | --- | --- | | Enterprise Integration | Message Channel | `MessageChannel` | Message Channel generator | +| Enterprise Integration | Polling Consumer | `PollingConsumer` | Polling Consumer generator | | Enterprise Integration | Message Envelope | `Message`, headers, context | Messaging generator | | Enterprise Integration | Message Translator | `MessageTranslator` | Message Translator generator | | Enterprise Integration | Claim Check | `ClaimCheck` | Claim Check generator | diff --git a/docs/patterns/messaging/polling-consumer.md b/docs/patterns/messaging/polling-consumer.md new file mode 100644 index 00000000..e3fe7031 --- /dev/null +++ b/docs/patterns/messaging/polling-consumer.md @@ -0,0 +1,16 @@ +# Polling Consumer + +Polling Consumer explicitly asks a message source for the next available message. + +```csharp +var consumer = PollingConsumer + .Create("warehouse-replenishment-poller") + .From(context => channel.TryReceive().Message) + .Build(); + +var result = consumer.Poll(); +``` + +Use it when application code controls the polling cadence, backoff, and transaction boundary. The fluent runtime path accepts any synchronous poll source and returns typed empty or received results. + +The source-generated path uses `[GeneratePollingConsumer]` and `[PollingConsumerSource]`. Import the warehouse example through `AddWarehousePollingConsumerDemo()` or `AddPatternKitExamples()`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index c8b784bc..9b748024 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -297,6 +297,8 @@ items: - name: Message Channel href: messaging/message-channel.md + - name: Polling Consumer + href: messaging/polling-consumer.md - name: Message Envelope and Context href: messaging/message-envelope.md - name: Message Translator diff --git a/src/PatternKit.Core/Messaging/Consumers/PollingConsumer.cs b/src/PatternKit.Core/Messaging/Consumers/PollingConsumer.cs new file mode 100644 index 00000000..2ac7d357 --- /dev/null +++ b/src/PatternKit.Core/Messaging/Consumers/PollingConsumer.cs @@ -0,0 +1,69 @@ +namespace PatternKit.Messaging.Consumers; + +/// Pull-based consumer that explicitly polls a message source. +public sealed class PollingConsumer +{ + public delegate Message? PollSource(MessageContext context); + + private readonly PollSource _source; + + private PollingConsumer(string name, PollSource source) + => (Name, _source) = (name, source); + + public string Name { get; } + + public PollingConsumerResult Poll(MessageContext? context = null) + { + var effectiveContext = context ?? MessageContext.Empty; + var message = _source(effectiveContext); + return message is null + ? PollingConsumerResult.Empty(Name) + : PollingConsumerResult.Success(Name, message); + } + + public static Builder Create(string name = "polling-consumer") => new(name); + + public sealed class Builder + { + private readonly string _name; + private PollSource? _source; + + internal Builder(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Polling consumer name cannot be null, empty, or whitespace.", nameof(name)); + + _name = name; + } + + public Builder From(PollSource source) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + return this; + } + + public PollingConsumer Build() + { + if (_source is null) + throw new InvalidOperationException("Polling consumer requires a message source."); + + return new(_name, _source); + } + } +} + +public sealed class PollingConsumerResult +{ + private PollingConsumerResult(string consumerName, bool received, Message? message) + => (ConsumerName, Received, Message) = (consumerName, received, message); + + public string ConsumerName { get; } + + public bool Received { get; } + + public Message? Message { get; } + + internal static PollingConsumerResult Success(string consumerName, Message message) => new(consumerName, true, message); + + internal static PollingConsumerResult Empty(string consumerName) => new(consumerName, false, null); +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index bd2dcf4d..cb2b80be 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -60,6 +60,7 @@ using PatternKit.Examples.UnitOfWorkDemo; using PatternKit.Examples.VisitorDemo; using PatternKit.Messaging.Channels; +using PatternKit.Messaging.Consumers; using PatternKit.Messaging.Routing; using PatternKit.Messaging.Storage; using PatternKit.Messaging.ControlBus; @@ -125,6 +126,7 @@ public sealed record ApiExceptionMappingVisitorExample(Func RunAsync); public sealed record EventProcessingVisitorExample(Func RunAsync); public sealed record MessageRouterVisitorExample(Func Run); public sealed record InventoryMessageChannelExampleService(MessageChannel Channel, InventoryMessageChannelService Service); +public sealed record WarehousePollingConsumerExampleService(PollingConsumer Consumer, WarehousePollingConsumerService Service); public sealed record GeneratedMessageEnvelopeExample(MessageEnvelopeExampleRunner Runner); public sealed record GeneratedMessageTranslatorExample(PartnerEventTranslatorExampleRunner Runner, PartnerOrderImportService Service); public sealed record GeneratedClaimCheckExample(LargeDocumentClaimCheckExampleRunner Runner, LargeDocumentWorkflow Workflow); @@ -204,6 +206,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddEventProcessingVisitorExample() .AddMessageRouterVisitorExample() .AddInventoryMessageChannelExample() + .AddWarehousePollingConsumerExample() .AddGeneratedMessageEnvelopeExample() .AddGeneratedMessageTranslatorExample() .AddGeneratedClaimCheckExample() @@ -463,6 +466,15 @@ public static IServiceCollection AddInventoryMessageChannelExample(this IService return services.RegisterExample("Inventory Message Channel", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddWarehousePollingConsumerExample(this IServiceCollection services) + { + services.AddWarehousePollingConsumerDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService())); + return services.RegisterExample("Warehouse Polling Consumer", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddGeneratedMessageEnvelopeExample(this IServiceCollection services) { services.AddMessageEnvelopeExample(); diff --git a/src/PatternKit.Examples/Messaging/WarehousePollingConsumerExample.cs b/src/PatternKit.Examples/Messaging/WarehousePollingConsumerExample.cs new file mode 100644 index 00000000..db34e91a --- /dev/null +++ b/src/PatternKit.Examples/Messaging/WarehousePollingConsumerExample.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; +using PatternKit.Messaging.Channels; +using PatternKit.Messaging.Consumers; + +namespace PatternKit.Examples.Messaging; + +public sealed record ReplenishmentRequest(string Sku, int Quantity); + +public sealed record WarehousePollingSummary(bool Received, string? Sku); + +public sealed class WarehousePollingConsumerService(PollingConsumer consumer) +{ + public WarehousePollingSummary Poll() + { + var result = consumer.Poll(); + return new(result.Received, result.Message?.Payload.Sku); + } +} + +public static class WarehousePollingConsumers +{ + public static PollingConsumer Create(MessageChannel channel) + => PollingConsumer.Create("warehouse-replenishment-poller") + .From(_ => channel.TryReceive().Message) + .Build(); +} + +[GeneratePollingConsumer(typeof(ReplenishmentRequest), FactoryName = "Create", ConsumerName = "warehouse-replenishment-poller")] +public static partial class GeneratedWarehousePollingConsumer +{ + private static readonly Queue> Messages = new(); + + public static void Enqueue(ReplenishmentRequest request) => Messages.Enqueue(Message.Create(request)); + + [PollingConsumerSource] + private static Message? Poll(MessageContext context) => Messages.Count == 0 ? null : Messages.Dequeue(); +} + +public sealed class WarehousePollingConsumerExampleRunner(WarehousePollingConsumerService service) +{ + public WarehousePollingSummary RunGenerated() => service.Poll(); + + public static WarehousePollingSummary RunFluent(ReplenishmentRequest request) + { + var channel = MessageChannel.Create("warehouse-replenishment").Build(); + channel.Send(Message.Create(request)); + return new WarehousePollingConsumerService(WarehousePollingConsumers.Create(channel)).Poll(); + } +} + +public static class WarehousePollingConsumerExampleServiceCollectionExtensions +{ + public static IServiceCollection AddWarehousePollingConsumerDemo(this IServiceCollection services) + { + services.AddSingleton(_ => GeneratedWarehousePollingConsumer.Create()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index bf3a5dd0..25e35b6c 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -216,6 +216,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["MessageChannel"], ["typed queue boundary", "source-generated channel factory", "DI composition"]), + Descriptor( + "Warehouse Polling Consumer", + "src/PatternKit.Examples/Messaging/WarehousePollingConsumerExample.cs", + "test/PatternKit.Examples.Tests/Messaging/WarehousePollingConsumerExampleTests.cs", + "docs/examples/warehouse-polling-consumer.md", + ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["PollingConsumer", "MessageChannel"], + ["pull-based replenishment workflow", "source-generated polling consumer factory", "DI composition"]), Descriptor( "Patterns Showcase", "src/PatternKit.Examples/PatternShowcase/PatternShowcase.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 5a411461..aba0b860 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -376,6 +376,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/Messaging/InventoryMessageChannelExampleTests.cs", ["fluent typed message queue", "generated channel factory", "DI-importable inventory example"]), + Pattern("Polling Consumer", PatternFamily.EnterpriseIntegration, + "docs/patterns/messaging/polling-consumer.md", + "src/PatternKit.Core/Messaging/Consumers/PollingConsumer.cs", + "test/PatternKit.Tests/Messaging/Consumers/PollingConsumerTests.cs", + "docs/generators/polling-consumer.md", + "src/PatternKit.Generators/Messaging/PollingConsumerGenerator.cs", + "test/PatternKit.Generators.Tests/PollingConsumerGeneratorTests.cs", + null, + "docs/examples/warehouse-polling-consumer.md", + "src/PatternKit.Examples/Messaging/WarehousePollingConsumerExample.cs", + "test/PatternKit.Examples.Tests/Messaging/WarehousePollingConsumerExampleTests.cs", + ["fluent pull consumer", "generated polling source factory", "DI-importable warehouse replenishment example"]), + Pattern("Message Envelope", PatternFamily.EnterpriseIntegration, "docs/patterns/messaging/message-envelope.md", "src/PatternKit.Core/Messaging/Message.cs", diff --git a/src/PatternKit.Generators.Abstractions/Messaging/PollingConsumerAttributes.cs b/src/PatternKit.Generators.Abstractions/Messaging/PollingConsumerAttributes.cs new file mode 100644 index 00000000..75bcc7a0 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Messaging/PollingConsumerAttributes.cs @@ -0,0 +1,21 @@ +using System; + +namespace PatternKit.Generators.Messaging; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GeneratePollingConsumerAttribute : Attribute +{ + public GeneratePollingConsumerAttribute(Type payloadType) + => PayloadType = payloadType ?? throw new ArgumentNullException(nameof(payloadType)); + + public Type PayloadType { get; } + + public string FactoryName { get; set; } = "Create"; + + public string ConsumerName { get; set; } = "polling-consumer"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class PollingConsumerSourceAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 7a47f5bf..233e721c 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -314,3 +314,6 @@ PKRSEQ002 | PatternKit.Generators.Messaging | Error | Resequencer must declare e PKRSEQ003 | PatternKit.Generators.Messaging | Error | Resequencer sequence selector signature is invalid. PKCHN001 | PatternKit.Generators.Messaging | Error | Message Channel host type must be partial. PKCHN002 | PatternKit.Generators.Messaging | Error | Message Channel capacity is invalid. +PKPOLL001 | PatternKit.Generators.Messaging | Error | Polling Consumer host type must be partial. +PKPOLL002 | PatternKit.Generators.Messaging | Error | Polling Consumer must declare exactly one source. +PKPOLL003 | PatternKit.Generators.Messaging | Error | Polling Consumer source signature is invalid. diff --git a/src/PatternKit.Generators/Messaging/PollingConsumerGenerator.cs b/src/PatternKit.Generators/Messaging/PollingConsumerGenerator.cs new file mode 100644 index 00000000..224e2d5a --- /dev/null +++ b/src/PatternKit.Generators/Messaging/PollingConsumerGenerator.cs @@ -0,0 +1,106 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.Messaging; + +[Generator] +public sealed class PollingConsumerGenerator : IIncrementalGenerator +{ + private static readonly DiagnosticDescriptor MustBePartial = new("PKPOLL001", "Polling consumer type must be partial", "Type '{0}' is marked with [GeneratePollingConsumer] but is not declared as partial", "PatternKit.Generators.Messaging", DiagnosticSeverity.Error, true); + private static readonly DiagnosticDescriptor MissingSource = new("PKPOLL002", "Polling consumer source is missing", "Type '{0}' must declare exactly one [PollingConsumerSource] method", "PatternKit.Generators.Messaging", DiagnosticSeverity.Error, true); + private static readonly DiagnosticDescriptor InvalidSource = new("PKPOLL003", "Polling consumer source signature is invalid", "Source '{0}' must be static and return Message? with a MessageContext parameter", "PatternKit.Generators.Messaging", DiagnosticSeverity.Error, true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + "PatternKit.Generators.Messaging.GeneratePollingConsumerAttribute", + 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.GeneratePollingConsumerAttribute"); + 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 payloadType = attribute.ConstructorArguments.Length > 0 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + if (payloadType is null) + return; + + var sources = type.GetMembers().OfType().Where(static method => + method.GetAttributes().Any(static attr => attr.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.PollingConsumerSourceAttribute")).ToArray(); + if (sources.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingSource, node.Identifier.GetLocation(), type.Name)); + return; + } + + var source = sources[0]; + if (!IsSource(source, payloadType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidSource, source.Locations.FirstOrDefault(), source.Name)); + return; + } + + var factoryName = GetNamedString(attribute, "FactoryName") ?? "Create"; + var consumerName = GetNamedString(attribute, "ConsumerName") ?? "polling-consumer"; + + context.AddSource($"{type.Name}.PollingConsumer.g.cs", SourceText.From(GenerateSource(type, payloadType, source.Name, factoryName, consumerName), Encoding.UTF8)); + } + + private static bool IsSource(IMethodSymbol method, INamedTypeSymbol payloadType) + => method.IsStatic && + IsMessageOf(method.ReturnType, payloadType) && + method.Parameters.Length == 1 && + method.Parameters[0].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext"; + + private static bool IsMessageOf(ITypeSymbol type, INamedTypeSymbol payloadType) + => type is INamedTypeSymbol named && + named.ConstructedFrom.ToDisplayString() == "PatternKit.Messaging.Message" && + SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], payloadType); + + private static string GenerateSource(INamedTypeSymbol type, INamedTypeSymbol payloadType, string source, string factoryName, string consumerName) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Messaging.Consumers.PollingConsumer<") + .Append(payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append("> ").Append(factoryName).AppendLine("()"); + sb.Append(" => global::PatternKit.Messaging.Consumers.PollingConsumer<") + .Append(payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append(">.Create(").Append(ToLiteral(consumerName)).AppendLine(")"); + sb.Append(" .From(").Append(source).AppendLine(")"); + sb.AppendLine(" .Build();"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static string ToLiteral(string value) => "@\"" + value.Replace("\"", "\"\"") + "\""; +} diff --git a/test/PatternKit.Examples.Tests/Messaging/WarehousePollingConsumerExampleTests.cs b/test/PatternKit.Examples.Tests/Messaging/WarehousePollingConsumerExampleTests.cs new file mode 100644 index 00000000..eef8d27b --- /dev/null +++ b/test/PatternKit.Examples.Tests/Messaging/WarehousePollingConsumerExampleTests.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.Messaging; +using TinyBDD; + +namespace PatternKit.Examples.Tests.Messaging; + +public sealed class WarehousePollingConsumerExampleTests +{ + [Scenario("FluentPollingConsumer PullsReplenishmentRequest")] + [Fact] + public void FluentPollingConsumer_PullsReplenishmentRequest() + { + var summary = WarehousePollingConsumerExampleRunner.RunFluent(new("sku-1", 4)); + + ScenarioExpect.True(summary.Received); + ScenarioExpect.Equal("sku-1", summary.Sku); + } + + [Scenario("GeneratedPollingConsumer MatchesFluentPolling")] + [Fact] + public void GeneratedPollingConsumer_MatchesFluentPolling() + { + GeneratedWarehousePollingConsumer.Enqueue(new("sku-1", 4)); + var generated = new WarehousePollingConsumerService(GeneratedWarehousePollingConsumer.Create()).Poll(); + var fluent = WarehousePollingConsumerExampleRunner.RunFluent(new("sku-1", 4)); + + ScenarioExpect.Equal(fluent.Received, generated.Received); + ScenarioExpect.Equal(fluent.Sku, generated.Sku); + } + + [Scenario("ServiceCollection ImportsPollingConsumerExample")] + [Fact] + public void ServiceCollection_ImportsPollingConsumerExample() + { + var services = new ServiceCollection(); + services.AddWarehousePollingConsumerDemo(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + GeneratedWarehousePollingConsumer.Enqueue(new("sku-1", 4)); + var service = provider.GetRequiredService(); + + var summary = service.Poll(); + + ScenarioExpect.True(summary.Received); + ScenarioExpect.Equal("sku-1", summary.Sku); + } + + [Scenario("AggregateServiceCollection ImportsPollingConsumerExample")] + [Fact] + public void AggregateServiceCollection_ImportsPollingConsumerExample() + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + GeneratedWarehousePollingConsumer.Enqueue(new("sku-1", 4)); + var example = provider.GetRequiredService(); + + var summary = example.Service.Poll(); + + ScenarioExpect.Equal("sku-1", summary.Sku); + } +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 1972cbc3..e37ae7a9 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -39,6 +39,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti private static readonly string[] EnterprisePatternAdditions = [ "Message Channel", + "Polling Consumer", "Message Envelope", "Message Translator", "Claim Check", @@ -125,7 +126,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(22, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); + ScenarioExpect.Equal(23, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(7, 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 bda15aea..20f652d7 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -132,6 +132,8 @@ private enum TestTrigger { typeof(TraversalChildrenAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateDispatcherAttribute), AttributeTargets.Assembly, false, true }, { typeof(GenerateMessageChannelAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(GeneratePollingConsumerAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(PollingConsumerSourceAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateRoutingSlipAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GenerateCompetingConsumerGroupAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GeneratePipesAndFiltersPipelineAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, @@ -745,6 +747,11 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf Capacity = 12, BackpressurePolicy = "DropOldest" }; + var pollingConsumer = new GeneratePollingConsumerAttribute(typeof(string)) + { + FactoryName = "BuildPoller", + ConsumerName = "inventory-poller" + }; var routingSlip = new GenerateRoutingSlipAttribute(typeof(string)) { FactoryName = "Build", @@ -906,6 +913,9 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Equal("inventory", messageChannel.ChannelName); ScenarioExpect.Equal(12, messageChannel.Capacity); ScenarioExpect.Equal("DropOldest", messageChannel.BackpressurePolicy); + ScenarioExpect.Equal(typeof(string), pollingConsumer.PayloadType); + ScenarioExpect.Equal("BuildPoller", pollingConsumer.FactoryName); + ScenarioExpect.Equal("inventory-poller", pollingConsumer.ConsumerName); ScenarioExpect.Equal(typeof(string), routingSlip.PayloadType); ScenarioExpect.Equal("Build", routingSlip.FactoryName); ScenarioExpect.Equal("BuildAsync", routingSlip.AsyncFactoryName); @@ -1019,6 +1029,8 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Equal("content-type", translatorHeader.Name); ScenarioExpect.Equal("application/vnd.demo+json", translatorHeader.Value); ScenarioExpect.Throws(() => new GenerateMessageChannelAttribute(null!)); + ScenarioExpect.Throws(() => new GeneratePollingConsumerAttribute(null!)); + ScenarioExpect.IsType(new PollingConsumerSourceAttribute()); ScenarioExpect.Throws(() => new GenerateRoutingSlipAttribute(null!)); ScenarioExpect.Throws(() => new RoutingSlipStepAttribute("", 1)); ScenarioExpect.Throws(() => new GenerateSagaAttribute(null!)); diff --git a/test/PatternKit.Generators.Tests/PollingConsumerGeneratorTests.cs b/test/PatternKit.Generators.Tests/PollingConsumerGeneratorTests.cs new file mode 100644 index 00000000..3bb282c4 --- /dev/null +++ b/test/PatternKit.Generators.Tests/PollingConsumerGeneratorTests.cs @@ -0,0 +1,88 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.Messaging; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class PollingConsumerGeneratorTests +{ + [Scenario("GeneratesPollingConsumerFactory")] + [Fact] + public void GeneratesPollingConsumerFactory() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + namespace MyApp; + public sealed record Command(string Sku); + [GeneratePollingConsumer(typeof(Command), FactoryName = "Build", ConsumerName = "inventory-poller")] + public static partial class InventoryPollingConsumer + { + [PollingConsumerSource] + private static Message? Poll(MessageContext context) => null; + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesPollingConsumerFactory)); + _ = RoslynTestHelpers.Run(comp, new PollingConsumerGenerator(), 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)); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("PollingConsumer", text); + ScenarioExpect.Contains(".From(Poll)", text); + ScenarioExpect.True(updated.Emit(Stream.Null).Success); + } + + [Scenario("ReportsPollingConsumerDiagnostics")] + [Theory] + [InlineData("public static class InventoryPollingConsumer { }", "PKPOLL001")] + [InlineData("public static partial class InventoryPollingConsumer { }", "PKPOLL002")] + public void ReportsPollingConsumerDiagnostics(string declaration, string expected) + { + var source = $$""" + using PatternKit.Generators.Messaging; + namespace MyApp; + public sealed record Command(string Sku); + [GeneratePollingConsumer(typeof(Command))] + {{declaration}} + """; + + var comp = CreateCompilation(source, nameof(ReportsPollingConsumerDiagnostics) + expected); + _ = RoslynTestHelpers.Run(comp, new PollingConsumerGenerator(), out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal(expected, diagnostic.Id); + } + + [Scenario("ReportsInvalidPollingConsumerSource")] + [Fact] + public void ReportsInvalidPollingConsumerSource() + { + var source = """ + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + namespace MyApp; + public sealed record Command(string Sku); + [GeneratePollingConsumer(typeof(Command))] + public static partial class InventoryPollingConsumer + { + [PollingConsumerSource] + private static string Poll(MessageContext context) => "bad"; + } + """; + + var comp = CreateCompilation(source, nameof(ReportsInvalidPollingConsumerSource)); + _ = RoslynTestHelpers.Run(comp, new PollingConsumerGenerator(), out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKPOLL003", diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: MetadataReference.CreateFromFile(typeof(global::PatternKit.Messaging.Consumers.PollingConsumer<>).Assembly.Location)); +} diff --git a/test/PatternKit.Tests/Messaging/Consumers/PollingConsumerTests.cs b/test/PatternKit.Tests/Messaging/Consumers/PollingConsumerTests.cs new file mode 100644 index 00000000..357ee183 --- /dev/null +++ b/test/PatternKit.Tests/Messaging/Consumers/PollingConsumerTests.cs @@ -0,0 +1,58 @@ +using PatternKit.Messaging; +using PatternKit.Messaging.Consumers; +using TinyBDD; + +namespace PatternKit.Tests.Messaging.Consumers; + +public sealed class PollingConsumerTests +{ + [Scenario("Poll ReceivesAvailableMessage")] + [Fact] + public void Poll_ReceivesAvailableMessage() + { + var messages = new Queue>(); + messages.Enqueue(Message.Create(new("sku-1", 3))); + var consumer = PollingConsumer.Create("inventory-poller") + .From(_ => messages.Count == 0 ? null : messages.Dequeue()) + .Build(); + + var first = consumer.Poll(); + var second = consumer.Poll(); + + ScenarioExpect.True(first.Received); + ScenarioExpect.Equal("inventory-poller", first.ConsumerName); + ScenarioExpect.Equal("sku-1", first.Message!.Payload.Sku); + ScenarioExpect.False(second.Received); + ScenarioExpect.Null(second.Message); + } + + [Scenario("Poll PassesMessageContextToSource")] + [Fact] + public void Poll_PassesMessageContextToSource() + { + MessageContext? captured = null; + var context = MessageContext.Empty.WithItem("tenant", "north"); + var consumer = PollingConsumer.Create() + .From(sourceContext => + { + captured = sourceContext; + return null; + }) + .Build(); + + _ = consumer.Poll(context); + + ScenarioExpect.Same(context, captured); + } + + [Scenario("Builder RejectsInvalidConfiguration")] + [Fact] + public void Builder_RejectsInvalidConfiguration() + { + ScenarioExpect.Throws(() => PollingConsumer.Create("")); + ScenarioExpect.Throws(() => PollingConsumer.Create().From(null!)); + ScenarioExpect.Throws(() => PollingConsumer.Create().Build()); + } + + public sealed record Command(string Sku, int Quantity); +}