merge(furniture-bff): integrate furniture-bff-edge-policy

This commit is contained in:
José René White Enciso 2026-02-25 14:42:03 -06:00
commit ac4c7b291c
12 changed files with 345 additions and 6 deletions

View File

@ -0,0 +1,14 @@
# Furniture BFF Service Boundary
## Purpose
Preserve BFF as an edge adapter layer that depends on service contracts only.
## BFF Responsibilities
- REST edge exposure
- Service client adaptation
- Correlation/tracing propagation
- Single active edge protocol policy enforcement (`rest`)
## Prohibited
- Direct DAL access
- Domain business decision ownership

View File

@ -0,0 +1,6 @@
# Correlation Propagation Checks
## Checks
- Correlation identifiers are preserved across BFF -> Service calls.
- No new correlation behavior is introduced at edge.
- Trace metadata pass-through remains stable.

View File

@ -0,0 +1,9 @@
# Post-Domain Contract Alignment
## Goal
Align BFF adapter usage with service contracts after domain extraction.
## Steps
1. Map current BFF contract usage to updated service contracts.
2. Keep edge contract behavior stable.
3. Validate adapter compatibility.

View File

@ -0,0 +1,37 @@
using Furniture.Bff.Contracts.Api;
using Furniture.Service.Contracts.UseCases;
namespace Furniture.Bff.Application.Adapters;
/// <summary>
/// Default adapter implementation between furniture BFF edge and furniture service contracts.
/// </summary>
public sealed class FurnitureAvailabilityEdgeContractAdapter : IFurnitureAvailabilityEdgeContractAdapter
{
/// <inheritdoc />
public GetFurnitureAvailabilityRequest ToServiceRequest(GetFurnitureAvailabilityApiRequest request)
{
return new GetFurnitureAvailabilityRequest(
request.FurnitureId,
ResolveCorrelationId(request.CorrelationId));
}
/// <inheritdoc />
public GetFurnitureAvailabilityApiResponse ToApiResponse(GetFurnitureAvailabilityResponse response)
{
return new GetFurnitureAvailabilityApiResponse(
response.FurnitureId,
response.DisplayName,
response.QuantityAvailable);
}
private static string ResolveCorrelationId(string correlationId)
{
if (!string.IsNullOrWhiteSpace(correlationId))
{
return correlationId;
}
return $"corr-{Guid.NewGuid():N}";
}
}

View File

@ -0,0 +1,36 @@
using Furniture.Bff.Application.Grpc;
using Furniture.Bff.Contracts.Api;
namespace Furniture.Bff.Application.Adapters;
/// <summary>
/// Default adapter implementation for furniture edge gRPC contract translation.
/// </summary>
public sealed class FurnitureAvailabilityEdgeGrpcContractAdapter : IFurnitureAvailabilityEdgeGrpcContractAdapter
{
/// <inheritdoc />
public GetFurnitureAvailabilityEdgeGrpcContract ToGrpc(GetFurnitureAvailabilityApiRequest request)
{
return new GetFurnitureAvailabilityEdgeGrpcContract(
request.FurnitureId,
ResolveCorrelationId(request.CorrelationId));
}
/// <inheritdoc />
public GetFurnitureAvailabilityApiRequest FromGrpc(GetFurnitureAvailabilityEdgeGrpcContract contract)
{
return new GetFurnitureAvailabilityApiRequest(
contract.FurnitureId,
ResolveCorrelationId(contract.CorrelationId));
}
private static string ResolveCorrelationId(string correlationId)
{
if (!string.IsNullOrWhiteSpace(correlationId))
{
return correlationId;
}
return $"corr-{Guid.NewGuid():N}";
}
}

View File

@ -0,0 +1,26 @@
using Furniture.Bff.Application.Adapters;
using Furniture.Bff.Application.Handlers;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Furniture.Bff.Application.DependencyInjection;
/// <summary>
/// Registers application-layer runtime wiring for furniture-bff.
/// </summary>
public static class FurnitureBffApplicationServiceCollectionExtensions
{
/// <summary>
/// Adds furniture-bff application handlers and adapter implementations.
/// </summary>
/// <param name="services">Service collection.</param>
/// <returns>Service collection for fluent chaining.</returns>
public static IServiceCollection AddFurnitureBffApplicationRuntime(this IServiceCollection services)
{
services.TryAddSingleton<IFurnitureAvailabilityEdgeContractAdapter, FurnitureAvailabilityEdgeContractAdapter>();
services.TryAddSingleton<IFurnitureAvailabilityEdgeGrpcContractAdapter, FurnitureAvailabilityEdgeGrpcContractAdapter>();
services.TryAddScoped<IGetFurnitureAvailabilityHandler, GetFurnitureAvailabilityHandler>();
return services;
}
}

View File

@ -5,6 +5,7 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<ProjectReference Include="..\Furniture.Bff.Contracts\Furniture.Bff.Contracts.csproj" />
<ProjectReference Include="..\..\..\furniture-service\src\Furniture.Service.Contracts\Furniture.Service.Contracts.csproj" />
</ItemGroup>

View File

@ -0,0 +1,70 @@
using Furniture.Bff.Application.Adapters;
using Furniture.Service.Contracts.UseCases;
using Furniture.Service.Grpc;
using Grpc.Core;
using Microsoft.Extensions.Primitives;
namespace Furniture.Bff.Rest.Adapters;
/// <summary>
/// gRPC-backed adapter for downstream furniture-service calls.
/// </summary>
public sealed class FurnitureServiceGrpcClientAdapter(
FurnitureRuntime.FurnitureRuntimeClient grpcClient,
IHttpContextAccessor httpContextAccessor) : IFurnitureServiceClient
{
private const string CorrelationHeaderName = "x-correlation-id";
/// <inheritdoc />
public async Task<GetFurnitureAvailabilityResponse> GetAvailabilityAsync(GetFurnitureAvailabilityRequest request)
{
var correlationId = ResolveCorrelationId(request.CorrelationId);
var grpcRequest = new GetFurnitureAvailabilityGrpcRequest
{
FurnitureId = request.FurnitureId,
CorrelationId = correlationId
};
var grpcResponse = await grpcClient.GetFurnitureAvailabilityAsync(
grpcRequest,
headers: CreateHeaders(correlationId),
deadline: DateTime.UtcNow.AddSeconds(10));
return new GetFurnitureAvailabilityResponse(
grpcResponse.FurnitureId,
grpcResponse.DisplayName,
grpcResponse.QuantityAvailable);
}
private string ResolveCorrelationId(string? preferred = null)
{
if (!string.IsNullOrWhiteSpace(preferred))
{
return preferred;
}
var context = httpContextAccessor.HttpContext;
if (context?.Items.TryGetValue(CorrelationHeaderName, out var itemValue) == true &&
itemValue is string itemCorrelationId &&
!string.IsNullOrWhiteSpace(itemCorrelationId))
{
return itemCorrelationId;
}
if (context?.Request.Headers.TryGetValue(CorrelationHeaderName, out var headerValue) == true &&
!StringValues.IsNullOrEmpty(headerValue))
{
return headerValue.ToString();
}
return context?.TraceIdentifier ?? $"corr-{Guid.NewGuid():N}";
}
private static Metadata CreateHeaders(string correlationId)
{
return
[
new Metadata.Entry(CorrelationHeaderName, correlationId)
];
}
}

View File

@ -4,8 +4,22 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.31.1" />
<PackageReference Include="Grpc.Core.Api" Version="2.71.0" />
<PackageReference Include="Grpc.Net.Client" Version="2.71.0" />
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.71.0" />
<PackageReference Include="Grpc.Tools" Version="2.71.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Protobuf Include="..\..\..\furniture-service\src\Furniture.Service.Grpc\Protos\furniture_runtime.proto" GrpcServices="Client" Link="Protos\furniture_runtime.proto" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Furniture.Bff.Application\Furniture.Bff.Application.csproj" />
<ProjectReference Include="..\Furniture.Bff.Contracts\Furniture.Bff.Contracts.csproj" />
<ProjectReference Include="..\..\..\blueprint-platform\src\Core.Blueprint.Common\Core.Blueprint.Common.csproj" />
</ItemGroup>
</Project>

View File

@ -1,13 +1,76 @@
using Core.Blueprint.Common.DependencyInjection;
using Furniture.Bff.Application.Adapters;
using Furniture.Bff.Application.DependencyInjection;
using Furniture.Bff.Application.Handlers;
using Furniture.Bff.Contracts.Api;
using Furniture.Bff.Rest.Adapters;
using Furniture.Bff.Rest.Endpoints;
using Furniture.Service.Grpc;
using Microsoft.Extensions.Primitives;
const string CorrelationHeaderName = "x-correlation-id";
var builder = WebApplication.CreateBuilder(args);
// Stage 3 skeleton: single active external protocol for this deployment is REST.
var app = builder.Build();
app.MapGet("/api/furniture/{furnitureId}/availability", (string furnitureId) =>
var edgeProtocol = builder.Configuration["FurnitureBff:EdgeProtocol"] ?? "rest";
if (!string.Equals(edgeProtocol, "rest", StringComparison.OrdinalIgnoreCase))
{
return Results.Ok(new GetFurnitureAvailabilityApiResponse(furnitureId, string.Empty, 0));
throw new InvalidOperationException(
$"Furniture BFF supports one active edge protocol per deployment. Configured: '{edgeProtocol}'. Expected: 'rest'.");
}
builder.Services.AddHttpContextAccessor();
builder.Services.AddHealthChecks();
builder.Services.AddBlueprintRuntimeCore();
builder.Services.AddFurnitureBffApplicationRuntime();
builder.Services.AddScoped<IFurnitureServiceClient, FurnitureServiceGrpcClientAdapter>();
builder.Services.AddGrpcClient<FurnitureRuntime.FurnitureRuntimeClient>(options =>
{
var serviceAddress = builder.Configuration["FurnitureService:GrpcAddress"] ?? "http://localhost:5252";
options.Address = new Uri(serviceAddress);
});
var app = builder.Build();
app.Use(async (context, next) =>
{
var correlationId = ResolveCorrelationId(context);
context.Items[CorrelationHeaderName] = correlationId;
context.Request.Headers[CorrelationHeaderName] = correlationId;
context.Response.Headers[CorrelationHeaderName] = correlationId;
await next();
});
app.MapGet($"{EndpointConventions.ApiPrefix}/{{furnitureId}}/availability", async (
string furnitureId,
HttpContext context,
IGetFurnitureAvailabilityHandler handler) =>
{
var request = new GetFurnitureAvailabilityApiRequest(
furnitureId,
ResolveCorrelationId(context));
var response = await handler.HandleAsync(request);
return Results.Ok(response);
});
app.MapHealthChecks("/healthz");
app.Run();
string ResolveCorrelationId(HttpContext context)
{
if (context.Items.TryGetValue(CorrelationHeaderName, out var itemValue) &&
itemValue is string itemCorrelationId &&
!string.IsNullOrWhiteSpace(itemCorrelationId))
{
return itemCorrelationId;
}
if (context.Request.Headers.TryGetValue(CorrelationHeaderName, out var headerValue) &&
!StringValues.IsNullOrEmpty(headerValue))
{
return headerValue.ToString();
}
return context.TraceIdentifier;
}

View File

@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />

View File

@ -0,0 +1,62 @@
using Furniture.Bff.Application.Adapters;
using Furniture.Bff.Application.DependencyInjection;
using Furniture.Bff.Application.Grpc;
using Furniture.Bff.Application.Handlers;
using Furniture.Bff.Contracts.Api;
using Furniture.Service.Contracts.UseCases;
using Microsoft.Extensions.DependencyInjection;
namespace Furniture.Bff.Application.UnitTests;
public class RuntimeWiringTests
{
[Fact]
public async Task AddFurnitureBffApplicationRuntime_WhenResolved_WiresHandler()
{
var services = new ServiceCollection();
services.AddFurnitureBffApplicationRuntime();
services.AddSingleton<IFurnitureServiceClient, FakeFurnitureServiceClient>();
using var provider = services.BuildServiceProvider();
var handler = provider.GetRequiredService<IGetFurnitureAvailabilityHandler>();
var response = await handler.HandleAsync(new GetFurnitureAvailabilityApiRequest("FUR-001", "corr-123"));
Assert.Equal("FUR-001", response.FurnitureId);
Assert.Equal("Chair", response.DisplayName);
Assert.Equal(7, response.QuantityAvailable);
}
[Fact]
public void FurnitureAvailabilityEdgeGrpcContractAdapter_WhenMapped_PreservesValues()
{
var adapter = new FurnitureAvailabilityEdgeGrpcContractAdapter();
var request = new GetFurnitureAvailabilityApiRequest("FUR-002", "corr-456");
var grpcContract = adapter.ToGrpc(request);
var roundtrip = adapter.FromGrpc(grpcContract);
Assert.Equal("FUR-002", roundtrip.FurnitureId);
Assert.Equal("corr-456", roundtrip.CorrelationId);
}
[Fact]
public void FurnitureAvailabilityEdgeGrpcContractAdapter_WhenCorrelationMissing_GeneratesCorrelation()
{
var adapter = new FurnitureAvailabilityEdgeGrpcContractAdapter();
var grpcContract = new GetFurnitureAvailabilityEdgeGrpcContract("FUR-003", string.Empty);
var mapped = adapter.FromGrpc(grpcContract);
Assert.Equal("FUR-003", mapped.FurnitureId);
Assert.NotEmpty(mapped.CorrelationId);
}
private sealed class FakeFurnitureServiceClient : IFurnitureServiceClient
{
public Task<GetFurnitureAvailabilityResponse> GetAvailabilityAsync(GetFurnitureAvailabilityRequest request)
{
return Task.FromResult(new GetFurnitureAvailabilityResponse(request.FurnitureId, "Chair", 7));
}
}
}