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:
@@ -1,6 +1,6 @@
|
||||
# einherjar/web
|
||||
|
||||
[](https://code.nochebuena.dev/einherjar/web)
|
||||
[](https://code.nochebuena.dev/einherjar/web)
|
||||
[](LICENSE)
|
||||
[](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
|
||||
|
||||
Reference in New Issue
Block a user