docs+rules: teach httputil.Bind/BindEmpty (web v1.6.0); v1.3.4

Builtins README gains Bind/BindEmpty adapter rows + a path/query binding section
(uuid/time via TextUnmarshaler; default: with min not omitempty,min). New info
rule httputil.prefer-bind flags hand-rolled chi.URLParam / r.URL.Query() reads and
points to Bind. Escape-hatch note rescoped to custom responses only.
This commit is contained in:
2026-08-13 23:19:19 -06:00
parent 766adb2989
commit 0f476fd6de
6 changed files with 185 additions and 12 deletions
+62 -10
View File
@@ -410,26 +410,78 @@ func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
**Adapters and their default success status:**
| Adapter | Body | Default | Use for |
| Adapter | Fills `Req` from | Default | Use for |
|---|---|---|---|
| `httputil.Handle(v, logger, fn, opts…)` | req + res | **200** | POST/PUT returning a body |
| `httputil.HandleNoBody(logger, fn, opts…)` | res only | **200** | GET/HEAD |
| `httputil.HandleEmpty(v, logger, fn, opts…)` | req only | **204** | DELETE / body-less PUT |
| `httputil.Handle(v, logger, fn, opts…)` | JSON body | **200** | POST/PUT returning a body |
| `httputil.HandleNoBody(logger, fn, opts…)` | nothing (no input) | **200** | GET/HEAD with no parameters |
| `httputil.HandleEmpty(v, logger, fn, opts…)` | JSON body | **204** | body-less PUT that returns nothing |
| `httputil.Bind(v, logger, fn, opts…)` | **path + query + body** | **200** | any route with `{id}` or `?filters` (v1.6.0) |
| `httputil.BindEmpty(v, logger, fn, opts…)` | **path + query + body** | **204** | `DELETE /x/{id}` and other body-less routes keyed by a path param (v1.6.0) |
**Override the success status with `httputil.WithStatus(code)`**`WithStatus(http.StatusCreated)`
(201) on a create, `WithStatus(http.StatusAccepted)` (202) on an async `HandleEmpty`. The code **must
be 2xx**: these adapters own only the success path, so a non-2xx code is a routing mistake and
**panics at wiring** (the service fails to boot rather than emit a wrong status at runtime). Never
(201) on a create, `WithStatus(http.StatusAccepted)` (202) on an async `HandleEmpty`/`BindEmpty`. The
code **must be 2xx**: these adapters own only the success path, so a non-2xx code is a routing mistake
and **panics at wiring** (the service fails to boot rather than emit a wrong status at runtime). Never
pass a 4xx/5xx to `WithStatus`.
### Binding path & query parameters (`Bind` / `BindEmpty`, v1.6.0)
`Handle`/`HandleEmpty` fill `Req` from the **JSON body only**. A route with an identifier or a
filter needs the path or query too — and hand-rolling that parse in a raw `http.HandlerFunc` is the
one path that reaches production **with no validation** (unclamped bounds; client mistakes returned
as 500s). `Bind` closes the gap: each field declares its source with a struct tag — `path:`,
`query:` or `json:` — and the assembled struct is validated once, exactly like `Handle`.
```go
// GET /roles/{roleID}?expand=grants — path + query, validated, 200.
type getRoleReq struct {
RoleID uuid.UUID `path:"roleID" validate:"required"` // from the path, already typed
Expand []string `query:"expand" validate:"omitempty,dive,oneof=grants members"`
}
func (h *Handler) GetRole(w http.ResponseWriter, r *http.Request) {
httputil.Bind(h.v, h.logger, func(ctx context.Context, req getRoleReq) (RoleRes, error) {
return h.svc.Get(ctx, req)
})(w, r)
}
// DELETE /roles/{roleID} — path only, no body. BindEmpty writes 204 and does NOT
// fail on the empty body the way HandleEmpty would.
type deleteRoleReq struct {
RoleID uuid.UUID `path:"roleID" validate:"required"`
}
func (h *Handler) DeleteRole(w http.ResponseWriter, r *http.Request) {
httputil.BindEmpty(h.v, h.logger, func(ctx context.Context, req deleteRoleReq) error {
return h.svc.Delete(ctx, req.RoleID)
})(w, r)
}
```
Binding rules to encode when generating these structs:
- **One source per field** — at most one of `path:` / `query:` / `json:`. Two source tags, an
unsupported field type, or a bad `default:` **panic at wiring** (the service fails to boot).
- **Conversion** covers `string`, integer/unsigned/float, `bool`, and any `encoding.TextUnmarshaler`
— so `uuid.UUID` and `time.Time` bind from a path or query value with no extra code.
- **A malformed value is a 400** naming the parameter — never a 500.
- **`default:` fires only when the parameter is absent.** For a paged list use
`` `query:"per_page" default:"50" validate:"min=1,max=200"` `` — pair `default:` with a plain
`min=…`, **not** `omitempty,min=…`: the default guarantees presence, and `omitempty` would let an
explicit `?per_page=0` slip past the bound.
- **Repeated query params bind to a slice** (`?kind=POS&kind=KDS`); a comma inside one value is not split.
**Error status is automatic — never set it by hand.** Return the right `*xerrors.Err`
(`xerrors.NotFound(…)`, `xerrors.InvalidInput(…)`, `xerrors.PermissionDenied(…)`, …) and
`httputil.Error` maps it to the HTTP status and logs at the derived level (5xx→Error, 4xx→Warn,
499→Info). Validation failures become 400 automatically.
**Escape hatch:** for a response the adapters don't cover, write a raw `http.HandlerFunc` and call
`httputil.JSON(w, status, v)` or `httputil.NoContent(w)` yourself — that is the only place you pass
a status literal.
**Escape hatch:** for a *response* the adapters don't cover — streaming, file downloads, non-JSON
content types — write a raw `http.HandlerFunc` and call `httputil.JSON(w, status, v)` or
`httputil.NoContent(w)` yourself. That is the only place you pass a status literal. It is **not** the
way to read a path or query parameter — use `Bind`/`BindEmpty` so validation still runs; reaching for
`HandlerFunc` + `chi.URLParam` / `r.URL.Query()` just to read a parameter is the anti-pattern v1.6.0
removed.
## Authorization