Files
web/docs/adr/ADR-001-request-binding.md
T

111 lines
6.1 KiB
Markdown
Raw Permalink Normal View History

# ADR-001 — `httputil` request binding: `Bind` and `BindEmpty`
**Status:** Accepted
**Date:** 2026-08-13
**Module:** `web` (`httputil`)
**Shipped:** v1.6.0
## Context
The typed adapters (`Handle`, `HandleNoBody`, `HandleEmpty`) keep
`http.ResponseWriter` and `*http.Request` out of business handlers so that decode,
validation, encoding, status selection and error mapping happen once inside the
framework. But they define a handler's input as **the JSON body and nothing else**.
An HTTP request carries four input channels; only the body was reachable from a
typed handler:
| Channel | Before v1.6.0 |
|---|---|
| JSON body | decoded into `Req`, validated |
| Path parameter | only via `chi.URLParamFromCtx(ctx, …)` — untyped `string`, unvalidated, off the handler signature |
| Query parameter | unreachable — the adapters never pass `r.URL` through |
| Header | out of scope by design (middleware's concern) |
So the two most ordinary REST shapes — `GET /roles/{id}` and
`GET /roles?page=2&per_page=50` — had to abandon the typed adapters for
`HandlerFunc` and hand-write the decode, the validation call, the encoding and the
status. This is a **correctness** problem, not only ergonomics: `HandlerFunc` is the
single path by which a handler reaches production without `v.Struct(req)` ever
running. Two failure modes followed, both observed in a downstream service:
1. **Unvalidated bounds** — a list endpoint that forgets to clamp answers
`?per_page=99999`; the validator that would refuse it is not in the code path.
2. **Client mistakes reported as server faults** — a hand-written
`strconv.Atoi(...)` whose error is wrapped as internal answers **500** for a
plain **400**, misclassifying a client error as an outage.
A third, smaller trap: `HandleEmpty` decodes a body unconditionally, so a bodiless
`DELETE` fails on `io.EOF` before the handler runs.
## Decision
Add a fourth adapter family, `Bind` and `BindEmpty`, that fills `Req` from path,
query **and** body — each field declaring its source with a struct tag — and
validates the assembled struct once with the `valid.Validator` already in scope.
**The handler signature does not change**; only what `Req` may be filled from does.
`JSON`, `NoContent`, `Error` and `WithStatus` are reused unmodified.
### Binding rules
1. **One source per field.** A field carries at most one of `path:`/`query:`/`json:`.
Two source tags is a programming error, detected when the type is first reflected
over and reported as a **wiring failure at startup**, not per request.
2. **No body is not an error.** An empty body (`GET`, `DELETE`, `Content-Length: 0`)
decodes to `io.EOF`, which is treated as "no body" — retiring the `HandleEmpty`
bodiless trap.
3. **Conversion** covers `string`, the sized integer/unsigned/float types, `bool`,
and anything whose pointer implements `encoding.TextUnmarshaler`. That one
interface is the whole extensibility story: `uuid.UUID` and `time.Time` bind with
no special-casing and no new dependency in `web`.
4. **A conversion failure is `ErrInvalidInput`, naming the parameter** — never
`ErrInternal`. This turns failure mode 2 from a 500 into the 400 it always was.
5. **`default:` applies only when a parameter is absent** — never when present and
empty, because `?q=` is a caller deliberately clearing a filter. (Pair `default:`
with a plain `min=1`, not `omitempty,min=1`: the default guarantees presence, and
`omitempty` would let an explicit `?page=0` skip the bound.)
6. **Repeated query parameters bind to a slice.** A comma inside a single value is
**not** split — one syntax, so a value legitimately containing a comma survives.
7. **Metadata is parsed once per type and cached**, as `encoding/json` does. A
startup benchmark keeps the per-request cost flat in the number of tagged fields.
### Out of scope: header binding
There is no `header:` tag, in this version or a later one. Headers are middleware's
concern (authentication, request identity, tenancy). A `header:` tag would make one
specific mistake ergonomic — filling a tenant/actor identifier from a value the
client fully controls, exactly the boundary a service's own authorization rules
exist to defend. Reducing that mistake to one word in a struct tag would make it
likely rather than merely possible.
## Options considered
- **Fourth adapter family** *(chosen)* — additive, one concept, existing call sites
untouched.
- **Extend the existing three** — smallest diff, but `HandleNoBody` would need a
`Req` type parameter it does not have: a breaking signature change to the
most-used adapter, for the benefit of routes that could equally call something new.
- **One adapter per channel combination** (`HandleQuery`, `HandlePathBody`, …) —
eight exported functions expressing one idea; the caller must pick correctly each
time.
- **An `Option`** (`Handle(v, logger, fn, WithBinding())`) — leaves two ways to
express one thing, and `Option` currently means "adjust the response", not "change
how the request is read".
- **Leave it to `HandlerFunc`** — the status quo, and the only route to production
without validation. REST resources with identifiers are not an edge case.
## Consequences
- **Purely additive.** `Handle`, `HandleNoBody`, `HandleEmpty` and `HandlerFunc` are
behaviourally identical to v1.5.0; adoption is per route and per service.
- **`HandlerFunc`'s doc comment is amended** — it stops advertising itself for path
parameters and remains the answer for genuinely custom responses (streaming, file
downloads, non-JSON content types).
- **Routing coupling is acknowledged, not abstracted.** Path binding asks chi for a
named parameter, so `httputil` imports `chi/v5` directly (it was already a `web`
module dependency). `web/server` is chi and does not pretend to be swappable; an
indirection layer nothing else uses would cost more than it buys.
- **Reflection enters `httputil`** (a package that previously did none), mitigated by
the per-type cache and kept honest by the benchmark.
- **A new failure mode at startup, by design** — a mis-tagged struct fails the
service at boot rather than on the first request that exercises it.