From da7813a84e355433d854394896d6b58bae0ce907 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 2 Oct 2026 09:26:31 +0200 Subject: [PATCH] feat(bff): relay refused diplomas as 422 with reason, scanner down as 503 (refs #192) openapi.json and the generated portal client regenerated from the served spec. Co-Authored-By: Claude Opus 5.5 (1M context) --- libs/api-client/src/lib/generated/bff-api.ts | 4 ++++ services/bff/Bff.Api/DownstreamClients.cs | 18 ++++++++++++--- services/bff/Bff.Api/Program.cs | 17 ++++++++++---- services/bff/openapi.json | 24 ++++++++++++++++++++ 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/libs/api-client/src/lib/generated/bff-api.ts b/libs/api-client/src/lib/generated/bff-api.ts index e806978..a51924e 100644 --- a/libs/api-client/src/lib/generated/bff-api.ts +++ b/libs/api-client/src/lib/generated/bff-api.ts @@ -59,6 +59,10 @@ export interface ProvideDocumentsRequest { contentType?: string | null; } +export interface Refusal { + reason: string; +} + export interface SubmitAccepted { registrationId: string; status: string; diff --git a/services/bff/Bff.Api/DownstreamClients.cs b/services/bff/Bff.Api/DownstreamClients.cs index 8ca730d..e89ac15 100644 --- a/services/bff/Bff.Api/DownstreamClients.cs +++ b/services/bff/Bff.Api/DownstreamClients.cs @@ -51,6 +51,9 @@ public interface IDomainClient /// How the domain answered a provide-documents request (S-29). public enum ProvideDocumentsResult { Provided, NotFound, NotAPdf, Infected, ScannerUnavailable } +/// The body of a 422 provide-documents answer: why the file was refused. +public sealed record Refusal(string Reason); + /// Port to the read projection. public interface IProjectionClient { @@ -125,9 +128,18 @@ public sealed class DomainClient(HttpClient http) : IDomainClient using var response = await http.PostAsJsonAsync( $"registrations/{registrationId}/documents", new { bsn, contentBase64, fileName, contentType }, ct); - // The domain 404s an unknown or not-owned registration; relay that rather than fail hard. - if (response.StatusCode == System.Net.HttpStatusCode.NotFound) - return ProvideDocumentsResult.NotFound; + // The domain 404s an unknown or not-owned registration, 422s a refused file with its reason and + // 503s when its scanner is down (S-29); relay those rather than fail hard. + switch (response.StatusCode) + { + case System.Net.HttpStatusCode.NotFound: + return ProvideDocumentsResult.NotFound; + case System.Net.HttpStatusCode.ServiceUnavailable: + return ProvideDocumentsResult.ScannerUnavailable; + case System.Net.HttpStatusCode.UnprocessableEntity: + var refusal = await response.Content.ReadFromJsonAsync(ct); + return refusal?.Reason == "infected" ? ProvideDocumentsResult.Infected : ProvideDocumentsResult.NotAPdf; + } response.EnsureSuccessStatusCode(); return ProvideDocumentsResult.Provided; } diff --git a/services/bff/Bff.Api/Program.cs b/services/bff/Bff.Api/Program.cs index 5a9579b..26240d6 100644 --- a/services/bff/Bff.Api/Program.cs +++ b/services/bff/Bff.Api/Program.cs @@ -166,8 +166,8 @@ app.MapPost("/self-service/registrations/{id}/withdraw", async (string id, Claim // Self-service provide-documents (S-10a): the signed-in zorgprofessional supplies the documents their // registration is waiting for ("documenten aanleveren"). The bsn comes from the DigiD token and is // forwarded to the domain, which owner-scopes the action and completes the WachtOpDocumenten task; a -// registration that is unknown or not the caller's comes back 404. The real file upload + ZGW storage -// is S-10b — this is the trigger that unblocks the process. +// registration that is unknown or not the caller's comes back 404. A non-PDF or infected file is 422 +// with the reason; an unreachable scanner is 503 (S-29, ADR-0036). app.MapPost("/self-service/registrations/{id}/documents", async (string id, ProvideDocumentsRequest body, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) => { var bsn = user.FindFirstValue("bsn"); @@ -177,13 +177,22 @@ app.MapPost("/self-service/registrations/{id}/documents", async (string id, Prov return Results.BadRequest("A document is required."); var provided = await domain.ProvideDocumentsAsync(id, bsn, body.ContentBase64, body.FileName, body.ContentType, ct); - return provided == ProvideDocumentsResult.Provided ? Results.NoContent() : Results.NotFound(); + return provided switch + { + ProvideDocumentsResult.Provided => Results.NoContent(), + ProvideDocumentsResult.NotAPdf => Results.UnprocessableEntity(new Refusal("not-a-pdf")), + ProvideDocumentsResult.Infected => Results.UnprocessableEntity(new Refusal("infected")), + ProvideDocumentsResult.ScannerUnavailable => Results.StatusCode(StatusCodes.Status503ServiceUnavailable), + _ => Results.NotFound(), + }; }) .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status422UnprocessableEntity) + .Produces(StatusCodes.Status503ServiceUnavailable); // 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) => diff --git a/services/bff/openapi.json b/services/bff/openapi.json index a9a985c..cf7984d 100644 --- a/services/bff/openapi.json +++ b/services/bff/openapi.json @@ -124,6 +124,19 @@ }, "404": { "description": "Not Found" + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Refusal" + } + } + } + }, + "503": { + "description": "Service Unavailable" } } } @@ -415,6 +428,17 @@ } } }, + "Refusal": { + "required": [ + "reason" + ], + "type": "object", + "properties": { + "reason": { + "type": "string" + } + } + }, "SubmitAccepted": { "required": [ "registrationId",