From 9bc28f1c0f238a13cdee015105c44fcfa03a7ee4 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Fri, 31 Jul 2026 08:39:06 +0200 Subject: [PATCH] Move the API onto FastEndpoints, without moving the wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight endpoints today, around sixty planned. The minimal-API shape — a static class per area holding static local functions, route and policy and name asserted in one fluent chain with the handler somewhere below it — has not hurt yet, and would. A handler's dependencies are parameters rather than injected, a group's RequireAuthorization sits far from the handler it governs, and there is no type to hang an endpoint's own documentation on. FastEndpoints is one class per endpoint, its route and authorization in Configure(), its handler a method on the same type. Nothing about the wire moves, and the evidence is that the 94 existing HTTP tests pass with zero edits to any of them. Same routes, verbs, route constraints, status codes, operation ids, and the same RFC 9457 bodies with the same code values. Every place the idiomatic FastEndpoints answer would have changed one of those, it was refused: Endpoints are registered from an explicit List, not found by scanning. ADR 0002 rejected reflection discovery by name, and the reason it gave is sharper here than in general — under WebApplicationFactory the scan reaches the test assembly, so an endpoint written in a test would be registered into the host under test. The cost is a line per endpoint that can be forgotten, which is what the endpoint-inventory test is for. That test is the one ADR 0002 promised and never got. Handlers still return Results, NotFound, ProblemHttpResult> from ExecuteAsync. The union executes as an ordinary IResult, which is what keeps problem bodies going through the host's serialiser and IProblemDetailsService, and what keeps the compile-time record of which statuses an endpoint can produce. No Send.* call appears anywhere; the moment one does, a response has left the host's serialiser. Validation stays in the feature services. A Validator short-circuits before the handler and answers with FastEndpoints' own envelope, which carries no code — and the code is the only part of an error the client branches on. Twenty-odd tests assert a specific code on a 400. It is banned in BannedSymbols.txt rather than merely avoided, because the framework's documentation leads straight to it and it looks like an improvement. Three defects arrived with the framework and were caught in review. All three were green at the time, which is the part worth remembering. FastEndpoints maps GET /_test_url_cache_ unconditionally, in every environment, with no policy and no way to opt out; it answers with the whole endpoint-name-to-route table. It is short-circuited to 404 — by asking routing which endpoint it selected, after the first attempt compared the request path with Ordinal and was therefore bypassable at /_TEST_URL_CACHE_, certified by a test that only ever tried one spelling. The default request binder writes query-string values over the deserialised body, which would have let ?identityProviderToken=... put an ID token in a URL and from there into every proxy log on the path; every endpoint now binds from the body alone. And a route value read with Route() is invisible to ApiExplorer, so the generated document named {vaultId} in a path template with nothing declaring it — invalid OpenAPI, and unusable by the client generators the document exists for. Two changes to the surface, both deliberate. A body that cannot be deserialised now answers with a problem document carrying malformed-request, rather than an empty 400: FastEndpoints' default announces application/problem+json while sending something else, and names the failing .NET type on the wire, in a codebase that sets IncludeErrorDetails = false to prevent exactly that. And the route table above returns 404 where it would otherwise have answered any authenticated caller. Each of the three fixes has a regression test that was checked by reverting the fix and watching it fail — four failures for the route table and the binder, four for the document. That check is the whole reason to trust them, since all three defects passed a full green suite on the way in. 950 tests green across 16 projects, 14 of them new and no existing test edited. Zero warnings, format clean, locked restore clean. FluentValidation, JobQueues and Messaging are in the graph now and none is used. Not verified: the generated document's response schemas, which differ from before — FastEndpoints contributes its own Produces metadata. Nothing consumes the document yet, and MapOpenApi runs only in Development behind the fallback policy. It needs pinning if ADR 0002's build-time artifacts/openapi/v1.json is ever built. --- BannedSymbols.txt | 8 + Directory.Packages.props | 13 ++ docs/adr/0002-minimal-apis.md | 15 +- docs/adr/0008-fastendpoints.md | 129 +++++++++++ src/DodoSSH.Api/DodoSSH.Api.csproj | 1 + .../Features/Identity/IdentityEndpoints.cs | 208 ++++++++++-------- .../Features/Meta/MetaEndpoints.cs | 87 +++++--- .../Features/Sync/SyncEndpoints.cs | 118 +++++----- src/DodoSSH.Api/Program.cs | 5 + src/DodoSSH.Api/Setup/Auth.cs | 45 ++-- src/DodoSSH.Api/Setup/EndpointRegistration.cs | 139 ++++++++++-- src/DodoSSH.Api/Setup/Json.cs | 8 +- src/DodoSSH.Api/Setup/OpenApi.cs | 95 +++++++- src/DodoSSH.Api/Setup/Problems.cs | 72 ++++++ src/DodoSSH.Api/packages.lock.json | 49 +++++ src/DodoSSH.Contracts/ProblemCodes.cs | 12 + src/DodoSSH.Contracts/PublicAPI.Unshipped.txt | 1 + .../EndpointInventoryTests.cs | 151 +++++++++++++ .../DodoSSH.Api.Tests/OpenApiDocumentTests.cs | 103 +++++++++ .../DodoSSH.Api.Tests/RequestBindingTests.cs | 173 +++++++++++++++ tests/DodoSSH.Api.Tests/packages.lock.json | 56 +++++ 21 files changed, 1287 insertions(+), 201 deletions(-) create mode 100644 docs/adr/0008-fastendpoints.md create mode 100644 src/DodoSSH.Api/Setup/Problems.cs create mode 100644 tests/DodoSSH.Api.Tests/EndpointInventoryTests.cs create mode 100644 tests/DodoSSH.Api.Tests/OpenApiDocumentTests.cs create mode 100644 tests/DodoSSH.Api.Tests/RequestBindingTests.cs diff --git a/BannedSymbols.txt b/BannedSymbols.txt index b842337..a3df73e 100644 --- a/BannedSymbols.txt +++ b/BannedSymbols.txt @@ -23,6 +23,14 @@ M:System.Threading.Tasks.Task.WaitAll;Use Task.WhenAll with await. M:System.Threading.Tasks.Task.WaitAny;Use Task.WhenAny with await. M:System.Threading.Tasks.Task.GetAwaiter;Await the task directly rather than blocking on the awaiter. +## Request validation — FluentValidation arrives transitively with FastEndpoints and is +## deliberately unused. A validator short-circuits before the handler and answers with +## FastEndpoints' own envelope, which carries no ProblemDetails `code` — and the code is the only +## part of an error the client branches on. Validation lives in the feature services, where it can +## throw an exception the endpoint maps to a coded problem. See docs/adr/0008-fastendpoints.md. +T:FastEndpoints.Validator`1;Validate in the feature service and map its exception to a coded problem; a Validator answers with FastEndpoints' envelope, which has no `code`. +T:FluentValidation.AbstractValidator`1;As above. FluentValidation is a transitive dependency of FastEndpoints, not a chosen one. + ## Encoding — must be explicit, never the ambient codepage. P:System.Text.Encoding.Default;Specify the encoding explicitly; Encoding.Default varies by platform. diff --git a/Directory.Packages.props b/Directory.Packages.props index ff9358b..06507cb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,19 @@ + + + + +