feat(httputil): add Bind/BindEmpty request binding from path and query; v1.6.0

Bind and BindEmpty fill Req from path/query/json struct tags and validate
once, extending the typed decode->validate->call->encode pipeline to routes
with identifiers and filters. Conversion via builtins + encoding.TextUnmarshaler
(uuid.UUID, time.Time); malformed value -> 400 naming the parameter; default:
applies only when absent; repeated query -> slice; mis-tagged struct panics at
wiring. Purely additive; existing adapters unchanged. Coordinated lockstep v1.6.0.
This commit is contained in:
2026-08-13 23:11:23 -06:00
parent bcccfef443
commit a778227fc2
10 changed files with 920 additions and 14 deletions
+66 -1
View File
@@ -1,6 +1,6 @@
# einherjar/web
[![version](https://img.shields.io/badge/version-v1.5.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/web)
[![version](https://img.shields.io/badge/version-v1.6.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/web)
[![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE)
[![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white)](https://go.dev)
@@ -182,6 +182,71 @@ values are mapped to their canonical HTTP status codes (full 16-code table below
---
### Request binding (`Bind` / `BindEmpty`)
`Handle` and friends fill `Req` from the **JSON body only**. A route with an
identifier or a filter needs more, and hand-rolling the parse via `HandlerFunc` is
the one path that reaches production with **no validation**. `Bind` closes that gap:
each field declares its source with a struct tag — `path:`, `query:` or `json:`
and the assembled struct is validated once by the same `valid.Validator`.
```go
type listRolesReq struct {
Page int `query:"page" default:"1" validate:"min=1"`
PerPage int `query:"per_page" default:"50" validate:"min=1,max=200"`
Q string `query:"q" validate:"omitempty,max=100"`
Kind []string `query:"kind" validate:"omitempty,dive,oneof=POS KDS"`
}
// GET /roles?page=2&per_page=50&q=turno&kind=POS&kind=KDS → all validated, 200.
srv.Get("/roles", httputil.Bind(v, logger, func(ctx context.Context, req listRolesReq) (ListRes, error) {
return roleService.List(ctx, req)
}))
type updateRoleReq struct {
RoleID uuid.UUID `path:"roleID" validate:"required"` // from the path, already typed
Name string `json:"name" validate:"omitempty,max=200"` // from the body
}
// PATCH /roles/{roleID} — path + body in one struct; WithStatus still applies.
srv.Patch("/roles/{roleID}", httputil.Bind(v, logger, func(ctx context.Context, req updateRoleReq) (RoleRes, error) {
return roleService.Update(ctx, req)
}))
type deleteRoleReq struct {
RoleID uuid.UUID `path:"roleID" validate:"required"`
}
// DELETE /roles/{roleID} — path only, no body. BindEmpty writes 204 and does not
// fail on the empty body the way HandleEmpty would.
srv.Delete("/roles/{roleID}", httputil.BindEmpty(v, logger, func(ctx context.Context, req deleteRoleReq) error {
return roleService.Delete(ctx, req.RoleID)
}))
```
Binding rules:
- **One source per field.** A field carries at most one of `path:` / `query:` /
`json:`; declaring two fails at **wiring** (the service does not boot).
- **Conversion** covers `string`, the sized integer/unsigned/float types, `bool`,
and any type whose pointer implements `encoding.TextUnmarshaler` — so `uuid.UUID`
and `time.Time` bind with no special-casing and no new import in `web`.
- **A malformed value is a 400** naming the parameter (`ErrInvalidInput`), never a 500.
- **`default:` applies only when the parameter is absent** — a present-but-empty
`?q=` is the caller clearing a filter and is left as the zero value. 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.
- **Repeated query parameters bind to a slice** (`?kind=POS&kind=KDS``[]string{…}`);
a comma inside a single value is **not** split.
- **No body is not an error** — a bodiless `GET`/`DELETE` binds path/query directly.
- The struct is reflected over **once per type and cached**; per-request work does
not re-parse tags.
`HandlerFunc` remains for genuinely custom responses (streaming, file downloads,
non-JSON) — not for parameters.
---
### Health endpoint
```go