From 1a45c4193cb1812c21f7562d2ee198c07465944b Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 22 May 2026 08:56:49 -0500 Subject: [PATCH] feat: add gateway routing pattern --- docs/examples/index.md | 1 + docs/examples/product-gateway-routing.md | 12 + docs/examples/toc.yml | 3 + docs/generators/gateway-routing.md | 26 ++ docs/generators/index.md | 5 + docs/generators/toc.yml | 3 + docs/guides/pattern-coverage.md | 1 + docs/patterns/cloud/gateway-routing.md | 18 ++ docs/patterns/toc.yml | 2 + .../Cloud/GatewayRouting/GatewayRouting.cs | 110 +++++++++ ...rnKitExampleServiceCollectionExtensions.cs | 14 +- .../ProductGatewayRoutingDemo.cs | 93 +++++++ .../PatternKitExampleCatalog.cs | 10 +- .../PatternKitPatternCatalog.cs | 13 + .../Cloud/GatewayRoutingAttributes.cs | 31 +++ .../AnalyzerReleases.Unshipped.md | 5 + .../GatewayRouting/GatewayRoutingGenerator.cs | 226 ++++++++++++++++++ .../ProductGatewayRoutingDemoTests.cs | 58 +++++ .../PatternKitPatternCatalogTests.cs | 3 +- .../AbstractionsAttributeCoverageTests.cs | 32 +++ .../GatewayRoutingGeneratorTests.cs | 164 +++++++++++++ .../GatewayRouting/GatewayRoutingTests.cs | 101 ++++++++ 22 files changed, 928 insertions(+), 3 deletions(-) create mode 100644 docs/examples/product-gateway-routing.md create mode 100644 docs/generators/gateway-routing.md create mode 100644 docs/patterns/cloud/gateway-routing.md create mode 100644 src/PatternKit.Core/Cloud/GatewayRouting/GatewayRouting.cs create mode 100644 src/PatternKit.Examples/GatewayRoutingDemo/ProductGatewayRoutingDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/Cloud/GatewayRoutingAttributes.cs create mode 100644 src/PatternKit.Generators/GatewayRouting/GatewayRoutingGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/GatewayRoutingDemo/ProductGatewayRoutingDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/GatewayRoutingGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Cloud/GatewayRouting/GatewayRoutingTests.cs diff --git a/docs/examples/index.md b/docs/examples/index.md index 18acffcf..6259c5f8 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -166,6 +166,7 @@ dotnet test PatternKit.slnx -c Release * **Resilient Checkout:** `ResilientCheckoutDemo` (+ `ResilientCheckoutDemoTests`) — fallback checkout routes with compensation. * **Collaborating Mailboxes:** `ServiceCollaborationMailboxDemo` (+ `ServiceCollaborationMailboxDemoTests`) — inventory, payment, shipping, and notification services collaborating through serialized mailboxes. * **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. * **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/product-gateway-routing.md b/docs/examples/product-gateway-routing.md new file mode 100644 index 00000000..2fab58a4 --- /dev/null +++ b/docs/examples/product-gateway-routing.md @@ -0,0 +1,12 @@ +# Product Gateway Routing + +The product gateway routing example routes inventory and pricing requests to separate downstream APIs while unknown paths use a fallback response. + +```csharp +services.AddProductGatewayRoutingDemo(); + +var runner = provider.GetRequiredService(); +var result = runner.RunGenerated(new ProductGatewayRequest("/inventory/SKU-100", "tenant-a")); +``` + +The example includes fluent and source-generated construction, an `IServiceCollection` extension, and an ASP.NET Core minimal API mapping through `MapProductGatewayRouting()`. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 9c98d740..8379f161 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -241,5 +241,8 @@ - name: Customer Dashboard Gateway Aggregation href: customer-dashboard-gateway-aggregation.md +- name: Product Gateway Routing + href: product-gateway-routing.md + - name: Checkout Strangler Fig Migration href: checkout-strangler-fig.md diff --git a/docs/generators/gateway-routing.md b/docs/generators/gateway-routing.md new file mode 100644 index 00000000..9bc3dd97 --- /dev/null +++ b/docs/generators/gateway-routing.md @@ -0,0 +1,26 @@ +# Gateway Routing Generator + +`[GenerateGatewayRouting]` creates a typed `GatewayRouting` factory from route predicates, matching route handlers, and one fallback handler. + +```csharp +[GenerateGatewayRouting(typeof(ProductGatewayRequest), typeof(ProductGatewayResponse), GatewayName = "product-gateway-routing")] +public static partial class ProductGateway +{ + [GatewayRoute("inventory")] + private static bool IsInventory(ProductGatewayRequest request) => request.Path.StartsWith("/inventory/"); + + [GatewayRouteHandler("inventory")] + private static ProductGatewayResponse Inventory(ProductGatewayRequest request) => new("inventory", request.Path); + + [GatewayRouteFallback("not-found")] + private static ProductGatewayResponse NotFound(ProductGatewayRequest request) => new("fallback", request.Path); +} +``` + +Diagnostics: + +- `PKGR001`: host type must be partial. +- `PKGR002`: at least one route, matching route handlers, and exactly one fallback are required. +- `PKGR003`: route or handler signature is invalid. +- `PKGR004`: route names must be unique. +- `PKGR005`: each route predicate must have exactly one matching handler. diff --git a/docs/generators/index.md b/docs/generators/index.md index dd647014..b760cc1c 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -122,6 +122,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**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]` | +| [**Gateway Routing**](gateway-routing.md) | API gateway route dispatch factories | `[GenerateGatewayRouting]` | | [**Strangler Fig**](strangler-fig.md) | Legacy-to-modern migration routing factories | `[GenerateStranglerFig]` | ## Quick Reference @@ -300,6 +301,10 @@ public static partial class ProductSearchRateLimitPolicy { } [GenerateGatewayAggregation(typeof(CustomerDashboardRequest), typeof(CustomerDashboardResponse))] public static partial class CustomerDashboardGateway { } +// Gateway routing - generated route dispatch +[GenerateGatewayRouting(typeof(ProductGatewayRequest), typeof(ProductGatewayResponse))] +public static partial class ProductGatewayRouting { } + // Strangler Fig - generated legacy-to-modern migration routing [GenerateStranglerFig(typeof(CheckoutMigrationRequest), typeof(CheckoutMigrationResponse))] public static partial class CheckoutMigration { } diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 5a6643f5..52240eab 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -163,6 +163,9 @@ - name: Gateway Aggregation href: gateway-aggregation.md +- name: Gateway Routing + href: gateway-routing.md + - name: Strangler Fig href: strangler-fig.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 8f13d39b..245ce402 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -87,6 +87,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | 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 | +| Cloud Architecture | Gateway Routing | `GatewayRouting` | Gateway Routing generator | | Cloud Architecture | Strangler Fig | `StranglerFig` | Strangler Fig generator | | Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator | | Application Architecture | Specification | `Specification` and named registries | Specification generator | diff --git a/docs/patterns/cloud/gateway-routing.md b/docs/patterns/cloud/gateway-routing.md new file mode 100644 index 00000000..2c3f5f2d --- /dev/null +++ b/docs/patterns/cloud/gateway-routing.md @@ -0,0 +1,18 @@ +# Gateway Routing + +Gateway Routing dispatches one inbound request to one downstream handler based on ordered route predicates. + +```csharp +var router = GatewayRouting + .Create("product-gateway-routing") + .Route("inventory", request => request.Path.StartsWith("/inventory/"), inventory.Get) + .Route("pricing", request => request.Path.StartsWith("/pricing/"), pricing.Get) + .Fallback("not-found", request => new("fallback", $"not-found:{request.Path}")) + .Build(); + +var result = router.Route(request); +``` + +Use it in API gateways, BFFs, endpoint facades, and service ingress points where request shape determines the downstream owner. The runtime result records the selected route and whether fallback handling was used. + +The source-generated path uses `[GenerateGatewayRouting]`, `[GatewayRoute]`, `[GatewayRouteHandler]`, and `[GatewayRouteFallback]`. Import the example through `AddProductGatewayRoutingDemo()` or `AddPatternKitExamples()`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 82a52ceb..b1c5e469 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -373,6 +373,8 @@ href: cloud/external-configuration-store.md - name: Gateway Aggregation href: cloud/gateway-aggregation.md + - name: Gateway Routing + href: cloud/gateway-routing.md - name: Strangler Fig href: cloud/strangler-fig.md - name: Application Architecture diff --git a/src/PatternKit.Core/Cloud/GatewayRouting/GatewayRouting.cs b/src/PatternKit.Core/Cloud/GatewayRouting/GatewayRouting.cs new file mode 100644 index 00000000..38ff2461 --- /dev/null +++ b/src/PatternKit.Core/Cloud/GatewayRouting/GatewayRouting.cs @@ -0,0 +1,110 @@ +namespace PatternKit.Cloud.GatewayRouting; + +public sealed class GatewayRoutingResult +{ + private GatewayRoutingResult(string gatewayName, string routeName, TResponse response, bool fallback) + => (GatewayName, RouteName, Response, Fallback) = (gatewayName, routeName, response, fallback); + + public string GatewayName { get; } + + public string RouteName { get; } + + public TResponse Response { get; } + + public bool Fallback { get; } + + public bool MatchedRoute => !Fallback; + + public static GatewayRoutingResult Matched(string gatewayName, string routeName, TResponse response) + => new(gatewayName, routeName, response ?? throw new ArgumentNullException(nameof(response)), false); + + public static GatewayRoutingResult FromFallback(string gatewayName, string routeName, TResponse response) + => new(gatewayName, routeName, response ?? throw new ArgumentNullException(nameof(response)), true); +} + +public sealed class GatewayRouting +{ + private readonly IReadOnlyList _routes; + private readonly RouteEntry _fallback; + + private GatewayRouting(string name, IReadOnlyList routes, RouteEntry? fallback) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Gateway routing name is required.", nameof(name)); + if (routes is null) + throw new ArgumentNullException(nameof(routes)); + if (routes.Count == 0) + throw new InvalidOperationException("Gateway routing requires at least one route."); + + Name = name; + _routes = routes; + _fallback = fallback ?? throw new InvalidOperationException("Gateway routing requires a fallback route."); + } + + public string Name { get; } + + public GatewayRoutingResult Route(TRequest request) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + foreach (var route in _routes) + { + if (route.Matches(request)) + return GatewayRoutingResult.Matched(Name, route.Name, route.Handle(request)); + } + + return GatewayRoutingResult.FromFallback(Name, _fallback.Name, _fallback.Handle(request)); + } + + public static Builder Create(string name = "gateway-routing") => new(name); + + public sealed class Builder + { + private readonly string _name; + private readonly List _routes = []; + private RouteEntry? _fallback; + + internal Builder(string name) => _name = name; + + public Builder Route(string name, Func predicate, Func handler) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Gateway route name is required.", nameof(name)); + if (predicate is null) + throw new ArgumentNullException(nameof(predicate)); + if (handler is null) + throw new ArgumentNullException(nameof(handler)); + if (_routes.Any(route => string.Equals(route.Name, name, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException($"Gateway route '{name}' is already registered."); + + _routes.Add(new(name, predicate, handler)); + return this; + } + + public Builder Fallback(string name, Func handler) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Gateway fallback route name is required.", nameof(name)); + if (handler is null) + throw new ArgumentNullException(nameof(handler)); + + _fallback = new(name, static _ => true, handler); + return this; + } + + public GatewayRouting Build() => new(_name, _routes.ToArray(), _fallback); + } + + private sealed class RouteEntry + { + public RouteEntry(string name, Func matches, Func handle) + => (Name, Matches, Handle) = (name, matches, handle); + + public string Name { get; } + + public Func Matches { get; } + + public Func Handle { get; } + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 8f6b9ed8..f308d465 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -38,6 +38,7 @@ using PatternKit.Examples.FeatureToggleDemo; using PatternKit.Examples.FlyweightDemo; using PatternKit.Examples.GatewayAggregationDemo; +using PatternKit.Examples.GatewayRoutingDemo; using PatternKit.Examples.Generators.Builders.CorporateApplicationBuilderDemo; using PatternKit.Examples.Generators.Visitors; using PatternKit.Examples.HealthEndpointMonitoringDemo; @@ -204,6 +205,7 @@ public sealed record ProductSearchRateLimitingExample(RateLimitPolicy /// Fluent registration helpers for importing every documented PatternKit example into Microsoft.Extensions.DependencyInjection. @@ -294,7 +296,8 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddProductSearchRateLimitingExample() .AddTenantExternalConfigurationStoreExample() .AddCustomerDashboardGatewayAggregationExample() - .AddCheckoutStranglerFigExample(); + .AddCheckoutStranglerFigExample() + .AddProductGatewayRoutingExample(); public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services) { @@ -1041,6 +1044,15 @@ public static IServiceCollection AddCheckoutStranglerFigExample(this IServiceCol return services.RegisterExample("Checkout Strangler Fig Migration", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore); } + public static IServiceCollection AddProductGatewayRoutingExample(this IServiceCollection services) + { + services.AddProductGatewayRoutingDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("Product Gateway Routing", 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/GatewayRoutingDemo/ProductGatewayRoutingDemo.cs b/src/PatternKit.Examples/GatewayRoutingDemo/ProductGatewayRoutingDemo.cs new file mode 100644 index 00000000..43895726 --- /dev/null +++ b/src/PatternKit.Examples/GatewayRoutingDemo/ProductGatewayRoutingDemo.cs @@ -0,0 +1,93 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Cloud.GatewayRouting; +using PatternKit.Generators.GatewayRouting; + +namespace PatternKit.Examples.GatewayRoutingDemo; + +public sealed record ProductGatewayRequest(string Path, string TenantId); + +public sealed record ProductGatewayResponse(string Source, string Body); + +public interface IProductInventoryApi { ProductGatewayResponse Get(ProductGatewayRequest request); } + +public interface IProductPricingApi { ProductGatewayResponse Get(ProductGatewayRequest request); } + +public sealed class DemoProductInventoryApi : IProductInventoryApi +{ + public ProductGatewayResponse Get(ProductGatewayRequest request) => new("inventory", $"stock:{request.TenantId}:{request.Path}"); +} + +public sealed class DemoProductPricingApi : IProductPricingApi +{ + public ProductGatewayResponse Get(ProductGatewayRequest request) => new("pricing", $"price:{request.TenantId}:{request.Path}"); +} + +public sealed class ProductGatewayRoutingService(GatewayRouting router) +{ + public GatewayRoutingResult Route(ProductGatewayRequest request) => router.Route(request); +} + +public static class ProductGatewayRoutes +{ + public static GatewayRouting CreateFluent(IProductInventoryApi inventory, IProductPricingApi pricing) + => GatewayRouting.Create("product-gateway-routing") + .Route("inventory", IsInventory, inventory.Get) + .Route("pricing", IsPricing, pricing.Get) + .Fallback("not-found", static request => new("fallback", $"not-found:{request.Path}")) + .Build(); + + public static bool IsInventory(ProductGatewayRequest request) => request.Path.StartsWith("/inventory/", StringComparison.OrdinalIgnoreCase); + + public static bool IsPricing(ProductGatewayRequest request) => request.Path.StartsWith("/pricing/", StringComparison.OrdinalIgnoreCase); +} + +[GenerateGatewayRouting(typeof(ProductGatewayRequest), typeof(ProductGatewayResponse), FactoryMethodName = "Create", GatewayName = "product-gateway-routing")] +public static partial class GeneratedProductGatewayRouting +{ + [GatewayRoute("inventory")] + private static bool IsInventory(ProductGatewayRequest request) => ProductGatewayRoutes.IsInventory(request); + + [GatewayRouteHandler("inventory")] + private static ProductGatewayResponse Inventory(ProductGatewayRequest request) => new("inventory", $"stock:{request.TenantId}:{request.Path}"); + + [GatewayRoute("pricing")] + private static bool IsPricing(ProductGatewayRequest request) => ProductGatewayRoutes.IsPricing(request); + + [GatewayRouteHandler("pricing")] + private static ProductGatewayResponse Pricing(ProductGatewayRequest request) => new("pricing", $"price:{request.TenantId}:{request.Path}"); + + [GatewayRouteFallback("not-found")] + private static ProductGatewayResponse NotFound(ProductGatewayRequest request) => new("fallback", $"not-found:{request.Path}"); +} + +public sealed class ProductGatewayRoutingDemoRunner(ProductGatewayRoutingService service) +{ + public GatewayRoutingResult RunGenerated(ProductGatewayRequest request) => service.Route(request); + + public static GatewayRoutingResult RunFluent(ProductGatewayRequest request) + => ProductGatewayRoutes.CreateFluent(new DemoProductInventoryApi(), new DemoProductPricingApi()).Route(request); +} + +public static class ProductGatewayRoutingServiceCollectionExtensions +{ + public static IServiceCollection AddProductGatewayRoutingDemo(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(static _ => GeneratedProductGatewayRouting.Create()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } + + public static IEndpointRouteBuilder MapProductGatewayRouting(this IEndpointRouteBuilder endpoints, string pattern = "/product-gateway/{tenantId}/{**path}") + { + endpoints.MapGet(pattern, (string tenantId, string path, ProductGatewayRoutingService service) => + Results.Ok(service.Route(new ProductGatewayRequest("/" + path, tenantId)))) + .WithName("ProductGatewayRouting"); + return endpoints; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 33f7bea8..865eb2af 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -743,7 +743,15 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog "docs/examples/checkout-strangler-fig.md", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore, ["Strangler Fig"], - ["legacy fallback", "source-generated migration router", "ASP.NET Core endpoint mapping"]) + ["legacy fallback", "source-generated migration router", "ASP.NET Core endpoint mapping"]), + Descriptor( + "Product Gateway Routing", + "src/PatternKit.Examples/GatewayRoutingDemo/ProductGatewayRoutingDemo.cs", + "test/PatternKit.Examples.Tests/GatewayRoutingDemo/ProductGatewayRoutingDemoTests.cs", + "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"]) ]; public IReadOnlyList Entries => Items; diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 2cb3fc7e..1489bf05 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -935,6 +935,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/StranglerFigDemo/CheckoutStranglerFigDemoTests.cs", ["fluent migration routing", "generated Strangler Fig factory", "DI-importable ASP.NET Core checkout migration example"]), + Pattern("Gateway Routing", PatternFamily.CloudArchitecture, + "docs/patterns/cloud/gateway-routing.md", + "src/PatternKit.Core/Cloud/GatewayRouting/GatewayRouting.cs", + "test/PatternKit.Tests/Cloud/GatewayRouting/GatewayRoutingTests.cs", + "docs/generators/gateway-routing.md", + "src/PatternKit.Generators/GatewayRouting/GatewayRoutingGenerator.cs", + "test/PatternKit.Generators.Tests/GatewayRoutingGeneratorTests.cs", + null, + "docs/examples/product-gateway-routing.md", + "src/PatternKit.Examples/GatewayRoutingDemo/ProductGatewayRoutingDemo.cs", + "test/PatternKit.Examples.Tests/GatewayRoutingDemo/ProductGatewayRoutingDemoTests.cs", + ["fluent route dispatch", "generated gateway routing factory", "DI-importable ASP.NET Core product gateway example"]), + Pattern("CQRS", PatternFamily.ApplicationArchitecture, "docs/generators/dispatcher.md", "src/PatternKit.Core/Behavioral/Mediator/Mediator.cs", diff --git a/src/PatternKit.Generators.Abstractions/Cloud/GatewayRoutingAttributes.cs b/src/PatternKit.Generators.Abstractions/Cloud/GatewayRoutingAttributes.cs new file mode 100644 index 00000000..d5949d2a --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Cloud/GatewayRoutingAttributes.cs @@ -0,0 +1,31 @@ +namespace PatternKit.Generators.GatewayRouting; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateGatewayRoutingAttribute(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-routing"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class GatewayRouteAttribute(string name) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Route name is required.", nameof(name)) : name; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class GatewayRouteHandlerAttribute(string name) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Route handler name is required.", nameof(name)) : name; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class GatewayRouteFallbackAttribute(string name = "fallback") : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Fallback route name is required.", nameof(name)) : name; +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 43d02137..2de60fe3 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -306,6 +306,11 @@ PKGA001 | PatternKit.Generators.GatewayAggregation | Error | Gateway Aggregation 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. +PKGR001 | PatternKit.Generators.GatewayRouting | Error | Gateway Routing host must be partial. +PKGR002 | PatternKit.Generators.GatewayRouting | Error | Gateway Routing members are missing. +PKGR003 | PatternKit.Generators.GatewayRouting | Error | Gateway Routing method signature is invalid. +PKGR004 | PatternKit.Generators.GatewayRouting | Error | Gateway Routing route is duplicated. +PKGR005 | PatternKit.Generators.GatewayRouting | Error | Gateway Routing handler is missing. PKSF001 | PatternKit.Generators.StranglerFig | Error | Strangler Fig host must be partial. PKSF002 | PatternKit.Generators.StranglerFig | Error | Strangler Fig members are missing. PKSF003 | PatternKit.Generators.StranglerFig | Error | Strangler Fig method signature is invalid. diff --git a/src/PatternKit.Generators/GatewayRouting/GatewayRoutingGenerator.cs b/src/PatternKit.Generators/GatewayRouting/GatewayRoutingGenerator.cs new file mode 100644 index 00000000..76709f51 --- /dev/null +++ b/src/PatternKit.Generators/GatewayRouting/GatewayRoutingGenerator.cs @@ -0,0 +1,226 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.GatewayRouting; + +[Generator] +public sealed class GatewayRoutingGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.GatewayRouting.GenerateGatewayRoutingAttribute"; + private const string RouteAttributeName = "PatternKit.Generators.GatewayRouting.GatewayRouteAttribute"; + private const string HandlerAttributeName = "PatternKit.Generators.GatewayRouting.GatewayRouteHandlerAttribute"; + private const string FallbackAttributeName = "PatternKit.Generators.GatewayRouting.GatewayRouteFallbackAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKGR001", "Gateway Routing host must be partial", + "Type '{0}' is marked with [GenerateGatewayRouting] but is not declared as partial", + "PatternKit.Generators.GatewayRouting", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingMembers = new( + "PKGR002", "Gateway Routing members are missing", + "Gateway Routing type '{0}' must declare at least one route predicate, matching route handlers, and exactly one fallback handler", + "PatternKit.Generators.GatewayRouting", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidMember = new( + "PKGR003", "Gateway Routing method signature is invalid", + "Gateway Routing method '{0}' has an invalid static signature for the configured request or response type", + "PatternKit.Generators.GatewayRouting", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor DuplicateRoute = new( + "PKGR004", "Gateway Routing route is duplicated", + "Gateway Routing route name '{0}' is duplicated", + "PatternKit.Generators.GatewayRouting", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingHandler = new( + "PKGR005", "Gateway Routing handler is missing", + "Gateway Routing route '{0}' must have exactly one matching handler", + "PatternKit.Generators.GatewayRouting", 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 routes = NamedMembers(type, RouteAttributeName); + var handlers = NamedMembers(type, HandlerAttributeName); + var fallback = MembersWith(type, FallbackAttributeName); + if (routes.Length == 0 || fallback.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingMembers, node.Identifier.GetLocation(), type.Name)); + return; + } + + var duplicate = routes.Concat(handlers).GroupBy(static item => item.Name, StringComparer.OrdinalIgnoreCase).FirstOrDefault(static group => group.Count(item => item.Kind == MemberKind.Route) > 1 || group.Count(item => item.Kind == MemberKind.Handler) > 1); + if (duplicate is not null) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateRoute, node.Identifier.GetLocation(), duplicate.Key)); + return; + } + + var invalidRoute = routes.FirstOrDefault(item => !IsRoute(item.Method, requestType)); + if (invalidRoute is not null) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, invalidRoute.Method.Locations.FirstOrDefault(), invalidRoute.Method.Name)); + return; + } + + var invalidHandler = handlers.FirstOrDefault(item => !IsHandler(item.Method, requestType, responseType)); + if (invalidHandler is not null) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, invalidHandler.Method.Locations.FirstOrDefault(), invalidHandler.Method.Name)); + return; + } + + if (!IsHandler(fallback[0].Method, requestType, responseType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, fallback[0].Method.Locations.FirstOrDefault(), fallback[0].Method.Name)); + return; + } + + var pairs = routes.Select(route => new RoutePair(route.Name, route.Method, handlers.SingleOrDefault(handler => string.Equals(handler.Name, route.Name, StringComparison.OrdinalIgnoreCase))?.Method)).ToArray(); + var missing = pairs.FirstOrDefault(pair => pair.Handler is null); + if (missing is not null) + { + context.ReportDiagnostic(Diagnostic.Create(MissingHandler, node.Identifier.GetLocation(), missing.Name)); + return; + } + + context.AddSource($"{type.Name}.GatewayRouting.g.cs", SourceText.From(GenerateSource( + type, + requestType, + responseType, + pairs!, + fallback[0], + GetNamedString(attribute, "FactoryMethodName") ?? "Create", + GetNamedString(attribute, "GatewayName") ?? "gateway-routing"), 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(item => new NamedMember((string)item.Attribute!.ConstructorArguments[0].Value!, item.Method, attributeName == RouteAttributeName ? MemberKind.Route : MemberKind.Handler)) + .ToArray(); + + private static FallbackMember[] MembersWith(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 FallbackMember(item.Attribute!.ConstructorArguments.Length == 0 ? "fallback" : (string)item.Attribute.ConstructorArguments[0].Value!, item.Method)) + .ToArray(); + + private static bool IsRoute(IMethodSymbol method, INamedTypeSymbol requestType) + => method.IsStatic && + method.ReturnType.SpecialType == SpecialType.System_Boolean && + method.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, requestType); + + private static bool IsHandler(IMethodSymbol method, INamedTypeSymbol requestType, INamedTypeSymbol responseType) + => method.IsStatic && + SymbolEqualityComparer.Default.Equals(method.ReturnType, responseType) && + method.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, requestType); + + private static string GenerateSource(INamedTypeSymbol type, INamedTypeSymbol requestType, INamedTypeSymbol responseType, IReadOnlyList pairs, FallbackMember fallback, 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.GatewayRouting.GatewayRouting<") + .Append(requestTypeName).Append(", ").Append(responseTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" return global::PatternKit.Cloud.GatewayRouting.GatewayRouting<") + .Append(requestTypeName).Append(", ").Append(responseTypeName).Append(">.Create(\"").Append(Escape(gatewayName)).AppendLine("\")"); + foreach (var pair in pairs) + sb.Append(" .Route(\"").Append(Escape(pair.Name)).Append("\", ").Append(pair.Predicate.Name).Append(", ").Append(pair.Handler!.Name).AppendLine(")"); + sb.Append(" .Fallback(\"").Append(Escape(fallback.Name)).Append("\", ").Append(fallback.Method.Name).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 enum MemberKind { Route, Handler } + + private sealed record NamedMember(string Name, IMethodSymbol Method, MemberKind Kind); + + private sealed record FallbackMember(string Name, IMethodSymbol Method); + + private sealed record RoutePair(string Name, IMethodSymbol Predicate, IMethodSymbol? Handler); +} diff --git a/test/PatternKit.Examples.Tests/GatewayRoutingDemo/ProductGatewayRoutingDemoTests.cs b/test/PatternKit.Examples.Tests/GatewayRoutingDemo/ProductGatewayRoutingDemoTests.cs new file mode 100644 index 00000000..b5175a3a --- /dev/null +++ b/test/PatternKit.Examples.Tests/GatewayRoutingDemo/ProductGatewayRoutingDemoTests.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.GatewayRoutingDemo; +using PatternKit.Examples.ProductionReadiness; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.GatewayRoutingDemo; + +[Feature("Product Gateway Routing example")] +public sealed class ProductGatewayRoutingDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent product gateway routes requests by path")] + [Fact] + public Task Fluent_Product_Gateway_Routes_Requests_By_Path() + => Given("the fluent product gateway router", () => ProductGatewayRoutes.CreateFluent(new DemoProductInventoryApi(), new DemoProductPricingApi())) + .When("requests are routed", router => new + { + Inventory = router.Route(new ProductGatewayRequest("/inventory/SKU-100", "tenant-a")), + Fallback = router.Route(new ProductGatewayRequest("/unknown/SKU-100", "tenant-a")) + }) + .Then("matching traffic uses downstream APIs and unknown traffic uses fallback", result => + { + ScenarioExpect.Equal("inventory", result.Inventory.RouteName); + ScenarioExpect.Equal("inventory", result.Inventory.Response.Source); + ScenarioExpect.True(result.Fallback.Fallback); + ScenarioExpect.Equal("fallback", result.Fallback.Response.Source); + }) + .AssertPassed(); + + [Scenario("Generated product gateway routing is importable through IServiceCollection")] + [Fact] + public Task Generated_Product_Gateway_Routing_Is_Importable_Through_IServiceCollection() + => Given("a service collection with the gateway routing example", () => + { + var services = new ServiceCollection(); + services.AddProductGatewayRoutingExample(); + return services.BuildServiceProvider(); + }) + .When("the demo runner routes a pricing request", provider => provider.GetRequiredService().Runner.RunGenerated(new ProductGatewayRequest("/pricing/SKU-100", "tenant-a"))) + .Then("the generated router uses the pricing route", result => + { + ScenarioExpect.Equal("pricing", result.RouteName); + ScenarioExpect.Equal("pricing", result.Response.Source); + }) + .AssertPassed(); + + [Scenario("Product Gateway Routing example is cataloged as production ready")] + [Fact] + public Task Product_Gateway_Routing_Example_Is_Cataloged_As_Production_Ready() + => Given("the production readiness catalogs", () => new { Examples = new PatternKitExampleCatalog(), Patterns = new PatternKitPatternCatalog() }) + .Then("the example catalog includes product gateway routing", catalogs => + ScenarioExpect.Contains(catalogs.Examples.Entries, entry => entry.Name == "Product Gateway Routing" && entry.Integration.HasFlag(ExampleIntegrationSurface.AspNetCore))) + .And("the pattern catalog includes Gateway Routing", catalogs => + ScenarioExpect.Contains(catalogs.Patterns.Patterns, pattern => pattern.Name == "Gateway Routing" && pattern.Implementation.HasSourceGeneratedPath)) + .AssertPassed(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 8ee01bfd..e8d6a20f 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -82,6 +82,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "External Configuration Store", "Gateway Aggregation", "Strangler Fig", + "Gateway Routing", "CQRS", "Specification", "Repository", @@ -139,7 +140,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(11, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); + ScenarioExpect.Equal(12, 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 1721f5af..9c8c9a5c 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -20,6 +20,7 @@ using PatternKit.Generators.Flyweight; using PatternKit.Generators.Factories; using PatternKit.Generators.GatewayAggregation; +using PatternKit.Generators.GatewayRouting; using PatternKit.Generators.IdentityMap; using PatternKit.Generators.Interpreter; using PatternKit.Generators.Iterator; @@ -225,6 +226,10 @@ private enum TestTrigger { typeof(GenerateGatewayAggregationAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GatewayAggregationFetchAttribute), AttributeTargets.Method, false, false }, { typeof(GatewayAggregationComposerAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateGatewayRoutingAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(GatewayRouteAttribute), AttributeTargets.Method, false, false }, + { typeof(GatewayRouteHandlerAttribute), AttributeTargets.Method, false, false }, + { typeof(GatewayRouteFallbackAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateStranglerFigAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(StranglerFigRouteAttribute), AttributeTargets.Method, false, false }, { typeof(StranglerFigLegacyAttribute), AttributeTargets.Method, false, false }, @@ -505,6 +510,33 @@ public void GatewayAggregation_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.IsType(new GatewayAggregationComposerAttribute()); } + [Scenario("Gateway Routing Attributes Expose Defaults And Configuration")] + [Fact] + public void GatewayRouting_Attributes_Expose_Defaults_And_Configuration() + { + var gateway = new GenerateGatewayRoutingAttribute(typeof(string), typeof(int)) + { + FactoryMethodName = "BuildGateway", + GatewayName = "product-gateway" + }; + var route = new GatewayRouteAttribute("inventory"); + var handler = new GatewayRouteHandlerAttribute("inventory"); + var fallback = new GatewayRouteFallbackAttribute("not-found"); + + ScenarioExpect.Equal(typeof(string), gateway.RequestType); + ScenarioExpect.Equal(typeof(int), gateway.ResponseType); + ScenarioExpect.Equal("BuildGateway", gateway.FactoryMethodName); + ScenarioExpect.Equal("product-gateway", gateway.GatewayName); + ScenarioExpect.Equal("inventory", route.Name); + ScenarioExpect.Equal("inventory", handler.Name); + ScenarioExpect.Equal("not-found", fallback.Name); + ScenarioExpect.Throws(() => new GenerateGatewayRoutingAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateGatewayRoutingAttribute(typeof(string), null!)); + ScenarioExpect.Throws(() => new GatewayRouteAttribute("")); + ScenarioExpect.Throws(() => new GatewayRouteHandlerAttribute("")); + ScenarioExpect.Throws(() => new GatewayRouteFallbackAttribute("")); + } + [Scenario("Strangler Fig Attributes Expose Defaults And Configuration")] [Fact] public void StranglerFig_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/GatewayRoutingGeneratorTests.cs b/test/PatternKit.Generators.Tests/GatewayRoutingGeneratorTests.cs new file mode 100644 index 00000000..df772e04 --- /dev/null +++ b/test/PatternKit.Generators.Tests/GatewayRoutingGeneratorTests.cs @@ -0,0 +1,164 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Cloud.GatewayRouting; +using PatternKit.Generators.GatewayRouting; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Gateway Routing generator")] +public sealed partial class GatewayRoutingGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates Gateway Routing factory")] + [Fact] + public Task Generates_Gateway_Routing_Factory() + => Given("a Gateway Routing declaration", () => Compile(""" + using PatternKit.Generators.GatewayRouting; + namespace Demo; + public sealed record GatewayRequest(string Path); + public sealed record GatewayResponse(string Body); + [GenerateGatewayRouting(typeof(GatewayRequest), typeof(GatewayResponse), FactoryMethodName = "Build", GatewayName = "product-gateway")] + public static partial class ProductGateway + { + [GatewayRoute("inventory")] + private static bool IsInventory(GatewayRequest request) => request.Path.StartsWith("/inventory/"); + [GatewayRouteHandler("inventory")] + private static GatewayResponse Inventory(GatewayRequest request) => new("inventory"); + [GatewayRouteFallback("not-found")] + private static GatewayResponse NotFound(GatewayRequest request) => new("fallback"); + } + """)) + .Then("the generated source creates the configured router", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("Build()", source); + ScenarioExpect.Contains("GatewayRouting.Create(\"product-gateway\")", source); + ScenarioExpect.Contains(".Route(\"inventory\", IsInventory, Inventory)", source); + ScenarioExpect.Contains(".Fallback(\"not-found\", NotFound)", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid Gateway Routing declarations")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Gateway_Routing_Declarations() + => Given("invalid Gateway Routing declarations", () => new[] + { + Compile(""" + using PatternKit.Generators.GatewayRouting; + [GenerateGatewayRouting(typeof(string), typeof(int))] + public static class GatewayHost; + """), + Compile(""" + using PatternKit.Generators.GatewayRouting; + [GenerateGatewayRouting(typeof(string), typeof(int))] + public static partial class GatewayHost; + """), + Compile(""" + using PatternKit.Generators.GatewayRouting; + [GenerateGatewayRouting(typeof(string), typeof(int))] + public static partial class GatewayHost + { + [GatewayRoute("inventory")] + private static string InventoryRoute(string value) => value; + [GatewayRouteHandler("inventory")] + private static int Inventory(string value) => 1; + [GatewayRouteFallback] + private static int Fallback(string value) => 0; + } + """), + Compile(""" + using PatternKit.Generators.GatewayRouting; + [GenerateGatewayRouting(typeof(string), typeof(int))] + public static partial class GatewayHost + { + [GatewayRoute("inventory")] + private static bool InventoryRoute(string value) => true; + [GatewayRouteHandler("inventory")] + private static string Inventory(string value) => value; + [GatewayRouteFallback] + private static int Fallback(string value) => 0; + } + """), + Compile(""" + using PatternKit.Generators.GatewayRouting; + [GenerateGatewayRouting(typeof(string), typeof(int))] + public static partial class GatewayHost + { + [GatewayRoute("inventory")] + private static bool InventoryRoute(string value) => true; + [GatewayRouteHandler("inventory")] + private static int Inventory(string value) => 1; + [GatewayRouteFallback] + private static string Fallback(string value) => value; + } + """), + Compile(""" + using PatternKit.Generators.GatewayRouting; + [GenerateGatewayRouting(typeof(string), typeof(int))] + public static partial class GatewayHost + { + [GatewayRoute("inventory")] + private static bool InventoryRoute(string value) => true; + [GatewayRouteHandler("inventory")] + private static int Inventory(string value) => 1; + [GatewayRouteHandler("INVENTORY")] + private static int Inventory2(string value) => 2; + [GatewayRouteFallback] + private static int Fallback(string value) => 0; + } + """), + Compile(""" + using PatternKit.Generators.GatewayRouting; + [GenerateGatewayRouting(typeof(string), typeof(int))] + public static partial class GatewayHost + { + [GatewayRoute("inventory")] + private static bool InventoryRoute(string value) => true; + [GatewayRoute("INVENTORY")] + private static bool InventoryRoute2(string value) => true; + [GatewayRouteHandler("inventory")] + private static int Inventory(string value) => 1; + [GatewayRouteFallback] + private static int Fallback(string value) => 0; + } + """), + Compile(""" + using PatternKit.Generators.GatewayRouting; + [GenerateGatewayRouting(typeof(string), typeof(int))] + public static partial class GatewayHost + { + [GatewayRoute("inventory")] + private static bool InventoryRoute(string value) => true; + [GatewayRouteFallback] + private static int Fallback(string value) => 0; + } + """) + }) + .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 == "PKGR001"); + ScenarioExpect.Contains(ids, static id => id == "PKGR002"); + ScenarioExpect.Contains(ids, static id => id == "PKGR003"); + ScenarioExpect.Contains(ids, static id => id == "PKGR004"); + ScenarioExpect.Contains(ids, static id => id == "PKGR005"); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "GatewayRoutingGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(GatewayRouting<,>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new GatewayRoutingGenerator(), 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/GatewayRouting/GatewayRoutingTests.cs b/test/PatternKit.Tests/Cloud/GatewayRouting/GatewayRoutingTests.cs new file mode 100644 index 00000000..68e274e2 --- /dev/null +++ b/test/PatternKit.Tests/Cloud/GatewayRouting/GatewayRoutingTests.cs @@ -0,0 +1,101 @@ +using PatternKit.Cloud.GatewayRouting; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Cloud.GatewayRouting; + +[Feature("Gateway Routing")] +public sealed class GatewayRoutingTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Gateway routing dispatches requests to the first matching downstream")] + [Fact] + public Task Gateway_Routing_Dispatches_Requests_To_First_Matching_Downstream() + => Given("a product gateway router", CreateRouter) + .When("an inventory request is routed", router => router.Route(new GatewayRequest("/inventory/SKU-100"))) + .Then("the inventory route handles the request", result => + { + ScenarioExpect.True(result.MatchedRoute); + ScenarioExpect.Equal("inventory", result.RouteName); + ScenarioExpect.Equal("inventory:/inventory/SKU-100", result.Response.Body); + }) + .AssertPassed(); + + [Scenario("Gateway routing preserves ordered route precedence")] + [Fact] + public Task Gateway_Routing_Preserves_Ordered_Route_Precedence() + => Given("a router with overlapping predicates", () => GatewayRouting.Create("ordered") + .Route("first", static request => request.Path.StartsWith("/inventory/", StringComparison.OrdinalIgnoreCase), static request => new($"first:{request.Path}")) + .Route("second", static request => request.Path.Contains("SKU", StringComparison.OrdinalIgnoreCase), static request => new($"second:{request.Path}")) + .Fallback("not-found", Fallback) + .Build()) + .When("a request matches both routes", router => router.Route(new GatewayRequest("/inventory/SKU-100"))) + .Then("the first route wins", result => + { + ScenarioExpect.Equal("ordered", result.GatewayName); + ScenarioExpect.Equal("first", result.RouteName); + ScenarioExpect.False(result.Fallback); + }) + .AssertPassed(); + + [Scenario("Gateway routing uses fallback when no route matches")] + [Fact] + public Task Gateway_Routing_Uses_Fallback_When_No_Route_Matches() + => Given("a product gateway router", CreateRouter) + .When("an unknown request is routed", router => router.Route(new GatewayRequest("/unknown"))) + .Then("the fallback route handles the request", result => + { + ScenarioExpect.True(result.Fallback); + ScenarioExpect.Equal("not-found", result.RouteName); + ScenarioExpect.Equal("fallback:/unknown", result.Response.Body); + }) + .AssertPassed(); + + [Scenario("Gateway routing validates configuration")] + [Fact] + public Task Gateway_Routing_Validates_Configuration() + => Given("invalid Gateway Routing inputs", () => true) + .Then("invalid names are rejected", _ => + ScenarioExpect.Throws(() => GatewayRouting.Create("") + .Route("inventory", IsInventory, Inventory) + .Fallback("not-found", Fallback) + .Build())) + .And("missing routes are rejected", _ => + ScenarioExpect.Throws(() => GatewayRouting.Create().Fallback("not-found", Fallback).Build())) + .And("missing fallbacks are rejected", _ => + ScenarioExpect.Throws(() => GatewayRouting.Create().Route("inventory", IsInventory, Inventory).Build())) + .And("duplicate route names are rejected", _ => + ScenarioExpect.Throws(() => GatewayRouting.Create() + .Route("inventory", IsInventory, Inventory) + .Route("INVENTORY", IsInventory, Inventory))) + .And("null route delegates are rejected", _ => + { + ScenarioExpect.Throws(() => GatewayRouting.Create().Route("inventory", null!, Inventory)); + ScenarioExpect.Throws(() => GatewayRouting.Create().Route("inventory", IsInventory, null!)); + }) + .And("invalid fallback inputs are rejected", _ => + { + ScenarioExpect.Throws(() => GatewayRouting.Create().Fallback("", Fallback)); + ScenarioExpect.Throws(() => GatewayRouting.Create().Fallback("not-found", null!)); + }) + .And("null requests are rejected", _ => + ScenarioExpect.Throws(() => CreateRouter().Route(null!))) + .AssertPassed(); + + private static GatewayRouting CreateRouter() + => GatewayRouting.Create("product-gateway") + .Route("inventory", IsInventory, Inventory) + .Route("pricing", static request => request.Path.StartsWith("/pricing/", StringComparison.OrdinalIgnoreCase), static request => new($"pricing:{request.Path}")) + .Fallback("not-found", Fallback) + .Build(); + + private static bool IsInventory(GatewayRequest request) => request.Path.StartsWith("/inventory/", StringComparison.OrdinalIgnoreCase); + + private static GatewayResponse Inventory(GatewayRequest request) => new($"inventory:{request.Path}"); + + private static GatewayResponse Fallback(GatewayRequest request) => new($"fallback:{request.Path}"); + + private sealed record GatewayRequest(string Path); + + private sealed record GatewayResponse(string Body); +}