3 Commits
Author SHA1 Message Date
Rene Nochebuena a778227fc2 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.
2026-08-13 23:11:23 -06:00
Rene Nochebuena bcccfef443 feat(web): httputil.WithStatus — configurable success status on the handler adapters (v1.5.0) 2026-08-12 17:55:28 -06:00
Rene Nochebuena 80b28dfcc4 feat(web): add mw.RequestIDFrom (resolver sees the request); dependency refresh; v1.4.0 2026-08-12 15:27:32 -06:00
16 changed files with 1325 additions and 62 deletions
+77
View File
@@ -6,6 +6,83 @@ This module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html
--- ---
## [1.6.0] — 2026-08-13
Minor — request binding from path and query, not only the JSON body.
### Added
- **`httputil.Bind[Req, Res]`** and **`httputil.BindEmpty[Req]`** — a fourth adapter family
that fills `Req` from the path, the query string **and** the body, each field declaring its
source with a struct tag (`path:` / `query:` / `json:`), then validates the assembled struct
once with the same `valid.Validator`. The handler signature is identical to `Handle` /
`HandleEmpty`; `WithStatus` and the full error-mapping pipeline are reused unchanged.
- 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 dependency in `web`.
- A conversion failure is `ErrInvalidInput` naming the parameter (**400, never 500**).
- `default:` applies only when a parameter is **absent** (a present-but-empty `?q=` is left
as the zero value). Repeated query parameters bind to a slice; a comma inside a single value
is not split. A bodiless `GET`/`DELETE` is not an error — `BindEmpty` retires the
`HandleEmpty` empty-body (`io.EOF`) trap for routes keyed only by a path parameter.
- The struct is reflected over **once per type and cached**. A field with more than one source
tag, an unsupported field type, or a `default:` that is not a valid value for its field all
**panic at wiring** — a mis-tagged struct fails the service at boot, not on a request.
### Changed
- `HandlerFunc`'s doc comment no longer advertises itself for path/query parameters — those go
through `Bind` now; it remains the escape hatch for genuinely custom responses (streaming,
file downloads, non-JSON). `Handle`, `HandleNoBody`, `HandleEmpty` and `HandlerFunc` are
behaviourally unchanged.
- Bumped `contracts`, `core` to v1.6.0.
## [1.5.0] — 2026-08-09
Minor — configurable success status on the httputil handler adapters.
### Added
- **`httputil.WithStatus(code int) Option`** and variadic `opts ...Option` on `Handle`,
`HandleNoBody` and `HandleEmpty`. Override the success status — e.g. `WithStatus(201)` on a
resource-creating POST, `WithStatus(202)` on an async `HandleEmpty`. Non-breaking: existing
calls keep their defaults (200 / 200 / 204).
### Changed
- Bumped `contracts`, `core` to v1.5.0.
### Notes
- `WithStatus` is success-only: the adapters own only the happy path, so the code must be 2xx.
A non-2xx code panics at wiring (the service fails to boot) rather than emitting a wrong
status at runtime. Error status stays separate — resolved from the returned xerror by `Error`.
## [1.4.0] — 2026-08-09
Minor — resolvable request IDs, plus a dependency refresh.
### Added
- **`mw.RequestIDFrom(resolve func(*http.Request) string)`** — the resolver sees the request,
so a service can continue a correlation ID a client already sent (a distributed trace survives
this boundary). The framework provides plumbing only: it does not read a header, choose a header
name, or validate the value — that policy is the application's, because a value the framework
accepts on a service's behalf may be one that service cannot store. Generation becomes the
fallback branch of resolution rather than a separate mode.
### Changed
- `mw.RequestID(generator func() string)` is unchanged in signature and behaviour (always
generates, ignores inbound); it is now expressed as `RequestIDFrom` with a request-ignoring resolver.
- Refreshed dependencies (`go-chi/chi/v5` v5.3.1, `golang.org/x/time` v0.15.0).
- Bumped `contracts`, `core` to v1.4.0.
### Notes
- An empty resolver result attaches no ID (header omitted, context carries none) rather than a
silently-empty value; a resolver that can return "" is a caller error.
## [1.3.0] — 2026-08-08 ## [1.3.0] — 2026-08-08
Minor release carrying a **breaking API change** to CORS configuration. The framework is Minor release carrying a **breaking API change** to CORS configuration. The framework is
+80 -8
View File
@@ -1,6 +1,6 @@
# einherjar/web # einherjar/web
[![version](https://img.shields.io/badge/version-v1.3.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) [![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) [![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white)](https://go.dev)
@@ -150,31 +150,103 @@ type CreateUserRes struct {
v := valid.New() v := valid.New()
// POST /users — decode body → validate → call service → encode response // POST /users — decode → validate → call → encode. WithStatus makes it 201 Created.
srv.Post("/users", httputil.Handle(v, func(ctx context.Context, req CreateUserReq) (CreateUserRes, error) { srv.Post("/users", httputil.Handle(v, logger, func(ctx context.Context, req CreateUserReq) (CreateUserRes, error) {
id, err := userService.Create(ctx, req.Email, req.Name) id, err := userService.Create(ctx, req.Email, req.Name)
if err != nil { if err != nil {
return CreateUserRes{}, err return CreateUserRes{}, err
} }
return CreateUserRes{ID: id}, nil return CreateUserRes{ID: id}, nil
})) }, httputil.WithStatus(http.StatusCreated)))
// GET /users/{id} — no request body // GET /users/{id} — no request body (defaults to 200)
srv.Get("/users/{id}", httputil.HandleNoBody(func(ctx context.Context) (CreateUserRes, error) { srv.Get("/users/{id}", httputil.HandleNoBody(logger, func(ctx context.Context) (CreateUserRes, error) {
// ... // ...
})) }))
// DELETE /users/{id} — no response body // DELETE /users/{id} — no response body (defaults to 204)
srv.Delete("/users/{id}", httputil.HandleEmpty(v, func(ctx context.Context, req DeleteReq) error { srv.Delete("/users/{id}", httputil.HandleEmpty(v, logger, func(ctx context.Context, req DeleteReq) error {
return userService.Delete(ctx, req.ID) return userService.Delete(ctx, req.ID)
})) }))
``` ```
**Success status** defaults to 200 for the body-returning adapters and 204 for `HandleEmpty`;
override it with `httputil.WithStatus(code)` — e.g. `WithStatus(http.StatusCreated)` on a POST, or
`WithStatus(http.StatusAccepted)` for an async `HandleEmpty`. The code must be 2xx (these adapters
own only the success path); a non-2xx code panics at wiring, so the service fails to start rather
than emit a wrong status at runtime. **Error status is separate** — return the right `*xerrors.Err`
and `Error` maps it (full 16-code table below).
Validation failures return 400 with a structured JSON error. All `*xerrors.Err` Validation failures return 400 with a structured JSON error. All `*xerrors.Err`
values are mapped to their canonical HTTP status codes (full 16-code table below). 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 ### Health endpoint
```go ```go
+109
View File
@@ -0,0 +1,109 @@
# 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 consumer (`kch-core-svc`):
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 — which `kch-core-svc`'s own ADR-007 forbids. 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.
+9
View File
@@ -9,6 +9,12 @@ No module-level ADRs for v1.0.0 — all design decisions were consistent with
existing framework principles (ADR-001 through ADR-003 from `core`, framework existing framework principles (ADR-001 through ADR-003 from `core`, framework
ADRs 001004 from `docs`). No contested choices required a record. ADRs 001004 from `docs`). No contested choices required a record.
Module ADRs:
| ADR | Title | Shipped |
|---|---|---|
| [ADR-001](ADR-001-request-binding.md) | `httputil` request binding — `Bind` / `BindEmpty` | v1.6.0 |
Decisions worth noting (not ADR-worthy individually): Decisions worth noting (not ADR-worthy individually):
| Decision | Outcome | Rationale | | Decision | Outcome | Rationale |
@@ -19,3 +25,6 @@ Decisions worth noting (not ADR-worthy individually):
| UUID v7 for request IDs | v7 with v4 fallback | Time-ordered IDs sort chronologically in logs; fallback ensures generation never fails | | UUID v7 for request IDs | v7 with v4 fallback | Time-ordered IDs sort chronologically in logs; fallback ensures generation never fails |
| `observability.Checkable` not redefined | Imported from contracts | Starters implement contracts directly; no web import needed by db/cache starters | | `observability.Checkable` not redefined | Imported from contracts | Starters implement contracts directly; no web import needed by db/cache starters |
| Background goroutine for in-memory eviction | `time.Ticker` goroutine | Avoids `worker` module dependency; in-memory store is self-contained | | Background goroutine for in-memory eviction | `time.Ticker` goroutine | Avoids `worker` module dependency; in-memory store is self-contained |
| `mw.RequestIDFrom` resolver sees the request (v1.4.0) | New entry point takes `func(*http.Request) string`; `RequestID` becomes a request-ignoring wrapper over it | An inbound correlation id must be able to survive this boundary, but acceptability is per-service — a typed audit column rejects what an opaque log accepts. The framework provides plumbing only (context + header, once per request); the app owns policy (which header, validation, generation fallback). An empty resolver result attaches nothing rather than a silently-empty value |
| `httputil` success status is configurable (v1.5.0) | `Handle`/`HandleNoBody`/`HandleEmpty` take `opts ...Option`; `WithStatus(code)` overrides the default (200 / 200 / 204) | 201 Created / 202 Accepted are common and were only reachable by hand-rolling the handler (losing decode+validate+error-mapping). Variadic options are non-breaking and future-extensible (headers, etc.). `WithStatus` is success-only (2xx) and panics at wiring on a non-2xx code — a wrong status is a routing mistake that should fail to boot, not surface at runtime; error status stays separate, resolved from the xerror by `Error` |
| `httputil` request binding (v1.6.0) | `Bind`/`BindEmpty` fill `Req` from `path:`/`query:`/`json:` tags and validate once; see [ADR-001](ADR-001-request-binding.md) | Path/query were only reachable via `HandlerFunc`, the one route to production with no validation (unvalidated bounds; client mistakes as 500s). `encoding.TextUnmarshaler` is the whole extensibility story (uuid/time, no new dep). Mis-tagged struct panics at wiring. Purely additive |
+10 -10
View File
@@ -3,20 +3,20 @@ module code.nochebuena.dev/einherjar/web
go 1.26 go 1.26
require ( require (
code.nochebuena.dev/einherjar/contracts v1.3.0 code.nochebuena.dev/einherjar/contracts v1.6.0
code.nochebuena.dev/einherjar/core v1.3.0 code.nochebuena.dev/einherjar/core v1.6.0
github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/chi/v5 v5.3.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
golang.org/x/time v0.11.0 golang.org/x/time v0.15.0
) )
require ( require (
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-playground/validator/v10 v10.30.3 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.5.0 // indirect
golang.org/x/crypto v0.46.0 // indirect golang.org/x/crypto v0.55.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.41.0 // indirect
) )
+20 -20
View File
@@ -1,36 +1,36 @@
code.nochebuena.dev/einherjar/contracts v1.3.0 h1:rm5hqaA1NBtWgH8okwwt6WLoIne1SwQ1Ogi7qbbwfY8= code.nochebuena.dev/einherjar/contracts v1.6.0 h1:Y+8B+m4kQR5l/6lMY7SYdvIbwhFOnliCXDv9oBCOUP4=
code.nochebuena.dev/einherjar/contracts v1.3.0/go.mod h1:ccltUtrFb5+MEJdkx2VVEUL+xC5pupVlVVsMM8AlCWI= code.nochebuena.dev/einherjar/contracts v1.6.0/go.mod h1:ccltUtrFb5+MEJdkx2VVEUL+xC5pupVlVVsMM8AlCWI=
code.nochebuena.dev/einherjar/core v1.3.0 h1:LRT8gln+KJLLGzySVf3C0WX/qiufxg5Jp5v2jySBirE= code.nochebuena.dev/einherjar/core v1.6.0 h1:6cQIYZliw0hcuk7Cy44swleLC1Ch8WFxTkkHsGxkAFk=
code.nochebuena.dev/einherjar/core v1.3.0/go.mod h1:2Pdbb3Pni8dYBZKOpQKqzpR/WFq+Ln9+KSPycf7DQh0= code.nochebuena.dev/einherjar/core v1.6.0/go.mod h1:azRKvJBtMWGp8jhveG1fZc9jlPTwXu8clvBdXIjTB9k=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+325
View File
@@ -0,0 +1,325 @@
package httputil
import (
"context"
"encoding"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"reflect"
"strconv"
"sync"
"github.com/go-chi/chi/v5"
"code.nochebuena.dev/einherjar/contracts/logging"
"code.nochebuena.dev/einherjar/core/valid"
"code.nochebuena.dev/einherjar/core/xerrors"
)
// Bind adapts a typed business function whose request is assembled from more than
// the JSON body. Each field of Req declares its source with a struct tag:
//
// type updateRoleRequest struct {
// RoleID uuid.UUID `path:"roleID" validate:"required"`
// Page int `query:"page" default:"1" validate:"min=1"`
// Name string `json:"name" validate:"omitempty,max=200"`
// }
//
// - path: fills from a chi route parameter (r.Context()).
// - query: fills from the URL query string; a repeated parameter binds to a slice.
// - json: fills from the JSON body (standard encoding/json).
//
// It then validates the assembled struct once with v and calls fn — the handler
// signature is identical to [Handle]. On success Res is encoded as JSON (200 by
// default, or the [WithStatus] code). On error it flows through [Error].
//
// 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). A value that fails to convert is
// reported as [xerrors.ErrInvalidInput] naming the parameter — a 400, never a 500.
//
// The struct is reflected over once per type at wiring time and the result cached.
// A field declaring more than one source tag, an unsupported field type, or a
// default: that is not a valid value for its field all panic at wiring, so a
// mis-tagged struct fails the service at boot rather than on a request.
func Bind[Req, Res any](v valid.Validator, logger logging.Logger, fn func(ctx context.Context, req Req) (Res, error), opts ...Option) http.HandlerFunc {
status := resolveStatus(http.StatusOK, opts)
plan := planFor(reflect.TypeOf((*Req)(nil)).Elem())
return func(w http.ResponseWriter, r *http.Request) {
var req Req
if err := bindRequest(plan, v, r, &req); err != nil {
Error(logger, w, r, err)
return
}
res, err := fn(r.Context(), req)
if err != nil {
Error(logger, w, r, err)
return
}
JSON(w, status, res)
}
}
// BindEmpty is [Bind] for a function that returns no response body. Req is filled
// from path, query and body exactly as in Bind; on success a body-less status is
// written (204 by default, or the [WithStatus] code).
//
// Unlike [HandleEmpty] it does not require a request body: a bodiless request
// (GET, DELETE, Content-Length: 0) is not an error, so a DELETE /resource/{id}
// with a path: tag binds directly instead of failing on io.EOF.
func BindEmpty[Req any](v valid.Validator, logger logging.Logger, fn func(ctx context.Context, req Req) error, opts ...Option) http.HandlerFunc {
status := resolveStatus(http.StatusNoContent, opts)
plan := planFor(reflect.TypeOf((*Req)(nil)).Elem())
return func(w http.ResponseWriter, r *http.Request) {
var req Req
if err := bindRequest(plan, v, r, &req); err != nil {
Error(logger, w, r, err)
return
}
if err := fn(r.Context(), req); err != nil {
Error(logger, w, r, err)
return
}
w.WriteHeader(status)
}
}
// bindRequest decodes the body (when present), overlays path/query fields, and
// validates the assembled struct once. dst must be a pointer to Req.
func bindRequest(plan *bindPlan, v valid.Validator, r *http.Request, dst any) error {
// A body is optional: an empty body decodes to io.EOF, which we treat as
// "no body" rather than an error (retires the HandleEmpty bodiless trap).
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(dst); err != nil && !errors.Is(err, io.EOF) {
return xerrors.New(xerrors.ErrInvalidInput, "invalid JSON: "+err.Error())
}
}
if err := plan.apply(r, reflect.ValueOf(dst).Elem()); err != nil {
return err
}
if err := v.Struct(reflect.ValueOf(dst).Elem().Interface()); err != nil {
return err
}
return nil
}
// --- binding plan (reflected once per type, cached) ---
type sourceKind uint8
const (
sourcePath sourceKind = iota
sourceQuery
)
// fieldBind describes how one path/query field is filled. json/untagged fields
// are handled by the body decoder and never appear here.
type fieldBind struct {
index int
name string
source sourceKind
isSlice bool
hasDefault bool
defaultVal string
}
type bindPlan struct {
fields []fieldBind
}
var planCache sync.Map // reflect.Type -> *bindPlan
// planFor returns the cached plan for t, building (and validating) it once. It
// panics on a mis-tagged struct, so callers reach it at wiring time and the
// service fails to boot rather than at request time.
func planFor(t reflect.Type) *bindPlan {
if cached, ok := planCache.Load(t); ok {
return cached.(*bindPlan)
}
p := buildPlan(t)
actual, _ := planCache.LoadOrStore(t, p)
return actual.(*bindPlan)
}
func buildPlan(t reflect.Type) *bindPlan {
if t.Kind() != reflect.Struct {
panic(fmt.Sprintf("httputil.Bind: Req must be a struct, got %s", t))
}
p := &bindPlan{}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
pathTag, hasPath := f.Tag.Lookup("path")
queryTag, hasQuery := f.Tag.Lookup("query")
_, hasJSON := f.Tag.Lookup("json")
n := 0
for _, ok := range []bool{hasPath, hasQuery, hasJSON} {
if ok {
n++
}
}
if n > 1 {
panic(fmt.Sprintf("httputil.Bind: field %s.%s declares more than one source tag (path/query/json); a field binds from exactly one channel", t.Name(), f.Name))
}
if !hasPath && !hasQuery {
continue // json or untagged — the body decoder owns it
}
fb := fieldBind{index: i}
if hasPath {
fb.source, fb.name = sourcePath, pathTag
} else {
fb.source, fb.name = sourceQuery, queryTag
}
ft := f.Type
fb.isSlice = ft.Kind() == reflect.Slice && !implementsTextUnmarshaler(ft)
if fb.isSlice && fb.source == sourcePath {
panic(fmt.Sprintf("httputil.Bind: field %s.%s is a path parameter and cannot be a slice", t.Name(), f.Name))
}
elem := ft
if fb.isSlice {
elem = ft.Elem()
}
if !convertible(elem) {
panic(fmt.Sprintf("httputil.Bind: field %s.%s has unsupported type %s (want string, integer, float, bool, or encoding.TextUnmarshaler)", t.Name(), f.Name, ft))
}
if dv, ok := f.Tag.Lookup("default"); ok {
fb.hasDefault, fb.defaultVal = true, dv
// A default that cannot convert is a wiring mistake — fail at boot.
if err := setScalar(reflect.New(elem).Elem(), dv); err != nil {
panic(fmt.Sprintf("httputil.Bind: field %s.%s default %q is not a valid %s: %v", t.Name(), f.Name, dv, elem, err))
}
}
p.fields = append(p.fields, fb)
}
return p
}
// apply overlays the path/query fields onto an already body-decoded struct value.
func (p *bindPlan) apply(r *http.Request, sv reflect.Value) error {
var query map[string][]string
for _, fb := range p.fields {
var raw []string
present := false
switch fb.source {
case sourcePath:
if v := chi.URLParamFromCtx(r.Context(), fb.name); v != "" {
raw, present = []string{v}, true
}
case sourceQuery:
if query == nil {
query = r.URL.Query()
}
if vs, ok := query[fb.name]; ok {
raw, present = vs, true
}
}
field := sv.Field(fb.index)
if !present {
// Absent: apply the default if declared, else leave the zero value.
// A present-but-empty value (?q=) is *not* absent and skips this.
if fb.hasDefault {
if err := setScalar(field, fb.defaultVal); err != nil {
return xerrors.New(xerrors.ErrInvalidInput, fmt.Sprintf("invalid %s: %v", fb.name, err))
}
}
continue
}
if fb.isSlice {
slice := reflect.MakeSlice(field.Type(), len(raw), len(raw))
for i, s := range raw {
if err := setScalar(slice.Index(i), s); err != nil {
return xerrors.New(xerrors.ErrInvalidInput, fmt.Sprintf("invalid %s: %v", fb.name, err))
}
}
field.Set(slice)
continue
}
if err := setScalar(field, raw[0]); err != nil {
return xerrors.New(xerrors.ErrInvalidInput, fmt.Sprintf("invalid %s: %v", fb.name, err))
}
}
return nil
}
// --- conversion ---
var textUnmarshalerType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem()
func implementsTextUnmarshaler(t reflect.Type) bool {
return reflect.PointerTo(t).Implements(textUnmarshalerType)
}
// convertible reports whether a single value of type t can be set from a string.
func convertible(t reflect.Type) bool {
if implementsTextUnmarshaler(t) {
return true
}
switch t.Kind() {
case reflect.String,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64,
reflect.Bool:
return true
default:
return false
}
}
// setScalar sets one addressable value from its string form. TextUnmarshaler is
// preferred so uuid.UUID / time.Time bind through their own parsing.
func setScalar(field reflect.Value, s string) error {
if field.CanAddr() {
if u, ok := field.Addr().Interface().(encoding.TextUnmarshaler); ok {
return u.UnmarshalText([]byte(s))
}
}
switch field.Kind() {
case reflect.String:
field.SetString(s)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
n, err := strconv.ParseInt(s, 10, field.Type().Bits())
if err != nil {
return errNumeric(s, "integer")
}
field.SetInt(n)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
n, err := strconv.ParseUint(s, 10, field.Type().Bits())
if err != nil {
return errNumeric(s, "unsigned integer")
}
field.SetUint(n)
case reflect.Float32, reflect.Float64:
n, err := strconv.ParseFloat(s, field.Type().Bits())
if err != nil {
return errNumeric(s, "number")
}
field.SetFloat(n)
case reflect.Bool:
b, err := strconv.ParseBool(s)
if err != nil {
return fmt.Errorf("%q is not a boolean", s)
}
field.SetBool(b)
default:
// Unreachable: buildPlan rejects unsupported types at wiring time.
return fmt.Errorf("unsupported type %s", field.Type())
}
return nil
}
func errNumeric(s, kind string) error {
return fmt.Errorf("%q is not a valid %s", s, kind)
}
+343
View File
@@ -0,0 +1,343 @@
package httputil
import (
"context"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"code.nochebuena.dev/einherjar/core/valid"
)
// withPath attaches a chi route context carrying the given key/value path params,
// mirroring what the router injects before a handler runs.
func withPath(r *http.Request, kv ...string) *http.Request {
rctx := chi.NewRouteContext()
for i := 0; i+1 < len(kv); i += 2 {
rctx.URLParams.Add(kv[i], kv[i+1])
}
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
// AC1 — Bind fills path, query and json fields on one struct.
func TestBind_FillsAllThreeSources(t *testing.T) {
type req struct {
RoleID string `path:"roleID"`
Page int `query:"page"`
Name string `json:"name"`
}
var got req
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
got = r
return tRes{ID: r.RoleID}, nil
})
rec := httptest.NewRecorder()
r := withPath(httptest.NewRequest(http.MethodPatch, "/roles/abc?page=7", strings.NewReader(`{"name":"turno"}`)), "roleID", "abc")
h(rec, r)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if got.RoleID != "abc" || got.Page != 7 || got.Name != "turno" {
t.Fatalf("bound = %+v, want {abc 7 turno}", got)
}
}
// AC1 + AC3 — BindEmpty writes a body-less success and needs no request body.
func TestBindEmpty_PathOnly_NoBody(t *testing.T) {
type req struct {
RoleID string `path:"roleID"`
}
called := ""
h := BindEmpty(valid.New(), discardLogger(), func(_ context.Context, r req) error {
called = r.RoleID
return nil
})
rec := httptest.NewRecorder()
// DELETE with a nil body — the HandleEmpty io.EOF trap must not fire.
h(rec, withPath(httptest.NewRequest(http.MethodDelete, "/roles/xyz", nil), "roleID", "xyz"))
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
if rec.Body.Len() != 0 {
t.Errorf("expected empty body, got %q", rec.Body.String())
}
if called != "xyz" {
t.Errorf("path not bound: got %q", called)
}
}
// AC3 — a bodiless GET succeeds through Bind (query only, no io.EOF).
func TestBind_NoBody_QueryOnly(t *testing.T) {
type req struct {
Q string `query:"q"`
}
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
return tRes{ID: r.Q}, nil
})
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/roles?q=hola", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if got := strings.TrimSpace(rec.Body.String()); got != `{"id":"hola"}` {
t.Errorf("body = %q", got)
}
}
// AC2 — a field with two source tags fails at wiring (Bind panics at registration).
func TestBind_TwoSourceTags_PanicsAtWiring(t *testing.T) {
type req struct {
Bad string `path:"id" query:"id"`
}
defer func() {
if recover() == nil {
t.Fatal("Bind did not panic on a two-source-tag field")
}
}()
_ = Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) {
return tRes{}, nil
})
}
// AC2 (companion) — an unsupported field type and a bad default also fail at wiring.
func TestBind_UnsupportedType_PanicsAtWiring(t *testing.T) {
type req struct {
Ch chan int `query:"ch"`
}
defer func() {
if recover() == nil {
t.Fatal("Bind did not panic on an unsupported field type")
}
}()
_ = Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) { return tRes{}, nil })
}
func TestBind_BadDefault_PanicsAtWiring(t *testing.T) {
type req struct {
Page int `query:"page" default:"not-a-number"`
}
defer func() {
if recover() == nil {
t.Fatal("Bind did not panic on an invalid default tag")
}
}()
_ = Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) { return tRes{}, nil })
}
// AC4 — uuid.UUID and time.Time bind from path and query via TextUnmarshaler.
func TestBind_TextUnmarshaler_UUIDAndTime(t *testing.T) {
type req struct {
ID uuid.UUID `path:"id"`
From time.Time `query:"from"`
}
id := uuid.New()
var got req
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
got = r
return tRes{ID: r.ID.String()}, nil
})
rec := httptest.NewRecorder()
r := withPath(httptest.NewRequest(http.MethodGet, "/x/"+id.String()+"?from=2026-01-02T03:04:05Z", nil), "id", id.String())
h(rec, r)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if got.ID != id {
t.Errorf("uuid = %s, want %s", got.ID, id)
}
if !got.From.Equal(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) {
t.Errorf("time = %s, want 2026-01-02T03:04:05Z", got.From)
}
}
// AC5 — a malformed value answers 400 and names the parameter, never 500.
func TestBind_MalformedParam_400WithName(t *testing.T) {
type req struct {
Page int `query:"page"`
}
h := Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) {
return tRes{}, nil
})
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/roles?page=abc", nil))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if !strings.Contains(rec.Body.String(), "page") {
t.Errorf("error body %q does not name the parameter", rec.Body.String())
}
}
// AC4/AC5 — a malformed uuid path parameter is also a 400, not a 500.
func TestBind_MalformedUUID_400(t *testing.T) {
type req struct {
ID uuid.UUID `path:"id"`
}
h := Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) {
return tRes{}, nil
})
rec := httptest.NewRecorder()
h(rec, withPath(httptest.NewRequest(http.MethodGet, "/x/nope", nil), "id", "nope"))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
// AC6 — default applies when the parameter is absent, and NOT when present-and-empty.
func TestBind_Default_AbsentOnly(t *testing.T) {
type req struct {
Page int `query:"page" default:"1"`
Q string `query:"q" default:"all"`
}
// Absent → defaults applied.
var absent req
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
absent = r
return tRes{}, nil
})
h(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/roles", nil))
if absent.Page != 1 || absent.Q != "all" {
t.Fatalf("absent defaults = %+v, want {1 all}", absent)
}
// Present-but-empty (?q=) → the caller is clearing the filter; default must NOT win.
var present req
h2 := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
present = r
return tRes{}, nil
})
rec := httptest.NewRecorder()
h2(rec, httptest.NewRequest(http.MethodGet, "/roles?q=", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if present.Q != "" {
t.Errorf("present-empty q = %q, want \"\" (default must not override)", present.Q)
}
}
// AC7 — repeated query parameters bind to a slice; a comma inside a scalar survives.
func TestBind_RepeatedQuery_Slice(t *testing.T) {
type req struct {
Kind []string `query:"kind"`
Q string `query:"q"`
}
var got req
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
got = r
return tRes{}, nil
})
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/x?kind=POS&kind=KDS&q=a,b,c", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if len(got.Kind) != 2 || got.Kind[0] != "POS" || got.Kind[1] != "KDS" {
t.Errorf("kind = %v, want [POS KDS]", got.Kind)
}
if got.Q != "a,b,c" {
t.Errorf("q = %q, want verbatim a,b,c (no comma splitting)", got.Q)
}
}
// AC7 (companion) — a typed slice ([]uuid.UUID) binds each repeated value.
func TestBind_RepeatedQuery_TypedSlice(t *testing.T) {
type req struct {
IDs []uuid.UUID `query:"id"`
}
a, b := uuid.New(), uuid.New()
var got req
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
got = r
return tRes{}, nil
})
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/x?id="+a.String()+"&id="+b.String(), nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
}
if len(got.IDs) != 2 || got.IDs[0] != a || got.IDs[1] != b {
t.Errorf("ids = %v, want [%s %s]", got.IDs, a, b)
}
}
// WithStatus composes with Bind exactly as with Handle (201 on create).
func TestBind_WithStatus(t *testing.T) {
type req struct {
Name string `json:"name" validate:"required"`
}
h := Bind(valid.New(), discardLogger(), func(_ context.Context, r req) (tRes, error) {
return tRes{ID: r.Name}, nil
}, WithStatus(http.StatusCreated))
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodPost, "/roles", strings.NewReader(`{"name":"cajero"}`)))
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201", rec.Code)
}
}
// Validation runs on the assembled struct — a query bound value is validated too.
func TestBind_ValidatesAssembledStruct(t *testing.T) {
type req struct {
PerPage int `query:"per_page" default:"50" validate:"min=1,max=200"`
}
h := Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) {
return tRes{}, nil
})
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/roles?per_page=99999", nil))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (per_page over max should fail validation)", rec.Code)
}
}
// AC9 — the per-type plan is reflected once and cached (same pointer across calls).
func TestBind_PlanCachedPerType(t *testing.T) {
type req struct {
A string `query:"a"`
B int `query:"b"`
}
rt := reflect.TypeOf((*req)(nil)).Elem()
if planFor(rt) != planFor(rt) {
t.Fatal("planFor returned a different plan for the same type — cache not effective")
}
}
// AC9 — per-request work does not re-reflect the type; the plan metadata is
// parsed once and reused. Run with -benchmem to see allocations stay flat
// regardless of how many tagged fields the struct declares.
func BenchmarkBind_ManyFields(b *testing.B) {
type req struct {
ID uuid.UUID `path:"id"`
Page int `query:"page" default:"1"`
PerPage int `query:"per_page" default:"50"`
Q string `query:"q"`
Sort string `query:"sort" default:"name"`
Order string `query:"order" default:"asc"`
Kind []string `query:"kind"`
}
id := uuid.New()
h := Bind(valid.New(), discardLogger(), func(_ context.Context, _ req) (tRes, error) {
return tRes{}, nil
})
target := "/x/" + id.String() + "?page=2&per_page=25&q=turno&sort=name&order=desc&kind=POS&kind=KDS"
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
rec := httptest.NewRecorder()
h(rec, withPath(httptest.NewRequest(http.MethodGet, target, nil), "id", id.String()))
}
}
+26 -6
View File
@@ -22,6 +22,26 @@
// return CreateUserRes{ID: u.ID}, nil // return CreateUserRes{ID: u.ID}, nil
// })) // }))
// //
// # Request binding
//
// [Bind] and [BindEmpty] extend the same decode → validate → call → encode pipeline
// to requests that carry more than a JSON body. Each field declares its source with
// a struct tag — path:, query: or json: — and the assembled struct is validated once:
//
// type getRoleReq struct {
// RoleID uuid.UUID `path:"roleID" validate:"required"`
// Expand []string `query:"expand"`
// }
//
// r.Get("/roles/{roleID}", httputil.Bind(v, logger, func(ctx context.Context, req getRoleReq) (RoleRes, error) {
// return svc.GetRole(ctx, req.RoleID)
// }))
//
// A malformed value is a 400 naming the parameter (never a 500), uuid.UUID and
// time.Time bind via [encoding.TextUnmarshaler], and a mis-tagged struct fails at
// wiring rather than on a request. Use these instead of [HandlerFunc] for any route
// with an identifier or a filter.
//
// # Centralized error handler // # Centralized error handler
// //
// [Error] is the single point of error processing for all handlers: // [Error] is the single point of error processing for all handlers:
@@ -29,16 +49,16 @@
// - 4xx → Warn level (client mistake — not a server failure) // - 4xx → Warn level (client mistake — not a server failure)
// - 499 → Info level (client cancelled the request intentionally) // - 499 → Info level (client cancelled the request intentionally)
// //
// Call it directly from [HandlerFunc] when you need path parameters or custom logic: // Call it directly from [HandlerFunc] for genuinely custom responses — streaming,
// file downloads, non-JSON content types:
// //
// r.Get("/users/{id}", httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { // r.Get("/reports/{id}.csv", httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
// id := chi.URLParam(r, "id") // rows, err := svc.Export(r.Context(), chi.URLParam(r, "id"))
// u, err := svc.GetUser(r.Context(), id)
// if err != nil { // if err != nil {
// httputil.Error(logger, w, r, err) // httputil.Error(logger, w, r, err)
// return nil // return nil
// } // }
// httputil.JSON(w, http.StatusOK, u) // w.Header().Set("Content-Type", "text/csv")
// return nil // return csv.NewWriter(w).WriteAll(rows)
// }).ServeHTTP) // }).ServeHTTP)
package httputil package httputil
+16 -9
View File
@@ -14,9 +14,11 @@ import (
// - Decodes the JSON request body into Req. // - Decodes the JSON request body into Req.
// - Validates Req using the provided [valid.Validator]. // - Validates Req using the provided [valid.Validator].
// - Calls fn with the request context and decoded Req. // - Calls fn with the request context and decoded Req.
// - Encodes Res as JSON with HTTP 200 on success. // - Encodes Res as JSON on success — HTTP 200 by default, or the code given via
// [WithStatus] (e.g. WithStatus(http.StatusCreated) for a resource-creating POST).
// - On error: logs via [Error] (level derived from HTTP status) and writes the standardized JSON body. // - On error: logs via [Error] (level derived from HTTP status) and writes the standardized JSON body.
func Handle[Req, Res any](v valid.Validator, logger logging.Logger, fn func(ctx context.Context, req Req) (Res, error)) http.HandlerFunc { func Handle[Req, Res any](v valid.Validator, logger logging.Logger, fn func(ctx context.Context, req Req) (Res, error), opts ...Option) http.HandlerFunc {
status := resolveStatus(http.StatusOK, opts)
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var req Req var req Req
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -32,28 +34,33 @@ func Handle[Req, Res any](v valid.Validator, logger logging.Logger, fn func(ctx
Error(logger, w, r, err) Error(logger, w, r, err)
return return
} }
JSON(w, http.StatusOK, res) JSON(w, status, res)
} }
} }
// HandleNoBody adapts a typed function with no request body (GET, HEAD). // HandleNoBody adapts a typed function with no request body (GET, HEAD).
// Calls fn with the request context; encodes the result as JSON with HTTP 200. // Calls fn with the request context; encodes the result as JSON HTTP 200 by
// default, or the code given via [WithStatus].
// On error: logs via [Error] and writes the standardized JSON body. // On error: logs via [Error] and writes the standardized JSON body.
func HandleNoBody[Res any](logger logging.Logger, fn func(ctx context.Context) (Res, error)) http.HandlerFunc { func HandleNoBody[Res any](logger logging.Logger, fn func(ctx context.Context) (Res, error), opts ...Option) http.HandlerFunc {
status := resolveStatus(http.StatusOK, opts)
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
res, err := fn(r.Context()) res, err := fn(r.Context())
if err != nil { if err != nil {
Error(logger, w, r, err) Error(logger, w, r, err)
return return
} }
JSON(w, http.StatusOK, res) JSON(w, status, res)
} }
} }
// HandleEmpty adapts a typed function with a request body but no response body. // HandleEmpty adapts a typed function with a request body but no response body.
// Decodes and validates Req, calls fn, returns 204 No Content on success. // Decodes and validates Req, calls fn, and writes a body-less success — 204 No
// Content by default, or the code given via [WithStatus] (e.g.
// WithStatus(http.StatusAccepted) for async processing).
// On error: logs via [Error] and writes the standardized JSON body. // On error: logs via [Error] and writes the standardized JSON body.
func HandleEmpty[Req any](v valid.Validator, logger logging.Logger, fn func(ctx context.Context, req Req) error) http.HandlerFunc { func HandleEmpty[Req any](v valid.Validator, logger logging.Logger, fn func(ctx context.Context, req Req) error, opts ...Option) http.HandlerFunc {
status := resolveStatus(http.StatusNoContent, opts)
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var req Req var req Req
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -68,6 +75,6 @@ func HandleEmpty[Req any](v valid.Validator, logger logging.Logger, fn func(ctx
Error(logger, w, r, err) Error(logger, w, r, err)
return return
} }
NoContent(w) w.WriteHeader(status)
} }
} }
+118
View File
@@ -0,0 +1,118 @@
package httputil
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"code.nochebuena.dev/einherjar/contracts/logging"
"code.nochebuena.dev/einherjar/core/logz"
"code.nochebuena.dev/einherjar/core/valid"
)
type tReq struct {
Name string `json:"name" validate:"required"`
}
type tRes struct {
ID string `json:"id"`
}
func discardLogger() logging.Logger { return logz.New(logz.Config{Writer: io.Discard}) }
// Default: Handle writes 200 with the JSON payload (regression — no opts).
func TestHandle_Default200(t *testing.T) {
h := Handle(valid.New(), discardLogger(), func(context.Context, tReq) (tRes, error) {
return tRes{ID: "x"}, nil
})
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"a"}`)))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := strings.TrimSpace(rec.Body.String()); got != `{"id":"x"}` {
t.Errorf("body = %q, want {\"id\":\"x\"}", got)
}
}
// WithStatus(201) makes a resource-creating Handle return 201 Created + the body.
func TestHandle_WithStatusCreated(t *testing.T) {
h := Handle(valid.New(), discardLogger(), func(context.Context, tReq) (tRes, error) {
return tRes{ID: "x"}, nil
}, WithStatus(http.StatusCreated))
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"a"}`)))
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201", rec.Code)
}
if got := strings.TrimSpace(rec.Body.String()); got != `{"id":"x"}` {
t.Errorf("body = %q, want the payload", got)
}
}
func TestHandleNoBody_WithStatus(t *testing.T) {
h := HandleNoBody(discardLogger(), func(context.Context) (tRes, error) {
return tRes{ID: "y"}, nil
}, WithStatus(http.StatusAccepted))
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202", rec.Code)
}
}
// Default: HandleEmpty writes 204 with no body.
func TestHandleEmpty_Default204(t *testing.T) {
h := HandleEmpty(valid.New(), discardLogger(), func(context.Context, tReq) error { return nil })
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"a"}`)))
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
if rec.Body.Len() != 0 {
t.Errorf("expected empty body, got %q", rec.Body.String())
}
}
// WithStatus on a body-less adapter changes the code but keeps the empty body.
func TestHandleEmpty_WithStatusAccepted_NoBody(t *testing.T) {
h := HandleEmpty(valid.New(), discardLogger(), func(context.Context, tReq) error { return nil }, WithStatus(http.StatusAccepted))
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"a"}`)))
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202", rec.Code)
}
if rec.Body.Len() != 0 {
t.Errorf("expected empty body, got %q", rec.Body.String())
}
}
// A non-2xx code is a wiring mistake: WithStatus panics at construction so the
// service fails to boot rather than emitting a wrong status at request time.
func TestWithStatus_PanicsOnNon2xx(t *testing.T) {
for _, code := range []int{0, 100, 199, 300, 404, 500, 1000} {
func() {
defer func() {
if recover() == nil {
t.Errorf("WithStatus(%d) did not panic", code)
}
}()
_ = WithStatus(code)
}()
}
}
func TestWithStatus_Allows2xx(t *testing.T) {
for _, code := range []int{200, 201, 202, 204, 299} {
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("WithStatus(%d) panicked: %v", code, r)
}
}()
_ = WithStatus(code)
}()
}
}
+7 -1
View File
@@ -6,7 +6,13 @@ var _ http.Handler = HandlerFunc(nil)
// HandlerFunc is an http.Handler that returns an error. // HandlerFunc is an http.Handler that returns an error.
// On non-nil error the error is mapped to the appropriate HTTP response via [Error]. // On non-nil error the error is mapped to the appropriate HTTP response via [Error].
// Use for manual handlers that need path parameters or custom status codes. //
// Use it for genuinely custom responses — streaming, file downloads, non-JSON
// content types — where the typed adapters do not fit. It is no longer the answer
// for path or query parameters: [Bind] and [BindEmpty] fill those from struct tags
// with the same decode → validate → encode guarantees, and a custom success status
// is set with [WithStatus]. Reaching for HandlerFunc to read a parameter is the one
// path by which a handler reaches production without validation running.
type HandlerFunc func(w http.ResponseWriter, r *http.Request) error type HandlerFunc func(w http.ResponseWriter, r *http.Request) error
// ServeHTTP implements http.Handler. // ServeHTTP implements http.Handler.
+40
View File
@@ -0,0 +1,40 @@
package httputil
import "fmt"
// Option configures a Handle* adapter. With no options each adapter writes its
// default success status (200 for the body-returning adapters, 204 for
// [HandleEmpty]). Options are applied once at wiring time, not per request.
type Option func(*options)
type options struct {
status int
}
// WithStatus overrides the success status an adapter writes — e.g.
// WithStatus(http.StatusCreated) for a POST that creates a resource, or
// WithStatus(http.StatusAccepted) for an async [HandleEmpty].
//
// It exists because the Handle* adapters own only the happy path: they always
// write a success response, so the status is theirs to set, while error statuses
// are derived separately from the returned xerror by [Error]. The code must
// therefore be 2xx — anything else is a routing mistake, since an error status
// never belongs on the success path. WithStatus panics on a non-2xx code, and
// because routes are wired at startup that panic surfaces at boot: the service
// fails to start rather than emitting a wrong status at request time. (mw.Recover
// guards requests, so it does not catch a wiring-time panic — which is the point.)
func WithStatus(code int) Option {
if code < 200 || code > 299 {
panic(fmt.Sprintf("httputil.WithStatus: success status must be 2xx, got %d", code))
}
return func(o *options) { o.status = code }
}
// resolveStatus folds opts over the adapter's default success status.
func resolveStatus(def int, opts []Option) int {
o := options{status: def}
for _, opt := range opts {
opt(&o)
}
return o.status
}
+15
View File
@@ -12,6 +12,21 @@
// mw.CORS([]string{"https://example.com"}), // mw.CORS([]string{"https://example.com"}),
// ) // )
// //
// # Request IDs
//
// [RequestID] always generates a fresh ID. To continue a correlation ID a client
// already sent — so a distributed trace survives this boundary — use [RequestIDFrom]
// and read the ID off the request in your resolver. The framework does not read the
// header or validate the value: what is acceptable is per-service (a typed audit
// column rejects what an opaque log accepts), so that policy stays with the caller.
//
// mw.RequestIDFrom(func(r *http.Request) string {
// if id, err := uuid.Parse(r.Header.Get("X-Request-ID")); err == nil {
// return id.String() // continue the client's id
// }
// return uuid.NewString() // otherwise mint one
// })
//
// # Rate limiting // # Rate limiting
// //
// // In-memory (default — no extra dependencies) // // In-memory (default — no extra dependencies)
+34 -8
View File
@@ -6,17 +6,43 @@ import (
"code.nochebuena.dev/einherjar/core/logz" "code.nochebuena.dev/einherjar/core/logz"
) )
// RequestID injects a unique request ID into the context (via [logz.WithRequestID]) // RequestIDFrom injects a per-request ID into the context (via [logz.WithRequestID])
// and sets the X-Request-ID response header. // and the X-Request-ID response header, using the ID that resolve returns for the
// generator is called once per request — pass uuid.NewString or a custom function. // request.
func RequestID(generator func() string) func(http.Handler) http.Handler { //
// resolve receives the request so the application can decide the ID from it — most
// importantly, to continue a correlation ID a client already sent, so a distributed
// trace survives this boundary. The framework deliberately does not read a header,
// choose a header name, or validate the value: acceptability is per-service. A
// service that persists the ID in a typed column must reject what it cannot store
// and mint its own; a service that only logs an opaque string need not care. Reading
// the header here would accept, on a service's behalf, a value that service may be
// unable to store — so resolution is the application's to own, and generation is
// merely the fallback branch a resolver takes when there is no usable inbound ID.
//
// resolve is called exactly once per request and is expected to return a non-empty
// ID. When it returns "", no ID is attached — the response header is omitted and the
// context carries none — rather than propagating an empty value; supplying a resolver
// that can resolve to "" (e.g. one with no generation fallback) is a caller error.
func RequestIDFrom(resolve func(r *http.Request) string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := generator() if id := resolve(r); id != "" {
ctx := logz.WithRequestID(r.Context(), id) r = r.WithContext(logz.WithRequestID(r.Context(), id))
r = r.WithContext(ctx) w.Header().Set("X-Request-ID", id)
w.Header().Set("X-Request-ID", id) }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
} }
// RequestID injects a freshly generated request ID into the context (via
// [logz.WithRequestID]) and sets the X-Request-ID response header. generator is
// called once per request — pass uuid.NewString or a custom function.
//
// It always generates and ignores any inbound X-Request-ID. To continue a
// correlation ID the client supplied, use [RequestIDFrom] with a resolver that
// reads and validates it.
func RequestID(generator func() string) func(http.Handler) http.Handler {
return RequestIDFrom(func(*http.Request) string { return generator() })
}
+96
View File
@@ -0,0 +1,96 @@
package mw
import (
"net/http"
"net/http/httptest"
"testing"
"code.nochebuena.dev/einherjar/core/logz"
)
// A resolver that continues the client's X-Request-ID lands in both the context
// and the response header.
func TestRequestIDFrom_HonoursInbound(t *testing.T) {
const inbound = "client-supplied-123"
var ctxID string
h := RequestIDFrom(func(r *http.Request) string {
return r.Header.Get("X-Request-ID")
})(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
ctxID = logz.GetRequestID(r.Context())
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Request-ID", inbound)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if ctxID != inbound {
t.Errorf("context request id = %q, want %q", ctxID, inbound)
}
if got := rec.Header().Get("X-Request-ID"); got != inbound {
t.Errorf("response header = %q, want %q", got, inbound)
}
}
// Deliberate regression: RequestID(gen) always generates and never honours an
// inbound X-Request-ID. Callers that want to continue a client id use RequestIDFrom.
func TestRequestID_AlwaysGenerates_IgnoresInbound(t *testing.T) {
const inbound = "client-supplied-123"
const generated = "generated-999"
var ctxID string
h := RequestID(func() string { return generated })(
http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
ctxID = logz.GetRequestID(r.Context())
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Request-ID", inbound)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if ctxID != generated {
t.Errorf("context request id = %q, want generated %q (inbound must be ignored)", ctxID, generated)
}
if got := rec.Header().Get("X-Request-ID"); got != generated {
t.Errorf("response header = %q, want %q", got, generated)
}
}
// The resolver runs exactly once per request.
func TestRequestIDFrom_ResolverCalledOnce(t *testing.T) {
calls := 0
h := RequestIDFrom(func(*http.Request) string {
calls++
return "id"
})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
if calls != 1 {
t.Errorf("resolver called %d times, want 1", calls)
}
}
// An empty resolver result attaches nothing: no context id and no response header,
// rather than a silently-empty value.
func TestRequestIDFrom_EmptyResult_AttachesNothing(t *testing.T) {
var ctxID string
h := RequestIDFrom(func(*http.Request) string { return "" })(
http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
ctxID = logz.GetRequestID(r.Context())
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Request-ID", "should-be-ignored")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if ctxID != "" {
t.Errorf("context request id = %q, want empty (nothing attached)", ctxID)
}
if vals := rec.Header().Values("X-Request-ID"); len(vals) != 0 {
t.Errorf("X-Request-ID header = %v on empty resolve; want absent", vals)
}
}