From 6b31244cccf5d4b0c7ac3613e45391ea47651f65 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 22 May 2026 12:39:27 -0500 Subject: [PATCH 1/2] feat: add backends for frontends pattern --- .../commerce-backends-for-frontends.md | 12 + docs/examples/index.md | 1 + docs/examples/toc.yml | 3 + docs/generators/backends-for-frontends.md | 23 ++ docs/generators/index.md | 1 + docs/generators/toc.yml | 3 + docs/guides/pattern-coverage.md | 1 + docs/patterns/cloud/backends-for-frontends.md | 18 ++ docs/patterns/toc.yml | 2 + .../BackendsForFrontends.cs | 140 +++++++++++ .../CommerceBackendsForFrontendsDemo.cs | 106 +++++++++ ...rnKitExampleServiceCollectionExtensions.cs | 14 +- .../PatternKitExampleCatalog.cs | 10 +- .../PatternKitPatternCatalog.cs | 13 + .../Cloud/BackendsForFrontendsAttributes.cs | 30 +++ .../AnalyzerReleases.Unshipped.md | 4 + .../BackendsForFrontendsGenerator.cs | 222 ++++++++++++++++++ .../CommerceBackendsForFrontendsDemoTests.cs | 88 +++++++ .../PatternKitPatternCatalogTests.cs | 3 +- .../AbstractionsAttributeCoverageTests.cs | 30 +++ .../BackendsForFrontendsGeneratorTests.cs | 111 +++++++++ .../BackendsForFrontendsTests.cs | 80 +++++++ 22 files changed, 912 insertions(+), 3 deletions(-) create mode 100644 docs/examples/commerce-backends-for-frontends.md create mode 100644 docs/generators/backends-for-frontends.md create mode 100644 docs/patterns/cloud/backends-for-frontends.md create mode 100644 src/PatternKit.Core/Cloud/BackendsForFrontends/BackendsForFrontends.cs create mode 100644 src/PatternKit.Examples/BackendsForFrontendsDemo/CommerceBackendsForFrontendsDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/Cloud/BackendsForFrontendsAttributes.cs create mode 100644 src/PatternKit.Generators/BackendsForFrontends/BackendsForFrontendsGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/BackendsForFrontendsDemo/CommerceBackendsForFrontendsDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/BackendsForFrontendsGeneratorTests.cs create mode 100644 test/PatternKit.Tests/Cloud/BackendsForFrontends/BackendsForFrontendsTests.cs diff --git a/docs/examples/commerce-backends-for-frontends.md b/docs/examples/commerce-backends-for-frontends.md new file mode 100644 index 00000000..fbf19bb1 --- /dev/null +++ b/docs/examples/commerce-backends-for-frontends.md @@ -0,0 +1,12 @@ +# Commerce Backends for Frontends + +The commerce Backends for Frontends example shapes one customer summary workflow for web, mobile, and default clients. + +```csharp +services.AddCommerceBackendsForFrontendsDemo(); + +var runner = provider.GetRequiredService(); +var summary = runner.RunGenerated("mobile", "C-100"); +``` + +The example includes fluent and source-generated construction, an `IServiceCollection` extension, and an ASP.NET Core minimal API mapping through `MapCommerceBackendsForFrontends()`. diff --git a/docs/examples/index.md b/docs/examples/index.md index d57edd23..cbfca712 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -169,6 +169,7 @@ dotnet test PatternKit.slnx -c Release * **Product Gateway Routing:** `ProductGatewayRoutingDemo` (+ `ProductGatewayRoutingDemoTests`) — fluent and generated downstream route dispatch with DI and ASP.NET Core mapping. * **Checkout Strangler Fig Migration:** `CheckoutStranglerFigDemo` (+ `CheckoutStranglerFigDemoTests`) — fluent and generated legacy-to-modern checkout routing with DI and ASP.NET Core mapping. * **Order Telemetry Sidecar:** `OrderTelemetrySidecarDemo` (+ `OrderTelemetrySidecarDemoTests`) — fluent and generated companion telemetry behavior with DI and ASP.NET Core mapping. +* **Commerce Backends for Frontends:** `CommerceBackendsForFrontendsDemo` (+ `CommerceBackendsForFrontendsDemoTests`) — fluent and generated client-specific facade shaping 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 bf9d3955..16fd5ec6 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -249,3 +249,6 @@ - name: Order Telemetry Sidecar href: order-telemetry-sidecar.md + +- name: Commerce Backends for Frontends + href: commerce-backends-for-frontends.md diff --git a/docs/generators/backends-for-frontends.md b/docs/generators/backends-for-frontends.md new file mode 100644 index 00000000..1b7ca4f9 --- /dev/null +++ b/docs/generators/backends-for-frontends.md @@ -0,0 +1,23 @@ +# Backends for Frontends Generator + +`[GenerateBackendsForFrontends]` creates a typed `BackendsForFrontends` factory from selector and handler method pairs. + +```csharp +[GenerateBackendsForFrontends(typeof(CommerceClientRequest), typeof(CommerceClientResponse), GatewayName = "commerce-bff")] +public static partial class CommerceBff +{ + [FrontendSelector("mobile")] + private static bool IsMobile(CommerceClientRequest request) => request.Client == "mobile"; + + [FrontendHandler("mobile")] + private static CommerceClientResponse Mobile(BackendsForFrontendsContext ctx) + => new(ctx.Request.CustomerId, "compact", 2, false); +} +``` + +Diagnostics: + +- `PKBFF001`: host type must be partial. +- `PKBFF002`: selectors and handlers must be paired by frontend name. +- `PKBFF003`: selector, handler, or fallback signature is invalid. +- `PKBFF004`: frontend names must be unique. diff --git a/docs/generators/index.md b/docs/generators/index.md index 589cea50..2f9fe60d 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -125,6 +125,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Gateway Routing**](gateway-routing.md) | API gateway route dispatch factories | `[GenerateGatewayRouting]` | | [**Strangler Fig**](strangler-fig.md) | Legacy-to-modern migration routing factories | `[GenerateStranglerFig]` | | [**Sidecar**](sidecar.md) | Companion behavior pipeline factories | `[GenerateSidecar]` | +| [**Backends for Frontends**](backends-for-frontends.md) | Client-specific facade factories | `[GenerateBackendsForFrontends]` | ## Quick Reference diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 1ba4e792..b4255a98 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -172,6 +172,9 @@ - name: Sidecar href: sidecar.md +- name: Backends for Frontends + href: backends-for-frontends.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 b675fc60..5b6fb47c 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -90,6 +90,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Cloud Architecture | Gateway Routing | `GatewayRouting` | Gateway Routing generator | | Cloud Architecture | Strangler Fig | `StranglerFig` | Strangler Fig generator | | Cloud Architecture | Sidecar | `Sidecar` | Sidecar generator | +| Cloud Architecture | Backends for Frontends | `BackendsForFrontends` | Backends for Frontends 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/backends-for-frontends.md b/docs/patterns/cloud/backends-for-frontends.md new file mode 100644 index 00000000..c936baed --- /dev/null +++ b/docs/patterns/cloud/backends-for-frontends.md @@ -0,0 +1,18 @@ +# Backends for Frontends + +Backends for Frontends creates client-specific API facades over shared backend capabilities. + +```csharp +var bff = BackendsForFrontends + .Create("commerce-bff") + .Frontend("mobile", request => request.Client == "mobile", ctx => new(ctx.Request.CustomerId, "compact", 2, false)) + .Frontend("web", request => request.Client == "web", ctx => new(ctx.Request.CustomerId, "rich", 2, true)) + .Fallback(ctx => new(ctx.Request.CustomerId, "standard", 2, true)) + .Build(); + +var result = bff.Dispatch(request); +``` + +Use it when mobile, web, partner, or internal clients need different response shapes or orchestration paths while the same backend services remain shared. The runtime path returns an explicit result with the selected frontend name and captures handler failures without forcing exception-based control flow. + +The source-generated path uses `[GenerateBackendsForFrontends]`, `[FrontendSelector]`, `[FrontendHandler]`, and `[FrontendFallback]`. Import the example through `AddCommerceBackendsForFrontendsDemo()` or `AddPatternKitExamples()`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index 54751baf..e39ba989 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -379,6 +379,8 @@ href: cloud/strangler-fig.md - name: Sidecar href: cloud/sidecar.md + - name: Backends for Frontends + href: cloud/backends-for-frontends.md - name: Application Architecture items: - name: Anti-Corruption Layer diff --git a/src/PatternKit.Core/Cloud/BackendsForFrontends/BackendsForFrontends.cs b/src/PatternKit.Core/Cloud/BackendsForFrontends/BackendsForFrontends.cs new file mode 100644 index 00000000..325d7ffa --- /dev/null +++ b/src/PatternKit.Core/Cloud/BackendsForFrontends/BackendsForFrontends.cs @@ -0,0 +1,140 @@ +namespace PatternKit.Cloud.BackendsForFrontends; + +public sealed class BackendsForFrontendsContext +{ + internal BackendsForFrontendsContext(string frontendName, TRequest request) + => (FrontendName, Request) = (frontendName, request); + + public string FrontendName { get; } + + public TRequest Request { get; } + + public IDictionary Items { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); +} + +public sealed class BackendsForFrontendsResult +{ + private BackendsForFrontendsResult(string gatewayName, string? frontendName, TResponse? response, Exception? exception, bool handled) + => (GatewayName, FrontendName, Response, Exception, Handled) = (gatewayName, frontendName, response, exception, handled); + + public string GatewayName { get; } + + public string? FrontendName { get; } + + public TResponse? Response { get; } + + public Exception? Exception { get; } + + public bool Handled { get; } + + public bool Failed => !Handled; + + public static BackendsForFrontendsResult Success(string gatewayName, string frontendName, TResponse response) + => new(gatewayName, frontendName, response ?? throw new ArgumentNullException(nameof(response)), null, true); + + public static BackendsForFrontendsResult Failure(string gatewayName, string? frontendName, Exception exception) + => new(gatewayName, frontendName, default, exception ?? throw new ArgumentNullException(nameof(exception)), false); +} + +public sealed class BackendsForFrontends +{ + private readonly IReadOnlyList _frontends; + private readonly Func, TResponse>? _fallback; + + private BackendsForFrontends(string name, IReadOnlyList frontends, Func, TResponse>? fallback) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Backends for Frontends name is required.", nameof(name)); + if (frontends is null) + throw new ArgumentNullException(nameof(frontends)); + if (frontends.Count == 0) + throw new InvalidOperationException("Backends for Frontends requires at least one frontend."); + + Name = name; + _frontends = frontends; + _fallback = fallback; + } + + public string Name { get; } + + public BackendsForFrontendsResult Dispatch(TRequest request) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + foreach (var frontend in _frontends) + { + if (!frontend.Matches(request)) + continue; + + return Invoke(frontend.Name, request, frontend.Handle); + } + + return _fallback is null + ? BackendsForFrontendsResult.Failure(Name, null, new InvalidOperationException("No frontend matched the request.")) + : Invoke("fallback", request, _fallback); + } + + public static Builder Create(string name = "backends-for-frontends") => new(name); + + private BackendsForFrontendsResult Invoke(string frontendName, TRequest request, Func, TResponse> handler) + { + try + { + var response = handler(new(frontendName, request)); + if (response is null) + return BackendsForFrontendsResult.Failure(Name, frontendName, new InvalidOperationException($"Frontend '{frontendName}' returned null.")); + + return BackendsForFrontendsResult.Success(Name, frontendName, response); + } + catch (Exception ex) + { + return BackendsForFrontendsResult.Failure(Name, frontendName, ex); + } + } + + public sealed class Builder + { + private readonly string _name; + private readonly List _frontends = []; + private Func, TResponse>? _fallback; + + internal Builder(string name) => _name = name; + + public Builder Frontend(string name, Func match, Func, TResponse> handle) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Frontend name is required.", nameof(name)); + if (match is null) + throw new ArgumentNullException(nameof(match)); + if (handle is null) + throw new ArgumentNullException(nameof(handle)); + if (_frontends.Any(frontend => string.Equals(frontend.Name, name, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException($"Frontend '{name}' is already registered."); + + _frontends.Add(new(name, match, handle)); + return this; + } + + public Builder Fallback(Func, TResponse> handle) + { + _fallback = handle ?? throw new ArgumentNullException(nameof(handle)); + return this; + } + + public BackendsForFrontends Build() + => new(_name, _frontends.ToArray(), _fallback); + } + + private sealed class Frontend + { + public Frontend(string name, Func matches, Func, TResponse> handle) + => (Name, Matches, Handle) = (name, matches, handle); + + public string Name { get; } + + public Func Matches { get; } + + public Func, TResponse> Handle { get; } + } +} diff --git a/src/PatternKit.Examples/BackendsForFrontendsDemo/CommerceBackendsForFrontendsDemo.cs b/src/PatternKit.Examples/BackendsForFrontendsDemo/CommerceBackendsForFrontendsDemo.cs new file mode 100644 index 00000000..49567f07 --- /dev/null +++ b/src/PatternKit.Examples/BackendsForFrontendsDemo/CommerceBackendsForFrontendsDemo.cs @@ -0,0 +1,106 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Cloud.BackendsForFrontends; +using PatternKit.Generators.BackendsForFrontends; + +namespace PatternKit.Examples.BackendsForFrontendsDemo; + +public sealed record CommerceClientRequest(string Client, string CustomerId); + +public sealed record CommerceClientResponse(string CustomerId, string Shape, int ItemCount, bool IncludesPromotions); + +public interface ICommerceSummaryBackend +{ + int GetOpenItemCount(string customerId); +} + +public sealed class DemoCommerceSummaryBackend : ICommerceSummaryBackend +{ + public int GetOpenItemCount(string customerId) => customerId.Length % 3 + 1; +} + +public sealed class CommerceBackendsForFrontendsService(BackendsForFrontends bff) +{ + public CommerceClientResponse GetSummary(string client, string customerId) + { + var result = bff.Dispatch(new CommerceClientRequest(client, customerId)); + if (result.Failed) + throw new InvalidOperationException("Commerce client summary could not be shaped.", result.Exception); + + return result.Response!; + } +} + +public static class CommerceBackendsForFrontends +{ + public static BackendsForFrontends CreateFluent(ICommerceSummaryBackend backend) + => BackendsForFrontends.Create("commerce-bff") + .Frontend("mobile", static request => request.Client.Equals("mobile", StringComparison.OrdinalIgnoreCase), ctx => Mobile(ctx, backend)) + .Frontend("web", static request => request.Client.Equals("web", StringComparison.OrdinalIgnoreCase), ctx => Web(ctx, backend)) + .Fallback(ctx => Standard(ctx, backend)) + .Build(); + + public static CommerceClientResponse Mobile(BackendsForFrontendsContext ctx, ICommerceSummaryBackend backend) + => new(ctx.Request.CustomerId, "compact", backend.GetOpenItemCount(ctx.Request.CustomerId), IncludesPromotions: false); + + public static CommerceClientResponse Web(BackendsForFrontendsContext ctx, ICommerceSummaryBackend backend) + => new(ctx.Request.CustomerId, "rich", backend.GetOpenItemCount(ctx.Request.CustomerId), IncludesPromotions: true); + + public static CommerceClientResponse Standard(BackendsForFrontendsContext ctx, ICommerceSummaryBackend backend) + => new(ctx.Request.CustomerId, "standard", backend.GetOpenItemCount(ctx.Request.CustomerId), IncludesPromotions: true); +} + +[GenerateBackendsForFrontends(typeof(CommerceClientRequest), typeof(CommerceClientResponse), FactoryMethodName = "Create", GatewayName = "commerce-bff")] +public static partial class GeneratedCommerceBackendsForFrontends +{ + [FrontendSelector("mobile")] + private static bool IsMobile(CommerceClientRequest request) => request.Client.Equals("mobile", StringComparison.OrdinalIgnoreCase); + + [FrontendHandler("mobile")] + private static CommerceClientResponse Mobile(BackendsForFrontendsContext ctx) + => new(ctx.Request.CustomerId, "compact", 2, IncludesPromotions: false); + + [FrontendSelector("web")] + private static bool IsWeb(CommerceClientRequest request) => request.Client.Equals("web", StringComparison.OrdinalIgnoreCase); + + [FrontendHandler("web")] + private static CommerceClientResponse Web(BackendsForFrontendsContext ctx) + => new(ctx.Request.CustomerId, "rich", 2, IncludesPromotions: true); + + [FrontendFallback] + private static CommerceClientResponse Standard(BackendsForFrontendsContext ctx) + => new(ctx.Request.CustomerId, "standard", 2, IncludesPromotions: true); +} + +public sealed class CommerceBackendsForFrontendsDemoRunner(CommerceBackendsForFrontendsService service) +{ + public CommerceClientResponse RunGenerated(string client, string customerId) => service.GetSummary(client, customerId); + + public static CommerceClientResponse RunFluent() + { + var bff = CommerceBackendsForFrontends.CreateFluent(new DemoCommerceSummaryBackend()); + var result = bff.Dispatch(new CommerceClientRequest("web", "C-100")); + return result.Response!; + } +} + +public static class CommerceBackendsForFrontendsServiceCollectionExtensions +{ + public static IServiceCollection AddCommerceBackendsForFrontendsDemo(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(static _ => GeneratedCommerceBackendsForFrontends.Create()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } + + public static IEndpointRouteBuilder MapCommerceBackendsForFrontends(this IEndpointRouteBuilder endpoints, string pattern = "/commerce/{client}/{customerId}/summary") + { + endpoints.MapGet(pattern, (string client, string customerId, CommerceBackendsForFrontendsService service) => Results.Ok(service.GetSummary(client, customerId))) + .WithName("CommerceBackendsForFrontends"); + return endpoints; + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index d54347d0..ac63e125 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -22,6 +22,7 @@ using PatternKit.Examples.AntiCorruptionDemo; using PatternKit.Examples.AsyncStateDemo; using PatternKit.Examples.AuditLogDemo; +using PatternKit.Examples.BackendsForFrontendsDemo; using PatternKit.Examples.BulkheadDemo; using PatternKit.Examples.CacheAsideDemo; using PatternKit.Examples.CanonicalDataModelDemo; @@ -208,6 +209,7 @@ public sealed record CustomerDashboardGatewayAggregationExample(CustomerDashboar public sealed record CheckoutStranglerFigExample(CheckoutStranglerFigDemoRunner Runner, CheckoutMigrationService Service); public sealed record ProductGatewayRoutingExample(ProductGatewayRoutingDemoRunner Runner, ProductGatewayRoutingService Service); public sealed record OrderTelemetrySidecarExample(OrderTelemetrySidecarDemoRunner Runner, OrderTelemetrySidecarService Service); +public sealed record CommerceBackendsForFrontendsExample(CommerceBackendsForFrontendsDemoRunner Runner, CommerceBackendsForFrontendsService Service); /// /// Fluent registration helpers for importing every documented PatternKit example into Microsoft.Extensions.DependencyInjection. @@ -300,7 +302,8 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddCustomerDashboardGatewayAggregationExample() .AddCheckoutStranglerFigExample() .AddProductGatewayRoutingExample() - .AddOrderTelemetrySidecarExample(); + .AddOrderTelemetrySidecarExample() + .AddCommerceBackendsForFrontendsExample(); public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services) { @@ -1065,6 +1068,15 @@ public static IServiceCollection AddOrderTelemetrySidecarExample(this IServiceCo return services.RegisterExample("Order Telemetry Sidecar", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore); } + public static IServiceCollection AddCommerceBackendsForFrontendsExample(this IServiceCollection services) + { + services.AddCommerceBackendsForFrontendsDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("Commerce Backends for Frontends", 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 e20eef67..8016f0db 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -759,7 +759,15 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog "docs/examples/order-telemetry-sidecar.md", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore, ["Sidecar"], - ["trace context enrichment", "source-generated sidecar factory", "ASP.NET Core endpoint mapping"]) + ["trace context enrichment", "source-generated sidecar factory", "ASP.NET Core endpoint mapping"]), + Descriptor( + "Commerce Backends for Frontends", + "src/PatternKit.Examples/BackendsForFrontendsDemo/CommerceBackendsForFrontendsDemo.cs", + "test/PatternKit.Examples.Tests/BackendsForFrontendsDemo/CommerceBackendsForFrontendsDemoTests.cs", + "docs/examples/commerce-backends-for-frontends.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore, + ["Backends for Frontends"], + ["client-specific response shaping", "source-generated BFF factory", "ASP.NET Core endpoint mapping"]) ]; public IReadOnlyList Entries => Items; diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 41d28054..0e3eab8f 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -961,6 +961,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/SidecarDemo/OrderTelemetrySidecarDemoTests.cs", ["fluent companion pipeline", "generated sidecar factory", "DI-importable ASP.NET Core order telemetry example"]), + Pattern("Backends for Frontends", PatternFamily.CloudArchitecture, + "docs/patterns/cloud/backends-for-frontends.md", + "src/PatternKit.Core/Cloud/BackendsForFrontends/BackendsForFrontends.cs", + "test/PatternKit.Tests/Cloud/BackendsForFrontends/BackendsForFrontendsTests.cs", + "docs/generators/backends-for-frontends.md", + "src/PatternKit.Generators/BackendsForFrontends/BackendsForFrontendsGenerator.cs", + "test/PatternKit.Generators.Tests/BackendsForFrontendsGeneratorTests.cs", + null, + "docs/examples/commerce-backends-for-frontends.md", + "src/PatternKit.Examples/BackendsForFrontendsDemo/CommerceBackendsForFrontendsDemo.cs", + "test/PatternKit.Examples.Tests/BackendsForFrontendsDemo/CommerceBackendsForFrontendsDemoTests.cs", + ["fluent client-specific facade", "generated BFF factory", "DI-importable ASP.NET Core commerce example"]), + Pattern("CQRS", PatternFamily.ApplicationArchitecture, "docs/generators/dispatcher.md", "src/PatternKit.Core/Behavioral/Mediator/Mediator.cs", diff --git a/src/PatternKit.Generators.Abstractions/Cloud/BackendsForFrontendsAttributes.cs b/src/PatternKit.Generators.Abstractions/Cloud/BackendsForFrontendsAttributes.cs new file mode 100644 index 00000000..0bdb2be6 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Cloud/BackendsForFrontendsAttributes.cs @@ -0,0 +1,30 @@ +namespace PatternKit.Generators.BackendsForFrontends; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateBackendsForFrontendsAttribute(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; } = "backends-for-frontends"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class FrontendSelectorAttribute(string name) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Frontend name is required.", nameof(name)) : name; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class FrontendHandlerAttribute(string name) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Frontend name is required.", nameof(name)) : name; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class FrontendFallbackAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 778ea0e1..abb72e73 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. +PKBFF001 | PatternKit.Generators.BackendsForFrontends | Error | Backends for Frontends host must be partial. +PKBFF002 | PatternKit.Generators.BackendsForFrontends | Error | Backends for Frontends members are missing. +PKBFF003 | PatternKit.Generators.BackendsForFrontends | Error | Backends for Frontends method signature is invalid. +PKBFF004 | PatternKit.Generators.BackendsForFrontends | Error | Backends for Frontends frontend 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. diff --git a/src/PatternKit.Generators/BackendsForFrontends/BackendsForFrontendsGenerator.cs b/src/PatternKit.Generators/BackendsForFrontends/BackendsForFrontendsGenerator.cs new file mode 100644 index 00000000..d45cffdb --- /dev/null +++ b/src/PatternKit.Generators/BackendsForFrontends/BackendsForFrontendsGenerator.cs @@ -0,0 +1,222 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.BackendsForFrontends; + +[Generator] +public sealed class BackendsForFrontendsGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.BackendsForFrontends.GenerateBackendsForFrontendsAttribute"; + private const string SelectorAttributeName = "PatternKit.Generators.BackendsForFrontends.FrontendSelectorAttribute"; + private const string HandlerAttributeName = "PatternKit.Generators.BackendsForFrontends.FrontendHandlerAttribute"; + private const string FallbackAttributeName = "PatternKit.Generators.BackendsForFrontends.FrontendFallbackAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKBFF001", "Backends for Frontends host must be partial", + "Type '{0}' is marked with [GenerateBackendsForFrontends] but is not declared as partial", + "PatternKit.Generators.BackendsForFrontends", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingMembers = new( + "PKBFF002", "Backends for Frontends members are missing", + "Backends for Frontends type '{0}' must declare matching frontend selectors and handlers", + "PatternKit.Generators.BackendsForFrontends", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidMember = new( + "PKBFF003", "Backends for Frontends method signature is invalid", + "Backends for Frontends method '{0}' has an invalid static signature for the configured request or response type", + "PatternKit.Generators.BackendsForFrontends", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor DuplicateFrontend = new( + "PKBFF004", "Backends for Frontends frontend is duplicated", + "Frontend '{0}' is duplicated", + "PatternKit.Generators.BackendsForFrontends", 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 selectors = NamedMembers(type, SelectorAttributeName); + var handlers = NamedMembers(type, HandlerAttributeName); + var fallbacks = MembersWith(type, FallbackAttributeName); + if (selectors.Length == 0 || selectors.Length != handlers.Length || fallbacks.Length > 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingMembers, node.Identifier.GetLocation(), type.Name)); + return; + } + + var allNames = selectors.Select(static item => item.Name).Concat(handlers.Select(static item => item.Name)).ToArray(); + var duplicate = allNames.GroupBy(static item => item, StringComparer.OrdinalIgnoreCase).FirstOrDefault(static group => group.Count() > 2); + if (duplicate is not null) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateFrontend, node.Identifier.GetLocation(), duplicate.Key)); + return; + } + + var routes = selectors.Select(selector => new FrontendRoute(selector.Name, selector.Method, handlers.FirstOrDefault(handler => string.Equals(handler.Name, selector.Name, StringComparison.OrdinalIgnoreCase))?.Method)).ToArray(); + if (routes.Any(static route => route.Handler is null)) + { + context.ReportDiagnostic(Diagnostic.Create(MissingMembers, node.Identifier.GetLocation(), type.Name)); + return; + } + + var invalidSelector = selectors.FirstOrDefault(item => !IsSelector(item.Method, requestType)); + if (invalidSelector is not null) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, invalidSelector.Method.Locations.FirstOrDefault(), invalidSelector.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 (fallbacks.Length == 1 && !IsHandler(fallbacks[0], requestType, responseType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, fallbacks[0].Locations.FirstOrDefault(), fallbacks[0].Name)); + return; + } + + context.AddSource($"{type.Name}.BackendsForFrontends.g.cs", SourceText.From(GenerateSource( + type, + requestType, + responseType, + routes, + fallbacks.FirstOrDefault()?.Name, + GetNamedString(attribute, "FactoryMethodName") ?? "Create", + GetNamedString(attribute, "GatewayName") ?? "backends-for-frontends"), 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 NamedMember[] NamedMembers(INamedTypeSymbol type, string attributeName) + => type.GetMembers().OfType() + .Select(method => new + { + Method = method, + Attribute = method.GetAttributes().FirstOrDefault(attr => attr.AttributeClass?.ToDisplayString() == attributeName) + }) + .Where(static item => item.Attribute is not null) + .Select(static item => new NamedMember((string)item.Attribute!.ConstructorArguments[0].Value!, item.Method)) + .ToArray(); + + private static bool IsSelector(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 && + method.Parameters[0].Type is INamedTypeSymbol contextType && + contextType.ConstructedFrom.ToDisplayString() == "PatternKit.Cloud.BackendsForFrontends.BackendsForFrontendsContext" && + contextType.TypeArguments.Length == 1 && + SymbolEqualityComparer.Default.Equals(contextType.TypeArguments[0], requestType); + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol requestType, + INamedTypeSymbol responseType, + IReadOnlyList routes, + string? fallbackName, + 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.BackendsForFrontends.BackendsForFrontends<") + .Append(requestTypeName).Append(", ").Append(responseTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" return global::PatternKit.Cloud.BackendsForFrontends.BackendsForFrontends<") + .Append(requestTypeName).Append(", ").Append(responseTypeName).Append(">.Create(\"").Append(Escape(gatewayName)).AppendLine("\")"); + foreach (var route in routes) + sb.Append(" .Frontend(\"").Append(Escape(route.Name)).Append("\", ").Append(route.Selector.Name).Append(", ").Append(route.Handler!.Name).AppendLine(")"); + if (fallbackName is not null) + sb.Append(" .Fallback(").Append(fallbackName).AppendLine(")"); + sb.AppendLine(" .Build();"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; + + private sealed record NamedMember(string Name, IMethodSymbol Method); + + private sealed record FrontendRoute(string Name, IMethodSymbol Selector, IMethodSymbol? Handler); +} diff --git a/test/PatternKit.Examples.Tests/BackendsForFrontendsDemo/CommerceBackendsForFrontendsDemoTests.cs b/test/PatternKit.Examples.Tests/BackendsForFrontendsDemo/CommerceBackendsForFrontendsDemoTests.cs new file mode 100644 index 00000000..6370d681 --- /dev/null +++ b/test/PatternKit.Examples.Tests/BackendsForFrontendsDemo/CommerceBackendsForFrontendsDemoTests.cs @@ -0,0 +1,88 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.BackendsForFrontendsDemo; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.ProductionReadiness; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.BackendsForFrontendsDemo; + +[Feature("Commerce Backends for Frontends demo")] +public sealed class CommerceBackendsForFrontendsDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent BFF shapes client-specific responses")] + [Fact] + public Task Fluent_Bff_Shapes_Client_Specific_Responses() + => Given("a fluent commerce BFF", () => CommerceBackendsForFrontends.CreateFluent(new DemoCommerceSummaryBackend())) + .When("web and mobile clients request summaries", bff => new + { + Web = bff.Dispatch(new CommerceClientRequest("web", "C-100")), + Mobile = bff.Dispatch(new CommerceClientRequest("mobile", "C-100")) + }) + .Then("each response has the client-specific shape", result => + { + ScenarioExpect.Equal("rich", result.Web.Response!.Shape); + ScenarioExpect.True(result.Web.Response.IncludesPromotions); + ScenarioExpect.Equal("compact", result.Mobile.Response!.Shape); + ScenarioExpect.False(result.Mobile.Response.IncludesPromotions); + }) + .AssertPassed(); + + [Scenario("Generated BFF is importable through IServiceCollection")] + [Fact] + public Task Generated_Bff_Is_Importable_Through_IServiceCollection() + => Given("a service provider configured with the commerce BFF demo", () => + { + var services = new ServiceCollection(); + services.AddCommerceBackendsForFrontendsDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("the demo runner resolves and runs", provider => + { + using (provider) + return provider.GetRequiredService().RunGenerated("mobile", "C-100"); + }) + .Then("the generated BFF returns the expected response", response => + { + ScenarioExpect.Equal("C-100", response.CustomerId); + ScenarioExpect.Equal("compact", response.Shape); + ScenarioExpect.False(response.IncludesPromotions); + }) + .AssertPassed(); + + [Scenario("Commerce BFF appears in production catalogs")] + [Fact] + public Task Commerce_Bff_Appears_In_Production_Catalogs() + => Given("the production catalogs", () => new + { + Examples = new PatternKitExampleCatalog(), + Patterns = new PatternKitPatternCatalog() + }) + .Then("the example catalog includes the BFF demo", ctx => + ScenarioExpect.Contains(ctx.Examples.Entries, entry => entry.Name == "Commerce Backends for Frontends")) + .And("the pattern catalog includes Backends for Frontends", ctx => + ScenarioExpect.Contains(ctx.Patterns.Patterns, pattern => pattern.Name == "Backends for Frontends")) + .AssertPassed(); + + [Scenario("Aggregate example registration includes commerce BFF")] + [Fact] + public Task Aggregate_Example_Registration_Includes_Commerce_Bff() + => Given("all PatternKit examples registered in a service collection", () => + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("the BFF example is resolved", provider => + { + using (provider) + return provider.GetRequiredService().Runner.RunGenerated("web", "C-100"); + }) + .Then("the registered example executes through the generated BFF", response => + { + ScenarioExpect.Equal("rich", response.Shape); + ScenarioExpect.True(response.IncludesPromotions); + }) + .AssertPassed(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index bea00729..98b11031 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -84,6 +84,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Strangler Fig", "Gateway Routing", "Sidecar", + "Backends for Frontends", "CQRS", "Specification", "Repository", @@ -141,7 +142,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(13, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); + ScenarioExpect.Equal(14, 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 98e4048e..c55cb39a 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -49,6 +49,7 @@ using PatternKit.Generators; using PatternKit.Generators.AntiCorruption; using PatternKit.Generators.AuditLog; +using PatternKit.Generators.BackendsForFrontends; using TinyBDD; namespace PatternKit.Generators.Tests; @@ -227,6 +228,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(GenerateBackendsForFrontendsAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(FrontendSelectorAttribute), AttributeTargets.Method, false, false }, + { typeof(FrontendHandlerAttribute), AttributeTargets.Method, false, false }, + { typeof(FrontendFallbackAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateGatewayRoutingAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GatewayRouteAttribute), AttributeTargets.Method, false, false }, { typeof(GatewayRouteHandlerAttribute), AttributeTargets.Method, false, false }, @@ -567,6 +572,31 @@ public void Sidecar_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.IsType(new SidecarHandlerAttribute()); } + [Scenario("Backends for Frontends Attributes Expose Defaults And Configuration")] + [Fact] + public void Backends_For_Frontends_Attributes_Expose_Defaults_And_Configuration() + { + var bff = new GenerateBackendsForFrontendsAttribute(typeof(string), typeof(int)) + { + FactoryMethodName = "BuildCommerceBff", + GatewayName = "commerce-bff" + }; + var selector = new FrontendSelectorAttribute("mobile"); + var handler = new FrontendHandlerAttribute("mobile"); + + ScenarioExpect.Equal(typeof(string), bff.RequestType); + ScenarioExpect.Equal(typeof(int), bff.ResponseType); + ScenarioExpect.Equal("BuildCommerceBff", bff.FactoryMethodName); + ScenarioExpect.Equal("commerce-bff", bff.GatewayName); + ScenarioExpect.Equal("mobile", selector.Name); + ScenarioExpect.Equal("mobile", handler.Name); + ScenarioExpect.Throws(() => new GenerateBackendsForFrontendsAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateBackendsForFrontendsAttribute(typeof(string), null!)); + ScenarioExpect.Throws(() => new FrontendSelectorAttribute("")); + ScenarioExpect.Throws(() => new FrontendHandlerAttribute("")); + ScenarioExpect.IsType(new FrontendFallbackAttribute()); + } + [Scenario("Strangler Fig Attributes Expose Defaults And Configuration")] [Fact] public void StranglerFig_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/BackendsForFrontendsGeneratorTests.cs b/test/PatternKit.Generators.Tests/BackendsForFrontendsGeneratorTests.cs new file mode 100644 index 00000000..c3a3742e --- /dev/null +++ b/test/PatternKit.Generators.Tests/BackendsForFrontendsGeneratorTests.cs @@ -0,0 +1,111 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Cloud.BackendsForFrontends; +using PatternKit.Generators.BackendsForFrontends; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Backends for Frontends generator")] +public sealed partial class BackendsForFrontendsGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates Backends for Frontends factory")] + [Fact] + public Task Generates_Backends_For_Frontends_Factory() + => Given("a BFF declaration", () => Compile(""" + using PatternKit.Cloud.BackendsForFrontends; + using PatternKit.Generators.BackendsForFrontends; + namespace Demo; + public sealed record ClientRequest(string Client, string CustomerId); + public sealed record ClientResponse(string CustomerId, string Shape); + [GenerateBackendsForFrontends(typeof(ClientRequest), typeof(ClientResponse), FactoryMethodName = "Build", GatewayName = "commerce-bff")] + public static partial class CommerceBff + { + [FrontendSelector("mobile")] + private static bool IsMobile(ClientRequest request) => request.Client == "mobile"; + [FrontendHandler("mobile")] + private static ClientResponse Mobile(BackendsForFrontendsContext ctx) => new(ctx.Request.CustomerId, "compact"); + [FrontendFallback] + private static ClientResponse Fallback(BackendsForFrontendsContext ctx) => new(ctx.Request.CustomerId, "standard"); + } + """)) + .Then("the generated source creates the configured BFF", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("Build()", source); + ScenarioExpect.Contains("BackendsForFrontends.Create(\"commerce-bff\")", source); + ScenarioExpect.Contains(".Frontend(\"mobile\", IsMobile, Mobile)", source); + ScenarioExpect.Contains(".Fallback(Fallback)", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid Backends for Frontends declarations")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Backends_For_Frontends_Declarations() + => Given("invalid BFF declarations", () => new[] + { + Compile(""" + using PatternKit.Generators.BackendsForFrontends; + [GenerateBackendsForFrontends(typeof(string), typeof(int))] + public static class BffHost; + """), + Compile(""" + using PatternKit.Generators.BackendsForFrontends; + [GenerateBackendsForFrontends(typeof(string), typeof(int))] + public static partial class BffHost; + """), + Compile(""" + using PatternKit.Cloud.BackendsForFrontends; + using PatternKit.Generators.BackendsForFrontends; + [GenerateBackendsForFrontends(typeof(string), typeof(int))] + public static partial class BffHost + { + [FrontendSelector("web")] + private static string Web(string value) => value; + [FrontendHandler("web")] + private static int Handle(BackendsForFrontendsContext ctx) => 1; + } + """), + Compile(""" + using PatternKit.Cloud.BackendsForFrontends; + using PatternKit.Generators.BackendsForFrontends; + [GenerateBackendsForFrontends(typeof(string), typeof(int))] + public static partial class BffHost + { + [FrontendSelector("web")] + private static bool Web(string value) => true; + [FrontendSelector("WEB")] + private static bool Web2(string value) => true; + [FrontendHandler("web")] + private static int Handle(BackendsForFrontendsContext ctx) => 1; + [FrontendHandler("WEB")] + private static int Handle2(BackendsForFrontendsContext ctx) => 1; + } + """) + }) + .Then("diagnostics identify invalid declarations", results => + { + ScenarioExpect.Contains(results[0].Diagnostics, diagnostic => diagnostic.Id == "PKBFF001"); + ScenarioExpect.Contains(results[1].Diagnostics, diagnostic => diagnostic.Id == "PKBFF002"); + ScenarioExpect.Contains(results[2].Diagnostics, diagnostic => diagnostic.Id == "PKBFF003"); + ScenarioExpect.Contains(results[3].Diagnostics, diagnostic => diagnostic.Id == "PKBFF004"); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "BackendsForFrontendsGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(BackendsForFrontends<,>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new BackendsForFrontendsGenerator(), 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/BackendsForFrontends/BackendsForFrontendsTests.cs b/test/PatternKit.Tests/Cloud/BackendsForFrontends/BackendsForFrontendsTests.cs new file mode 100644 index 00000000..4cf0a744 --- /dev/null +++ b/test/PatternKit.Tests/Cloud/BackendsForFrontends/BackendsForFrontendsTests.cs @@ -0,0 +1,80 @@ +using PatternKit.Cloud.BackendsForFrontends; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Cloud.BackendsForFrontends; + +[Feature("Backends for Frontends")] +public sealed class BackendsForFrontendsTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Backends for Frontends dispatches to the matching frontend")] + [Fact] + public Task Backends_For_Frontends_Dispatches_To_The_Matching_Frontend() + => Given("a commerce BFF with web and mobile frontends", CreateBff) + .When("a mobile checkout summary is requested", bff => bff.Dispatch(new ClientRequest("mobile", "C-100"))) + .Then("the mobile response is shaped for the client experience", result => + { + ScenarioExpect.True(result.Handled); + ScenarioExpect.Equal("commerce-bff", result.GatewayName); + ScenarioExpect.Equal("mobile", result.FrontendName); + ScenarioExpect.Equal("compact", result.Response!.Shape); + }) + .AssertPassed(); + + [Scenario("Backends for Frontends uses fallback when no frontend matches")] + [Fact] + public Task Backends_For_Frontends_Uses_Fallback_When_No_Frontend_Matches() + => Given("a commerce BFF with a default frontend", CreateBff) + .When("an unrecognized client requests a summary", bff => bff.Dispatch(new ClientRequest("partner", "C-100"))) + .Then("the fallback response is returned", result => + { + ScenarioExpect.True(result.Handled); + ScenarioExpect.Equal("fallback", result.FrontendName); + ScenarioExpect.Equal("standard", result.Response!.Shape); + }) + .AssertPassed(); + + [Scenario("Backends for Frontends validates configuration and reports failures")] + [Fact] + public Task Backends_For_Frontends_Validates_Configuration_And_Reports_Failures() + => Given("invalid BFF inputs", () => true) + .Then("empty gateway names are rejected", _ => + ScenarioExpect.Throws(() => BackendsForFrontends.Create("") + .Frontend("web", MatchWeb, HandleWeb) + .Build())) + .And("missing frontends are rejected", _ => + ScenarioExpect.Throws(() => BackendsForFrontends.Create().Build())) + .And("duplicate frontends are rejected", _ => + ScenarioExpect.Throws(() => BackendsForFrontends.Create() + .Frontend("web", MatchWeb, HandleWeb) + .Frontend("WEB", MatchWeb, HandleWeb))) + .And("null requests are rejected", _ => + ScenarioExpect.Throws(() => CreateBff().Dispatch(null!))) + .And("handler failures are explicit result failures", _ => + { + var bff = BackendsForFrontends.Create("commerce-bff") + .Frontend("web", MatchWeb, static _ => throw new InvalidOperationException("backend unavailable")) + .Build(); + var result = bff.Dispatch(new ClientRequest("web", "C-100")); + ScenarioExpect.True(result.Failed); + ScenarioExpect.Equal("web", result.FrontendName); + ScenarioExpect.Contains("backend unavailable", result.Exception!.Message); + }) + .AssertPassed(); + + private static BackendsForFrontends CreateBff() + => BackendsForFrontends.Create("commerce-bff") + .Frontend("mobile", static request => request.Client == "mobile", static ctx => new(ctx.Request.CustomerId, "compact")) + .Frontend("web", MatchWeb, HandleWeb) + .Fallback(static ctx => new(ctx.Request.CustomerId, "standard")) + .Build(); + + private static bool MatchWeb(ClientRequest request) => request.Client == "web"; + + private static ClientResponse HandleWeb(BackendsForFrontendsContext ctx) => new(ctx.Request.CustomerId, "rich"); + + private sealed record ClientRequest(string Client, string CustomerId); + + private sealed record ClientResponse(string CustomerId, string Shape); +} From eec4eab1e946a47d87210d394a346c8718b30fdc Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 22 May 2026 12:50:59 -0500 Subject: [PATCH 2/2] test: cover backends for frontends failure paths --- .../BackendsForFrontendsGeneratorTests.cs | 15 ++++++++ .../BackendsForFrontendsTests.cs | 34 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/test/PatternKit.Generators.Tests/BackendsForFrontendsGeneratorTests.cs b/test/PatternKit.Generators.Tests/BackendsForFrontendsGeneratorTests.cs index c3a3742e..d925781f 100644 --- a/test/PatternKit.Generators.Tests/BackendsForFrontendsGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/BackendsForFrontendsGeneratorTests.cs @@ -84,6 +84,20 @@ public static partial class BffHost [FrontendHandler("WEB")] private static int Handle2(BackendsForFrontendsContext ctx) => 1; } + """), + Compile(""" + using PatternKit.Cloud.BackendsForFrontends; + using PatternKit.Generators.BackendsForFrontends; + [GenerateBackendsForFrontends(typeof(string), typeof(int))] + public static partial class BffHost + { + [FrontendSelector("web")] + private static bool Web(string value) => true; + [FrontendHandler("web")] + private static int Handle(BackendsForFrontendsContext ctx) => 1; + [FrontendFallback] + private static string Fallback(BackendsForFrontendsContext ctx) => ""; + } """) }) .Then("diagnostics identify invalid declarations", results => @@ -92,6 +106,7 @@ public static partial class BffHost ScenarioExpect.Contains(results[1].Diagnostics, diagnostic => diagnostic.Id == "PKBFF002"); ScenarioExpect.Contains(results[2].Diagnostics, diagnostic => diagnostic.Id == "PKBFF003"); ScenarioExpect.Contains(results[3].Diagnostics, diagnostic => diagnostic.Id == "PKBFF004"); + ScenarioExpect.Contains(results[4].Diagnostics, diagnostic => diagnostic.Id == "PKBFF003"); }) .AssertPassed(); diff --git a/test/PatternKit.Tests/Cloud/BackendsForFrontends/BackendsForFrontendsTests.cs b/test/PatternKit.Tests/Cloud/BackendsForFrontends/BackendsForFrontendsTests.cs index 4cf0a744..6ba51709 100644 --- a/test/PatternKit.Tests/Cloud/BackendsForFrontends/BackendsForFrontendsTests.cs +++ b/test/PatternKit.Tests/Cloud/BackendsForFrontends/BackendsForFrontendsTests.cs @@ -51,6 +51,35 @@ public Task Backends_For_Frontends_Validates_Configuration_And_Reports_Failures( .Frontend("WEB", MatchWeb, HandleWeb))) .And("null requests are rejected", _ => ScenarioExpect.Throws(() => CreateBff().Dispatch(null!))) + .And("null delegates are rejected", _ => + { + ScenarioExpect.Throws(() => BackendsForFrontends.Create() + .Frontend("web", null!, HandleWeb)); + ScenarioExpect.Throws(() => BackendsForFrontends.Create() + .Frontend("web", MatchWeb, null!)); + ScenarioExpect.Throws(() => BackendsForFrontends.Create() + .Fallback(null!)); + }) + .And("unmatched requests without a fallback are explicit failures", _ => + { + var result = BackendsForFrontends.Create("commerce-bff") + .Frontend("web", MatchWeb, HandleWeb) + .Build() + .Dispatch(new ClientRequest("mobile", "C-100")); + ScenarioExpect.True(result.Failed); + ScenarioExpect.Null(result.FrontendName); + ScenarioExpect.Contains("No frontend matched", result.Exception!.Message); + }) + .And("null responses are explicit failures", _ => + { + var result = BackendsForFrontends.Create("commerce-bff") + .Frontend("web", MatchWeb, static _ => null!) + .Build() + .Dispatch(new ClientRequest("web", "C-100")); + ScenarioExpect.True(result.Failed); + ScenarioExpect.Equal("web", result.FrontendName); + ScenarioExpect.Contains("returned null", result.Exception!.Message); + }) .And("handler failures are explicit result failures", _ => { var bff = BackendsForFrontends.Create("commerce-bff") @@ -61,6 +90,11 @@ public Task Backends_For_Frontends_Validates_Configuration_And_Reports_Failures( ScenarioExpect.Equal("web", result.FrontendName); ScenarioExpect.Contains("backend unavailable", result.Exception!.Message); }) + .And("result factories validate required values", _ => + { + ScenarioExpect.Throws(() => BackendsForFrontendsResult.Success("commerce-bff", "web", null!)); + ScenarioExpect.Throws(() => BackendsForFrontendsResult.Failure("commerce-bff", "web", null!)); + }) .AssertPassed(); private static BackendsForFrontends CreateBff()