From d70796374e63a8c59f2d76ff0f6c039359cd4964 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 22 May 2026 08:01:50 -0500 Subject: [PATCH] feat: add gateway aggregation pattern --- .../customer-dashboard-gateway-aggregation.md | 12 ++ docs/examples/index.md | 3 + docs/examples/toc.yml | 3 + docs/generators/gateway-aggregation.md | 23 ++ docs/generators/index.md | 5 + docs/generators/toc.yml | 3 + docs/guides/pattern-coverage.md | 1 + docs/patterns/cloud/gateway-aggregation.md | 20 ++ docs/patterns/toc.yml | 2 + .../GatewayAggregation/GatewayAggregation.cs | 170 +++++++++++++++ ...rnKitExampleServiceCollectionExtensions.cs | 14 +- ...CustomerDashboardGatewayAggregationDemo.cs | 135 ++++++++++++ .../PatternKitExampleCatalog.cs | 10 +- .../PatternKitPatternCatalog.cs | 13 ++ .../Cloud/GatewayAggregationAttributes.cs | 24 +++ .../AnalyzerReleases.Unshipped.md | 4 + .../GatewayAggregationGenerator.cs | 202 ++++++++++++++++++ ...merDashboardGatewayAggregationDemoTests.cs | 48 +++++ .../PatternKitPatternCatalogTests.cs | 3 +- .../AbstractionsAttributeCoverageTests.cs | 26 +++ .../GatewayAggregationGeneratorTests.cs | 112 ++++++++++ .../GatewayAggregationTests.cs | 83 +++++++ 22 files changed, 913 insertions(+), 3 deletions(-) create mode 100644 docs/examples/customer-dashboard-gateway-aggregation.md create mode 100644 docs/generators/gateway-aggregation.md create mode 100644 docs/patterns/cloud/gateway-aggregation.md create mode 100644 src/PatternKit.Core/Cloud/GatewayAggregation/GatewayAggregation.cs create mode 100644 src/PatternKit.Examples/GatewayAggregationDemo/CustomerDashboardGatewayAggregationDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/Cloud/GatewayAggregationAttributes.cs create mode 100644 src/PatternKit.Generators/GatewayAggregation/GatewayAggregationGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/GatewayAggregationDemo/CustomerDashboardGatewayAggregationDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/GatewayAggregationGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Cloud/GatewayAggregation/GatewayAggregationTests.cs diff --git a/docs/examples/customer-dashboard-gateway-aggregation.md b/docs/examples/customer-dashboard-gateway-aggregation.md new file mode 100644 index 0000000..0f361fe --- /dev/null +++ b/docs/examples/customer-dashboard-gateway-aggregation.md @@ -0,0 +1,12 @@ +# Customer Dashboard Gateway Aggregation + +The customer dashboard gateway aggregation example composes profile, order, and recommendation clients into one API-facing dashboard response. + +```csharp +services.AddCustomerDashboardGatewayAggregationDemo(); + +var runner = provider.GetRequiredService(); +var dashboard = runner.RunGenerated("C-100"); +``` + +The example includes fluent and source-generated construction, an `IServiceCollection` extension, and an ASP.NET Core minimal API mapping through `MapCustomerDashboardGatewayAggregation()`. diff --git a/docs/examples/index.md b/docs/examples/index.md index 3e0cf11..80c2fc6 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -84,6 +84,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Fulfillment Health Endpoint Monitoring** Shows fluent and source-generated health checks with `IServiceCollection`, Generic Host-friendly services, and ASP.NET Core route mapping. See [Fulfillment Health Endpoint Monitoring](fulfillment-health-endpoint-monitoring.md). +* **Customer Dashboard Gateway Aggregation** + Shows fluent and source-generated API gateway aggregation with `IServiceCollection` and ASP.NET Core endpoint mapping. See [Customer Dashboard Gateway Aggregation](customer-dashboard-gateway-aggregation.md). + * **Generated Message Envelope** Shows fluent and source-generated message envelope contracts side by side, with an importable `IServiceCollection` extension. See [Generated Message Envelope](generated-message-envelope.md). diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index f12319b..9bb4baa 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -237,3 +237,6 @@ - name: Tenant External Configuration Store href: tenant-external-configuration-store.md + +- name: Customer Dashboard Gateway Aggregation + href: customer-dashboard-gateway-aggregation.md diff --git a/docs/generators/gateway-aggregation.md b/docs/generators/gateway-aggregation.md new file mode 100644 index 0000000..3465e2e --- /dev/null +++ b/docs/generators/gateway-aggregation.md @@ -0,0 +1,23 @@ +# Gateway Aggregation Generator + +`[GenerateGatewayAggregation]` creates a typed `GatewayAggregation` factory from downstream fetch methods and one composer. + +```csharp +[GenerateGatewayAggregation(typeof(CustomerDashboardRequest), typeof(CustomerDashboardResponse), GatewayName = "customer-dashboard")] +public static partial class CustomerDashboardGateway +{ + [GatewayAggregationFetch("profile")] + private static CustomerProfile Profile(CustomerDashboardRequest request) => new(request.CustomerId, "Ada"); + + [GatewayAggregationComposer] + private static CustomerDashboardResponse Compose(GatewayAggregationContext ctx) + => new(ctx.Require("profile").CustomerId, 0); +} +``` + +Diagnostics: + +- `PKGA001`: host type must be partial. +- `PKGA002`: at least one fetch and exactly one composer are required. +- `PKGA003`: fetch or composer signature is invalid. +- `PKGA004`: fetch names must be unique. diff --git a/docs/generators/index.md b/docs/generators/index.md index 761e064..5f82a38 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -121,6 +121,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Cache-Aside**](cache-aside.md) | Read-through cache policy factories with TTL and cache predicates | `[GenerateCacheAsidePolicy]` | | [**Rate Limiting**](rate-limiting.md) | Key-partitioned fixed-window rate limit policy factories | `[GenerateRateLimitPolicy]` | | [**External Configuration Store**](external-configuration-store.md) | Typed centralized configuration loaders | `[GenerateExternalConfigurationStore]` | +| [**Gateway Aggregation**](gateway-aggregation.md) | API gateway response composition factories | `[GenerateGatewayAggregation]` | ## Quick Reference @@ -293,6 +294,10 @@ public static partial class ProductCatalogCachePolicy { } // Rate limiting - generated tenant or key budget policy [GenerateRateLimitPolicy(typeof(SearchResponse), PermitLimit = 2, WindowMilliseconds = 60000)] public static partial class ProductSearchRateLimitPolicy { } + +// Gateway aggregation - generated downstream response composition +[GenerateGatewayAggregation(typeof(CustomerDashboardRequest), typeof(CustomerDashboardResponse))] +public static partial class CustomerDashboardGateway { } ``` ## Examples diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index c5ce03e..5033535 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -160,6 +160,9 @@ - name: External Configuration Store href: external-configuration-store.md +- name: Gateway Aggregation + href: gateway-aggregation.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 64efce6..0f8bbad 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -86,6 +86,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Cloud Architecture | Cache-Aside | `CacheAsidePolicy` | Cache-Aside generator | | Cloud Architecture | Rate Limiting | `RateLimitPolicy` | Rate Limiting generator | | Cloud Architecture | External Configuration Store | `ExternalConfigurationStore` | External Configuration Store generator | +| Cloud Architecture | Gateway Aggregation | `GatewayAggregation` | Gateway Aggregation 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/gateway-aggregation.md b/docs/patterns/cloud/gateway-aggregation.md new file mode 100644 index 0000000..ee7f951 --- /dev/null +++ b/docs/patterns/cloud/gateway-aggregation.md @@ -0,0 +1,20 @@ +# Gateway Aggregation + +Gateway Aggregation composes several downstream calls behind one API-facing operation. + +```csharp +var gateway = GatewayAggregation + .Create("customer-dashboard") + .Fetch("profile", profiles.GetProfile) + .Fetch("orders", orders.GetOrders) + .Compose(ctx => new( + ctx.Require("profile").CustomerId, + ctx.Require("orders").OpenOrders)) + .Build(); + +var result = gateway.Aggregate(request); +``` + +Use it at an API gateway, BFF, or application facade boundary when the caller needs one response but the data lives behind multiple internal services. The runtime path captures downstream failures per part and returns an explicit aggregate failure when the response cannot be composed. + +The source-generated path uses `[GenerateGatewayAggregation]`, `[GatewayAggregationFetch]`, and `[GatewayAggregationComposer]`. Import the example through `AddCustomerDashboardGatewayAggregationDemo()` or `AddPatternKitExamples()`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 13e6ac0..3522d76 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -371,6 +371,8 @@ href: cloud/rate-limiting.md - name: External Configuration Store href: cloud/external-configuration-store.md + - name: Gateway Aggregation + href: cloud/gateway-aggregation.md - name: Application Architecture items: - name: Anti-Corruption Layer diff --git a/src/PatternKit.Core/Cloud/GatewayAggregation/GatewayAggregation.cs b/src/PatternKit.Core/Cloud/GatewayAggregation/GatewayAggregation.cs new file mode 100644 index 0000000..683aead --- /dev/null +++ b/src/PatternKit.Core/Cloud/GatewayAggregation/GatewayAggregation.cs @@ -0,0 +1,170 @@ +namespace PatternKit.Cloud.GatewayAggregation; + +public sealed class GatewayAggregationPart +{ + private GatewayAggregationPart(string name, object? value, Exception? exception, bool succeeded) + => (Name, Value, Exception, Succeeded) = (name, value, exception, succeeded); + + public string Name { get; } + + public object? Value { get; } + + public Exception? Exception { get; } + + public bool Succeeded { get; } + + public bool Failed => !Succeeded; + + public static GatewayAggregationPart Success(string name, object value) + => new(name, value ?? throw new ArgumentNullException(nameof(value)), null, true); + + public static GatewayAggregationPart Failure(string name, Exception exception) + => new(name, null, exception ?? throw new ArgumentNullException(nameof(exception)), false); +} + +public sealed class GatewayAggregationContext +{ + internal GatewayAggregationContext(TRequest request, IReadOnlyDictionary parts) + => (Request, Parts) = (request, parts); + + public TRequest Request { get; } + + public IReadOnlyDictionary Parts { get; } + + public TPart Require(string name) + { + if (!Parts.TryGetValue(name, out var part)) + throw new InvalidOperationException($"Gateway aggregation part '{name}' is not registered."); + if (part.Failed) + throw new InvalidOperationException($"Gateway aggregation part '{name}' failed.", part.Exception); + if (part.Value is not TPart value) + throw new InvalidOperationException($"Gateway aggregation part '{name}' is not '{typeof(TPart).FullName}'."); + + return value; + } +} + +public sealed class GatewayAggregationResult +{ + private GatewayAggregationResult(string gatewayName, TResponse? response, IReadOnlyDictionary parts, Exception? exception, bool aggregated) + => (GatewayName, Response, Parts, Exception, Aggregated) = (gatewayName, response, parts, exception, aggregated); + + public string GatewayName { get; } + + public TResponse? Response { get; } + + public IReadOnlyDictionary Parts { get; } + + public Exception? Exception { get; } + + public bool Aggregated { get; } + + public bool Failed => !Aggregated; + + public static GatewayAggregationResult Success(string gatewayName, TResponse response, IReadOnlyDictionary parts) + => new(gatewayName, response, parts, null, true); + + public static GatewayAggregationResult Failure(string gatewayName, IReadOnlyDictionary parts, Exception exception) + => new(gatewayName, default, parts, exception ?? throw new ArgumentNullException(nameof(exception)), false); +} + +public sealed class GatewayAggregation +{ + private readonly IReadOnlyList _fetches; + private readonly Func, TResponse> _compose; + + private GatewayAggregation(string name, IReadOnlyList fetches, Func, TResponse>? compose) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Gateway aggregation name is required.", nameof(name)); + if (fetches is null) + throw new ArgumentNullException(nameof(fetches)); + if (fetches.Count == 0) + throw new InvalidOperationException("Gateway aggregation requires at least one downstream fetch."); + + Name = name; + _fetches = fetches; + _compose = compose ?? throw new InvalidOperationException("Gateway aggregation requires a response composer."); + } + + public string Name { get; } + + public GatewayAggregationResult Aggregate(TRequest request) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + var parts = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var fetch in _fetches) + { + try + { + var value = fetch.Execute(request); + if (value is null) + parts[fetch.Name] = GatewayAggregationPart.Failure(fetch.Name, new InvalidOperationException($"Gateway aggregation part '{fetch.Name}' returned null.")); + else + parts[fetch.Name] = GatewayAggregationPart.Success(fetch.Name, value); + } + catch (Exception ex) + { + parts[fetch.Name] = GatewayAggregationPart.Failure(fetch.Name, ex); + } + } + + try + { + var response = _compose(new(request, parts)); + if (response is null) + return GatewayAggregationResult.Failure(Name, parts, new InvalidOperationException("Gateway aggregation composer returned null.")); + + return GatewayAggregationResult.Success(Name, response, parts); + } + catch (Exception ex) + { + return GatewayAggregationResult.Failure(Name, parts, ex); + } + } + + public static Builder Create(string name = "gateway-aggregation") => new(name); + + public sealed class Builder + { + private readonly string _name; + private readonly List _fetches = []; + private Func, TResponse>? _compose; + + internal Builder(string name) => _name = name; + + public Builder Fetch(string name, Func fetch) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Gateway aggregation fetch name is required.", nameof(name)); + if (fetch is null) + throw new ArgumentNullException(nameof(fetch)); + if (_fetches.Any(part => string.Equals(part.Name, name, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException($"Gateway aggregation fetch '{name}' is already registered."); + + _fetches.Add(new(name, request => fetch(request)!)); + return this; + } + + public Builder Compose(Func, TResponse> compose) + { + _compose = compose ?? throw new ArgumentNullException(nameof(compose)); + return this; + } + + public GatewayAggregation Build() + => new(_name, _fetches.ToArray(), _compose); + } + + private sealed class Fetch + { + public Fetch(string name, Func execute) + => (Name, Execute) = (name, execute); + + public string Name { get; } + + public Func Execute { get; } + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 9b3ee94..829bc24 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -37,6 +37,7 @@ using PatternKit.Examples.ExternalConfigurationStoreDemo; using PatternKit.Examples.FeatureToggleDemo; using PatternKit.Examples.FlyweightDemo; +using PatternKit.Examples.GatewayAggregationDemo; using PatternKit.Examples.Generators.Builders.CorporateApplicationBuilderDemo; using PatternKit.Examples.Generators.Visitors; using PatternKit.Examples.HealthEndpointMonitoringDemo; @@ -200,6 +201,7 @@ public sealed record FulfillmentPriorityQueueExample(PriorityQueuePolicy Policy, ProductCatalogCacheAsideService Service); public sealed record ProductSearchRateLimitingExample(RateLimitPolicy Policy, ProductSearchRateLimitService Service); public sealed record TenantExternalConfigurationStoreExample(TenantExternalConfigurationStoreDemoRunner Runner, TenantExternalConfigurationService Service); +public sealed record CustomerDashboardGatewayAggregationExample(CustomerDashboardGatewayAggregationDemoRunner Runner, CustomerDashboardGatewayService Service); /// /// Fluent registration helpers for importing every documented PatternKit example into Microsoft.Extensions.DependencyInjection. @@ -288,7 +290,8 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddFulfillmentPriorityQueueExample() .AddProductCatalogCacheAsideExample() .AddProductSearchRateLimitingExample() - .AddTenantExternalConfigurationStoreExample(); + .AddTenantExternalConfigurationStoreExample() + .AddCustomerDashboardGatewayAggregationExample(); public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services) { @@ -1017,6 +1020,15 @@ public static IServiceCollection AddTenantExternalConfigurationStoreExample(this return services.RegisterExample("Tenant External Configuration Store", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddCustomerDashboardGatewayAggregationExample(this IServiceCollection services) + { + services.AddCustomerDashboardGatewayAggregationDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("Customer Dashboard Gateway Aggregation", 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/GatewayAggregationDemo/CustomerDashboardGatewayAggregationDemo.cs b/src/PatternKit.Examples/GatewayAggregationDemo/CustomerDashboardGatewayAggregationDemo.cs new file mode 100644 index 0000000..2bfb416 --- /dev/null +++ b/src/PatternKit.Examples/GatewayAggregationDemo/CustomerDashboardGatewayAggregationDemo.cs @@ -0,0 +1,135 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Cloud.GatewayAggregation; +using PatternKit.Generators.GatewayAggregation; + +namespace PatternKit.Examples.GatewayAggregationDemo; + +public sealed record CustomerDashboardRequest(string CustomerId); + +public sealed record CustomerProfile(string CustomerId, string Name, string Tier); + +public sealed record CustomerOrderSummary(string CustomerId, int OpenOrders); + +public sealed record CustomerRecommendationSummary(string CustomerId, int RecommendedProducts); + +public sealed record CustomerDashboardResponse(string CustomerId, string Name, string Tier, int OpenOrders, int RecommendedProducts); + +public interface ICustomerProfileClient +{ + CustomerProfile GetProfile(CustomerDashboardRequest request); +} + +public interface ICustomerOrdersClient +{ + CustomerOrderSummary GetOrders(CustomerDashboardRequest request); +} + +public interface ICustomerRecommendationClient +{ + CustomerRecommendationSummary GetRecommendations(CustomerDashboardRequest request); +} + +public sealed class DemoCustomerProfileClient : ICustomerProfileClient +{ + public CustomerProfile GetProfile(CustomerDashboardRequest request) => new(request.CustomerId, "Ada Lovelace", "gold"); +} + +public sealed class DemoCustomerOrdersClient : ICustomerOrdersClient +{ + public CustomerOrderSummary GetOrders(CustomerDashboardRequest request) => new(request.CustomerId, 2); +} + +public sealed class DemoCustomerRecommendationClient : ICustomerRecommendationClient +{ + public CustomerRecommendationSummary GetRecommendations(CustomerDashboardRequest request) => new(request.CustomerId, 4); +} + +public sealed class CustomerDashboardGatewayService(GatewayAggregation gateway) +{ + public CustomerDashboardResponse GetDashboard(string customerId) + { + var result = gateway.Aggregate(new CustomerDashboardRequest(customerId)); + if (result.Failed) + throw new InvalidOperationException("Customer dashboard could not be aggregated.", result.Exception); + + return result.Response!; + } +} + +public static class CustomerDashboardGateways +{ + public static GatewayAggregation CreateFluent( + ICustomerProfileClient profiles, + ICustomerOrdersClient orders, + ICustomerRecommendationClient recommendations) + => GatewayAggregation.Create("customer-dashboard") + .Fetch("profile", profiles.GetProfile) + .Fetch("orders", orders.GetOrders) + .Fetch("recommendations", recommendations.GetRecommendations) + .Compose(Compose) + .Build(); + + public static CustomerDashboardResponse Compose(GatewayAggregationContext ctx) + { + var profile = ctx.Require("profile"); + var orders = ctx.Require("orders"); + var recommendations = ctx.Require("recommendations"); + return new(profile.CustomerId, profile.Name, profile.Tier, orders.OpenOrders, recommendations.RecommendedProducts); + } +} + +[GenerateGatewayAggregation(typeof(CustomerDashboardRequest), typeof(CustomerDashboardResponse), FactoryMethodName = "Create", GatewayName = "customer-dashboard")] +public static partial class GeneratedCustomerDashboardGateway +{ + [GatewayAggregationFetch("profile")] + private static CustomerProfile Profile(CustomerDashboardRequest request) => new(request.CustomerId, "Ada Lovelace", "gold"); + + [GatewayAggregationFetch("orders")] + private static CustomerOrderSummary Orders(CustomerDashboardRequest request) => new(request.CustomerId, 2); + + [GatewayAggregationFetch("recommendations")] + private static CustomerRecommendationSummary Recommendations(CustomerDashboardRequest request) => new(request.CustomerId, 4); + + [GatewayAggregationComposer] + private static CustomerDashboardResponse Compose(GatewayAggregationContext ctx) + => CustomerDashboardGateways.Compose(ctx); +} + +public sealed class CustomerDashboardGatewayAggregationDemoRunner(CustomerDashboardGatewayService service) +{ + public CustomerDashboardResponse RunGenerated(string customerId) => service.GetDashboard(customerId); + + public static CustomerDashboardResponse RunFluent() + { + var gateway = CustomerDashboardGateways.CreateFluent( + new DemoCustomerProfileClient(), + new DemoCustomerOrdersClient(), + new DemoCustomerRecommendationClient()); + var result = gateway.Aggregate(new CustomerDashboardRequest("C-100")); + return result.Response!; + } +} + +public static class CustomerDashboardGatewayAggregationServiceCollectionExtensions +{ + public static IServiceCollection AddCustomerDashboardGatewayAggregationDemo(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(static _ => GeneratedCustomerDashboardGateway.Create()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } + + public static IEndpointRouteBuilder MapCustomerDashboardGatewayAggregation(this IEndpointRouteBuilder endpoints, string pattern = "/customers/{customerId}/dashboard") + { + endpoints.MapGet(pattern, (string customerId, CustomerDashboardGatewayService service) => Results.Ok(service.GetDashboard(customerId))) + .WithName("CustomerDashboardGatewayAggregation"); + return endpoints; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 9851899..f88d6e6 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -727,7 +727,15 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog "docs/examples/tenant-external-configuration-store.md", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["External Configuration Store"], - ["central settings provider", "typed validation", "source-generated store factory", "DI composition"]) + ["central settings provider", "typed validation", "source-generated store factory", "DI composition"]), + Descriptor( + "Customer Dashboard Gateway Aggregation", + "src/PatternKit.Examples/GatewayAggregationDemo/CustomerDashboardGatewayAggregationDemo.cs", + "test/PatternKit.Examples.Tests/GatewayAggregationDemo/CustomerDashboardGatewayAggregationDemoTests.cs", + "docs/examples/customer-dashboard-gateway-aggregation.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore, + ["Gateway Aggregation"], + ["downstream dashboard composition", "source-generated gateway aggregation", "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 fa82cd6..3df02dd 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -909,6 +909,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/ExternalConfigurationStoreDemo/TenantExternalConfigurationStoreDemoTests.cs", ["fluent external configuration store", "generated external configuration store", "DI-importable tenant settings example"]), + Pattern("Gateway Aggregation", PatternFamily.CloudArchitecture, + "docs/patterns/cloud/gateway-aggregation.md", + "src/PatternKit.Core/Cloud/GatewayAggregation/GatewayAggregation.cs", + "test/PatternKit.Tests/Cloud/GatewayAggregation/GatewayAggregationTests.cs", + "docs/generators/gateway-aggregation.md", + "src/PatternKit.Generators/GatewayAggregation/GatewayAggregationGenerator.cs", + "test/PatternKit.Generators.Tests/GatewayAggregationGeneratorTests.cs", + null, + "docs/examples/customer-dashboard-gateway-aggregation.md", + "src/PatternKit.Examples/GatewayAggregationDemo/CustomerDashboardGatewayAggregationDemo.cs", + "test/PatternKit.Examples.Tests/GatewayAggregationDemo/CustomerDashboardGatewayAggregationDemoTests.cs", + ["fluent downstream aggregation", "generated gateway aggregation factory", "DI-importable ASP.NET Core dashboard example"]), + Pattern("CQRS", PatternFamily.ApplicationArchitecture, "docs/generators/dispatcher.md", "src/PatternKit.Core/Behavioral/Mediator/Mediator.cs", diff --git a/src/PatternKit.Generators.Abstractions/Cloud/GatewayAggregationAttributes.cs b/src/PatternKit.Generators.Abstractions/Cloud/GatewayAggregationAttributes.cs new file mode 100644 index 0000000..c85d9fb --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Cloud/GatewayAggregationAttributes.cs @@ -0,0 +1,24 @@ +namespace PatternKit.Generators.GatewayAggregation; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateGatewayAggregationAttribute(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 GatewayName { get; set; } = "gateway-aggregation"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class GatewayAggregationFetchAttribute(string name) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Fetch name is required.", nameof(name)) : name; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class GatewayAggregationComposerAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index d3f8abd..57439a6 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -302,6 +302,10 @@ PKECS004 | PatternKit.Generators.Cloud | Error | External Configuration Store va PKHEM001 | PatternKit.Generators.HealthEndpointMonitoring | Error | Health Endpoint host must be partial. PKHEM002 | PatternKit.Generators.HealthEndpointMonitoring | Error | Health Endpoint checks are missing. PKHEM003 | PatternKit.Generators.HealthEndpointMonitoring | Error | Health Endpoint check signature is invalid. +PKGA001 | PatternKit.Generators.GatewayAggregation | Error | Gateway Aggregation host must be partial. +PKGA002 | PatternKit.Generators.GatewayAggregation | Error | Gateway Aggregation members are missing. +PKGA003 | PatternKit.Generators.GatewayAggregation | Error | Gateway Aggregation method signature is invalid. +PKGA004 | PatternKit.Generators.GatewayAggregation | Error | Gateway Aggregation fetch 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/GatewayAggregation/GatewayAggregationGenerator.cs b/src/PatternKit.Generators/GatewayAggregation/GatewayAggregationGenerator.cs new file mode 100644 index 0000000..22b134b --- /dev/null +++ b/src/PatternKit.Generators/GatewayAggregation/GatewayAggregationGenerator.cs @@ -0,0 +1,202 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.GatewayAggregation; + +[Generator] +public sealed class GatewayAggregationGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.GatewayAggregation.GenerateGatewayAggregationAttribute"; + private const string FetchAttributeName = "PatternKit.Generators.GatewayAggregation.GatewayAggregationFetchAttribute"; + private const string ComposerAttributeName = "PatternKit.Generators.GatewayAggregation.GatewayAggregationComposerAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKGA001", "Gateway Aggregation host must be partial", + "Type '{0}' is marked with [GenerateGatewayAggregation] but is not declared as partial", + "PatternKit.Generators.GatewayAggregation", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingMembers = new( + "PKGA002", "Gateway Aggregation members are missing", + "Gateway Aggregation type '{0}' must declare at least one fetch and exactly one composer", + "PatternKit.Generators.GatewayAggregation", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidMember = new( + "PKGA003", "Gateway Aggregation method signature is invalid", + "Gateway Aggregation method '{0}' has an invalid static signature for the configured request or response type", + "PatternKit.Generators.GatewayAggregation", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor DuplicateFetch = new( + "PKGA004", "Gateway Aggregation fetch is duplicated", + "Gateway Aggregation fetch name '{0}' is duplicated", + "PatternKit.Generators.GatewayAggregation", 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 fetches = FetchMembers(type); + var composers = MembersWith(type, ComposerAttributeName); + if (fetches.Length == 0 || composers.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingMembers, node.Identifier.GetLocation(), type.Name)); + return; + } + + var duplicate = fetches.GroupBy(static item => item.Name, StringComparer.OrdinalIgnoreCase).FirstOrDefault(static group => group.Count() > 1); + if (duplicate is not null) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateFetch, node.Identifier.GetLocation(), duplicate.Key)); + return; + } + + var invalidFetch = fetches.FirstOrDefault(item => !IsFetch(item.Method, requestType)); + if (invalidFetch is not null) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, invalidFetch.Method.Locations.FirstOrDefault(), invalidFetch.Method.Name)); + return; + } + + if (!IsComposer(composers[0], requestType, responseType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, composers[0].Locations.FirstOrDefault(), composers[0].Name)); + return; + } + + context.AddSource($"{type.Name}.GatewayAggregation.g.cs", SourceText.From(GenerateSource( + type, + requestType, + responseType, + fetches, + composers[0].Name, + GetNamedString(attribute, "FactoryMethodName") ?? "Create", + GetNamedString(attribute, "GatewayName") ?? "gateway-aggregation"), Encoding.UTF8)); + } + + private static IMethodSymbol[] MembersWith(INamedTypeSymbol type, string attributeName) + => type.GetMembers().OfType() + .Where(method => method.GetAttributes().Any(attr => attr.AttributeClass?.ToDisplayString() == attributeName)) + .ToArray(); + + private static FetchMember[] FetchMembers(INamedTypeSymbol type) + => type.GetMembers().OfType() + .Select(method => new + { + Method = method, + Attribute = method.GetAttributes().FirstOrDefault(attr => attr.AttributeClass?.ToDisplayString() == FetchAttributeName) + }) + .Where(static item => item.Attribute is not null) + .Select(static item => new FetchMember((string)item.Attribute!.ConstructorArguments[0].Value!, item.Method)) + .ToArray(); + + private static bool IsFetch(IMethodSymbol method, INamedTypeSymbol requestType) + => method.IsStatic && + method.ReturnsVoid == false && + method.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, requestType); + + private static bool IsComposer(IMethodSymbol method, INamedTypeSymbol requestType, INamedTypeSymbol responseType) + => method.IsStatic && + SymbolEqualityComparer.Default.Equals(method.ReturnType, responseType) && + method.Parameters.Length == 1 && + method.Parameters[0].Type is INamedTypeSymbol contextType && + contextType.ConstructedFrom.ToDisplayString() == "PatternKit.Cloud.GatewayAggregation.GatewayAggregationContext" && + contextType.TypeArguments.Length == 1 && + SymbolEqualityComparer.Default.Equals(contextType.TypeArguments[0], requestType); + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol requestType, + INamedTypeSymbol responseType, + IReadOnlyList fetches, + string composerName, + string factoryMethodName, + string gatewayName) + { + 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.GatewayAggregation.GatewayAggregation<") + .Append(requestTypeName).Append(", ").Append(responseTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" return global::PatternKit.Cloud.GatewayAggregation.GatewayAggregation<") + .Append(requestTypeName).Append(", ").Append(responseTypeName).Append(">.Create(\"").Append(Escape(gatewayName)).AppendLine("\")"); + foreach (var fetch in fetches) + sb.Append(" .Fetch<").Append(fetch.Method.ReturnType.ToDisplayString(TypeFormat)).Append(">(\"").Append(Escape(fetch.Name)).Append("\", ").Append(fetch.Method.Name).AppendLine(")"); + sb.Append(" .Compose(").Append(composerName).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 FetchMember(string Name, IMethodSymbol Method); +} diff --git a/test/PatternKit.Examples.Tests/GatewayAggregationDemo/CustomerDashboardGatewayAggregationDemoTests.cs b/test/PatternKit.Examples.Tests/GatewayAggregationDemo/CustomerDashboardGatewayAggregationDemoTests.cs new file mode 100644 index 0000000..1a92282 --- /dev/null +++ b/test/PatternKit.Examples.Tests/GatewayAggregationDemo/CustomerDashboardGatewayAggregationDemoTests.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.GatewayAggregationDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.GatewayAggregationDemo; + +[Feature("Customer Dashboard Gateway Aggregation example")] +public sealed class CustomerDashboardGatewayAggregationDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent and generated paths aggregate a customer dashboard")] + [Fact] + public Task Fluent_And_Generated_Paths_Aggregate_A_Customer_Dashboard() + => Given("a customer id", () => "C-100") + .When("fluent and generated gateways aggregate the dashboard", customerId => new + { + Fluent = CustomerDashboardGatewayAggregationDemoRunner.RunFluent(), + Generated = BuildServiceProvider().GetRequiredService().RunGenerated(customerId) + }) + .Then("both paths compose the downstream response", result => + { + ScenarioExpect.Equal("C-100", result.Fluent.CustomerId); + ScenarioExpect.Equal("Ada Lovelace", result.Generated.Name); + ScenarioExpect.Equal(2, result.Generated.OpenOrders); + ScenarioExpect.Equal(4, result.Generated.RecommendedProducts); + }) + .AssertPassed(); + + [Scenario("Gateway aggregation is importable through AddPatternKitExamples")] + [Fact] + public Task Gateway_Aggregation_Is_Importable_Through_AddPatternKitExamples() + => Given("the aggregate PatternKit example registration", () => new ServiceCollection().AddPatternKitExamples().BuildServiceProvider()) + .When("the gateway aggregation example is resolved", provider => provider.GetRequiredService()) + .Then("the runner and service are available through standard IoC", example => + { + var dashboard = example.Runner.RunGenerated("C-200"); + ScenarioExpect.Equal("C-200", dashboard.CustomerId); + ScenarioExpect.NotNull(example.Service); + }) + .AssertPassed(); + + private static ServiceProvider BuildServiceProvider() + => new ServiceCollection() + .AddCustomerDashboardGatewayAggregationDemo() + .BuildServiceProvider(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 9b27411..1a20414 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -80,6 +80,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Cache-Aside", "Rate Limiting", "External Configuration Store", + "Gateway Aggregation", "CQRS", "Specification", "Repository", @@ -137,7 +138,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(9, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); + ScenarioExpect.Equal(10, 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.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 017dc3f..ea60cf3 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -19,6 +19,7 @@ using PatternKit.Generators.FeatureToggles; using PatternKit.Generators.Flyweight; using PatternKit.Generators.Factories; +using PatternKit.Generators.GatewayAggregation; using PatternKit.Generators.IdentityMap; using PatternKit.Generators.Interpreter; using PatternKit.Generators.Iterator; @@ -220,6 +221,9 @@ private enum TestTrigger { typeof(ProxyIgnoreAttribute), AttributeTargets.Method | AttributeTargets.Property, false, false }, { typeof(GenerateHealthEndpointAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(HealthEndpointCheckAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateGatewayAggregationAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(GatewayAggregationFetchAttribute), AttributeTargets.Method, false, false }, + { typeof(GatewayAggregationComposerAttribute), AttributeTargets.Method, false, false }, { typeof(GeneratePriorityQueueAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(PriorityQueuePrioritySelectorAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateQueueLoadLevelingPolicyAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, @@ -474,6 +478,28 @@ public void EventNotification_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.IsType(new EventNotificationRuleAttribute()); } + [Scenario("Gateway Aggregation Attributes Expose Defaults And Configuration")] + [Fact] + public void GatewayAggregation_Attributes_Expose_Defaults_And_Configuration() + { + var gateway = new GenerateGatewayAggregationAttribute(typeof(string), typeof(int)) + { + FactoryMethodName = "BuildDashboard", + GatewayName = "customer-dashboard" + }; + var fetch = new GatewayAggregationFetchAttribute("profile"); + + ScenarioExpect.Equal(typeof(string), gateway.RequestType); + ScenarioExpect.Equal(typeof(int), gateway.ResponseType); + ScenarioExpect.Equal("BuildDashboard", gateway.FactoryMethodName); + ScenarioExpect.Equal("customer-dashboard", gateway.GatewayName); + ScenarioExpect.Equal("profile", fetch.Name); + ScenarioExpect.Throws(() => new GenerateGatewayAggregationAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateGatewayAggregationAttribute(typeof(string), null!)); + ScenarioExpect.Throws(() => new GatewayAggregationFetchAttribute("")); + ScenarioExpect.IsType(new GatewayAggregationComposerAttribute()); + } + [Scenario("Priority Queue Attributes Expose Defaults And Configuration")] [Fact] public void PriorityQueue_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/GatewayAggregationGeneratorTests.cs b/test/PatternKit.Generators.Tests/GatewayAggregationGeneratorTests.cs new file mode 100644 index 0000000..d24bf72 --- /dev/null +++ b/test/PatternKit.Generators.Tests/GatewayAggregationGeneratorTests.cs @@ -0,0 +1,112 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Cloud.GatewayAggregation; +using PatternKit.Generators.GatewayAggregation; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Gateway Aggregation generator")] +public sealed partial class GatewayAggregationGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates gateway aggregation factory")] + [Fact] + public Task Generates_Gateway_Aggregation_Factory() + => Given("a gateway aggregation declaration", () => Compile(""" + using PatternKit.Cloud.GatewayAggregation; + using PatternKit.Generators.GatewayAggregation; + namespace Demo; + public sealed record DashboardRequest(string CustomerId); + public sealed record CustomerProfile(string CustomerId, string Name); + public sealed record OrderSummary(string CustomerId, int OpenOrders); + public sealed record DashboardResponse(string CustomerId, int OpenOrders); + [GenerateGatewayAggregation(typeof(DashboardRequest), typeof(DashboardResponse), FactoryMethodName = "Build", GatewayName = "customer-dashboard")] + public static partial class CustomerDashboardGateway + { + [GatewayAggregationFetch("profile")] + private static CustomerProfile Profile(DashboardRequest request) => new(request.CustomerId, "Ada"); + [GatewayAggregationFetch("orders")] + private static OrderSummary Orders(DashboardRequest request) => new(request.CustomerId, 2); + [GatewayAggregationComposer] + private static DashboardResponse Compose(GatewayAggregationContext ctx) => new(ctx.Require("profile").CustomerId, ctx.Require("orders").OpenOrders); + } + """)) + .Then("the generated source creates the configured gateway", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("Build()", source); + ScenarioExpect.Contains("GatewayAggregation.Create(\"customer-dashboard\")", source); + ScenarioExpect.Contains(".Fetch(\"profile\", Profile)", source); + ScenarioExpect.Contains(".Fetch(\"orders\", Orders)", source); + ScenarioExpect.Contains(".Compose(Compose)", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid gateway aggregation declarations")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Gateway_Aggregation_Declarations() + => Given("invalid gateway aggregation declarations", () => new[] + { + Compile(""" + using PatternKit.Generators.GatewayAggregation; + [GenerateGatewayAggregation(typeof(string), typeof(int))] + public static class GatewayHost; + """), + Compile(""" + using PatternKit.Generators.GatewayAggregation; + [GenerateGatewayAggregation(typeof(string), typeof(int))] + public static partial class GatewayHost; + """), + Compile(""" + using PatternKit.Cloud.GatewayAggregation; + using PatternKit.Generators.GatewayAggregation; + [GenerateGatewayAggregation(typeof(string), typeof(int))] + public static partial class GatewayHost + { + [GatewayAggregationFetch("profile")] + private static void Profile(string value) { } + [GatewayAggregationComposer] + private static int Compose(GatewayAggregationContext ctx) => 1; + } + """), + Compile(""" + using PatternKit.Cloud.GatewayAggregation; + using PatternKit.Generators.GatewayAggregation; + [GenerateGatewayAggregation(typeof(string), typeof(int))] + public static partial class GatewayHost + { + [GatewayAggregationFetch("profile")] + private static string Profile(string value) => value; + [GatewayAggregationFetch("PROFILE")] + private static string Profile2(string value) => value; + [GatewayAggregationComposer] + private static int Compose(GatewayAggregationContext ctx) => 1; + } + """) + }) + .Then("diagnostics identify invalid declarations", results => + { + ScenarioExpect.Contains(results[0].Diagnostics, diagnostic => diagnostic.Id == "PKGA001"); + ScenarioExpect.Contains(results[1].Diagnostics, diagnostic => diagnostic.Id == "PKGA002"); + ScenarioExpect.Contains(results[2].Diagnostics, diagnostic => diagnostic.Id == "PKGA003"); + ScenarioExpect.Contains(results[3].Diagnostics, diagnostic => diagnostic.Id == "PKGA004"); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "GatewayAggregationGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(GatewayAggregation<,>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new GatewayAggregationGenerator(), 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/GatewayAggregation/GatewayAggregationTests.cs b/test/PatternKit.Tests/Cloud/GatewayAggregation/GatewayAggregationTests.cs new file mode 100644 index 0000000..3ba2c37 --- /dev/null +++ b/test/PatternKit.Tests/Cloud/GatewayAggregation/GatewayAggregationTests.cs @@ -0,0 +1,83 @@ +using PatternKit.Cloud.GatewayAggregation; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Cloud.GatewayAggregation; + +[Feature("Gateway Aggregation")] +public sealed class GatewayAggregationTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Gateway aggregation composes downstream results")] + [Fact] + public Task Gateway_Aggregation_Composes_Downstream_Results() + => Given("a customer dashboard gateway aggregation", CreateAggregation) + .When("a dashboard request is aggregated", gateway => gateway.Aggregate(new DashboardRequest("C-100"))) + .Then("the response combines downstream parts", result => + { + ScenarioExpect.True(result.Aggregated); + ScenarioExpect.Equal("customer-dashboard", result.GatewayName); + ScenarioExpect.Equal("C-100", result.Response!.CustomerId); + ScenarioExpect.Equal(2, result.Response.OpenOrders); + ScenarioExpect.True(result.Parts["profile"].Succeeded); + }) + .AssertPassed(); + + [Scenario("Gateway aggregation reports downstream and composer failures")] + [Fact] + public Task Gateway_Aggregation_Reports_Downstream_And_Composer_Failures() + => Given("a gateway with a failing downstream fetch", () => GatewayAggregation.Create("customer-dashboard") + .Fetch("profile", static request => new(request.CustomerId, "Ada")) + .Fetch("orders", static _ => throw new InvalidOperationException("orders unavailable")) + .Compose(static ctx => new(ctx.Require("profile").CustomerId, ctx.Require("orders").OpenOrders)) + .Build()) + .When("the request is aggregated", gateway => gateway.Aggregate(new DashboardRequest("C-100"))) + .Then("the failed part and failed aggregate are explicit", result => + { + ScenarioExpect.True(result.Failed); + ScenarioExpect.True(result.Parts["orders"].Failed); + ScenarioExpect.Contains("orders", result.Exception!.Message); + }) + .AssertPassed(); + + [Scenario("Gateway aggregation validates configuration")] + [Fact] + public Task Gateway_Aggregation_Validates_Configuration() + => Given("invalid gateway aggregation inputs", () => true) + .Then("invalid names are rejected", _ => + ScenarioExpect.Throws(() => GatewayAggregation.Create("") + .Fetch("profile", FetchProfile) + .Compose(Compose) + .Build())) + .And("missing fetches are rejected", _ => + ScenarioExpect.Throws(() => GatewayAggregation.Create().Compose(Compose).Build())) + .And("missing composers are rejected", _ => + ScenarioExpect.Throws(() => GatewayAggregation.Create().Fetch("profile", FetchProfile).Build())) + .And("duplicate fetch names are rejected", _ => + ScenarioExpect.Throws(() => GatewayAggregation.Create() + .Fetch("profile", FetchProfile) + .Fetch("PROFILE", FetchProfile))) + .And("null requests are rejected", _ => + ScenarioExpect.Throws(() => CreateAggregation().Aggregate(null!))) + .AssertPassed(); + + private static GatewayAggregation CreateAggregation() + => GatewayAggregation.Create("customer-dashboard") + .Fetch("profile", FetchProfile) + .Fetch("orders", static request => new(request.CustomerId, 2)) + .Compose(Compose) + .Build(); + + private static CustomerProfile FetchProfile(DashboardRequest request) => new(request.CustomerId, "Ada"); + + private static DashboardResponse Compose(GatewayAggregationContext ctx) + => new(ctx.Require("profile").CustomerId, ctx.Require("orders").OpenOrders); + + private sealed record DashboardRequest(string CustomerId); + + private sealed record CustomerProfile(string CustomerId, string Name); + + private sealed record OrderSummary(string CustomerId, int OpenOrders); + + private sealed record DashboardResponse(string CustomerId, int OpenOrders); +}