diff --git a/docs/examples/index.md b/docs/examples/index.md index 6259c5f8..d57edd23 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -168,6 +168,7 @@ dotnet test PatternKit.slnx -c Release * **Messaging Backplane Facade:** `BackplaneFacadeDemo` (+ `BackplaneFacadeDemoTests`) — host setup, request/reply, pub/sub, outbox, idempotency, and mailbox-backed transport subscribers. * **Product Gateway Routing:** `ProductGatewayRoutingDemo` (+ `ProductGatewayRoutingDemoTests`) — fluent and generated downstream route dispatch with DI and ASP.NET Core mapping. * **Checkout Strangler Fig Migration:** `CheckoutStranglerFigDemo` (+ `CheckoutStranglerFigDemoTests`) — fluent and generated legacy-to-modern checkout routing with DI and ASP.NET Core mapping. +* **Order Telemetry Sidecar:** `OrderTelemetrySidecarDemo` (+ `OrderTelemetrySidecarDemoTests`) — fluent and generated companion telemetry behavior with DI and ASP.NET Core mapping. * **Production-Ready Example Catalog:** `PatternKitExampleCatalog` (+ `PatternKitExampleCatalogTests`) — DI registration, generic host validation, ASP.NET Core endpoint mapping, and source/test/docs manifest checks. * **Tests:** `PatternKit.Examples.Tests/*` use TinyBDD scenarios that read like specs. diff --git a/docs/examples/order-telemetry-sidecar.md b/docs/examples/order-telemetry-sidecar.md new file mode 100644 index 00000000..78eb9f4b --- /dev/null +++ b/docs/examples/order-telemetry-sidecar.md @@ -0,0 +1,12 @@ +# Order Telemetry Sidecar + +The order telemetry sidecar example adds trace context and telemetry capture around an order submission handler. + +```csharp +services.AddOrderTelemetrySidecarDemo(); + +var runner = provider.GetRequiredService(); +var result = runner.RunGenerated(new OrderTelemetryRequest("O-100", 42m)); +``` + +The example includes fluent and source-generated construction, an `IServiceCollection` extension, and an ASP.NET Core minimal API mapping through `MapOrderTelemetrySidecar()`. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 8379f161..bf9d3955 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -246,3 +246,6 @@ - name: Checkout Strangler Fig Migration href: checkout-strangler-fig.md + +- name: Order Telemetry Sidecar + href: order-telemetry-sidecar.md diff --git a/docs/generators/index.md b/docs/generators/index.md index b760cc1c..589cea50 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -124,6 +124,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Gateway Aggregation**](gateway-aggregation.md) | API gateway response composition factories | `[GenerateGatewayAggregation]` | | [**Gateway Routing**](gateway-routing.md) | API gateway route dispatch factories | `[GenerateGatewayRouting]` | | [**Strangler Fig**](strangler-fig.md) | Legacy-to-modern migration routing factories | `[GenerateStranglerFig]` | +| [**Sidecar**](sidecar.md) | Companion behavior pipeline factories | `[GenerateSidecar]` | ## Quick Reference @@ -308,6 +309,10 @@ public static partial class ProductGatewayRouting { } // Strangler Fig - generated legacy-to-modern migration routing [GenerateStranglerFig(typeof(CheckoutMigrationRequest), typeof(CheckoutMigrationResponse))] public static partial class CheckoutMigration { } + +// Sidecar - generated companion behavior around a primary handler +[GenerateSidecar(typeof(OrderTelemetryRequest), typeof(OrderTelemetryResponse))] +public static partial class OrderTelemetrySidecar { } ``` ## Examples diff --git a/docs/generators/sidecar.md b/docs/generators/sidecar.md new file mode 100644 index 00000000..13b94979 --- /dev/null +++ b/docs/generators/sidecar.md @@ -0,0 +1,25 @@ +# Sidecar Generator + +`[GenerateSidecar]` creates a typed `Sidecar` factory from before steps, after steps, and one primary handler. + +```csharp +[GenerateSidecar(typeof(OrderTelemetryRequest), typeof(OrderTelemetryResponse), SidecarName = "order-telemetry-sidecar")] +public static partial class OrderSidecars +{ + [SidecarBefore("trace-context")] + private static void AddTrace(SidecarContext ctx) => ctx.Items["trace-id"] = "trace-1"; + + [SidecarAfter("telemetry")] + private static void Capture(SidecarContext ctx, OrderTelemetryResponse response) { } + + [SidecarHandler] + private static OrderTelemetryResponse Submit(SidecarContext ctx) => new("accepted", "trace-1"); +} +``` + +Diagnostics: + +- `PKSC001`: host type must be partial. +- `PKSC002`: at least one companion step and exactly one handler are required. +- `PKSC003`: before, after, or handler signature is invalid. +- `PKSC004`: companion step names must be unique. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 52240eab..1ba4e792 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -169,6 +169,9 @@ - name: Strangler Fig href: strangler-fig.md +- name: Sidecar + href: sidecar.md + - name: Queue Load Leveling href: queue-load-leveling.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 245ce402..b675fc60 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -89,6 +89,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Cloud Architecture | Gateway Aggregation | `GatewayAggregation` | Gateway Aggregation generator | | Cloud Architecture | Gateway Routing | `GatewayRouting` | Gateway Routing generator | | Cloud Architecture | Strangler Fig | `StranglerFig` | Strangler Fig generator | +| Cloud Architecture | Sidecar | `Sidecar` | Sidecar generator | | Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator | | Application Architecture | Specification | `Specification` and named registries | Specification generator | | Application Architecture | Repository | `IRepository` and `InMemoryRepository` | Repository generator | diff --git a/docs/patterns/cloud/sidecar.md b/docs/patterns/cloud/sidecar.md new file mode 100644 index 00000000..0108ee92 --- /dev/null +++ b/docs/patterns/cloud/sidecar.md @@ -0,0 +1,18 @@ +# Sidecar + +Sidecar attaches companion behavior to a primary operation without mixing that behavior into the application handler. + +```csharp +var sidecar = Sidecar + .Create("order-telemetry-sidecar") + .Before("trace-context", ctx => ctx.Items["trace-id"] = $"trace-{ctx.Request.OrderId}") + .After("telemetry", (ctx, response) => telemetry.Capture("order.accepted", response.Confirmation)) + .Handle(ctx => new($"ACCEPTED-{ctx.Request.OrderId}", (string)ctx.Items["trace-id"])) + .Build(); + +var result = sidecar.Invoke(request); +``` + +Use it when request tracing, metrics, connectivity policy, local proxy behavior, or other companion responsibilities should be modeled beside the primary app capability. The runtime result records completed companion steps and reports primary or companion failures explicitly. + +The source-generated path uses `[GenerateSidecar]`, `[SidecarBefore]`, `[SidecarAfter]`, and `[SidecarHandler]`. Import the example through `AddOrderTelemetrySidecarDemo()` or `AddPatternKitExamples()`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index b1c5e469..54751baf 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -377,6 +377,8 @@ href: cloud/gateway-routing.md - name: Strangler Fig href: cloud/strangler-fig.md + - name: Sidecar + href: cloud/sidecar.md - name: Application Architecture items: - name: Anti-Corruption Layer diff --git a/src/PatternKit.Core/Cloud/Sidecar/Sidecar.cs b/src/PatternKit.Core/Cloud/Sidecar/Sidecar.cs new file mode 100644 index 00000000..acdf8bbf --- /dev/null +++ b/src/PatternKit.Core/Cloud/Sidecar/Sidecar.cs @@ -0,0 +1,169 @@ +namespace PatternKit.Cloud.Sidecar; + +public sealed class SidecarContext +{ + internal SidecarContext(TRequest request) + => Request = request; + + public TRequest Request { get; } + + public IDictionary Items { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public IList Events { get; } = new List(); + + public void Record(string eventName) + { + if (string.IsNullOrWhiteSpace(eventName)) + throw new ArgumentException("Event name is required.", nameof(eventName)); + + Events.Add(eventName); + } +} + +public sealed class SidecarResult +{ + private SidecarResult(string sidecarName, TResponse? response, IReadOnlyList events, Exception? exception, bool succeeded) + => (SidecarName, Response, Events, Exception, Succeeded) = (sidecarName, response, events, exception, succeeded); + + public string SidecarName { get; } + + public TResponse? Response { get; } + + public IReadOnlyList Events { get; } + + public Exception? Exception { get; } + + public bool Succeeded { get; } + + public bool Failed => !Succeeded; + + public static SidecarResult Success(string sidecarName, TResponse response, IReadOnlyList events) + => new(sidecarName, response ?? throw new ArgumentNullException(nameof(response)), events, null, true); + + public static SidecarResult Failure(string sidecarName, IReadOnlyList events, Exception exception) + => new(sidecarName, default, events, exception ?? throw new ArgumentNullException(nameof(exception)), false); +} + +public sealed class Sidecar +{ + private readonly IReadOnlyList _before; + private readonly IReadOnlyList _after; + private readonly Func, TResponse> _handler; + + private Sidecar(string name, IReadOnlyList before, IReadOnlyList after, Func, TResponse>? handler) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Sidecar name is required.", nameof(name)); + if (before is null) + throw new ArgumentNullException(nameof(before)); + if (after is null) + throw new ArgumentNullException(nameof(after)); + if (before.Count == 0 && after.Count == 0) + throw new InvalidOperationException("Sidecar requires at least one companion step."); + + Name = name; + _before = before; + _after = after; + _handler = handler ?? throw new InvalidOperationException("Sidecar requires a primary handler."); + } + + public string Name { get; } + + public SidecarResult Invoke(TRequest request) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + var context = new SidecarContext(request); + try + { + foreach (var step in _before) + { + step.Execute(context); + context.Record(step.Name); + } + + var response = _handler(context); + if (response is null) + return SidecarResult.Failure(Name, context.Events.ToArray(), new InvalidOperationException("Sidecar handler returned null.")); + + foreach (var step in _after) + { + step.Execute(context, response); + context.Record(step.Name); + } + + return SidecarResult.Success(Name, response, context.Events.ToArray()); + } + catch (Exception ex) + { + return SidecarResult.Failure(Name, context.Events.ToArray(), ex); + } + } + + public static Builder Create(string name = "sidecar") => new(name); + + public sealed class Builder + { + private readonly string _name; + private readonly List _before = []; + private readonly List _after = []; + private Func, TResponse>? _handler; + + internal Builder(string name) => _name = name; + + public Builder Before(string name, Action> step) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Sidecar step name is required.", nameof(name)); + if (step is null) + throw new ArgumentNullException(nameof(step)); + if (_before.Any(item => string.Equals(item.Name, name, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException($"Sidecar before step '{name}' is already registered."); + + _before.Add(new(name, step)); + return this; + } + + public Builder After(string name, Action, TResponse> step) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Sidecar step name is required.", nameof(name)); + if (step is null) + throw new ArgumentNullException(nameof(step)); + if (_after.Any(item => string.Equals(item.Name, name, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException($"Sidecar after step '{name}' is already registered."); + + _after.Add(new(name, step)); + return this; + } + + public Builder Handle(Func, TResponse> handler) + { + _handler = handler ?? throw new ArgumentNullException(nameof(handler)); + return this; + } + + public Sidecar Build() => new(_name, _before.ToArray(), _after.ToArray(), _handler); + } + + private sealed class BeforeStep + { + public BeforeStep(string name, Action> execute) + => (Name, Execute) = (name, execute); + + public string Name { get; } + + public Action> Execute { get; } + } + + private sealed class AfterStep + { + public AfterStep(string name, Action, TResponse> execute) + => (Name, Execute) = (name, execute); + + public string Name { get; } + + public Action, TResponse> Execute { get; } + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index f308d465..d54347d0 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -59,6 +59,7 @@ using PatternKit.Examples.RepositoryDemo; using PatternKit.Examples.RetryDemo; using PatternKit.Examples.ServiceLayerDemo; +using PatternKit.Examples.SidecarDemo; using PatternKit.Examples.Singleton; using PatternKit.Examples.SpecificationDemo; using PatternKit.Examples.StranglerFigDemo; @@ -206,6 +207,7 @@ public sealed record TenantExternalConfigurationStoreExample(TenantExternalConfi public sealed record CustomerDashboardGatewayAggregationExample(CustomerDashboardGatewayAggregationDemoRunner Runner, CustomerDashboardGatewayService Service); public sealed record CheckoutStranglerFigExample(CheckoutStranglerFigDemoRunner Runner, CheckoutMigrationService Service); public sealed record ProductGatewayRoutingExample(ProductGatewayRoutingDemoRunner Runner, ProductGatewayRoutingService Service); +public sealed record OrderTelemetrySidecarExample(OrderTelemetrySidecarDemoRunner Runner, OrderTelemetrySidecarService Service); /// /// Fluent registration helpers for importing every documented PatternKit example into Microsoft.Extensions.DependencyInjection. @@ -297,7 +299,8 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddTenantExternalConfigurationStoreExample() .AddCustomerDashboardGatewayAggregationExample() .AddCheckoutStranglerFigExample() - .AddProductGatewayRoutingExample(); + .AddProductGatewayRoutingExample() + .AddOrderTelemetrySidecarExample(); public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services) { @@ -1053,6 +1056,15 @@ public static IServiceCollection AddProductGatewayRoutingExample(this IServiceCo return services.RegisterExample("Product Gateway Routing", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore); } + public static IServiceCollection AddOrderTelemetrySidecarExample(this IServiceCollection services) + { + services.AddOrderTelemetrySidecarDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("Order Telemetry Sidecar", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore); + } + private static IServiceCollection RegisterExample( this IServiceCollection services, string name, diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 865eb2af..e20eef67 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -751,7 +751,15 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog "docs/examples/product-gateway-routing.md", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore, ["Gateway Routing"], - ["path-based downstream dispatch", "source-generated gateway router", "ASP.NET Core endpoint mapping"]) + ["path-based downstream dispatch", "source-generated gateway router", "ASP.NET Core endpoint mapping"]), + Descriptor( + "Order Telemetry Sidecar", + "src/PatternKit.Examples/SidecarDemo/OrderTelemetrySidecarDemo.cs", + "test/PatternKit.Examples.Tests/SidecarDemo/OrderTelemetrySidecarDemoTests.cs", + "docs/examples/order-telemetry-sidecar.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore, + ["Sidecar"], + ["trace context enrichment", "source-generated sidecar factory", "ASP.NET Core endpoint mapping"]) ]; public IReadOnlyList Entries => Items; diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 1489bf05..41d28054 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -948,6 +948,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/GatewayRoutingDemo/ProductGatewayRoutingDemoTests.cs", ["fluent route dispatch", "generated gateway routing factory", "DI-importable ASP.NET Core product gateway example"]), + Pattern("Sidecar", PatternFamily.CloudArchitecture, + "docs/patterns/cloud/sidecar.md", + "src/PatternKit.Core/Cloud/Sidecar/Sidecar.cs", + "test/PatternKit.Tests/Cloud/Sidecar/SidecarTests.cs", + "docs/generators/sidecar.md", + "src/PatternKit.Generators/Sidecar/SidecarGenerator.cs", + "test/PatternKit.Generators.Tests/SidecarGeneratorTests.cs", + null, + "docs/examples/order-telemetry-sidecar.md", + "src/PatternKit.Examples/SidecarDemo/OrderTelemetrySidecarDemo.cs", + "test/PatternKit.Examples.Tests/SidecarDemo/OrderTelemetrySidecarDemoTests.cs", + ["fluent companion pipeline", "generated sidecar factory", "DI-importable ASP.NET Core order telemetry example"]), + Pattern("CQRS", PatternFamily.ApplicationArchitecture, "docs/generators/dispatcher.md", "src/PatternKit.Core/Behavioral/Mediator/Mediator.cs", diff --git a/src/PatternKit.Examples/SidecarDemo/OrderTelemetrySidecarDemo.cs b/src/PatternKit.Examples/SidecarDemo/OrderTelemetrySidecarDemo.cs new file mode 100644 index 00000000..7eecf99c --- /dev/null +++ b/src/PatternKit.Examples/SidecarDemo/OrderTelemetrySidecarDemo.cs @@ -0,0 +1,91 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Cloud.Sidecar; +using PatternKit.Generators.Sidecar; + +namespace PatternKit.Examples.SidecarDemo; + +public sealed record OrderTelemetryRequest(string OrderId, decimal Total); + +public sealed record OrderTelemetryResponse(string Confirmation, string TraceId); + +public interface IOrderTelemetrySink +{ + void Capture(string eventName, string value); +} + +public sealed class DemoOrderTelemetrySink : IOrderTelemetrySink +{ + public List Captured { get; } = []; + + public void Capture(string eventName, string value) => Captured.Add($"{eventName}:{value}"); +} + +public sealed class OrderTelemetrySidecarService(Sidecar sidecar) +{ + public SidecarResult Submit(OrderTelemetryRequest request) => sidecar.Invoke(request); +} + +public static class OrderTelemetrySidecars +{ + public static Sidecar CreateFluent(IOrderTelemetrySink telemetry) + => Sidecar.Create("order-telemetry-sidecar") + .Before("trace-context", AddTraceContext) + .After("telemetry", (ctx, response) => telemetry.Capture("order.accepted", response.Confirmation)) + .Handle(SubmitOrder) + .Build(); + + public static void AddTraceContext(SidecarContext ctx) + => ctx.Items["trace-id"] = $"trace-{ctx.Request.OrderId}"; + + public static OrderTelemetryResponse SubmitOrder(SidecarContext ctx) + => new($"ACCEPTED-{ctx.Request.OrderId}", (string)ctx.Items["trace-id"]); +} + +[GenerateSidecar(typeof(OrderTelemetryRequest), typeof(OrderTelemetryResponse), FactoryMethodName = "Create", SidecarName = "order-telemetry-sidecar")] +public static partial class GeneratedOrderTelemetrySidecar +{ + [SidecarBefore("trace-context")] + private static void AddTraceContext(SidecarContext ctx) + => OrderTelemetrySidecars.AddTraceContext(ctx); + + [SidecarAfter("telemetry")] + private static void CaptureTelemetry(SidecarContext ctx, OrderTelemetryResponse response) + => ctx.Items["telemetry"] = response.Confirmation; + + [SidecarHandler] + private static OrderTelemetryResponse SubmitOrder(SidecarContext ctx) + => OrderTelemetrySidecars.SubmitOrder(ctx); +} + +public sealed class OrderTelemetrySidecarDemoRunner(OrderTelemetrySidecarService service) +{ + public SidecarResult RunGenerated(OrderTelemetryRequest request) => service.Submit(request); + + public static SidecarResult RunFluent(OrderTelemetryRequest request) + => OrderTelemetrySidecars.CreateFluent(new DemoOrderTelemetrySink()).Invoke(request); +} + +public static class OrderTelemetrySidecarServiceCollectionExtensions +{ + public static IServiceCollection AddOrderTelemetrySidecarDemo(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(static _ => GeneratedOrderTelemetrySidecar.Create()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } + + public static IEndpointRouteBuilder MapOrderTelemetrySidecar(this IEndpointRouteBuilder endpoints, string pattern = "/orders/{orderId}/sidecar") + { + endpoints.MapPost(pattern, (string orderId, OrderTelemetryRequestBody body, OrderTelemetrySidecarService service) => + Results.Ok(service.Submit(new OrderTelemetryRequest(orderId, body.Total)))) + .WithName("OrderTelemetrySidecar"); + return endpoints; + } +} + +public sealed record OrderTelemetryRequestBody(decimal Total); diff --git a/src/PatternKit.Generators.Abstractions/Cloud/SidecarAttributes.cs b/src/PatternKit.Generators.Abstractions/Cloud/SidecarAttributes.cs new file mode 100644 index 00000000..abba9bc7 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Cloud/SidecarAttributes.cs @@ -0,0 +1,30 @@ +namespace PatternKit.Generators.Sidecar; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateSidecarAttribute(Type requestType, Type responseType) : Attribute +{ + public Type RequestType { get; } = requestType ?? throw new ArgumentNullException(nameof(requestType)); + + public Type ResponseType { get; } = responseType ?? throw new ArgumentNullException(nameof(responseType)); + + public string FactoryMethodName { get; set; } = "Create"; + + public string SidecarName { get; set; } = "sidecar"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class SidecarBeforeAttribute(string name) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Before step name is required.", nameof(name)) : name; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class SidecarAfterAttribute(string name) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("After step name is required.", nameof(name)) : name; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class SidecarHandlerAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 2de60fe3..778ea0e1 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -315,6 +315,10 @@ PKSF001 | PatternKit.Generators.StranglerFig | Error | Strangler Fig host must b PKSF002 | PatternKit.Generators.StranglerFig | Error | Strangler Fig members are missing. PKSF003 | PatternKit.Generators.StranglerFig | Error | Strangler Fig method signature is invalid. PKSF004 | PatternKit.Generators.StranglerFig | Error | Strangler Fig route is duplicated. +PKSC001 | PatternKit.Generators.Sidecar | Error | Sidecar host must be partial. +PKSC002 | PatternKit.Generators.Sidecar | Error | Sidecar members are missing. +PKSC003 | PatternKit.Generators.Sidecar | Error | Sidecar method signature is invalid. +PKSC004 | PatternKit.Generators.Sidecar | Error | Sidecar step is duplicated. PKMS001 | PatternKit.Generators.Messaging | Error | Message store type must be partial. PKMS002 | PatternKit.Generators.Messaging | Error | Message store identity signature is invalid. PKMS003 | PatternKit.Generators.Messaging | Error | Message store retention signature is invalid. diff --git a/src/PatternKit.Generators/Sidecar/SidecarGenerator.cs b/src/PatternKit.Generators/Sidecar/SidecarGenerator.cs new file mode 100644 index 00000000..614ab10b --- /dev/null +++ b/src/PatternKit.Generators/Sidecar/SidecarGenerator.cs @@ -0,0 +1,217 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.Sidecar; + +[Generator] +public sealed class SidecarGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.Sidecar.GenerateSidecarAttribute"; + private const string BeforeAttributeName = "PatternKit.Generators.Sidecar.SidecarBeforeAttribute"; + private const string AfterAttributeName = "PatternKit.Generators.Sidecar.SidecarAfterAttribute"; + private const string HandlerAttributeName = "PatternKit.Generators.Sidecar.SidecarHandlerAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKSC001", "Sidecar host must be partial", + "Type '{0}' is marked with [GenerateSidecar] but is not declared as partial", + "PatternKit.Generators.Sidecar", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingMembers = new( + "PKSC002", "Sidecar members are missing", + "Sidecar type '{0}' must declare at least one companion step and exactly one handler", + "PatternKit.Generators.Sidecar", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidMember = new( + "PKSC003", "Sidecar method signature is invalid", + "Sidecar method '{0}' has an invalid static signature for the configured request or response type", + "PatternKit.Generators.Sidecar", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor DuplicateStep = new( + "PKSC004", "Sidecar step is duplicated", + "Sidecar step name '{0}' is duplicated", + "PatternKit.Generators.Sidecar", DiagnosticSeverity.Error, true); + + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + AttributeName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static a => a.AttributeClass?.ToDisplayString() == AttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol type, TypeDeclarationSyntax node, AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var requestType = attribute.ConstructorArguments.Length >= 1 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + var responseType = attribute.ConstructorArguments.Length >= 2 ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol : null; + if (requestType is null || responseType is null) + return; + + var before = NamedMembers(type, BeforeAttributeName); + var after = NamedMembers(type, AfterAttributeName); + var handlers = MembersWith(type, HandlerAttributeName); + if (before.Length + after.Length == 0 || handlers.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingMembers, node.Identifier.GetLocation(), type.Name)); + return; + } + + var duplicate = before.Concat(after).GroupBy(static item => item.Name, StringComparer.OrdinalIgnoreCase).FirstOrDefault(static group => group.Count() > 1); + if (duplicate is not null) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateStep, node.Identifier.GetLocation(), duplicate.Key)); + return; + } + + var invalidBefore = before.FirstOrDefault(item => !IsBefore(item.Method, requestType)); + if (invalidBefore is not null) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, invalidBefore.Method.Locations.FirstOrDefault(), invalidBefore.Method.Name)); + return; + } + + var invalidAfter = after.FirstOrDefault(item => !IsAfter(item.Method, requestType, responseType)); + if (invalidAfter is not null) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, invalidAfter.Method.Locations.FirstOrDefault(), invalidAfter.Method.Name)); + return; + } + + if (!IsHandler(handlers[0], requestType, responseType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, handlers[0].Locations.FirstOrDefault(), handlers[0].Name)); + return; + } + + context.AddSource($"{type.Name}.Sidecar.g.cs", SourceText.From(GenerateSource( + type, + requestType, + responseType, + before, + after, + handlers[0].Name, + GetNamedString(attribute, "FactoryMethodName") ?? "Create", + GetNamedString(attribute, "SidecarName") ?? "sidecar"), Encoding.UTF8)); + } + + private static NamedMember[] NamedMembers(INamedTypeSymbol type, string attributeName) + => type.GetMembers().OfType() + .Select(method => new + { + Method = method, + Attribute = method.GetAttributes().FirstOrDefault(attr => attr.AttributeClass?.ToDisplayString() == attributeName) + }) + .Where(static item => item.Attribute is not null) + .Select(static item => new NamedMember((string)item.Attribute!.ConstructorArguments[0].Value!, item.Method)) + .ToArray(); + + private static IMethodSymbol[] MembersWith(INamedTypeSymbol type, string attributeName) + => type.GetMembers().OfType() + .Where(method => method.GetAttributes().Any(attr => attr.AttributeClass?.ToDisplayString() == attributeName)) + .ToArray(); + + private static bool IsBefore(IMethodSymbol method, INamedTypeSymbol requestType) + => method.IsStatic && + method.ReturnsVoid && + method.Parameters.Length == 1 && + IsSidecarContext(method.Parameters[0].Type, requestType); + + private static bool IsAfter(IMethodSymbol method, INamedTypeSymbol requestType, INamedTypeSymbol responseType) + => method.IsStatic && + method.ReturnsVoid && + method.Parameters.Length == 2 && + IsSidecarContext(method.Parameters[0].Type, requestType) && + SymbolEqualityComparer.Default.Equals(method.Parameters[1].Type, responseType); + + private static bool IsHandler(IMethodSymbol method, INamedTypeSymbol requestType, INamedTypeSymbol responseType) + => method.IsStatic && + SymbolEqualityComparer.Default.Equals(method.ReturnType, responseType) && + method.Parameters.Length == 1 && + IsSidecarContext(method.Parameters[0].Type, requestType); + + private static bool IsSidecarContext(ITypeSymbol type, INamedTypeSymbol requestType) + => type is INamedTypeSymbol contextType && + contextType.ConstructedFrom.ToDisplayString() == "PatternKit.Cloud.Sidecar.SidecarContext" && + contextType.TypeArguments.Length == 1 && + SymbolEqualityComparer.Default.Equals(contextType.TypeArguments[0], requestType); + + private static string GenerateSource(INamedTypeSymbol type, INamedTypeSymbol requestType, INamedTypeSymbol responseType, IReadOnlyList before, IReadOnlyList after, string handlerName, string factoryMethodName, string sidecarName) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var requestTypeName = requestType.ToDisplayString(TypeFormat); + var responseTypeName = responseType.ToDisplayString(TypeFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsAbstract && type.TypeKind == TypeKind.Class) + sb.Append("abstract "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.Cloud.Sidecar.Sidecar<") + .Append(requestTypeName).Append(", ").Append(responseTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" return global::PatternKit.Cloud.Sidecar.Sidecar<") + .Append(requestTypeName).Append(", ").Append(responseTypeName).Append(">.Create(\"").Append(Escape(sidecarName)).AppendLine("\")"); + foreach (var step in before) + sb.Append(" .Before(\"").Append(Escape(step.Name)).Append("\", ").Append(step.Method.Name).AppendLine(")"); + foreach (var step in after) + sb.Append(" .After(\"").Append(Escape(step.Name)).Append("\", ").Append(step.Method.Name).AppendLine(")"); + sb.Append(" .Handle(").Append(handlerName).AppendLine(")"); + sb.AppendLine(" .Build();"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; + + private sealed record NamedMember(string Name, IMethodSymbol Method); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index e8d6a20f..bea00729 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -83,6 +83,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Gateway Aggregation", "Strangler Fig", "Gateway Routing", + "Sidecar", "CQRS", "Specification", "Repository", @@ -140,7 +141,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() { ScenarioExpect.Equal(30, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); - ScenarioExpect.Equal(12, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); + ScenarioExpect.Equal(13, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); ScenarioExpect.Equal(15, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Examples.Tests/SidecarDemo/OrderTelemetrySidecarDemoTests.cs b/test/PatternKit.Examples.Tests/SidecarDemo/OrderTelemetrySidecarDemoTests.cs new file mode 100644 index 00000000..eaf0962b --- /dev/null +++ b/test/PatternKit.Examples.Tests/SidecarDemo/OrderTelemetrySidecarDemoTests.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.ProductionReadiness; +using PatternKit.Examples.SidecarDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.SidecarDemo; + +[Feature("Order Telemetry Sidecar example")] +public sealed class OrderTelemetrySidecarDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent order telemetry sidecar wraps order submission")] + [Fact] + public Task Fluent_Order_Telemetry_Sidecar_Wraps_Order_Submission() + => Given("the fluent order telemetry sidecar", () => OrderTelemetrySidecars.CreateFluent(new DemoOrderTelemetrySink())) + .When("an order is submitted", sidecar => sidecar.Invoke(new OrderTelemetryRequest("O-100", 42m))) + .Then("trace and telemetry sidecar steps surround the primary handler", result => + { + ScenarioExpect.True(result.Succeeded); + ScenarioExpect.Equal("ACCEPTED-O-100", result.Response!.Confirmation); + ScenarioExpect.Equal(["trace-context", "telemetry"], result.Events); + }) + .AssertPassed(); + + [Scenario("Generated order telemetry sidecar is importable through IServiceCollection")] + [Fact] + public Task Generated_Order_Telemetry_Sidecar_Is_Importable_Through_IServiceCollection() + => Given("a service collection with the sidecar example", () => + { + var services = new ServiceCollection(); + services.AddOrderTelemetrySidecarExample(); + return services.BuildServiceProvider(); + }) + .When("the demo runner submits an order", provider => provider.GetRequiredService().Runner.RunGenerated(new OrderTelemetryRequest("O-200", 50m))) + .Then("the generated sidecar returns a traced response", result => + { + ScenarioExpect.True(result.Succeeded); + ScenarioExpect.Equal("trace-O-200", result.Response!.TraceId); + }) + .AssertPassed(); + + [Scenario("Order Telemetry Sidecar example is cataloged as production ready")] + [Fact] + public Task Order_Telemetry_Sidecar_Example_Is_Cataloged_As_Production_Ready() + => Given("the production readiness catalogs", () => new { Examples = new PatternKitExampleCatalog(), Patterns = new PatternKitPatternCatalog() }) + .Then("the example catalog includes order telemetry sidecar", catalogs => + ScenarioExpect.Contains(catalogs.Examples.Entries, entry => entry.Name == "Order Telemetry Sidecar" && entry.Integration.HasFlag(ExampleIntegrationSurface.DependencyInjection))) + .And("the pattern catalog includes Sidecar", catalogs => + ScenarioExpect.Contains(catalogs.Patterns.Patterns, pattern => pattern.Name == "Sidecar" && pattern.Implementation.HasSourceGeneratedPath)) + .AssertPassed(); +} diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 9c8c9a5c..98e4048e 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -36,6 +36,7 @@ using PatternKit.Generators.Repository; using PatternKit.Generators.Retry; using PatternKit.Generators.ServiceLayer; +using PatternKit.Generators.Sidecar; using PatternKit.Generators.Singleton; using PatternKit.Generators.Specification; using PatternKit.Generators.State; @@ -230,6 +231,10 @@ private enum TestTrigger { typeof(GatewayRouteAttribute), AttributeTargets.Method, false, false }, { typeof(GatewayRouteHandlerAttribute), AttributeTargets.Method, false, false }, { typeof(GatewayRouteFallbackAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateSidecarAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(SidecarBeforeAttribute), AttributeTargets.Method, false, false }, + { typeof(SidecarAfterAttribute), AttributeTargets.Method, false, false }, + { typeof(SidecarHandlerAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateStranglerFigAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(StranglerFigRouteAttribute), AttributeTargets.Method, false, false }, { typeof(StranglerFigLegacyAttribute), AttributeTargets.Method, false, false }, @@ -537,6 +542,31 @@ public void GatewayRouting_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.Throws(() => new GatewayRouteFallbackAttribute("")); } + [Scenario("Sidecar Attributes Expose Defaults And Configuration")] + [Fact] + public void Sidecar_Attributes_Expose_Defaults_And_Configuration() + { + var sidecar = new GenerateSidecarAttribute(typeof(string), typeof(int)) + { + FactoryMethodName = "BuildOrderSidecar", + SidecarName = "order-sidecar" + }; + var before = new SidecarBeforeAttribute("trace"); + var after = new SidecarAfterAttribute("metrics"); + + ScenarioExpect.Equal(typeof(string), sidecar.RequestType); + ScenarioExpect.Equal(typeof(int), sidecar.ResponseType); + ScenarioExpect.Equal("BuildOrderSidecar", sidecar.FactoryMethodName); + ScenarioExpect.Equal("order-sidecar", sidecar.SidecarName); + ScenarioExpect.Equal("trace", before.Name); + ScenarioExpect.Equal("metrics", after.Name); + ScenarioExpect.Throws(() => new GenerateSidecarAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateSidecarAttribute(typeof(string), null!)); + ScenarioExpect.Throws(() => new SidecarBeforeAttribute("")); + ScenarioExpect.Throws(() => new SidecarAfterAttribute("")); + ScenarioExpect.IsType(new SidecarHandlerAttribute()); + } + [Scenario("Strangler Fig Attributes Expose Defaults And Configuration")] [Fact] public void StranglerFig_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/SidecarGeneratorTests.cs b/test/PatternKit.Generators.Tests/SidecarGeneratorTests.cs new file mode 100644 index 00000000..6031254e --- /dev/null +++ b/test/PatternKit.Generators.Tests/SidecarGeneratorTests.cs @@ -0,0 +1,111 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Cloud.Sidecar; +using PatternKit.Generators.Sidecar; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Sidecar generator")] +public sealed partial class SidecarGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates Sidecar factory")] + [Fact] + public Task Generates_Sidecar_Factory() + => Given("a Sidecar declaration", () => Compile(""" + using PatternKit.Cloud.Sidecar; + using PatternKit.Generators.Sidecar; + namespace Demo; + public sealed record OrderRequest(string OrderId); + public sealed record OrderResponse(string Confirmation); + [GenerateSidecar(typeof(OrderRequest), typeof(OrderResponse), FactoryMethodName = "Build", SidecarName = "order-sidecar")] + public static partial class OrderSidecars + { + [SidecarBefore("trace")] + private static void Trace(SidecarContext ctx) => ctx.Items["trace-id"] = "trace-1"; + [SidecarAfter("metrics")] + private static void Metrics(SidecarContext ctx, OrderResponse response) => ctx.Items["confirmation"] = response.Confirmation; + [SidecarHandler] + private static OrderResponse Handle(SidecarContext ctx) => new(ctx.Request.OrderId); + } + """)) + .Then("the generated source creates the configured sidecar", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("Build()", source); + ScenarioExpect.Contains("Sidecar.Create(\"order-sidecar\")", source); + ScenarioExpect.Contains(".Before(\"trace\", Trace)", source); + ScenarioExpect.Contains(".After(\"metrics\", Metrics)", source); + ScenarioExpect.Contains(".Handle(Handle)", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid Sidecar declarations")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Sidecar_Declarations() + => Given("invalid Sidecar declarations", () => new[] + { + Compile(""" + using PatternKit.Generators.Sidecar; + [GenerateSidecar(typeof(string), typeof(int))] + public static class SidecarHost; + """), + Compile(""" + using PatternKit.Generators.Sidecar; + [GenerateSidecar(typeof(string), typeof(int))] + public static partial class SidecarHost; + """), + Compile(""" + using PatternKit.Cloud.Sidecar; + using PatternKit.Generators.Sidecar; + [GenerateSidecar(typeof(string), typeof(int))] + public static partial class SidecarHost + { + [SidecarBefore("trace")] + private static string Trace(SidecarContext ctx) => ctx.Request; + [SidecarHandler] + private static int Handle(SidecarContext ctx) => 1; + } + """), + Compile(""" + using PatternKit.Cloud.Sidecar; + using PatternKit.Generators.Sidecar; + [GenerateSidecar(typeof(string), typeof(int))] + public static partial class SidecarHost + { + [SidecarBefore("trace")] + private static void Trace(SidecarContext ctx) { } + [SidecarAfter("TRACE")] + private static void Metrics(SidecarContext ctx, int response) { } + [SidecarHandler] + private static int Handle(SidecarContext ctx) => 1; + } + """) + }) + .Then("diagnostics identify invalid declarations", results => + { + var ids = results.SelectMany(static result => result.Diagnostics.Select(static diagnostic => diagnostic.Id)).ToArray(); + ScenarioExpect.Contains(ids, static id => id == "PKSC001"); + ScenarioExpect.Contains(ids, static id => id == "PKSC002"); + ScenarioExpect.Contains(ids, static id => id == "PKSC003"); + ScenarioExpect.Contains(ids, static id => id == "PKSC004"); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "SidecarGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(Sidecar<,>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new SidecarGenerator(), out var run, out var updated); + var result = run.Results.Single(); + var emit = updated.Emit(Stream.Null); + return new(result.Diagnostics.ToArray(), result.GeneratedSources.Select(static source => source.SourceText.ToString()).ToArray(), emit.Success, emit.Diagnostics.Select(static diagnostic => diagnostic.ToString()).ToArray()); + } + + private sealed record GeneratorResult(IReadOnlyList Diagnostics, IReadOnlyList GeneratedSources, bool EmitSuccess, IReadOnlyList EmitDiagnostics); +} diff --git a/test/PatternKit.Tests/Cloud/Sidecar/SidecarTests.cs b/test/PatternKit.Tests/Cloud/Sidecar/SidecarTests.cs new file mode 100644 index 00000000..a7b2fff3 --- /dev/null +++ b/test/PatternKit.Tests/Cloud/Sidecar/SidecarTests.cs @@ -0,0 +1,78 @@ +using PatternKit.Cloud.Sidecar; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Cloud.Sidecar; + +[Feature("Sidecar")] +public sealed class SidecarTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Sidecar invokes companion steps around primary handler")] + [Fact] + public Task Sidecar_Invokes_Companion_Steps_Around_Primary_Handler() + => Given("an order sidecar", CreateSidecar) + .When("an order request is invoked", sidecar => sidecar.Invoke(new OrderRequest("O-100"))) + .Then("the sidecar enriches and observes the primary operation", result => + { + ScenarioExpect.True(result.Succeeded); + ScenarioExpect.Equal("order-sidecar", result.SidecarName); + ScenarioExpect.Equal("accepted:O-100:trace-1", result.Response!.Confirmation); + ScenarioExpect.Equal(["trace", "metrics"], result.Events); + }) + .AssertPassed(); + + [Scenario("Sidecar reports companion or handler failures")] + [Fact] + public Task Sidecar_Reports_Companion_Or_Handler_Failures() + => Given("a sidecar with a failing companion", () => Sidecar.Create("order-sidecar") + .Before("trace", static ctx => ctx.Items["trace-id"] = "trace-1") + .After("metrics", static (_, _) => throw new InvalidOperationException("metrics unavailable")) + .Handle(static ctx => new OrderResponse($"accepted:{ctx.Request.OrderId}")) + .Build()) + .When("the operation is invoked", sidecar => sidecar.Invoke(new OrderRequest("O-100"))) + .Then("the failed result preserves completed companion events", result => + { + ScenarioExpect.True(result.Failed); + ScenarioExpect.Equal(["trace"], result.Events); + ScenarioExpect.Contains("metrics unavailable", result.Exception!.Message); + }) + .AssertPassed(); + + [Scenario("Sidecar validates configuration")] + [Fact] + public Task Sidecar_Validates_Configuration() + => Given("invalid sidecar inputs", () => true) + .Then("invalid names are rejected", _ => + ScenarioExpect.Throws(() => Sidecar.Create("") + .Before("trace", AddTrace) + .Handle(Handle) + .Build())) + .And("missing companion steps are rejected", _ => + ScenarioExpect.Throws(() => Sidecar.Create().Handle(Handle).Build())) + .And("missing handlers are rejected", _ => + ScenarioExpect.Throws(() => Sidecar.Create().Before("trace", AddTrace).Build())) + .And("duplicate companion names are rejected", _ => + ScenarioExpect.Throws(() => Sidecar.Create() + .Before("trace", AddTrace) + .Before("TRACE", AddTrace))) + .And("null requests are rejected", _ => + ScenarioExpect.Throws(() => CreateSidecar().Invoke(null!))) + .AssertPassed(); + + private static Sidecar CreateSidecar() + => Sidecar.Create("order-sidecar") + .Before("trace", AddTrace) + .After("metrics", static (ctx, response) => ctx.Items["confirmation"] = response.Confirmation) + .Handle(Handle) + .Build(); + + private static void AddTrace(SidecarContext ctx) => ctx.Items["trace-id"] = "trace-1"; + + private static OrderResponse Handle(SidecarContext ctx) + => new($"accepted:{ctx.Request.OrderId}:{ctx.Items["trace-id"]}"); + + private sealed record OrderRequest(string OrderId); + + private sealed record OrderResponse(string Confirmation); +}