From 69d6e803783a5a6e5c4193233d2feb49473f1be1 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Wed, 1 Jul 2026 11:07:55 +0200 Subject: [PATCH] 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) --- services/bff/Bff.Api/Program.cs | 18 +++- services/bff/Bff.Tests/OpenApiSpecTests.cs | 34 +++++++ services/bff/openapi.json | 104 +++++++++++++++++++++ 3 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 services/bff/Bff.Tests/OpenApiSpecTests.cs create mode 100644 services/bff/openapi.json diff --git a/services/bff/Bff.Api/Program.cs b/services/bff/Bff.Api/Program.cs index 1a91d42..d705bd5 100644 --- a/services/bff/Bff.Api/Program.cs +++ b/services/bff/Bff.Api/Program.cs @@ -27,7 +27,14 @@ builder.Services.AddHttpClient(c => c.BaseAddress = builder.Services.AddHttpClient(c => c.BaseAddress = new Uri(projectionBaseUrl)); 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(); @@ -47,14 +54,19 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC var accepted = await domain.SubmitRegistrationAsync(bsn, ct); return Results.Accepted($"/self-service/registrations/{accepted.RegistrationId}", accepted); -}).RequireAuthorization(); +}) + .RequireAuthorization() + .Produces(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>(StatusCodes.Status200OK); app.Run(); diff --git a/services/bff/Bff.Tests/OpenApiSpecTests.cs b/services/bff/Bff.Tests/OpenApiSpecTests.cs new file mode 100644 index 0000000..93db800 --- /dev/null +++ b/services/bff/Bff.Tests/OpenApiSpecTests.cs @@ -0,0 +1,34 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; + +namespace Bff.Tests; + +/// +/// Guards the committed OpenAPI contract (services/bff/openapi.json) 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 /openapi/v1.json over the file. +/// +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"); +} diff --git a/services/bff/openapi.json b/services/bff/openapi.json new file mode 100644 index 0000000..f1305b2 --- /dev/null +++ b/services/bff/openapi.json @@ -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" + } + ] +} \ No newline at end of file