From 19b3a25838af3436f4c165cf319ba0fc98adb2b4 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 22 May 2026 08:30:48 -0500 Subject: [PATCH] feat: add strangler fig pattern --- docs/examples/checkout-strangler-fig.md | 12 + docs/examples/index.md | 1 + docs/examples/toc.yml | 3 + docs/generators/index.md | 5 + docs/generators/strangler-fig.md | 25 +++ docs/generators/toc.yml | 3 + docs/guides/pattern-coverage.md | 1 + docs/patterns/cloud/strangler-fig.md | 19 ++ docs/patterns/toc.yml | 2 + .../Cloud/StranglerFig/StranglerFig.cs | 133 +++++++++++ ...rnKitExampleServiceCollectionExtensions.cs | 14 +- .../PatternKitExampleCatalog.cs | 10 +- .../PatternKitPatternCatalog.cs | 13 ++ .../CheckoutStranglerFigDemo.cs | 114 ++++++++++ .../Cloud/StranglerFigAttributes.cs | 29 +++ .../AnalyzerReleases.Unshipped.md | 4 + .../StranglerFig/StranglerFigGenerator.cs | 210 ++++++++++++++++++ .../PatternKitPatternCatalogTests.cs | 3 +- .../CheckoutStranglerFigDemoTests.cs | 75 +++++++ .../AbstractionsAttributeCoverageTests.cs | 28 +++ .../StranglerFigGeneratorTests.cs | 111 +++++++++ .../Cloud/StranglerFig/StranglerFigTests.cs | 75 +++++++ 22 files changed, 887 insertions(+), 3 deletions(-) create mode 100644 docs/examples/checkout-strangler-fig.md create mode 100644 docs/generators/strangler-fig.md create mode 100644 docs/patterns/cloud/strangler-fig.md create mode 100644 src/PatternKit.Core/Cloud/StranglerFig/StranglerFig.cs create mode 100644 src/PatternKit.Examples/StranglerFigDemo/CheckoutStranglerFigDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/Cloud/StranglerFigAttributes.cs create mode 100644 src/PatternKit.Generators/StranglerFig/StranglerFigGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/StranglerFigDemo/CheckoutStranglerFigDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/StranglerFigGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Cloud/StranglerFig/StranglerFigTests.cs diff --git a/docs/examples/checkout-strangler-fig.md b/docs/examples/checkout-strangler-fig.md new file mode 100644 index 00000000..19943c92 --- /dev/null +++ b/docs/examples/checkout-strangler-fig.md @@ -0,0 +1,12 @@ +# Checkout Strangler Fig Migration + +The checkout Strangler Fig example routes migrated tenants and pilot orders to a modern checkout service while all other traffic falls back to the legacy system. + +```csharp +services.AddCheckoutStranglerFigDemo(); + +var runner = provider.GetRequiredService(); +var result = runner.RunGenerated(new CheckoutMigrationRequest("enterprise-west", "O-100", 100m)); +``` + +The example includes fluent and source-generated construction, an `IServiceCollection` extension, and an ASP.NET Core minimal API mapping through `MapCheckoutStranglerFig()`. diff --git a/docs/examples/index.md b/docs/examples/index.md index 80c2fc63..18acffcf 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. +* **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/toc.yml b/docs/examples/toc.yml index 9bb4baa0..9c98d740 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -240,3 +240,6 @@ - name: Customer Dashboard Gateway Aggregation href: customer-dashboard-gateway-aggregation.md + +- name: Checkout Strangler Fig Migration + href: checkout-strangler-fig.md diff --git a/docs/generators/index.md b/docs/generators/index.md index 5f82a381..dd647014 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]` | +| [**Strangler Fig**](strangler-fig.md) | Legacy-to-modern migration routing factories | `[GenerateStranglerFig]` | ## Quick Reference @@ -298,6 +299,10 @@ public static partial class ProductSearchRateLimitPolicy { } // Gateway aggregation - generated downstream response composition [GenerateGatewayAggregation(typeof(CustomerDashboardRequest), typeof(CustomerDashboardResponse))] public static partial class CustomerDashboardGateway { } + +// Strangler Fig - generated legacy-to-modern migration routing +[GenerateStranglerFig(typeof(CheckoutMigrationRequest), typeof(CheckoutMigrationResponse))] +public static partial class CheckoutMigration { } ``` ## Examples diff --git a/docs/generators/strangler-fig.md b/docs/generators/strangler-fig.md new file mode 100644 index 00000000..0d52af6b --- /dev/null +++ b/docs/generators/strangler-fig.md @@ -0,0 +1,25 @@ +# Strangler Fig Generator + +`[GenerateStranglerFig]` creates a typed `StranglerFig` factory from route predicates and one handler for each side of the migration. + +```csharp +[GenerateStranglerFig(typeof(CheckoutMigrationRequest), typeof(CheckoutMigrationResponse), MigrationName = "checkout-strangler")] +public static partial class CheckoutMigration +{ + [StranglerFigRoute("enterprise-tenant")] + private static bool IsEnterprise(CheckoutMigrationRequest request) => request.TenantId.StartsWith("enterprise-"); + + [StranglerFigLegacy] + private static CheckoutMigrationResponse Legacy(CheckoutMigrationRequest request) => LegacySystem.Submit(request); + + [StranglerFigModern] + private static CheckoutMigrationResponse Modern(CheckoutMigrationRequest request) => ModernSystem.Submit(request); +} +``` + +Diagnostics: + +- `PKSF001`: host type must be partial. +- `PKSF002`: at least one route, exactly one legacy handler, and exactly one modern handler are required. +- `PKSF003`: route or handler signature is invalid. +- `PKSF004`: route names must be unique. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 5033535e..5a6643f5 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -163,6 +163,9 @@ - name: Gateway Aggregation href: gateway-aggregation.md +- name: Strangler Fig + href: strangler-fig.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 0f8bbad8..8f13d39b 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 | 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 | | Application Architecture | Repository | `IRepository` and `InMemoryRepository` | Repository generator | diff --git a/docs/patterns/cloud/strangler-fig.md b/docs/patterns/cloud/strangler-fig.md new file mode 100644 index 00000000..85f9026e --- /dev/null +++ b/docs/patterns/cloud/strangler-fig.md @@ -0,0 +1,19 @@ +# Strangler Fig + +Strangler Fig routes traffic between a legacy implementation and a modern replacement while migration rules are rolled out incrementally. + +```csharp +var migration = StranglerFig + .Create("checkout-strangler") + .RouteToModern("enterprise-tenant", request => request.TenantId.StartsWith("enterprise-")) + .RouteToModern("large-order-pilot", request => request.Total >= 1_000m) + .Legacy(legacy.Submit) + .Modern(modern.Submit) + .Build(); + +var result = migration.Route(request); +``` + +Use it at an API facade, gateway, service layer, or endpoint boundary when an existing system is being replaced feature-by-feature. The runtime result records whether the request used the legacy or modern path and which migration rule matched. + +The source-generated path uses `[GenerateStranglerFig]`, `[StranglerFigRoute]`, `[StranglerFigLegacy]`, and `[StranglerFigModern]`. Import the example through `AddCheckoutStranglerFigDemo()` or `AddPatternKitExamples()`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 3522d769..82a52ceb 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: Strangler Fig + href: cloud/strangler-fig.md - name: Application Architecture items: - name: Anti-Corruption Layer diff --git a/src/PatternKit.Core/Cloud/StranglerFig/StranglerFig.cs b/src/PatternKit.Core/Cloud/StranglerFig/StranglerFig.cs new file mode 100644 index 00000000..ba97442c --- /dev/null +++ b/src/PatternKit.Core/Cloud/StranglerFig/StranglerFig.cs @@ -0,0 +1,133 @@ +namespace PatternKit.Cloud.StranglerFig; + +public enum StranglerFigRoute +{ + Legacy, + Modern +} + +public sealed class StranglerFigDecision +{ + private StranglerFigDecision(StranglerFigRoute route, string ruleName) + => (Route, RuleName) = (route, ruleName); + + public StranglerFigRoute Route { get; } + + public string RuleName { get; } + + public bool UsedLegacy => Route == StranglerFigRoute.Legacy; + + public bool UsedModern => Route == StranglerFigRoute.Modern; + + public static StranglerFigDecision Legacy(string ruleName = "fallback") + => new(StranglerFigRoute.Legacy, string.IsNullOrWhiteSpace(ruleName) ? "fallback" : ruleName); + + public static StranglerFigDecision Modern(string ruleName) + => new(StranglerFigRoute.Modern, string.IsNullOrWhiteSpace(ruleName) ? throw new ArgumentException("Rule name is required.", nameof(ruleName)) : ruleName); +} + +public sealed class StranglerFigResult +{ + private StranglerFigResult(string migrationName, StranglerFigDecision decision, TResponse response) + => (MigrationName, Decision, Response) = (migrationName, decision, response); + + public string MigrationName { get; } + + public StranglerFigDecision Decision { get; } + + public TResponse Response { get; } + + public bool UsedLegacy => Decision.UsedLegacy; + + public bool UsedModern => Decision.UsedModern; + + public static StranglerFigResult From(string migrationName, StranglerFigDecision decision, TResponse response) + => new(migrationName, decision ?? throw new ArgumentNullException(nameof(decision)), response ?? throw new ArgumentNullException(nameof(response))); +} + +public sealed class StranglerFig +{ + private readonly IReadOnlyList _routes; + private readonly Func _legacy; + private readonly Func _modern; + + private StranglerFig(string name, IReadOnlyList routes, Func? legacy, Func? modern) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Strangler Fig migration name is required.", nameof(name)); + if (routes is null) + throw new ArgumentNullException(nameof(routes)); + if (routes.Count == 0) + throw new InvalidOperationException("Strangler Fig requires at least one modern route rule."); + + Name = name; + _routes = routes; + _legacy = legacy ?? throw new InvalidOperationException("Strangler Fig requires a legacy handler."); + _modern = modern ?? throw new InvalidOperationException("Strangler Fig requires a modern handler."); + } + + public string Name { get; } + + public StranglerFigResult Route(TRequest request) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + foreach (var route in _routes) + { + if (route.Matches(request)) + return StranglerFigResult.From(Name, StranglerFigDecision.Modern(route.Name), _modern(request)); + } + + return StranglerFigResult.From(Name, StranglerFigDecision.Legacy(), _legacy(request)); + } + + public static Builder Create(string name = "strangler-fig") => new(name); + + public sealed class Builder + { + private readonly string _name; + private readonly List _routes = []; + private Func? _legacy; + private Func? _modern; + + internal Builder(string name) => _name = name; + + public Builder RouteToModern(string name, Func predicate) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Strangler Fig route name is required.", nameof(name)); + if (predicate is null) + throw new ArgumentNullException(nameof(predicate)); + if (_routes.Any(route => string.Equals(route.Name, name, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException($"Strangler Fig route '{name}' is already registered."); + + _routes.Add(new(name, predicate)); + return this; + } + + public Builder Legacy(Func handler) + { + _legacy = handler ?? throw new ArgumentNullException(nameof(handler)); + return this; + } + + public Builder Modern(Func handler) + { + _modern = handler ?? throw new ArgumentNullException(nameof(handler)); + return this; + } + + public StranglerFig Build() => new(_name, _routes.ToArray(), _legacy, _modern); + } + + private sealed class RouteRule + { + public RouteRule(string name, Func matches) + => (Name, Matches) = (name, matches); + + public string Name { get; } + + public Func Matches { get; } + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 829bc247..8f6b9ed8 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -60,6 +60,7 @@ using PatternKit.Examples.ServiceLayerDemo; using PatternKit.Examples.Singleton; using PatternKit.Examples.SpecificationDemo; +using PatternKit.Examples.StranglerFigDemo; using PatternKit.Examples.Strategies.Coercion; using PatternKit.Examples.Strategies.Composed; using PatternKit.Examples.TableDataGatewayDemo; @@ -202,6 +203,7 @@ public sealed record ProductCatalogCacheAsideExample(CacheAsidePolicy Policy, ProductSearchRateLimitService Service); public sealed record TenantExternalConfigurationStoreExample(TenantExternalConfigurationStoreDemoRunner Runner, TenantExternalConfigurationService Service); public sealed record CustomerDashboardGatewayAggregationExample(CustomerDashboardGatewayAggregationDemoRunner Runner, CustomerDashboardGatewayService Service); +public sealed record CheckoutStranglerFigExample(CheckoutStranglerFigDemoRunner Runner, CheckoutMigrationService Service); /// /// Fluent registration helpers for importing every documented PatternKit example into Microsoft.Extensions.DependencyInjection. @@ -291,7 +293,8 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddProductCatalogCacheAsideExample() .AddProductSearchRateLimitingExample() .AddTenantExternalConfigurationStoreExample() - .AddCustomerDashboardGatewayAggregationExample(); + .AddCustomerDashboardGatewayAggregationExample() + .AddCheckoutStranglerFigExample(); public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services) { @@ -1029,6 +1032,15 @@ public static IServiceCollection AddCustomerDashboardGatewayAggregationExample(t return services.RegisterExample("Customer Dashboard Gateway Aggregation", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore); } + public static IServiceCollection AddCheckoutStranglerFigExample(this IServiceCollection services) + { + services.AddCheckoutStranglerFigDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("Checkout Strangler Fig Migration", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore); + } + private static IServiceCollection RegisterExample( this IServiceCollection services, string name, diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index f88d6e6a..33f7bea8 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -735,7 +735,15 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog "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"]) + ["downstream dashboard composition", "source-generated gateway aggregation", "ASP.NET Core endpoint mapping"]), + Descriptor( + "Checkout Strangler Fig Migration", + "src/PatternKit.Examples/StranglerFigDemo/CheckoutStranglerFigDemo.cs", + "test/PatternKit.Examples.Tests/StranglerFigDemo/CheckoutStranglerFigDemoTests.cs", + "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"]) ]; public IReadOnlyList Entries => Items; diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 3df02ddc..2cb3fc7e 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -922,6 +922,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/GatewayAggregationDemo/CustomerDashboardGatewayAggregationDemoTests.cs", ["fluent downstream aggregation", "generated gateway aggregation factory", "DI-importable ASP.NET Core dashboard example"]), + Pattern("Strangler Fig", PatternFamily.CloudArchitecture, + "docs/patterns/cloud/strangler-fig.md", + "src/PatternKit.Core/Cloud/StranglerFig/StranglerFig.cs", + "test/PatternKit.Tests/Cloud/StranglerFig/StranglerFigTests.cs", + "docs/generators/strangler-fig.md", + "src/PatternKit.Generators/StranglerFig/StranglerFigGenerator.cs", + "test/PatternKit.Generators.Tests/StranglerFigGeneratorTests.cs", + null, + "docs/examples/checkout-strangler-fig.md", + "src/PatternKit.Examples/StranglerFigDemo/CheckoutStranglerFigDemo.cs", + "test/PatternKit.Examples.Tests/StranglerFigDemo/CheckoutStranglerFigDemoTests.cs", + ["fluent migration routing", "generated Strangler Fig factory", "DI-importable ASP.NET Core checkout migration example"]), + Pattern("CQRS", PatternFamily.ApplicationArchitecture, "docs/generators/dispatcher.md", "src/PatternKit.Core/Behavioral/Mediator/Mediator.cs", diff --git a/src/PatternKit.Examples/StranglerFigDemo/CheckoutStranglerFigDemo.cs b/src/PatternKit.Examples/StranglerFigDemo/CheckoutStranglerFigDemo.cs new file mode 100644 index 00000000..613b1927 --- /dev/null +++ b/src/PatternKit.Examples/StranglerFigDemo/CheckoutStranglerFigDemo.cs @@ -0,0 +1,114 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Cloud.StranglerFig; +using PatternKit.Generators.StranglerFig; + +namespace PatternKit.Examples.StranglerFigDemo; + +public sealed record CheckoutMigrationRequest(string TenantId, string OrderId, decimal Total); + +public sealed record CheckoutMigrationResponse(string Confirmation, string Processor, decimal CapturedTotal); + +public interface ILegacyCheckoutSystem +{ + CheckoutMigrationResponse Submit(CheckoutMigrationRequest request); +} + +public interface IModernCheckoutSystem +{ + CheckoutMigrationResponse Submit(CheckoutMigrationRequest request); +} + +public sealed class DemoLegacyCheckoutSystem : ILegacyCheckoutSystem +{ + public CheckoutMigrationResponse Submit(CheckoutMigrationRequest request) + => new($"LEGACY-{request.OrderId}", "legacy-mainframe", request.Total); +} + +public sealed class DemoModernCheckoutSystem : IModernCheckoutSystem +{ + public CheckoutMigrationResponse Submit(CheckoutMigrationRequest request) + => new($"MODERN-{request.OrderId}", "modern-checkout", request.Total); +} + +public sealed class CheckoutMigrationService(StranglerFig migration) +{ + public StranglerFigResult Submit(CheckoutMigrationRequest request) + => migration.Route(request); +} + +public static class CheckoutMigrationRoutes +{ + public static StranglerFig CreateFluent( + ILegacyCheckoutSystem legacy, + IModernCheckoutSystem modern) + => StranglerFig.Create("checkout-strangler") + .RouteToModern("enterprise-tenant", IsEnterpriseTenant) + .RouteToModern("large-order-pilot", IsLargeOrderPilot) + .Legacy(legacy.Submit) + .Modern(modern.Submit) + .Build(); + + public static bool IsEnterpriseTenant(CheckoutMigrationRequest request) + => request.TenantId.StartsWith("enterprise-", StringComparison.OrdinalIgnoreCase); + + public static bool IsLargeOrderPilot(CheckoutMigrationRequest request) + => request.Total >= 1_000m; +} + +[GenerateStranglerFig(typeof(CheckoutMigrationRequest), typeof(CheckoutMigrationResponse), FactoryMethodName = "Create", MigrationName = "checkout-strangler")] +public static partial class GeneratedCheckoutMigration +{ + [StranglerFigRoute("enterprise-tenant")] + private static bool IsEnterpriseTenant(CheckoutMigrationRequest request) + => CheckoutMigrationRoutes.IsEnterpriseTenant(request); + + [StranglerFigRoute("large-order-pilot")] + private static bool IsLargeOrderPilot(CheckoutMigrationRequest request) + => CheckoutMigrationRoutes.IsLargeOrderPilot(request); + + [StranglerFigLegacy] + private static CheckoutMigrationResponse Legacy(CheckoutMigrationRequest request) + => new($"LEGACY-{request.OrderId}", "legacy-mainframe", request.Total); + + [StranglerFigModern] + private static CheckoutMigrationResponse Modern(CheckoutMigrationRequest request) + => new($"MODERN-{request.OrderId}", "modern-checkout", request.Total); +} + +public sealed class CheckoutStranglerFigDemoRunner(CheckoutMigrationService service) +{ + public StranglerFigResult RunGenerated(CheckoutMigrationRequest request) + => service.Submit(request); + + public static StranglerFigResult RunFluent(CheckoutMigrationRequest request) + { + var migration = CheckoutMigrationRoutes.CreateFluent(new DemoLegacyCheckoutSystem(), new DemoModernCheckoutSystem()); + return migration.Route(request); + } +} + +public static class CheckoutStranglerFigServiceCollectionExtensions +{ + public static IServiceCollection AddCheckoutStranglerFigDemo(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(static _ => GeneratedCheckoutMigration.Create()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } + + public static IEndpointRouteBuilder MapCheckoutStranglerFig(this IEndpointRouteBuilder endpoints, string pattern = "/checkout/{tenantId}/{orderId}") + { + endpoints.MapPost(pattern, (string tenantId, string orderId, CheckoutRequestBody body, CheckoutMigrationService service) => + Results.Ok(service.Submit(new CheckoutMigrationRequest(tenantId, orderId, body.Total)))) + .WithName("CheckoutStranglerFig"); + return endpoints; + } +} + +public sealed record CheckoutRequestBody(decimal Total); diff --git a/src/PatternKit.Generators.Abstractions/Cloud/StranglerFigAttributes.cs b/src/PatternKit.Generators.Abstractions/Cloud/StranglerFigAttributes.cs new file mode 100644 index 00000000..327d31ef --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Cloud/StranglerFigAttributes.cs @@ -0,0 +1,29 @@ +namespace PatternKit.Generators.StranglerFig; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateStranglerFigAttribute(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 MigrationName { get; set; } = "strangler-fig"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class StranglerFigRouteAttribute(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 StranglerFigLegacyAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class StranglerFigModernAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 57439a6b..43d02137 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -306,6 +306,10 @@ 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. +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. +PKSF004 | PatternKit.Generators.StranglerFig | Error | Strangler Fig route 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/StranglerFig/StranglerFigGenerator.cs b/src/PatternKit.Generators/StranglerFig/StranglerFigGenerator.cs new file mode 100644 index 00000000..43901f99 --- /dev/null +++ b/src/PatternKit.Generators/StranglerFig/StranglerFigGenerator.cs @@ -0,0 +1,210 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.StranglerFig; + +[Generator] +public sealed class StranglerFigGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.StranglerFig.GenerateStranglerFigAttribute"; + private const string RouteAttributeName = "PatternKit.Generators.StranglerFig.StranglerFigRouteAttribute"; + private const string LegacyAttributeName = "PatternKit.Generators.StranglerFig.StranglerFigLegacyAttribute"; + private const string ModernAttributeName = "PatternKit.Generators.StranglerFig.StranglerFigModernAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKSF001", "Strangler Fig host must be partial", + "Type '{0}' is marked with [GenerateStranglerFig] but is not declared as partial", + "PatternKit.Generators.StranglerFig", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingMembers = new( + "PKSF002", "Strangler Fig members are missing", + "Strangler Fig type '{0}' must declare at least one route, exactly one legacy handler, and exactly one modern handler", + "PatternKit.Generators.StranglerFig", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidMember = new( + "PKSF003", "Strangler Fig method signature is invalid", + "Strangler Fig method '{0}' has an invalid static signature for the configured request or response type", + "PatternKit.Generators.StranglerFig", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor DuplicateRoute = new( + "PKSF004", "Strangler Fig route is duplicated", + "Strangler Fig route name '{0}' is duplicated", + "PatternKit.Generators.StranglerFig", 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 = RouteMembers(type); + var legacy = MembersWith(type, LegacyAttributeName); + var modern = MembersWith(type, ModernAttributeName); + if (routes.Length == 0 || legacy.Length != 1 || modern.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingMembers, node.Identifier.GetLocation(), type.Name)); + return; + } + + var duplicate = routes.GroupBy(static item => item.Name, StringComparer.OrdinalIgnoreCase).FirstOrDefault(static group => group.Count() > 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; + } + + if (!IsHandler(legacy[0], requestType, responseType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, legacy[0].Locations.FirstOrDefault(), legacy[0].Name)); + return; + } + + if (!IsHandler(modern[0], requestType, responseType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, modern[0].Locations.FirstOrDefault(), modern[0].Name)); + return; + } + + context.AddSource($"{type.Name}.StranglerFig.g.cs", SourceText.From(GenerateSource( + type, + requestType, + responseType, + routes, + legacy[0].Name, + modern[0].Name, + GetNamedString(attribute, "FactoryMethodName") ?? "Create", + GetNamedString(attribute, "MigrationName") ?? "strangler-fig"), 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 RouteMember[] RouteMembers(INamedTypeSymbol type) + => type.GetMembers().OfType() + .Select(method => new + { + Method = method, + Attribute = method.GetAttributes().FirstOrDefault(attr => attr.AttributeClass?.ToDisplayString() == RouteAttributeName) + }) + .Where(static item => item.Attribute is not null) + .Select(static item => new RouteMember((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 routes, + string legacyName, + string modernName, + string factoryMethodName, + string migrationName) + { + 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.StranglerFig.StranglerFig<") + .Append(requestTypeName).Append(", ").Append(responseTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" return global::PatternKit.Cloud.StranglerFig.StranglerFig<") + .Append(requestTypeName).Append(", ").Append(responseTypeName).Append(">.Create(\"").Append(Escape(migrationName)).AppendLine("\")"); + foreach (var route in routes) + sb.Append(" .RouteToModern(\"").Append(Escape(route.Name)).Append("\", ").Append(route.Method.Name).AppendLine(")"); + sb.Append(" .Legacy(").Append(legacyName).AppendLine(")"); + sb.Append(" .Modern(").Append(modernName).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 RouteMember(string Name, IMethodSymbol Method); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 1a20414a..8ee01bfd 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -81,6 +81,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Rate Limiting", "External Configuration Store", "Gateway Aggregation", + "Strangler Fig", "CQRS", "Specification", "Repository", @@ -138,7 +139,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(10, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); + ScenarioExpect.Equal(11, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); ScenarioExpect.Equal(15, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Examples.Tests/StranglerFigDemo/CheckoutStranglerFigDemoTests.cs b/test/PatternKit.Examples.Tests/StranglerFigDemo/CheckoutStranglerFigDemoTests.cs new file mode 100644 index 00000000..4997b97b --- /dev/null +++ b/test/PatternKit.Examples.Tests/StranglerFigDemo/CheckoutStranglerFigDemoTests.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Cloud.StranglerFig; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.ProductionReadiness; +using PatternKit.Examples.StranglerFigDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.StranglerFigDemo; + +[Feature("Checkout Strangler Fig example")] +public sealed class CheckoutStranglerFigDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent checkout migration routes modern and legacy traffic")] + [Fact] + public Task Fluent_Checkout_Migration_Routes_Modern_And_Legacy_Traffic() + => Given("the fluent checkout migration", () => CheckoutMigrationRoutes.CreateFluent(new DemoLegacyCheckoutSystem(), new DemoModernCheckoutSystem())) + .When("requests are submitted", migration => new + { + Modern = migration.Route(new CheckoutMigrationRequest("enterprise-west", "O-100", 100m)), + Legacy = migration.Route(new CheckoutMigrationRequest("retail", "O-200", 25m)) + }) + .Then("traffic is routed by migration rules", result => + { + ScenarioExpect.True(result.Modern.UsedModern); + ScenarioExpect.Equal("modern-checkout", result.Modern.Response.Processor); + ScenarioExpect.True(result.Legacy.UsedLegacy); + ScenarioExpect.Equal("legacy-mainframe", result.Legacy.Response.Processor); + }) + .AssertPassed(); + + [Scenario("Generated checkout migration is importable through IServiceCollection")] + [Fact] + public Task Generated_Checkout_Migration_Is_Importable_Through_IServiceCollection() + => Given("a service collection with the checkout Strangler Fig example", () => + { + var services = new ServiceCollection(); + services.AddCheckoutStranglerFigDemo(); + services.AddCheckoutStranglerFigExample(); + return services.BuildServiceProvider(); + }) + .When("the demo runner submits a pilot request", provider => + { + var example = provider.GetRequiredService(); + return example.Runner.RunGenerated(new CheckoutMigrationRequest("retail", "O-300", 1_500m)); + }) + .Then("the generated migration uses the modern implementation", result => + { + ScenarioExpect.True(result.UsedModern); + ScenarioExpect.Equal(StranglerFigRoute.Modern, result.Decision.Route); + ScenarioExpect.Equal("large-order-pilot", result.Decision.RuleName); + }) + .AssertPassed(); + + [Scenario("Checkout Strangler Fig example is cataloged as production ready")] + [Fact] + public Task Checkout_Strangler_Fig_Example_Is_Cataloged_As_Production_Ready() + => Given("the production readiness catalogs", () => new + { + Examples = new PatternKitExampleCatalog(), + Patterns = new PatternKitPatternCatalog() + }) + .Then("the example catalog includes the checkout migration", catalogs => + ScenarioExpect.Contains(catalogs.Examples.Entries, entry => + entry.Name == "Checkout Strangler Fig Migration" + && entry.Integration.HasFlag(ExampleIntegrationSurface.DependencyInjection) + && entry.Integration.HasFlag(ExampleIntegrationSurface.AspNetCore) + && entry.Integration.HasFlag(ExampleIntegrationSurface.SourceGenerator))) + .And("the pattern catalog includes Strangler Fig", catalogs => + ScenarioExpect.Contains(catalogs.Patterns.Patterns, pattern => + pattern.Name == "Strangler Fig" + && pattern.Implementation.HasSourceGeneratedPath)) + .AssertPassed(); +} diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index ea60cf36..1721f5af 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -38,6 +38,7 @@ using PatternKit.Generators.Singleton; using PatternKit.Generators.Specification; using PatternKit.Generators.State; +using PatternKit.Generators.StranglerFig; using PatternKit.Generators.TableDataGateway; using PatternKit.Generators.Template; using PatternKit.Generators.TransactionScript; @@ -224,6 +225,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(GenerateStranglerFigAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(StranglerFigRouteAttribute), AttributeTargets.Method, false, false }, + { typeof(StranglerFigLegacyAttribute), AttributeTargets.Method, false, false }, + { typeof(StranglerFigModernAttribute), 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 }, @@ -500,6 +505,29 @@ public void GatewayAggregation_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.IsType(new GatewayAggregationComposerAttribute()); } + [Scenario("Strangler Fig Attributes Expose Defaults And Configuration")] + [Fact] + public void StranglerFig_Attributes_Expose_Defaults_And_Configuration() + { + var migration = new GenerateStranglerFigAttribute(typeof(string), typeof(int)) + { + FactoryMethodName = "BuildCheckoutMigration", + MigrationName = "checkout-strangler" + }; + var route = new StranglerFigRouteAttribute("enterprise-tenant"); + + ScenarioExpect.Equal(typeof(string), migration.RequestType); + ScenarioExpect.Equal(typeof(int), migration.ResponseType); + ScenarioExpect.Equal("BuildCheckoutMigration", migration.FactoryMethodName); + ScenarioExpect.Equal("checkout-strangler", migration.MigrationName); + ScenarioExpect.Equal("enterprise-tenant", route.Name); + ScenarioExpect.Throws(() => new GenerateStranglerFigAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateStranglerFigAttribute(typeof(string), null!)); + ScenarioExpect.Throws(() => new StranglerFigRouteAttribute("")); + ScenarioExpect.IsType(new StranglerFigLegacyAttribute()); + ScenarioExpect.IsType(new StranglerFigModernAttribute()); + } + [Scenario("Priority Queue Attributes Expose Defaults And Configuration")] [Fact] public void PriorityQueue_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/StranglerFigGeneratorTests.cs b/test/PatternKit.Generators.Tests/StranglerFigGeneratorTests.cs new file mode 100644 index 00000000..516f2c4a --- /dev/null +++ b/test/PatternKit.Generators.Tests/StranglerFigGeneratorTests.cs @@ -0,0 +1,111 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Cloud.StranglerFig; +using PatternKit.Generators.StranglerFig; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Strangler Fig generator")] +public sealed partial class StranglerFigGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates Strangler Fig factory")] + [Fact] + public Task Generates_Strangler_Fig_Factory() + => Given("a Strangler Fig declaration", () => Compile(""" + using PatternKit.Generators.StranglerFig; + namespace Demo; + public sealed record CheckoutRequest(string Tenant, string OrderId); + public sealed record CheckoutResponse(string Confirmation); + [GenerateStranglerFig(typeof(CheckoutRequest), typeof(CheckoutResponse), FactoryMethodName = "Build", MigrationName = "checkout-migration")] + public static partial class CheckoutMigration + { + [StranglerFigRoute("enterprise-cutover")] + private static bool IsEnterprise(CheckoutRequest request) => request.Tenant == "enterprise"; + [StranglerFigLegacy] + private static CheckoutResponse Legacy(CheckoutRequest request) => new("legacy:" + request.OrderId); + [StranglerFigModern] + private static CheckoutResponse Modern(CheckoutRequest request) => new("modern:" + request.OrderId); + } + """)) + .Then("the generated source creates the configured migration", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("Build()", source); + ScenarioExpect.Contains("StranglerFig.Create(\"checkout-migration\")", source); + ScenarioExpect.Contains(".RouteToModern(\"enterprise-cutover\", IsEnterprise)", source); + ScenarioExpect.Contains(".Legacy(Legacy)", source); + ScenarioExpect.Contains(".Modern(Modern)", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid Strangler Fig declarations")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Strangler_Fig_Declarations() + => Given("invalid Strangler Fig declarations", () => new[] + { + Compile(""" + using PatternKit.Generators.StranglerFig; + [GenerateStranglerFig(typeof(string), typeof(int))] + public static class MigrationHost; + """), + Compile(""" + using PatternKit.Generators.StranglerFig; + [GenerateStranglerFig(typeof(string), typeof(int))] + public static partial class MigrationHost; + """), + Compile(""" + using PatternKit.Generators.StranglerFig; + [GenerateStranglerFig(typeof(string), typeof(int))] + public static partial class MigrationHost + { + [StranglerFigRoute("modern")] + private static string ModernRoute(string value) => value; + [StranglerFigLegacy] + private static int Legacy(string value) => 1; + [StranglerFigModern] + private static int Modern(string value) => 2; + } + """), + Compile(""" + using PatternKit.Generators.StranglerFig; + [GenerateStranglerFig(typeof(string), typeof(int))] + public static partial class MigrationHost + { + [StranglerFigRoute("modern")] + private static bool Route1(string value) => true; + [StranglerFigRoute("MODERN")] + private static bool Route2(string value) => true; + [StranglerFigLegacy] + private static int Legacy(string value) => 1; + [StranglerFigModern] + private static int Modern(string value) => 2; + } + """) + }) + .Then("diagnostics identify invalid declarations", results => + { + ScenarioExpect.Contains(results[0].Diagnostics, diagnostic => diagnostic.Id == "PKSF001"); + ScenarioExpect.Contains(results[1].Diagnostics, diagnostic => diagnostic.Id == "PKSF002"); + ScenarioExpect.Contains(results[2].Diagnostics, diagnostic => diagnostic.Id == "PKSF003"); + ScenarioExpect.Contains(results[3].Diagnostics, diagnostic => diagnostic.Id == "PKSF004"); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "StranglerFigGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(StranglerFig<,>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new StranglerFigGenerator(), 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/StranglerFig/StranglerFigTests.cs b/test/PatternKit.Tests/Cloud/StranglerFig/StranglerFigTests.cs new file mode 100644 index 00000000..c554d607 --- /dev/null +++ b/test/PatternKit.Tests/Cloud/StranglerFig/StranglerFigTests.cs @@ -0,0 +1,75 @@ +using PatternKit.Cloud.StranglerFig; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Cloud.StranglerFig; + +[Feature("Strangler Fig")] +public sealed class StranglerFigTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Strangler Fig routes migrated traffic to modern implementation")] + [Fact] + public Task Strangler_Fig_Routes_Migrated_Traffic_To_Modern_Implementation() + => Given("a checkout migration with a migrated tenant", CreateMigration) + .When("the migrated tenant is routed", migration => migration.Route(new CheckoutRequest("enterprise", "O-100"))) + .Then("the modern implementation handles the request", result => + { + ScenarioExpect.True(result.UsedModern); + ScenarioExpect.Equal("enterprise-cutover", result.Decision.RuleName); + ScenarioExpect.Equal("modern:O-100", result.Response.Confirmation); + }) + .AssertPassed(); + + [Scenario("Strangler Fig keeps unmigrated traffic on legacy implementation")] + [Fact] + public Task Strangler_Fig_Keeps_Unmigrated_Traffic_On_Legacy_Implementation() + => Given("a checkout migration with a fallback", CreateMigration) + .When("an unmigrated tenant is routed", migration => migration.Route(new CheckoutRequest("retail", "O-200"))) + .Then("the legacy implementation handles the request", result => + { + ScenarioExpect.True(result.UsedLegacy); + ScenarioExpect.Equal("fallback", result.Decision.RuleName); + ScenarioExpect.Equal("legacy:O-200", result.Response.Confirmation); + }) + .AssertPassed(); + + [Scenario("Strangler Fig validates migration configuration")] + [Fact] + public Task Strangler_Fig_Validates_Migration_Configuration() + => Given("invalid Strangler Fig inputs", () => true) + .Then("invalid names are rejected", _ => + ScenarioExpect.Throws(() => StranglerFig.Create("") + .RouteToModern("enterprise", IsEnterprise) + .Legacy(Legacy) + .Modern(Modern) + .Build())) + .And("missing routes are rejected", _ => + ScenarioExpect.Throws(() => StranglerFig.Create().Legacy(Legacy).Modern(Modern).Build())) + .And("missing handlers are rejected", _ => + ScenarioExpect.Throws(() => StranglerFig.Create().RouteToModern("enterprise", IsEnterprise).Build())) + .And("duplicate route names are rejected", _ => + ScenarioExpect.Throws(() => StranglerFig.Create() + .RouteToModern("enterprise", IsEnterprise) + .RouteToModern("ENTERPRISE", IsEnterprise))) + .And("null requests are rejected", _ => + ScenarioExpect.Throws(() => CreateMigration().Route(null!))) + .AssertPassed(); + + private static StranglerFig CreateMigration() + => StranglerFig.Create("checkout-migration") + .RouteToModern("enterprise-cutover", IsEnterprise) + .Legacy(Legacy) + .Modern(Modern) + .Build(); + + private static bool IsEnterprise(CheckoutRequest request) => request.Tenant == "enterprise"; + + private static CheckoutResponse Legacy(CheckoutRequest request) => new($"legacy:{request.OrderId}"); + + private static CheckoutResponse Modern(CheckoutRequest request) => new($"modern:{request.OrderId}"); + + private sealed record CheckoutRequest(string Tenant, string OrderId); + + private sealed record CheckoutResponse(string Confirmation); +}