Files
register-referentie/services/bff/Bff.Api/Program.cs
Niek Otten 69d6e80378 feat(bff): committed OpenAPI contract + drift guard (refs #8)
Document typed responses (202 SubmitAccepted / 400 / 401 on self-service; 200
OpenbaarEntry[] on openbaar) so the generated spec carries real schemas for S-08's
client. A document transformer clears the auto-populated servers block so the spec
is host-independent and deterministic. Commit services/bff/openapi.json and add a
test asserting it matches the served /openapi/v1.json (fails on drift).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:07:55 +02:00

75 lines
3.3 KiB
C#

using System.Security.Claims;
using Bff.Api;
using Microsoft.AspNetCore.Authentication.JwtBearer;
var builder = WebApplication.CreateBuilder(args);
var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'");
var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"]
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
?? throw new InvalidOperationException("Missing configuration 'Downstream:Projection:BaseUrl'");
// Validate Keycloak-issued tokens (ADR-0010). Audience validation is off for the walking skeleton —
// Keycloak's audience mapping is a later hardening; signature/issuer/expiry are validated.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = keycloakAuthority;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters.ValidateAudience = false;
});
builder.Services.AddAuthorization();
// The BFF is the portals' only backend; it fans out to the domain and projection (§8.3).
builder.Services.AddHttpClient<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl));
builder.Services.AddHttpClient<IProjectionClient, ProjectionClient>(c => c.BaseAddress = new Uri(projectionBaseUrl));
builder.Services.AddHealthChecks();
// Clear the auto-populated `servers` block so the committed spec is stable regardless of the host
// the doc was generated from (the client sets its own base URL). Keeps the drift guard deterministic.
builder.Services.AddOpenApi(options =>
options.AddDocumentTransformer((document, _, _) =>
{
document.Servers?.Clear();
return Task.CompletedTask;
}));
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/health");
app.MapOpenApi();
// Self-service submit: requires a valid digid token; the bsn comes from the token, not the body,
// and is forwarded to the domain (ADR-0010). Returns 202 — the zaak is opened asynchronously (S-05).
app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
{
var bsn = user.FindFirstValue("bsn");
if (string.IsNullOrWhiteSpace(bsn))
return Results.BadRequest("The token carries no bsn claim.");
var accepted = await domain.SubmitRegistrationAsync(bsn, ct);
return Results.Accepted($"/self-service/registrations/{accepted.RegistrationId}", accepted);
})
.RequireAuthorization()
.Produces<SubmitAccepted>(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized);
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
{
var entries = await projection.GetRegisterAsync(ct);
return Results.Ok(OpenbaarProjection.PublicView(entries, q));
})
.Produces<IReadOnlyList<OpenbaarEntry>>(StatusCodes.Status200OK);
app.Run();
// Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
public partial class Program;