Status: Accepted
Date: October 7, 2026 (#121)
Answers: how a route reads its path, query, headers, cookies, body and who
is asking, typed, without a bag of strings, and so that an API description
sees all of it
Related:
ADR-333 (value parameters of a type);
ADR-289 D14 (routes are .route(…)), D17-D29;
ADR-304 (reflection, unrolled while building);
ADR-330 (a type as an argument, kinds);
ADR-331 (attributes, defaults);
ADR-329 (an API description from what the program says);
Part III 17.1
How does a handler receive the inputs of a request - typed, declared before it runs, and visible to an API description - and how does a check that many routes share, such as who is asking, hand its result to the handler?
A handler names nothing or takes the whole request and reads
request.query("name"): text, looked up by a name written as text, and
invisible to an OpenAPI generator. There are no path placeholders and no
middleware.
| Inputs | Where an input comes from | A shared check’s result | |
|---|---|---|---|
| Express, Go | req.params.id, r.PathValue("id") |
the lookup’s text | req.user, ctx.Value(k), untyped |
| Spring, ASP.NET | handler parameters | an annotation per parameter | @AuthenticationPrincipal, HttpContext.User |
| FastAPI | handler parameters | by convention: in the path, a plain type, a model | Depends(f), where f declares its own inputs; all reach OpenAPI |
| axum | handler parameters | a wrapper type: Path<T>, Json<T> |
an extractor type |
| Spring, ASP.NET, Django REST | the body’s type | a converter chosen by Content-Type |
- |
| Go 1.22, Ktor | path values | {path...} for the rest |
- |
From memory, not checked against a version. What transfers: FastAPI’s dependencies that declare their own inputs, axum’s extractor types, and the body’s format chosen by the request rather than the handler.
| Option | Why it lost |
|---|---|
| The request’s inputs as a struct per route | a struct for a single id |
A route pattern with types, "/users/{id: i64}", or a dsl block for it |
types in a string; a dsl … eod inside a call reads badly |
| Attributes on parameters, before or inside the signature | a second mechanism beside the one a shared check needs |
Header("X-Api-Key") as a type |
Type(…) is a constructor call, and since ADR-330 a type may stand where a value does |
A type per header, @http::Header("X-Api-Key") struct ApiKey |
the name is text there too; a typo moves, it does not go |
| Every plain parameter not in the path is the query’s | a parameter could fall into the query unseen; the string is the contract |
Optional segments, {tab?} |
two routes say it without the ambiguity beside /users/{id}/edit |
{*path} (Spring, ASP.NET, axum) |
... already means the rest in Nikaia (...args); Go and Ktor write {path...} |
The method in the path, "GET /users" |
a parameter of its own is chosen, and an IDE offers it |
Choices and ranges in the path, {tab:a|b}, {id:[0-9]{2}} |
a type says it, and { clashes with a pattern’s own braces |
| A wrapper that reads as its value inside the body | the written type would not be the type; a wrapper may carry more than the value |
Json[T], Form[T] on the handler |
a handler would be rewritten to accept a second format |
An extractor that reads the raw request, req.header("Authorization") |
a header name as text again, and the description does not see it |
| An error for a parameter the body never reads | a parameter may be declared before the code that uses it |
Fn answers its parameters while the program is built.Changes ADR-330 D3: a function type’s kind is Fn(F); NK1232 is retired.
pub fn route[F: Fn](self, path: String, handler: F) {
for p in F::params { … }
}
| What it is | |
|---|---|
F: Fn |
a bound: F is a function, a lambda or a named one, whose parameters may be asked |
F::params |
its parameters in order, a list known while building; a loop over it is unrolled (ADR-304) |
p.name |
the parameter’s name, as text |
p.ty |
its type (ADR-330) |
p.default |
its default as a build-time value, or null |
p.attribute(X), p.attributes(X) |
its attributes (ADR-331) |
F::result |
the result type |
T::kind of a function type is Fn(F).
.get("/orders/{id}?expand&page-size={page_size}") fn(id: i64, expand: bool = false, page_size: i64 = 20) -> Order { … }
.get("/reports/{year}.pdf") fn(year: i64) -> Bytes { … }
.get("/files/{path...}") fn(path: fs::Path) -> http::File { … }
.route([http::GET, http::HEAD], "/health") fn { "ok" }
{name} is a whole segment, or part of one ({year}.pdf).{name...} is the rest of the path: named, last, its value without the
leading /; /files/ matches with "", /files does not. The raw path is
split at / before each segment is decoded; a . or .. segment, or a
%2F, in the rest answers 400. Nothing is normalised: a trailing / stays,
a//b keeps its empty segment.?name is a query value named as the parameter; ?wire={name} one whose
name on the wire differs.String, Vec[String] for the
rest’s segments, fs::Path, i64, an enum for a choice of words - and a
value that does not convert answers 400. A default makes a value optional.{name}, and
{name} over {name...}; two routes as specific as each other are refused
while the program is built. A parameter’s type plays no part.comptime parameter, fn get[F: Fn](self, comptime path: String,
handler: F) (ADR-333 D3); a path that does not read is refused at
the string in the call..get, .post, .put, .patch, .delete, … and
.route(methods, path) for several.impl http::Extract for Session {
fn extract(cookie: Cookie["session"]) -> Session throws http::Reject {
return sessions::load(cookie.value) ?? throw http::Reject(401)
}
}
.get("/me") fn(session: Session, key: Header["X-Api-Key"], agent: Header[http::USER_AGENT]?) -> Profile { … }
Header[name] and Cookie[name] take the name on the wire as a value
parameter (ADR-333); http declares the standard names as
constants, http::USER_AGENT, http::AUTHORIZATION, and a name that is not a
header’s form is refused while the program is built. http::Extract’s
extract takes its inputs as typed parameters, read by http as a handler’s
are (D1). Only http’s own extractors - path, query, header, cookie, body -
read the raw request. A failed extraction answers its Reject’s status, and
the handler does not run.
Body[T], in no format of its own; the server or a group of routes names the formats, and the request chooses.comptime ROUTES = http::Routes()
.post("/users") fn(data: Body[CreateUser]) -> User { … data.value … }
http::Server(ROUTES; formats: [json::Format, form::Format])
Content-Type chooses the format a body is read in, Accept the one a result
is written in; a format not named answers 415. data.format says which one
came. A format that cannot express T - a form and a nested struct - is
refused while the program is built.
.value.key.value, data.value. A wrapper may carry more than the value - the name
it arrived under, the format.
No lookup by text reaches a typed handler. An input the request brings and no
parameter declares is not reachable. A handler that needs the raw request
declares req: http::Request. Whether the body reads a parameter is not
checked.
The path, the query, each header and cookie, the body’s formats, the result,
and what an extractor such as AuthUser asks for, are read while the program
is built (ADR-329).
.value on
wrappers; a custom header’s name is text nobody but the client can check.comptime value."a" | "b", i64 in 1..=100);
until then an enum or a type of its own says them.