Files
DodoSSH/docs/adr/0002-minimal-apis.md
jaap-jan 9bc28f1c0f Move the API onto FastEndpoints, without moving the wire
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<Type>, 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<Ok<T>, 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<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. 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<T>() 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.
2026-07-31 08:39:06 +02:00

64 lines
3.6 KiB
Markdown

# ADR 0002 — Minimal APIs with explicitly registered feature modules
- Status: accepted; the endpoint-framework decision is superseded by [ADR 0008](0008-fastendpoints.md)
- Date: 2026-07-28
## Context
The API is roughly 60 endpoints: identity and enrollment, a public-key directory, teams,
vaults and grants, sync, read-only item queries, relay, audit and admin. It is consumed by
one first-party desktop client.
## Decision
Minimal APIs, grouped into feature modules under `Features/`, registered **explicitly** from
`Setup/EndpointRegistration.cs`. Typed results (`Results<Ok<T>, ForbidHttpResult,
ProblemHttpResult>`) throughout, one endpoint per file.
Version with a hard-coded `/api/v1` prefix and **no `Asp.Versioning` package**. Instead ship
capability negotiation:
- `GET /api/v1/meta` — server version, sync protocol version, crypto spec version, feature
flags, `minClientVersion`, and the push caps.
- `GET /.well-known/dodossh-configuration` — OIDC authority, client id, scopes, relay URL.
## Consequences
- Per-endpoint filters and metadata compose cleanly, and typed results give an accurate
OpenAPI document without attribute noise.
- Explicit registration over reflection scanning: predictable startup cost, trimming-friendly,
and every route is greppable. The cost is one line per module, which is worth paying.
- Minimal APIs' real weakness is that a cross-cutting concern is easy to forget. Two
mitigations, both required: group-level `RequireAuthorization`, and an **endpoint-inventory
test** that enumerates `EndpointDataSource` and fails the build if any endpoint is absent
from an explicit authorization expectations table. A new endpoint therefore cannot be added
without making an authorization decision.
- Capability negotiation over version routing is the right trade for a self-hosted product,
where client and server upgrade independently and skew is normal rather than exceptional.
`.well-known` also *is* the onboarding story: the user types one server URL and the client
discovers the rest. `Asp.Versioning` gets added when a v2 actually exists, not before.
- The generated OpenAPI document is for third parties and a future CLI. It is emitted at build
time to `artifacts/openapi/v1.json`, committed and CI-diffed. **The client's real contract is
the `DodoSSH.Contracts` assembly**, guarded by PublicApiAnalyzers so an accidental DTO change
is a build error rather than a runtime deserialisation failure on a user's laptop.
### Rejected
- **MVC controllers.** Would work, but bring filter/model-binding machinery this API does not
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.