ADR-332: A Handler Gets Only What Its Signature Declares — Path and Query by Name, Everything Else by a Type That Extracts Itself From Declared Inputs; a Function Answers Its Parameters While the Program Is Built

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

1. Question

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.

2. How others do it

  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.

3. Options, weighed

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

4. Decision

D1 — A function passed with the bound 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).

D2 — A route’s path and query name their values; the handler’s parameters of those names give their types.

.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" }

D3 — Every other input is a type that extracts itself, from inputs it declares as a handler does.

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.

D4 — The body is 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.

D5 — A written type is the type: a wrapper is read with .value.

key.value, data.value. A wrapper may carry more than the value - the name it arrived under, the format.

D6 — A handler reaches only what its signature declares.

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.

D7 — An API description follows the route, its parameters and every extractor’s inputs.

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).

5. Consequences

6. What this does not decide

This site is open source. Improve this page.