feat(furniture-bff): wire rest runtime and grpc client adapter

This commit is contained in:
José René White Enciso 2026-02-22 19:26:50 -06:00
parent cbafd039c3
commit fd2d61701d
9 changed files with 309 additions and 5 deletions

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> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<ProjectReference Include="..\Furniture.Bff.Contracts\Furniture.Bff.Contracts.csproj" /> <ProjectReference Include="..\Furniture.Bff.Contracts\Furniture.Bff.Contracts.csproj" />
<ProjectReference Include="..\..\..\furniture-service\src\Furniture.Service.Contracts\Furniture.Service.Contracts.csproj" /> <ProjectReference Include="..\..\..\furniture-service\src\Furniture.Service.Contracts\Furniture.Service.Contracts.csproj" />
</ItemGroup> </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> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </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> <ItemGroup>
<ProjectReference Include="..\Furniture.Bff.Application\Furniture.Bff.Application.csproj" /> <ProjectReference Include="..\Furniture.Bff.Application\Furniture.Bff.Application.csproj" />
<ProjectReference Include="..\Furniture.Bff.Contracts\Furniture.Bff.Contracts.csproj" /> <ProjectReference Include="..\Furniture.Bff.Contracts\Furniture.Bff.Contracts.csproj" />
<ProjectReference Include="..\..\..\blueprint-platform\src\Core.Blueprint.Common\Core.Blueprint.Common.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -1,13 +1,70 @@
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.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); var builder = WebApplication.CreateBuilder(args);
// Stage 3 skeleton: single active external protocol for this deployment is REST. builder.Services.AddHttpContextAccessor();
var app = builder.Build(); builder.Services.AddHealthChecks();
builder.Services.AddBlueprintRuntimeCore();
app.MapGet("/api/furniture/{furnitureId}/availability", (string furnitureId) => builder.Services.AddFurnitureBffApplicationRuntime();
builder.Services.AddScoped<IFurnitureServiceClient, FurnitureServiceGrpcClientAdapter>();
builder.Services.AddGrpcClient<FurnitureRuntime.FurnitureRuntimeClient>(options =>
{ {
return Results.Ok(new GetFurnitureAvailabilityApiResponse(furnitureId, string.Empty, 0)); 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(); 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> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" /> <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="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> <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));
}
}
}