Files
web/docs/adr/ADR-001-request-binding.md
T
Rene Nochebuena e76dc48688 docs(adr): generalize ADR-001 motivation — drop private consumer name and its internal ADR reference
The framework ADR must be self-contained: it described the motivating failure
modes by naming a specific downstream service and one of its internal ADRs, which
no framework reader has context for. Reworded to 'a downstream service' and to the
general authorization-boundary rationale.
2026-08-14 01:08:36 -06:00

6.1 KiB

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.