diff --git a/docs/examples/index.md b/docs/examples/index.md index cbfca712..1b5fdb38 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -170,6 +170,7 @@ dotnet test PatternKit.slnx -c Release * **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. +* **Inventory Ambassador:** `InventoryAmbassadorDemo` (+ `InventoryAmbassadorDemoTests`) — fluent and generated outbound connectivity wrapper 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/inventory-ambassador.md b/docs/examples/inventory-ambassador.md new file mode 100644 index 00000000..dbfc0344 --- /dev/null +++ b/docs/examples/inventory-ambassador.md @@ -0,0 +1,12 @@ +# Inventory Ambassador + +The inventory ambassador example wraps outbound availability calls with SKU normalization, tenant connection policy, telemetry, and fallback cache behavior. + +```csharp +services.AddInventoryAmbassadorDemo(); + +var runner = provider.GetRequiredService(); +var availability = runner.RunGenerated("tenant-a", "sku-1"); +``` + +The example includes fluent and source-generated construction, an `IServiceCollection` extension, and an ASP.NET Core minimal API mapping through `MapInventoryAmbassador()`. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 16fd5ec6..561a4979 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -252,3 +252,6 @@ - name: Commerce Backends for Frontends href: commerce-backends-for-frontends.md + +- name: Inventory Ambassador + href: inventory-ambassador.md diff --git a/docs/generators/ambassador.md b/docs/generators/ambassador.md new file mode 100644 index 00000000..3b54115b --- /dev/null +++ b/docs/generators/ambassador.md @@ -0,0 +1,23 @@ +# Ambassador Generator + +`[GenerateAmbassador]` creates a typed `Ambassador` factory from outbound call, policy, transform, telemetry, and fallback methods. + +```csharp +[GenerateAmbassador(typeof(InventoryAmbassadorRequest), typeof(InventoryAmbassadorResponse), AmbassadorName = "inventory-ambassador")] +public static partial class InventoryAmbassador +{ + [AmbassadorTransform] + private static InventoryAmbassadorRequest Normalize(InventoryAmbassadorRequest request) => request; + + [AmbassadorCall] + private static InventoryAmbassadorResponse Call(AmbassadorContext ctx) + => new(ctx.Request.Sku, "available", "inventory-api"); +} +``` + +Diagnostics: + +- `PKAMB001`: host type must be partial. +- `PKAMB002`: exactly one outbound call handler is required. +- `PKAMB003`: transform, policy, telemetry, call, or fallback signature is invalid. +- `PKAMB004`: telemetry names must be unique. diff --git a/docs/generators/index.md b/docs/generators/index.md index 2f9fe60d..87b00796 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -126,6 +126,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**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]` | +| [**Ambassador**](ambassador.md) | Outbound connectivity wrapper factories | `[GenerateAmbassador]` | ## Quick Reference diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index b4255a98..e6c09279 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -175,6 +175,9 @@ - name: Backends for Frontends href: backends-for-frontends.md +- name: Ambassador + href: ambassador.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 5b6fb47c..5cc63761 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -91,6 +91,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Cloud Architecture | Strangler Fig | `StranglerFig` | Strangler Fig generator | | Cloud Architecture | Sidecar | `Sidecar` | Sidecar generator | | Cloud Architecture | Backends for Frontends | `BackendsForFrontends` | Backends for Frontends generator | +| Cloud Architecture | Ambassador | `Ambassador` | Ambassador 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/ambassador.md b/docs/patterns/cloud/ambassador.md new file mode 100644 index 00000000..33d62889 --- /dev/null +++ b/docs/patterns/cloud/ambassador.md @@ -0,0 +1,20 @@ +# Ambassador + +Ambassador wraps outbound service calls with connectivity, transformation, telemetry, and fallback behavior. + +```csharp +var ambassador = Ambassador + .Create("inventory-ambassador") + .Transform(request => request with { Sku = request.Sku.ToUpperInvariant() }) + .ConnectionPolicy(request => request.Tenant != "blocked") + .Telemetry("trace", ctx => ctx.Items["tenant"] = ctx.Request.Tenant) + .Call(ctx => inventory.GetAvailability(ctx.Request)) + .Fallback(ctx => new(ctx.Request.Sku, "cached", "fallback-cache")) + .Build(); + +var result = ambassador.Invoke(request); +``` + +Use it at outbound integration boundaries where every caller needs the same connection policy, request normalization, telemetry enrichment, and fallback handling before reaching a remote dependency. The runtime path returns an explicit result with recorded events and fallback status. + +The source-generated path uses `[GenerateAmbassador]`, `[AmbassadorTransform]`, `[AmbassadorConnectionPolicy]`, `[AmbassadorTelemetry]`, `[AmbassadorCall]`, and `[AmbassadorFallback]`. Import the example through `AddInventoryAmbassadorDemo()` or `AddPatternKitExamples()`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index e39ba989..61342f68 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -381,6 +381,8 @@ href: cloud/sidecar.md - name: Backends for Frontends href: cloud/backends-for-frontends.md + - name: Ambassador + href: cloud/ambassador.md - name: Application Architecture items: - name: Anti-Corruption Layer diff --git a/src/PatternKit.Core/Cloud/Ambassador/Ambassador.cs b/src/PatternKit.Core/Cloud/Ambassador/Ambassador.cs new file mode 100644 index 00000000..95223b56 --- /dev/null +++ b/src/PatternKit.Core/Cloud/Ambassador/Ambassador.cs @@ -0,0 +1,202 @@ +namespace PatternKit.Cloud.Ambassador; + +public sealed class AmbassadorContext +{ + internal AmbassadorContext(string ambassadorName, TRequest request) + => (AmbassadorName, Request) = (ambassadorName, request); + + public string AmbassadorName { get; } + + public TRequest Request { get; internal set; } + + public IDictionary Items { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public IList Events { get; } = []; + + public void Record(string eventName) + { + if (string.IsNullOrWhiteSpace(eventName)) + throw new ArgumentException("Event name is required.", nameof(eventName)); + + Events.Add(eventName); + } +} + +public sealed class AmbassadorResult +{ + private AmbassadorResult(string ambassadorName, TResponse? response, Exception? exception, IReadOnlyList events, bool succeeded, bool usedFallback) + => (AmbassadorName, Response, Exception, Events, Succeeded, UsedFallback) = (ambassadorName, response, exception, events, succeeded, usedFallback); + + public string AmbassadorName { get; } + + public TResponse? Response { get; } + + public Exception? Exception { get; } + + public IReadOnlyList Events { get; } + + public bool Succeeded { get; } + + public bool Failed => !Succeeded; + + public bool UsedFallback { get; } + + public static AmbassadorResult Success(string ambassadorName, TResponse response, IReadOnlyList events, bool usedFallback = false) + => new(ambassadorName, response ?? throw new ArgumentNullException(nameof(response)), null, events ?? throw new ArgumentNullException(nameof(events)), true, usedFallback); + + public static AmbassadorResult Failure(string ambassadorName, Exception exception, IReadOnlyList events) + => new(ambassadorName, default, exception ?? throw new ArgumentNullException(nameof(exception)), events ?? throw new ArgumentNullException(nameof(events)), false, false); +} + +public sealed class Ambassador +{ + private readonly IReadOnlyList _telemetry; + private readonly IReadOnlyList> _transforms; + private readonly Func _connectionPolicy; + private readonly Func, TResponse> _call; + private readonly Func, TResponse>? _fallback; + + private Ambassador( + string name, + IReadOnlyList telemetry, + IReadOnlyList> transforms, + Func? connectionPolicy, + Func, TResponse>? call, + Func, TResponse>? fallback) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Ambassador name is required.", nameof(name)); + if (call is null) + throw new InvalidOperationException("Ambassador requires an outbound call handler."); + + Name = name; + _telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry)); + _transforms = transforms ?? throw new ArgumentNullException(nameof(transforms)); + _connectionPolicy = connectionPolicy ?? (_ => true); + _call = call; + _fallback = fallback; + } + + public string Name { get; } + + public AmbassadorResult Invoke(TRequest request) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + var context = new AmbassadorContext(Name, request); + try + { + foreach (var transform in _transforms) + { + context.Request = transform(context.Request); + if (context.Request is null) + return AmbassadorResult.Failure(Name, new InvalidOperationException("Ambassador transform returned null."), context.Events.ToArray()); + context.Record("transform"); + } + + foreach (var step in _telemetry) + { + step.Execute(context); + context.Record(step.Name); + } + + if (!_connectionPolicy(context.Request)) + return InvokeFallback(context, new InvalidOperationException("Ambassador connection policy rejected the request.")); + + var response = _call(context); + if (response is null) + return InvokeFallback(context, new InvalidOperationException("Ambassador outbound call returned null.")); + + return AmbassadorResult.Success(Name, response, context.Events.ToArray()); + } + catch (Exception ex) + { + return InvokeFallback(context, ex); + } + } + + public static Builder Create(string name = "ambassador") => new(name); + + private AmbassadorResult InvokeFallback(AmbassadorContext context, Exception exception) + { + if (_fallback is null) + return AmbassadorResult.Failure(Name, exception, context.Events.ToArray()); + + try + { + var response = _fallback(context); + if (response is null) + return AmbassadorResult.Failure(Name, new InvalidOperationException("Ambassador fallback returned null.", exception), context.Events.ToArray()); + + context.Record("fallback"); + return AmbassadorResult.Success(Name, response, context.Events.ToArray(), usedFallback: true); + } + catch (Exception fallbackException) + { + return AmbassadorResult.Failure(Name, fallbackException, context.Events.ToArray()); + } + } + + public sealed class Builder + { + private readonly string _name; + private readonly List _telemetry = []; + private readonly List> _transforms = []; + private Func? _connectionPolicy; + private Func, TResponse>? _call; + private Func, TResponse>? _fallback; + + internal Builder(string name) => _name = name; + + public Builder Transform(Func transform) + { + _transforms.Add(transform ?? throw new ArgumentNullException(nameof(transform))); + return this; + } + + public Builder ConnectionPolicy(Func connectionPolicy) + { + _connectionPolicy = connectionPolicy ?? throw new ArgumentNullException(nameof(connectionPolicy)); + return this; + } + + public Builder Telemetry(string name, Action> telemetry) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Telemetry name is required.", nameof(name)); + if (telemetry is null) + throw new ArgumentNullException(nameof(telemetry)); + if (_telemetry.Any(step => string.Equals(step.Name, name, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException($"Ambassador telemetry step '{name}' is already registered."); + + _telemetry.Add(new(name, telemetry)); + return this; + } + + public Builder Call(Func, TResponse> call) + { + _call = call ?? throw new ArgumentNullException(nameof(call)); + return this; + } + + public Builder Fallback(Func, TResponse> fallback) + { + _fallback = fallback ?? throw new ArgumentNullException(nameof(fallback)); + return this; + } + + public Ambassador Build() + => new(_name, _telemetry.ToArray(), _transforms.ToArray(), _connectionPolicy, _call, _fallback); + } + + private sealed class Step + { + public Step(string name, Action> execute) + => (Name, Execute) = (name, execute); + + public string Name { get; } + + public Action> Execute { get; } + } +} diff --git a/src/PatternKit.Examples/AmbassadorDemo/InventoryAmbassadorDemo.cs b/src/PatternKit.Examples/AmbassadorDemo/InventoryAmbassadorDemo.cs new file mode 100644 index 00000000..e9673aec --- /dev/null +++ b/src/PatternKit.Examples/AmbassadorDemo/InventoryAmbassadorDemo.cs @@ -0,0 +1,102 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Cloud.Ambassador; +using PatternKit.Generators.Ambassador; + +namespace PatternKit.Examples.AmbassadorDemo; + +public sealed record InventoryAmbassadorRequest(string Sku, string Tenant); + +public sealed record InventoryAmbassadorResponse(string Sku, string Status, string Source); + +public interface IInventoryAvailabilityClient +{ + InventoryAmbassadorResponse GetAvailability(InventoryAmbassadorRequest request); +} + +public sealed class DemoInventoryAvailabilityClient : IInventoryAvailabilityClient +{ + public InventoryAmbassadorResponse GetAvailability(InventoryAmbassadorRequest request) + => new(request.Sku, "available", "inventory-api"); +} + +public sealed class InventoryAmbassadorService(Ambassador ambassador) +{ + public InventoryAmbassadorResponse GetAvailability(string tenant, string sku) + { + var result = ambassador.Invoke(new InventoryAmbassadorRequest(sku, tenant)); + if (result.Failed) + throw new InvalidOperationException("Inventory ambassador could not complete the outbound call.", result.Exception); + + return result.Response!; + } +} + +public static class InventoryAmbassadors +{ + public static Ambassador CreateFluent(IInventoryAvailabilityClient client) + => Ambassador.Create("inventory-ambassador") + .Transform(static request => request with { Sku = request.Sku.ToUpperInvariant() }) + .ConnectionPolicy(static request => !request.Tenant.Equals("blocked", StringComparison.OrdinalIgnoreCase)) + .Telemetry("trace", static ctx => ctx.Items["tenant"] = ctx.Request.Tenant) + .Call(ctx => client.GetAvailability(ctx.Request)) + .Fallback(static ctx => new(ctx.Request.Sku, "cached", "fallback-cache")) + .Build(); +} + +[GenerateAmbassador(typeof(InventoryAmbassadorRequest), typeof(InventoryAmbassadorResponse), FactoryMethodName = "Create", AmbassadorName = "inventory-ambassador")] +public static partial class GeneratedInventoryAmbassador +{ + [AmbassadorTransform] + private static InventoryAmbassadorRequest Normalize(InventoryAmbassadorRequest request) + => request with { Sku = request.Sku.ToUpperInvariant() }; + + [AmbassadorConnectionPolicy] + private static bool CanConnect(InventoryAmbassadorRequest request) + => !request.Tenant.Equals("blocked", StringComparison.OrdinalIgnoreCase); + + [AmbassadorTelemetry("trace")] + private static void Trace(AmbassadorContext ctx) + => ctx.Items["tenant"] = ctx.Request.Tenant; + + [AmbassadorCall] + private static InventoryAmbassadorResponse Call(AmbassadorContext ctx) + => new(ctx.Request.Sku, "available", "inventory-api"); + + [AmbassadorFallback] + private static InventoryAmbassadorResponse Fallback(AmbassadorContext ctx) + => new(ctx.Request.Sku, "cached", "fallback-cache"); +} + +public sealed class InventoryAmbassadorDemoRunner(InventoryAmbassadorService service) +{ + public InventoryAmbassadorResponse RunGenerated(string tenant, string sku) => service.GetAvailability(tenant, sku); + + public static InventoryAmbassadorResponse RunFluent() + { + var ambassador = InventoryAmbassadors.CreateFluent(new DemoInventoryAvailabilityClient()); + var result = ambassador.Invoke(new InventoryAmbassadorRequest("sku-1", "tenant-a")); + return result.Response!; + } +} + +public static class InventoryAmbassadorServiceCollectionExtensions +{ + public static IServiceCollection AddInventoryAmbassadorDemo(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(static _ => GeneratedInventoryAmbassador.Create()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } + + public static IEndpointRouteBuilder MapInventoryAmbassador(this IEndpointRouteBuilder endpoints, string pattern = "/inventory/{tenant}/{sku}/availability") + { + endpoints.MapGet(pattern, (string tenant, string sku, InventoryAmbassadorService service) => Results.Ok(service.GetAvailability(tenant, sku))) + .WithName("InventoryAmbassador"); + return endpoints; + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index ac63e125..9a534373 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -19,6 +19,7 @@ using PatternKit.Creational.Prototype; using PatternKit.Creational.Singleton; using PatternKit.Examples.ApiGateway; +using PatternKit.Examples.AmbassadorDemo; using PatternKit.Examples.AntiCorruptionDemo; using PatternKit.Examples.AsyncStateDemo; using PatternKit.Examples.AuditLogDemo; @@ -210,6 +211,7 @@ public sealed record CheckoutStranglerFigExample(CheckoutStranglerFigDemoRunner public sealed record ProductGatewayRoutingExample(ProductGatewayRoutingDemoRunner Runner, ProductGatewayRoutingService Service); public sealed record OrderTelemetrySidecarExample(OrderTelemetrySidecarDemoRunner Runner, OrderTelemetrySidecarService Service); public sealed record CommerceBackendsForFrontendsExample(CommerceBackendsForFrontendsDemoRunner Runner, CommerceBackendsForFrontendsService Service); +public sealed record InventoryAmbassadorExample(InventoryAmbassadorDemoRunner Runner, InventoryAmbassadorService Service); /// /// Fluent registration helpers for importing every documented PatternKit example into Microsoft.Extensions.DependencyInjection. @@ -303,7 +305,8 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddCheckoutStranglerFigExample() .AddProductGatewayRoutingExample() .AddOrderTelemetrySidecarExample() - .AddCommerceBackendsForFrontendsExample(); + .AddCommerceBackendsForFrontendsExample() + .AddInventoryAmbassadorExample(); public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services) { @@ -1077,6 +1080,15 @@ public static IServiceCollection AddCommerceBackendsForFrontendsExample(this ISe return services.RegisterExample("Commerce Backends for Frontends", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore); } + public static IServiceCollection AddInventoryAmbassadorExample(this IServiceCollection services) + { + services.AddInventoryAmbassadorDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("Inventory Ambassador", 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 8016f0db..0229fe03 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -767,7 +767,15 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog "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"]) + ["client-specific response shaping", "source-generated BFF factory", "ASP.NET Core endpoint mapping"]), + Descriptor( + "Inventory Ambassador", + "src/PatternKit.Examples/AmbassadorDemo/InventoryAmbassadorDemo.cs", + "test/PatternKit.Examples.Tests/AmbassadorDemo/InventoryAmbassadorDemoTests.cs", + "docs/examples/inventory-ambassador.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost | ExampleIntegrationSurface.AspNetCore, + ["Ambassador"], + ["outbound call wrapping", "source-generated ambassador 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 0e3eab8f..077280b9 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -974,6 +974,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/BackendsForFrontendsDemo/CommerceBackendsForFrontendsDemoTests.cs", ["fluent client-specific facade", "generated BFF factory", "DI-importable ASP.NET Core commerce example"]), + Pattern("Ambassador", PatternFamily.CloudArchitecture, + "docs/patterns/cloud/ambassador.md", + "src/PatternKit.Core/Cloud/Ambassador/Ambassador.cs", + "test/PatternKit.Tests/Cloud/Ambassador/AmbassadorTests.cs", + "docs/generators/ambassador.md", + "src/PatternKit.Generators/Ambassador/AmbassadorGenerator.cs", + "test/PatternKit.Generators.Tests/AmbassadorGeneratorTests.cs", + null, + "docs/examples/inventory-ambassador.md", + "src/PatternKit.Examples/AmbassadorDemo/InventoryAmbassadorDemo.cs", + "test/PatternKit.Examples.Tests/AmbassadorDemo/InventoryAmbassadorDemoTests.cs", + ["fluent outbound call wrapper", "generated ambassador factory", "DI-importable ASP.NET Core inventory example"]), + Pattern("CQRS", PatternFamily.ApplicationArchitecture, "docs/generators/dispatcher.md", "src/PatternKit.Core/Behavioral/Mediator/Mediator.cs", diff --git a/src/PatternKit.Generators.Abstractions/Cloud/AmbassadorAttributes.cs b/src/PatternKit.Generators.Abstractions/Cloud/AmbassadorAttributes.cs new file mode 100644 index 00000000..bf0d651b --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Cloud/AmbassadorAttributes.cs @@ -0,0 +1,39 @@ +namespace PatternKit.Generators.Ambassador; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateAmbassadorAttribute(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 AmbassadorName { get; set; } = "ambassador"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class AmbassadorTransformAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class AmbassadorConnectionPolicyAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class AmbassadorTelemetryAttribute(string name) : Attribute +{ + public string Name { get; } = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Telemetry name is required.", nameof(name)) : name; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class AmbassadorCallAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class AmbassadorFallbackAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators/Ambassador/AmbassadorGenerator.cs b/src/PatternKit.Generators/Ambassador/AmbassadorGenerator.cs new file mode 100644 index 00000000..9e271353 --- /dev/null +++ b/src/PatternKit.Generators/Ambassador/AmbassadorGenerator.cs @@ -0,0 +1,256 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.Ambassador; + +[Generator] +public sealed class AmbassadorGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.Ambassador.GenerateAmbassadorAttribute"; + private const string TransformAttributeName = "PatternKit.Generators.Ambassador.AmbassadorTransformAttribute"; + private const string PolicyAttributeName = "PatternKit.Generators.Ambassador.AmbassadorConnectionPolicyAttribute"; + private const string TelemetryAttributeName = "PatternKit.Generators.Ambassador.AmbassadorTelemetryAttribute"; + private const string CallAttributeName = "PatternKit.Generators.Ambassador.AmbassadorCallAttribute"; + private const string FallbackAttributeName = "PatternKit.Generators.Ambassador.AmbassadorFallbackAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKAMB001", "Ambassador host must be partial", + "Type '{0}' is marked with [GenerateAmbassador] but is not declared as partial", + "PatternKit.Generators.Ambassador", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingMembers = new( + "PKAMB002", "Ambassador members are missing", + "Ambassador type '{0}' must declare exactly one outbound call handler", + "PatternKit.Generators.Ambassador", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidMember = new( + "PKAMB003", "Ambassador method signature is invalid", + "Ambassador method '{0}' has an invalid static signature for the configured request or response type", + "PatternKit.Generators.Ambassador", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor DuplicateTelemetry = new( + "PKAMB004", "Ambassador telemetry is duplicated", + "Ambassador telemetry step '{0}' is duplicated", + "PatternKit.Generators.Ambassador", 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 transforms = MembersWith(type, TransformAttributeName); + var policies = MembersWith(type, PolicyAttributeName); + var telemetry = TelemetryMembers(type); + var calls = MembersWith(type, CallAttributeName); + var fallbacks = MembersWith(type, FallbackAttributeName); + + if (calls.Length != 1 || policies.Length > 1 || fallbacks.Length > 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingMembers, node.Identifier.GetLocation(), type.Name)); + return; + } + + var duplicate = telemetry.GroupBy(static item => item.Name, StringComparer.OrdinalIgnoreCase).FirstOrDefault(static group => group.Count() > 1); + if (duplicate is not null) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateTelemetry, node.Identifier.GetLocation(), duplicate.Key)); + return; + } + + var invalidTransform = transforms.FirstOrDefault(method => !IsTransform(method, requestType)); + if (invalidTransform is not null) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, invalidTransform.Locations.FirstOrDefault(), invalidTransform.Name)); + return; + } + + var invalidPolicy = policies.FirstOrDefault(method => !IsPolicy(method, requestType)); + if (invalidPolicy is not null) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, invalidPolicy.Locations.FirstOrDefault(), invalidPolicy.Name)); + return; + } + + var invalidTelemetry = telemetry.FirstOrDefault(item => !IsTelemetry(item.Method, requestType)); + if (invalidTelemetry is not null) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, invalidTelemetry.Method.Locations.FirstOrDefault(), invalidTelemetry.Method.Name)); + return; + } + + if (!IsHandler(calls[0], requestType, responseType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, calls[0].Locations.FirstOrDefault(), calls[0].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}.Ambassador.g.cs", SourceText.From(GenerateSource( + type, + requestType, + responseType, + transforms, + policies.FirstOrDefault()?.Name, + telemetry, + calls[0].Name, + fallbacks.FirstOrDefault()?.Name, + GetNamedString(attribute, "FactoryMethodName") ?? "Create", + GetNamedString(attribute, "AmbassadorName") ?? "ambassador"), 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 TelemetryMember[] TelemetryMembers(INamedTypeSymbol type) + => type.GetMembers().OfType() + .Select(method => new + { + Method = method, + Attribute = method.GetAttributes().FirstOrDefault(attr => attr.AttributeClass?.ToDisplayString() == TelemetryAttributeName) + }) + .Where(static item => item.Attribute is not null) + .Select(static item => new TelemetryMember((string)item.Attribute!.ConstructorArguments[0].Value!, item.Method)) + .ToArray(); + + private static bool IsTransform(IMethodSymbol method, INamedTypeSymbol requestType) + => method.IsStatic && + SymbolEqualityComparer.Default.Equals(method.ReturnType, requestType) && + method.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, requestType); + + private static bool IsPolicy(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 IsTelemetry(IMethodSymbol method, INamedTypeSymbol requestType) + => method.IsStatic && + method.ReturnsVoid && + method.Parameters.Length == 1 && + IsContext(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 && + IsContext(method.Parameters[0].Type, requestType); + + private static bool IsContext(ITypeSymbol type, INamedTypeSymbol requestType) + => type is INamedTypeSymbol contextType && + contextType.ConstructedFrom.ToDisplayString() == "PatternKit.Cloud.Ambassador.AmbassadorContext" && + contextType.TypeArguments.Length == 1 && + SymbolEqualityComparer.Default.Equals(contextType.TypeArguments[0], requestType); + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol requestType, + INamedTypeSymbol responseType, + IReadOnlyList transforms, + string? policyName, + IReadOnlyList telemetry, + string callName, + string? fallbackName, + string factoryMethodName, + string ambassadorName) + { + 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.Ambassador.Ambassador<") + .Append(requestTypeName).Append(", ").Append(responseTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" return global::PatternKit.Cloud.Ambassador.Ambassador<") + .Append(requestTypeName).Append(", ").Append(responseTypeName).Append(">.Create(\"").Append(Escape(ambassadorName)).AppendLine("\")"); + foreach (var transform in transforms) + sb.Append(" .Transform(").Append(transform.Name).AppendLine(")"); + if (policyName is not null) + sb.Append(" .ConnectionPolicy(").Append(policyName).AppendLine(")"); + foreach (var item in telemetry) + sb.Append(" .Telemetry(\"").Append(Escape(item.Name)).Append("\", ").Append(item.Method.Name).AppendLine(")"); + sb.Append(" .Call(").Append(callName).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 TelemetryMember(string Name, IMethodSymbol Method); +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index abb72e73..ed09f8f4 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -310,6 +310,10 @@ PKBFF001 | PatternKit.Generators.BackendsForFrontends | Error | Backends for Fro 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. +PKAMB001 | PatternKit.Generators.Ambassador | Error | Ambassador host must be partial. +PKAMB002 | PatternKit.Generators.Ambassador | Error | Ambassador members are missing. +PKAMB003 | PatternKit.Generators.Ambassador | Error | Ambassador method signature is invalid. +PKAMB004 | PatternKit.Generators.Ambassador | Error | Ambassador telemetry 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/test/PatternKit.Examples.Tests/AmbassadorDemo/InventoryAmbassadorDemoTests.cs b/test/PatternKit.Examples.Tests/AmbassadorDemo/InventoryAmbassadorDemoTests.cs new file mode 100644 index 00000000..8b2fe530 --- /dev/null +++ b/test/PatternKit.Examples.Tests/AmbassadorDemo/InventoryAmbassadorDemoTests.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.AmbassadorDemo; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.ProductionReadiness; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.AmbassadorDemo; + +[Feature("Inventory Ambassador demo")] +public sealed class InventoryAmbassadorDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent ambassador wraps outbound inventory calls")] + [Fact] + public Task Fluent_Ambassador_Wraps_Outbound_Inventory_Calls() + => Given("a fluent inventory ambassador", () => InventoryAmbassadors.CreateFluent(new DemoInventoryAvailabilityClient())) + .When("availability is requested", ambassador => ambassador.Invoke(new InventoryAmbassadorRequest("sku-1", "tenant-a"))) + .Then("the outbound call is transformed and completed", result => + { + ScenarioExpect.True(result.Succeeded); + ScenarioExpect.Equal("SKU-1", result.Response!.Sku); + ScenarioExpect.Equal("inventory-api", result.Response.Source); + ScenarioExpect.Contains("trace", result.Events); + }) + .AssertPassed(); + + [Scenario("Generated ambassador is importable through IServiceCollection")] + [Fact] + public Task Generated_Ambassador_Is_Importable_Through_IServiceCollection() + => Given("a service provider configured with the inventory ambassador demo", () => + { + var services = new ServiceCollection(); + services.AddInventoryAmbassadorDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("the demo runner resolves and runs", provider => + { + using (provider) + return provider.GetRequiredService().RunGenerated("tenant-a", "sku-1"); + }) + .Then("the generated ambassador returns the expected response", response => + { + ScenarioExpect.Equal("SKU-1", response.Sku); + ScenarioExpect.Equal("available", response.Status); + }) + .AssertPassed(); + + [Scenario("Inventory ambassador appears in production catalogs")] + [Fact] + public Task Inventory_Ambassador_Appears_In_Production_Catalogs() + => Given("the production catalogs", () => new + { + Examples = new PatternKitExampleCatalog(), + Patterns = new PatternKitPatternCatalog() + }) + .Then("the example catalog includes the ambassador demo", ctx => + ScenarioExpect.Contains(ctx.Examples.Entries, entry => entry.Name == "Inventory Ambassador")) + .And("the pattern catalog includes Ambassador", ctx => + ScenarioExpect.Contains(ctx.Patterns.Patterns, pattern => pattern.Name == "Ambassador")) + .AssertPassed(); + + [Scenario("Aggregate example registration includes inventory ambassador")] + [Fact] + public Task Aggregate_Example_Registration_Includes_Inventory_Ambassador() + => Given("all PatternKit examples registered in a service collection", () => + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("the ambassador example is resolved", provider => + { + using (provider) + return provider.GetRequiredService().Runner.RunGenerated("blocked", "sku-1"); + }) + .Then("the registered example executes fallback behavior", response => + { + ScenarioExpect.Equal("SKU-1", response.Sku); + ScenarioExpect.Equal("cached", response.Status); + ScenarioExpect.Equal("fallback-cache", response.Source); + }) + .AssertPassed(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 98b11031..2618cc49 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -85,6 +85,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Gateway Routing", "Sidecar", "Backends for Frontends", + "Ambassador", "CQRS", "Specification", "Repository", @@ -142,7 +143,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(14, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); + ScenarioExpect.Equal(15, 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 c55cb39a..8255090e 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.Ambassador; using PatternKit.Generators.BackendsForFrontends; using TinyBDD; @@ -232,6 +233,12 @@ private enum TestTrigger { typeof(FrontendSelectorAttribute), AttributeTargets.Method, false, false }, { typeof(FrontendHandlerAttribute), AttributeTargets.Method, false, false }, { typeof(FrontendFallbackAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateAmbassadorAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(AmbassadorTransformAttribute), AttributeTargets.Method, false, false }, + { typeof(AmbassadorConnectionPolicyAttribute), AttributeTargets.Method, false, false }, + { typeof(AmbassadorTelemetryAttribute), AttributeTargets.Method, false, false }, + { typeof(AmbassadorCallAttribute), AttributeTargets.Method, false, false }, + { typeof(AmbassadorFallbackAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateGatewayRoutingAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(GatewayRouteAttribute), AttributeTargets.Method, false, false }, { typeof(GatewayRouteHandlerAttribute), AttributeTargets.Method, false, false }, @@ -597,6 +604,31 @@ public void Backends_For_Frontends_Attributes_Expose_Defaults_And_Configuration( ScenarioExpect.IsType(new FrontendFallbackAttribute()); } + [Scenario("Ambassador Attributes Expose Defaults And Configuration")] + [Fact] + public void Ambassador_Attributes_Expose_Defaults_And_Configuration() + { + var ambassador = new GenerateAmbassadorAttribute(typeof(string), typeof(int)) + { + FactoryMethodName = "BuildInventoryAmbassador", + AmbassadorName = "inventory-ambassador" + }; + var telemetry = new AmbassadorTelemetryAttribute("trace"); + + ScenarioExpect.Equal(typeof(string), ambassador.RequestType); + ScenarioExpect.Equal(typeof(int), ambassador.ResponseType); + ScenarioExpect.Equal("BuildInventoryAmbassador", ambassador.FactoryMethodName); + ScenarioExpect.Equal("inventory-ambassador", ambassador.AmbassadorName); + ScenarioExpect.Equal("trace", telemetry.Name); + ScenarioExpect.Throws(() => new GenerateAmbassadorAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateAmbassadorAttribute(typeof(string), null!)); + ScenarioExpect.Throws(() => new AmbassadorTelemetryAttribute("")); + ScenarioExpect.IsType(new AmbassadorTransformAttribute()); + ScenarioExpect.IsType(new AmbassadorConnectionPolicyAttribute()); + ScenarioExpect.IsType(new AmbassadorCallAttribute()); + ScenarioExpect.IsType(new AmbassadorFallbackAttribute()); + } + [Scenario("Strangler Fig Attributes Expose Defaults And Configuration")] [Fact] public void StranglerFig_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/AmbassadorGeneratorTests.cs b/test/PatternKit.Generators.Tests/AmbassadorGeneratorTests.cs new file mode 100644 index 00000000..bd586e81 --- /dev/null +++ b/test/PatternKit.Generators.Tests/AmbassadorGeneratorTests.cs @@ -0,0 +1,114 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Cloud.Ambassador; +using PatternKit.Generators.Ambassador; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Ambassador generator")] +public sealed partial class AmbassadorGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates ambassador factory")] + [Fact] + public Task Generates_Ambassador_Factory() + => Given("an ambassador declaration", () => Compile(""" + using PatternKit.Cloud.Ambassador; + using PatternKit.Generators.Ambassador; + namespace Demo; + public sealed record InventoryRequest(string Sku, string Tenant); + public sealed record InventoryResponse(string Sku, string Status); + [GenerateAmbassador(typeof(InventoryRequest), typeof(InventoryResponse), FactoryMethodName = "Build", AmbassadorName = "inventory-ambassador")] + public static partial class InventoryAmbassador + { + [AmbassadorTransform] + private static InventoryRequest Normalize(InventoryRequest request) => request with { Sku = request.Sku.ToUpperInvariant() }; + [AmbassadorConnectionPolicy] + private static bool CanConnect(InventoryRequest request) => request.Tenant != "blocked"; + [AmbassadorTelemetry("trace")] + private static void Trace(AmbassadorContext ctx) => ctx.Items["trace"] = ctx.Request.Tenant; + [AmbassadorCall] + private static InventoryResponse Call(AmbassadorContext ctx) => new(ctx.Request.Sku, "available"); + [AmbassadorFallback] + private static InventoryResponse Fallback(AmbassadorContext ctx) => new(ctx.Request.Sku, "cached"); + } + """)) + .Then("the generated source creates the configured ambassador", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("Build()", source); + ScenarioExpect.Contains("Ambassador.Create(\"inventory-ambassador\")", source); + ScenarioExpect.Contains(".Transform(Normalize)", source); + ScenarioExpect.Contains(".ConnectionPolicy(CanConnect)", source); + ScenarioExpect.Contains(".Telemetry(\"trace\", Trace)", source); + ScenarioExpect.Contains(".Call(Call)", source); + ScenarioExpect.Contains(".Fallback(Fallback)", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid ambassador declarations")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Ambassador_Declarations() + => Given("invalid ambassador declarations", () => new[] + { + Compile(""" + using PatternKit.Generators.Ambassador; + [GenerateAmbassador(typeof(string), typeof(int))] + public static class AmbassadorHost; + """), + Compile(""" + using PatternKit.Generators.Ambassador; + [GenerateAmbassador(typeof(string), typeof(int))] + public static partial class AmbassadorHost; + """), + Compile(""" + using PatternKit.Cloud.Ambassador; + using PatternKit.Generators.Ambassador; + [GenerateAmbassador(typeof(string), typeof(int))] + public static partial class AmbassadorHost + { + [AmbassadorCall] + private static string Call(AmbassadorContext ctx) => ""; + } + """), + Compile(""" + using PatternKit.Cloud.Ambassador; + using PatternKit.Generators.Ambassador; + [GenerateAmbassador(typeof(string), typeof(int))] + public static partial class AmbassadorHost + { + [AmbassadorTelemetry("trace")] + private static void Trace(AmbassadorContext ctx) { } + [AmbassadorTelemetry("TRACE")] + private static void Trace2(AmbassadorContext ctx) { } + [AmbassadorCall] + private static int Call(AmbassadorContext ctx) => 1; + } + """) + }) + .Then("diagnostics identify invalid declarations", results => + { + ScenarioExpect.Contains(results[0].Diagnostics, diagnostic => diagnostic.Id == "PKAMB001"); + ScenarioExpect.Contains(results[1].Diagnostics, diagnostic => diagnostic.Id == "PKAMB002"); + ScenarioExpect.Contains(results[2].Diagnostics, diagnostic => diagnostic.Id == "PKAMB003"); + ScenarioExpect.Contains(results[3].Diagnostics, diagnostic => diagnostic.Id == "PKAMB004"); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "AmbassadorGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(Ambassador<,>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new AmbassadorGenerator(), 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/Ambassador/AmbassadorTests.cs b/test/PatternKit.Tests/Cloud/Ambassador/AmbassadorTests.cs new file mode 100644 index 00000000..e347ffa5 --- /dev/null +++ b/test/PatternKit.Tests/Cloud/Ambassador/AmbassadorTests.cs @@ -0,0 +1,98 @@ +using PatternKit.Cloud.Ambassador; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Cloud.Ambassador; + +[Feature("Ambassador")] +public sealed class AmbassadorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Ambassador transforms requests and records telemetry around outbound calls")] + [Fact] + public Task Ambassador_Transforms_Requests_And_Records_Telemetry_Around_Outbound_Calls() + => Given("an inventory ambassador", CreateAmbassador) + .When("a request is invoked", ambassador => ambassador.Invoke(new InventoryRequest("sku-1", "tenant-a"))) + .Then("the transformed request reaches the outbound call", result => + { + ScenarioExpect.True(result.Succeeded); + ScenarioExpect.Equal("inventory-ambassador", result.AmbassadorName); + ScenarioExpect.Equal("SKU-1", result.Response!.Sku); + ScenarioExpect.Equal("available", result.Response.Status); + ScenarioExpect.Contains("transform", result.Events); + ScenarioExpect.Contains("trace", result.Events); + }) + .AssertPassed(); + + [Scenario("Ambassador applies connection policy and fallback")] + [Fact] + public Task Ambassador_Applies_Connection_Policy_And_Fallback() + => Given("an ambassador with fallback behavior", CreateAmbassador) + .When("a blocked tenant calls the ambassador", ambassador => ambassador.Invoke(new InventoryRequest("sku-1", "blocked"))) + .Then("fallback response is returned and flagged", result => + { + ScenarioExpect.True(result.Succeeded); + ScenarioExpect.True(result.UsedFallback); + ScenarioExpect.Equal("cached", result.Response!.Status); + ScenarioExpect.Contains("fallback", result.Events); + }) + .AssertPassed(); + + [Scenario("Ambassador validates configuration and reports failures")] + [Fact] + public Task Ambassador_Validates_Configuration_And_Reports_Failures() + => Given("invalid ambassador inputs", () => true) + .Then("invalid names and missing calls are rejected", _ => + { + ScenarioExpect.Throws(() => Ambassador.Create("") + .Call(CallInventory) + .Build()); + ScenarioExpect.Throws(() => Ambassador.Create().Build()); + }) + .And("null delegates are rejected", _ => + { + ScenarioExpect.Throws(() => Ambassador.Create().Transform(null!)); + ScenarioExpect.Throws(() => Ambassador.Create().ConnectionPolicy(null!)); + ScenarioExpect.Throws(() => Ambassador.Create().Telemetry("trace", null!)); + ScenarioExpect.Throws(() => Ambassador.Create().Call(null!)); + ScenarioExpect.Throws(() => Ambassador.Create().Fallback(null!)); + }) + .And("duplicate telemetry names are rejected", _ => + ScenarioExpect.Throws(() => Ambassador.Create() + .Telemetry("trace", static _ => { }) + .Telemetry("TRACE", static _ => { }))) + .And("unhandled failures are explicit", _ => + { + var result = Ambassador.Create("inventory-ambassador") + .Call(static _ => throw new InvalidOperationException("downstream unavailable")) + .Build() + .Invoke(new InventoryRequest("sku-1", "tenant-a")); + ScenarioExpect.True(result.Failed); + ScenarioExpect.Contains("downstream unavailable", result.Exception!.Message); + }) + .And("null requests and results are guarded", _ => + { + ScenarioExpect.Throws(() => CreateAmbassador().Invoke(null!)); + ScenarioExpect.Throws(() => AmbassadorResult.Success("inventory-ambassador", null!, [])); + ScenarioExpect.Throws(() => AmbassadorResult.Success("inventory-ambassador", new("sku", "ok"), null!)); + ScenarioExpect.Throws(() => AmbassadorResult.Failure("inventory-ambassador", null!, [])); + ScenarioExpect.Throws(() => AmbassadorResult.Failure("inventory-ambassador", new InvalidOperationException(), null!)); + }) + .AssertPassed(); + + private static Ambassador CreateAmbassador() + => Ambassador.Create("inventory-ambassador") + .Transform(static request => request with { Sku = request.Sku.ToUpperInvariant() }) + .ConnectionPolicy(static request => request.Tenant != "blocked") + .Telemetry("trace", static ctx => ctx.Items["traceId"] = ctx.Request.Tenant) + .Call(CallInventory) + .Fallback(static ctx => new(ctx.Request.Sku, "cached")) + .Build(); + + private static InventoryResponse CallInventory(AmbassadorContext ctx) + => new(ctx.Request.Sku, "available"); + + private sealed record InventoryRequest(string Sku, string Tenant); + + private sealed record InventoryResponse(string Sku, string Status); +}