feat(bff): BFF with one endpoint per portal + OIDC validation (closes #8) #64

Merged
not merged 7 commits from feat/8-bff into main 2026-07-01 09:32:45 +00:00
3 changed files with 153 additions and 3 deletions
Showing only changes of commit 69d6e80378 - Show all commits

View File

@@ -27,7 +27,14 @@ builder.Services.AddHttpClient<IDomainClient, DomainClient>(c => c.BaseAddress =
builder.Services.AddHttpClient<IProjectionClient, ProjectionClient>(c => c.BaseAddress = new Uri(projectionBaseUrl)); builder.Services.AddHttpClient<IProjectionClient, ProjectionClient>(c => c.BaseAddress = new Uri(projectionBaseUrl));
builder.Services.AddHealthChecks(); builder.Services.AddHealthChecks();
builder.Services.AddOpenApi(); // 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(); var app = builder.Build();
@@ -47,14 +54,19 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
var accepted = await domain.SubmitRegistrationAsync(bsn, ct); var accepted = await domain.SubmitRegistrationAsync(bsn, ct);
return Results.Accepted($"/self-service/registrations/{accepted.RegistrationId}", accepted); return Results.Accepted($"/self-service/registrations/{accepted.RegistrationId}", accepted);
}).RequireAuthorization(); })
.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). // 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) => app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
{ {
var entries = await projection.GetRegisterAsync(ct); var entries = await projection.GetRegisterAsync(ct);
return Results.Ok(OpenbaarProjection.PublicView(entries, q)); return Results.Ok(OpenbaarProjection.PublicView(entries, q));
}); })
.Produces<IReadOnlyList<OpenbaarEntry>>(StatusCodes.Status200OK);
app.Run(); app.Run();

View File

@@ -0,0 +1,34 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
namespace Bff.Tests;
/// <summary>
/// Guards the committed OpenAPI contract (<c>services/bff/openapi.json</c>) against drift: it must
/// equal the document the running BFF serves. S-08's Angular client is generated from this file, so a
/// stale spec is a bug. To regenerate: run the BFF and save <c>/openapi/v1.json</c> over the file.
/// </summary>
public class OpenApiSpecTests
{
[Fact]
public async Task Committed_openapi_spec_matches_the_served_document()
{
using var factory = new BffFactory();
var served = await factory.CreateClient().GetStringAsync("/openapi/v1.json");
var committed = await File.ReadAllTextAsync(SpecPath());
Assert.Equal(Canonical(committed), Canonical(served));
}
// Reformat both sides identically so the comparison is about content, not whitespace.
private static string Canonical(string json)
{
using var doc = JsonDocument.Parse(json);
return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
}
// The committed spec sits at services/bff/openapi.json — one level up from this test file.
private static string SpecPath([CallerFilePath] string thisFile = "")
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "openapi.json");
}

104
services/bff/openapi.json Normal file
View File

@@ -0,0 +1,104 @@
{
"openapi": "3.1.1",
"info": {
"title": "Bff.Api | v1",
"version": "1.0.0"
},
"paths": {
"/self-service/registrations": {
"post": {
"tags": [
"Bff.Api"
],
"responses": {
"202": {
"description": "Accepted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SubmitAccepted"
}
}
}
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
}
}
}
},
"/openbaar/register": {
"get": {
"tags": [
"Bff.Api"
],
"parameters": [
{
"name": "q",
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OpenbaarEntry"
}
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"OpenbaarEntry": {
"required": [
"id",
"status"
],
"type": "object",
"properties": {
"id": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"SubmitAccepted": {
"required": [
"registrationId",
"status"
],
"type": "object",
"properties": {
"registrationId": {
"type": "string"
},
"status": {
"type": "string"
}
}
}
}
},
"tags": [
{
"name": "Bff.Api"
}
]
}