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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/examples/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 12 additions & 0 deletions docs/examples/inventory-ambassador.md
Original file line number Diff line number Diff line change
@@ -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<InventoryAmbassadorDemoRunner>();
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()`.
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,6 @@

- name: Commerce Backends for Frontends
href: commerce-backends-for-frontends.md

- name: Inventory Ambassador
href: inventory-ambassador.md
23 changes: 23 additions & 0 deletions docs/generators/ambassador.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Ambassador Generator

`[GenerateAmbassador]` creates a typed `Ambassador<TRequest, TResponse>` 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<InventoryAmbassadorRequest> 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.
1 change: 1 addition & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/guides/pattern-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Cloud Architecture | Strangler Fig | `StranglerFig<TRequest,TResponse>` | Strangler Fig generator |
| Cloud Architecture | Sidecar | `Sidecar<TRequest,TResponse>` | Sidecar generator |
| Cloud Architecture | Backends for Frontends | `BackendsForFrontends<TRequest,TResponse>` | Backends for Frontends generator |
| Cloud Architecture | Ambassador | `Ambassador<TRequest,TResponse>` | Ambassador generator |
| Application Architecture | CQRS | Mediator/dispatcher command-query split | Dispatcher generator |
| Application Architecture | Specification | `Specification<T>` and named registries | Specification generator |
| Application Architecture | Repository | `IRepository<TEntity,TKey>` and `InMemoryRepository<TEntity,TKey>` | Repository generator |
Expand Down
20 changes: 20 additions & 0 deletions docs/patterns/cloud/ambassador.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Ambassador

Ambassador wraps outbound service calls with connectivity, transformation, telemetry, and fallback behavior.

```csharp
var ambassador = Ambassador<InventoryAmbassadorRequest, InventoryAmbassadorResponse>
.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()`.
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
202 changes: 202 additions & 0 deletions src/PatternKit.Core/Cloud/Ambassador/Ambassador.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
namespace PatternKit.Cloud.Ambassador;

public sealed class AmbassadorContext<TRequest>
{
internal AmbassadorContext(string ambassadorName, TRequest request)
=> (AmbassadorName, Request) = (ambassadorName, request);

public string AmbassadorName { get; }

public TRequest Request { get; internal set; }

public IDictionary<string, object?> Items { get; } = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);

public IList<string> 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<TResponse>
{
private AmbassadorResult(string ambassadorName, TResponse? response, Exception? exception, IReadOnlyList<string> 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<string> Events { get; }

public bool Succeeded { get; }

public bool Failed => !Succeeded;

public bool UsedFallback { get; }

public static AmbassadorResult<TResponse> Success(string ambassadorName, TResponse response, IReadOnlyList<string> events, bool usedFallback = false)
=> new(ambassadorName, response ?? throw new ArgumentNullException(nameof(response)), null, events ?? throw new ArgumentNullException(nameof(events)), true, usedFallback);

public static AmbassadorResult<TResponse> Failure(string ambassadorName, Exception exception, IReadOnlyList<string> events)
=> new(ambassadorName, default, exception ?? throw new ArgumentNullException(nameof(exception)), events ?? throw new ArgumentNullException(nameof(events)), false, false);
}

public sealed class Ambassador<TRequest, TResponse>
{
private readonly IReadOnlyList<Step> _telemetry;
private readonly IReadOnlyList<Func<TRequest, TRequest>> _transforms;
private readonly Func<TRequest, bool> _connectionPolicy;
private readonly Func<AmbassadorContext<TRequest>, TResponse> _call;
private readonly Func<AmbassadorContext<TRequest>, TResponse>? _fallback;

private Ambassador(
string name,
IReadOnlyList<Step> telemetry,
IReadOnlyList<Func<TRequest, TRequest>> transforms,
Func<TRequest, bool>? connectionPolicy,
Func<AmbassadorContext<TRequest>, TResponse>? call,
Func<AmbassadorContext<TRequest>, 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<TResponse> Invoke(TRequest request)
{
if (request is null)
throw new ArgumentNullException(nameof(request));

var context = new AmbassadorContext<TRequest>(Name, request);
try
{
foreach (var transform in _transforms)
{
context.Request = transform(context.Request);
if (context.Request is null)
return AmbassadorResult<TResponse>.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<TResponse>.Success(Name, response, context.Events.ToArray());
}
catch (Exception ex)
{
return InvokeFallback(context, ex);
}
}

public static Builder Create(string name = "ambassador") => new(name);

private AmbassadorResult<TResponse> InvokeFallback(AmbassadorContext<TRequest> context, Exception exception)
{
if (_fallback is null)
return AmbassadorResult<TResponse>.Failure(Name, exception, context.Events.ToArray());

try
{
var response = _fallback(context);
if (response is null)
return AmbassadorResult<TResponse>.Failure(Name, new InvalidOperationException("Ambassador fallback returned null.", exception), context.Events.ToArray());

context.Record("fallback");
return AmbassadorResult<TResponse>.Success(Name, response, context.Events.ToArray(), usedFallback: true);
}
catch (Exception fallbackException)
{
return AmbassadorResult<TResponse>.Failure(Name, fallbackException, context.Events.ToArray());
}
}

public sealed class Builder
{
private readonly string _name;
private readonly List<Step> _telemetry = [];
private readonly List<Func<TRequest, TRequest>> _transforms = [];
private Func<TRequest, bool>? _connectionPolicy;
private Func<AmbassadorContext<TRequest>, TResponse>? _call;
private Func<AmbassadorContext<TRequest>, TResponse>? _fallback;

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

public Builder Transform(Func<TRequest, TRequest> transform)
{
_transforms.Add(transform ?? throw new ArgumentNullException(nameof(transform)));
return this;
}

public Builder ConnectionPolicy(Func<TRequest, bool> connectionPolicy)
{
_connectionPolicy = connectionPolicy ?? throw new ArgumentNullException(nameof(connectionPolicy));
return this;
}

public Builder Telemetry(string name, Action<AmbassadorContext<TRequest>> 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<AmbassadorContext<TRequest>, TResponse> call)
{
_call = call ?? throw new ArgumentNullException(nameof(call));
return this;
}

public Builder Fallback(Func<AmbassadorContext<TRequest>, TResponse> fallback)
{
_fallback = fallback ?? throw new ArgumentNullException(nameof(fallback));
return this;
}

public Ambassador<TRequest, TResponse> Build()
=> new(_name, _telemetry.ToArray(), _transforms.ToArray(), _connectionPolicy, _call, _fallback);
}

private sealed class Step
{
public Step(string name, Action<AmbassadorContext<TRequest>> execute)
=> (Name, Execute) = (name, execute);

public string Name { get; }

public Action<AmbassadorContext<TRequest>> Execute { get; }
}
}
Loading
Loading