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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/examples/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions docs/examples/product-gateway-routing.md
Original file line number Diff line number Diff line change
@@ -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<ProductGatewayRoutingDemoRunner>();
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()`.
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
26 changes: 26 additions & 0 deletions docs/generators/gateway-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Gateway Routing Generator

`[GenerateGatewayRouting]` creates a typed `GatewayRouting<TRequest, TResponse>` 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.
5 changes: 5 additions & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 { }
Expand Down
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/guides/pattern-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Cloud Architecture | Rate Limiting | `RateLimitPolicy<T>` | Rate Limiting generator |
| Cloud Architecture | External Configuration Store | `ExternalConfigurationStore<TSettings>` | External Configuration Store generator |
| Cloud Architecture | Gateway Aggregation | `GatewayAggregation<TRequest,TResponse>` | Gateway Aggregation generator |
| Cloud Architecture | Gateway Routing | `GatewayRouting<TRequest,TResponse>` | Gateway Routing generator |
| Cloud Architecture | Strangler Fig | `StranglerFig<TRequest,TResponse>` | Strangler Fig generator |
| Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator |
| Application Architecture | Specification | `Specification<T>` and named registries | Specification generator |
Expand Down
18 changes: 18 additions & 0 deletions docs/patterns/cloud/gateway-routing.md
Original file line number Diff line number Diff line change
@@ -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<ProductGatewayRequest, ProductGatewayResponse>
.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()`.
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
110 changes: 110 additions & 0 deletions src/PatternKit.Core/Cloud/GatewayRouting/GatewayRouting.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
namespace PatternKit.Cloud.GatewayRouting;

public sealed class GatewayRoutingResult<TResponse>
{
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<TResponse> Matched(string gatewayName, string routeName, TResponse response)
=> new(gatewayName, routeName, response ?? throw new ArgumentNullException(nameof(response)), false);

public static GatewayRoutingResult<TResponse> FromFallback(string gatewayName, string routeName, TResponse response)
=> new(gatewayName, routeName, response ?? throw new ArgumentNullException(nameof(response)), true);
}

public sealed class GatewayRouting<TRequest, TResponse>
{
private readonly IReadOnlyList<RouteEntry> _routes;
private readonly RouteEntry _fallback;

private GatewayRouting(string name, IReadOnlyList<RouteEntry> 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<TResponse> Route(TRequest request)
{
if (request is null)
throw new ArgumentNullException(nameof(request));

foreach (var route in _routes)
{
if (route.Matches(request))
return GatewayRoutingResult<TResponse>.Matched(Name, route.Name, route.Handle(request));
}

return GatewayRoutingResult<TResponse>.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<RouteEntry> _routes = [];
private RouteEntry? _fallback;

internal Builder(string name) => _name = name;

public Builder Route(string name, Func<TRequest, bool> predicate, Func<TRequest, TResponse> 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<TRequest, TResponse> 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<TRequest, TResponse> Build() => new(_name, _routes.ToArray(), _fallback);
}

private sealed class RouteEntry
{
public RouteEntry(string name, Func<TRequest, bool> matches, Func<TRequest, TResponse> handle)
=> (Name, Matches, Handle) = (name, matches, handle);

public string Name { get; }

public Func<TRequest, bool> Matches { get; }

public Func<TRequest, TResponse> Handle { get; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -204,6 +205,7 @@ public sealed record ProductSearchRateLimitingExample(RateLimitPolicy<SearchResp
public sealed record TenantExternalConfigurationStoreExample(TenantExternalConfigurationStoreDemoRunner Runner, TenantExternalConfigurationService Service);
public sealed record CustomerDashboardGatewayAggregationExample(CustomerDashboardGatewayAggregationDemoRunner Runner, CustomerDashboardGatewayService Service);
public sealed record CheckoutStranglerFigExample(CheckoutStranglerFigDemoRunner Runner, CheckoutMigrationService Service);
public sealed record ProductGatewayRoutingExample(ProductGatewayRoutingDemoRunner Runner, ProductGatewayRoutingService Service);

/// <summary>
/// Fluent registration helpers for importing every documented PatternKit example into Microsoft.Extensions.DependencyInjection.
Expand Down Expand Up @@ -294,7 +296,8 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s
.AddProductSearchRateLimitingExample()
.AddTenantExternalConfigurationStoreExample()
.AddCustomerDashboardGatewayAggregationExample()
.AddCheckoutStranglerFigExample();
.AddCheckoutStranglerFigExample()
.AddProductGatewayRoutingExample();

public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services)
{
Expand Down Expand Up @@ -1041,6 +1044,15 @@ public static IServiceCollection AddCheckoutStranglerFigExample(this IServiceCol
return services.RegisterExample<CheckoutStranglerFigExample>("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<ProductGatewayRoutingExample>(sp => new(
sp.GetRequiredService<ProductGatewayRoutingDemoRunner>(),
sp.GetRequiredService<ProductGatewayRoutingService>()));
return services.RegisterExample<ProductGatewayRoutingExample>("Product Gateway Routing", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore);
}

private static IServiceCollection RegisterExample<T>(
this IServiceCollection services,
string name,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ProductGatewayRequest, ProductGatewayResponse> router)
{
public GatewayRoutingResult<ProductGatewayResponse> Route(ProductGatewayRequest request) => router.Route(request);
}

public static class ProductGatewayRoutes
{
public static GatewayRouting<ProductGatewayRequest, ProductGatewayResponse> CreateFluent(IProductInventoryApi inventory, IProductPricingApi pricing)
=> GatewayRouting<ProductGatewayRequest, ProductGatewayResponse>.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<ProductGatewayResponse> RunGenerated(ProductGatewayRequest request) => service.Route(request);

public static GatewayRoutingResult<ProductGatewayResponse> RunFluent(ProductGatewayRequest request)
=> ProductGatewayRoutes.CreateFluent(new DemoProductInventoryApi(), new DemoProductPricingApi()).Route(request);
}

public static class ProductGatewayRoutingServiceCollectionExtensions
{
public static IServiceCollection AddProductGatewayRoutingDemo(this IServiceCollection services)
{
services.AddSingleton<IProductInventoryApi, DemoProductInventoryApi>();
services.AddSingleton<IProductPricingApi, DemoProductPricingApi>();
services.AddSingleton(static _ => GeneratedProductGatewayRouting.Create());
services.AddSingleton<ProductGatewayRoutingService>();
services.AddSingleton<ProductGatewayRoutingDemoRunner>();
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;
}
}
Loading
Loading