Public Access
Merge branch 'claude/api-fastendpoints-migration-020431'
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# ADR 0002 — Minimal APIs with explicitly registered feature modules
|
||||
|
||||
- Status: accepted
|
||||
- Status: accepted; the endpoint-framework decision is superseded by [ADR 0008](0008-fastendpoints.md)
|
||||
- Date: 2026-07-28
|
||||
|
||||
## Context
|
||||
@@ -48,3 +48,16 @@ capability negotiation:
|
||||
need and blur the one-endpoint-one-file structure that keeps the authz surface reviewable.
|
||||
- **Reflection-based endpoint discovery.** Convenient until a route silently disappears
|
||||
because an assembly was not loaded, or trimming removes it.
|
||||
|
||||
## Superseded in part
|
||||
|
||||
[ADR 0008](0008-fastendpoints.md) replaces minimal APIs with FastEndpoints. Everything else on this
|
||||
page still stands and is still the reason those endpoints look the way they do: the hard-coded
|
||||
`/api/v1` prefix, no `Asp.Versioning`, capability negotiation through `/api/v1/meta` and
|
||||
`/.well-known/dodossh-configuration`, and `DodoSSH.Contracts` as the client's real contract.
|
||||
|
||||
The two mitigations above survive the change with their reasoning intact. Registration is still
|
||||
explicit rather than a reflection scan — 0008 feeds FastEndpoints a literal type list for the reason
|
||||
this page gives, and then some. The endpoint-inventory test is no longer a plan: it exists as of
|
||||
0008, and it matters more under a hand-maintained list than it did here, because a forgotten line is
|
||||
now the way a route goes missing.
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# ADR 0008 — FastEndpoints instead of minimal APIs
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-30
|
||||
- Supersedes, in part: [ADR 0002](0002-minimal-apis.md)
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0002](0002-minimal-apis.md) chose minimal APIs grouped into feature modules. That has worked
|
||||
for the eight endpoints M1 ships, but the shape it produces — a static class per area holding
|
||||
static local functions, with route, policy, name and summary asserted in one fluent chain and the
|
||||
handler somewhere below it — does not obviously scale to the ~60 endpoints the API is planned to
|
||||
have. The specific costs are that a handler's dependencies are parameters rather than
|
||||
constructor-injected, that a group's `RequireAuthorization` sits far from the handler it governs,
|
||||
and that there is no type to hang an endpoint's own documentation on.
|
||||
|
||||
FastEndpoints implements REPR: one class per endpoint, its route and authorization declared in
|
||||
`Configure()`, its dependencies injected, its handler a method on the same type.
|
||||
|
||||
## Decision
|
||||
|
||||
FastEndpoints 8.2.0, with the endpoint classes living in the same `Features/<Area>/` files the
|
||||
static modules occupied.
|
||||
|
||||
Everything about the **wire** is unchanged, deliberately and verifiably: the same eight routes, the
|
||||
same verbs and route constraints, the same status codes, the same operation ids, and the same
|
||||
RFC 9457 problem bodies with the same `code` values. The existing HTTP test suite passes without a
|
||||
single edit.
|
||||
|
||||
Five choices make that true, and each one is a place where the idiomatic FastEndpoints answer was
|
||||
rejected:
|
||||
|
||||
- **Endpoints are registered from an explicit `List<Type>`**, not found by scanning. ADR 0002
|
||||
rejected reflection discovery and the reason it gave applies with more force here, not less:
|
||||
under `WebApplicationFactory` the scan reaches the test assembly, so an endpoint written in a
|
||||
test would be registered into the host under test.
|
||||
- **Handlers keep returning `Results<Ok<T>, NotFound, ProblemHttpResult>`** as the endpoint's
|
||||
response type, 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 each endpoint can produce. None of the `Send.*`
|
||||
API is used.
|
||||
- **Validation stays in the feature services.** A `Validator<T>` 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. Better than twenty tests assert a specific code on a 400.
|
||||
This is banned in `BannedSymbols.txt` rather than merely avoided, because it is the single most
|
||||
attractive wrong turn available: the framework's documentation leads straight to it and it looks
|
||||
like an improvement.
|
||||
- **Request DTOs are bound from the JSON body only** (`BodyOnlyRequestBinder<T>`, installed for every
|
||||
endpoint through `Config.Endpoints.Configurator`). FastEndpoints' default binder deserialises the
|
||||
body and then writes route values, query-string parameters, headers, claims and cookies over the
|
||||
top. Minimal APIs bound a body parameter from the body alone, so the default would have widened
|
||||
every request on this API — `?cursor=…` overriding a pull cursor, and `?identityProviderToken=…`
|
||||
letting an ID token travel in a URL, through every proxy log on the path.
|
||||
- **OpenAPI stays on `Microsoft.AspNetCore.OpenApi`.** `Description(b => b.WithName(…))` writes the
|
||||
same operation ids, summaries and tags the minimal APIs wrote. It does not write the same
|
||||
*parameters*: a route value read with `Route<T>()` is invisible to ApiExplorer, so the document
|
||||
emitted `/api/v1/vaults/{vaultId}/sync/pull` with nothing declaring `vaultId` — a template
|
||||
expression with no parameter, which the specification forbids and no client generator can fill in.
|
||||
`RouteParameterTransformer` in `Setup/OpenApi.cs` puts them back, recovering the type from the
|
||||
route constraint rather than guessing it from the name. `FastEndpoints.OpenApi` would auto-tag
|
||||
every operation from the first path segment, advertise a bearer scheme that is not the OAuth2+PKCE
|
||||
scheme this API actually uses, and inject a module initializer through a build target — all for a
|
||||
`Summary()` mechanism we would not use.
|
||||
- **Full absolute routes on every endpoint**, no `Group<T>`. A group would save a repeated prefix
|
||||
and cost three startup-time failure modes, one of which — applying the group's policy to all four
|
||||
`/me` endpoints — would put `Enrolled` on `DELETE /devices` and turn today's 404 into a 403 with
|
||||
no failing test.
|
||||
|
||||
Two things about the surface did change, both on purpose:
|
||||
|
||||
- **A request body that cannot be deserialised now answers with a problem document**
|
||||
(`malformed-request`) rather than an empty 400. FastEndpoints' default for this path announces
|
||||
`application/problem+json` while sending something else, and names the failing .NET type on the
|
||||
wire — in a codebase that sets `IncludeErrorDetails = false` on the bearer handler precisely to
|
||||
avoid that. `Config.Errors.ResponseBuilder` is replaced.
|
||||
- **`GET /_test_url_cache_` is answered 404 by middleware.** `UseFastEndpoints` maps it
|
||||
unconditionally, in every environment, carrying neither a policy nor `AllowAnonymous`; it returns
|
||||
the server's entire endpoint-name-to-route table. There is no switch to turn it off. Deny-by-
|
||||
default means a caller must be authenticated to read it, which is not the same as it being
|
||||
nobody's business. The middleware matches on the endpoint routing selected, not on the request
|
||||
path — the first version compared the path with `StringComparison.Ordinal` and was therefore
|
||||
bypassable at `/_TEST_URL_CACHE_`, which a single-spelling test happily certified.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The endpoint-inventory test ADR 0002 promised now exists
|
||||
(`tests/DodoSSH.Api.Tests/EndpointInventoryTests.cs`) and asserts the complete route set — verb,
|
||||
pattern, operation id, tags, policies and anonymity. It is not optional under a hand-maintained
|
||||
type list: a type left off `EndpointRegistration.cs` is otherwise a route that quietly does not
|
||||
exist, with no compile error. It also brings `/api/v1/meta` and
|
||||
`/.well-known/dodossh-configuration` into a suite a developer actually runs before pushing —
|
||||
`DodoSSH.SystemTests` does fetch both anonymously, but only with a Docker daemon and several
|
||||
containers. That matters because FastEndpoints authenticates by default and both routes are
|
||||
unreachable-by-design if they 401.
|
||||
- FastEndpoints' serialiser is a **copy** of the host's JSON options, taken once while the pipeline
|
||||
is built, and the copy is process-wide and undocumented. `AddDodoJson` still configures the host,
|
||||
and `EndpointRegistration` applies the same settings to the copy explicitly rather than trusting
|
||||
it. A second `WebApplicationFactory` in the same test process would silently inherit the first
|
||||
host's JSON configuration; there is one host per assembly today, so this is inert — but it is the
|
||||
kind of thing that costs an afternoon.
|
||||
- `FluentValidation`, `FastEndpoints.JobQueues` and `FastEndpoints.Messaging` are now in the
|
||||
dependency graph and none of them is used. They are left as plain transitives.
|
||||
- [ADR 0005](0005-no-application-layer.md) still stands and needed no amendment. No mediator, no
|
||||
behaviour pipeline, no validators and no mappers were introduced. An endpoint class replaced a
|
||||
static local function and still calls the same feature service inline, so "reading an endpoint
|
||||
means reading one file" is as true as it was — the file now holds two to four classes instead of
|
||||
one, and the ~80-line rule that ADR 0005 sets for extracting a service is unaffected.
|
||||
- The explicit type list is, as it happens, also the AOT-supported path. The two scanning overloads
|
||||
carry `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]` and their message points at
|
||||
`AddFastEndpoints(DiscoveredTypes.All)`; the `List<Type>` overload this uses carries neither. If
|
||||
trimming ever becomes a requirement, `FastEndpoints.Generator` produces that same list from source
|
||||
and nothing else here has to change.
|
||||
|
||||
### Rejected
|
||||
|
||||
- **Assembly-scanning discovery** (`AddFastEndpoints()`), for the reasons above.
|
||||
- **`Validator<T>` / FluentValidation**, which would replace every coded 400 with an uncoded one.
|
||||
- **FastEndpoints' `ErrorResponse` and its `ProblemDetails`.** Neither carries a `code`; the latter
|
||||
also points `type` at RFC 7231 anchors and makes `errors` an array of objects.
|
||||
- **The default request binder.** Its extra binding sources are not a feature this API wants, and
|
||||
the one that matters is not obvious from reading an endpoint: nothing in a `Configure()` method
|
||||
hints that the query string can reach the DTO. Rejected globally rather than per endpoint so that
|
||||
a future endpoint cannot opt into it by forgetting.
|
||||
- **`FastEndpoints.OpenApi`**, and **`Group<T>` route prefixes**, and **the `Send.*` response API**.
|
||||
- **Staying on minimal APIs.** Defensible — the objection is to a shape that has not hurt yet. The
|
||||
reason not to wait is that this migration is cheap at eight endpoints and gets linearly more
|
||||
expensive, and the contract hazards it surfaced — `/_test_url_cache_`, the query-string binding
|
||||
widening, and a strict unmapped-member rule that turned out to have no test at all — were worth
|
||||
finding either way.
|
||||
Reference in New Issue
Block a user