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.
308 lines
15 KiB
Markdown
308 lines
15 KiB
Markdown
# Changelog — einherjar/web
|
|
|
|
All notable changes to this module are documented here.
|
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
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
|
|
|
|
Minor release carrying a **breaking API change** to CORS configuration. The framework is
|
|
private with controlled consumers, so this ships in the 1.x line with a loud compile break
|
|
instead of a v2 module-path (`/v2`) migration.
|
|
|
|
### Removed
|
|
|
|
- **⚠️ BREAKING: `web.Config.AllowedOrigins` removed.** CORS origins now have a single
|
|
home: `server.Config.CORSOrigins` (env `EINHERJAR_SERVER_CORS_ORIGINS`). The field was
|
|
env-backed through v1.1.x and a code-only override in v1.2.0 — reading it after the env
|
|
tag moved silently served *no* CORS. Removing it turns that runtime trap into a compile
|
|
error.
|
|
|
|
**Migration:** replace `cfg.Web.AllowedOrigins` with `cfg.Server.CORSOrigins`, and
|
|
`web.Config{AllowedOrigins: o}` with `web.Config{Server: server.Config{CORSOrigins: o}}`
|
|
— or just let `web.New` read `EINHERJAR_SERVER_CORS_ORIGINS`. The MCP flags any leftover
|
|
reference (`validate_snippet` rule `web.allowedorigins-removed`).
|
|
|
|
### Changed
|
|
|
|
- Bumped `contracts`, `core` to v1.3.0.
|
|
|
|
## [1.2.0] — 2026-08-08
|
|
|
|
Minor — CORS configuration moved to its rightful struct; `web.New` made safe-by-default.
|
|
|
|
### Changed
|
|
|
|
- **`CORSOrigins` now lives on `server.Config`** (env var `EINHERJAR_SERVER_CORS_ORIGINS`), the
|
|
struct its name advertises — it previously loaded into `web.Config`. `web.Config.AllowedOrigins`
|
|
remains as a code-only override (no env tag). Wiring via `web.New` or the env var is unaffected.
|
|
- Bumped `contracts`, `core` to v1.2.0.
|
|
|
|
### Added
|
|
|
|
- `web.New` logs a warning when no CORS origins are configured, instead of silently disabling CORS.
|
|
- Package docs (`web`, `web/server`) document when to use `web.New` vs `server.New`, with compiling
|
|
examples and the env-gated allow-all CORS convention.
|
|
|
|
## [1.1.3] — 2026-08-08
|
|
|
|
Patch — CORS documentation discoverability.
|
|
|
|
### Fixed
|
|
|
|
- `mw.CORS` and `CORSAllowAll` doc comments now document the `"*"` rejection (panic) and the
|
|
env-gated CORS convention (`local -> CORSAllowAll`, else `mw.CORS(origins)`), so `search_symbols`
|
|
surfaces it — previously the convention lived only in code comments and the wire example.
|
|
|
|
### Changed
|
|
|
|
- Bumped `contracts`, `core` to v1.1.3.
|
|
|
|
## [1.1.2] — 2026-08-08
|
|
|
|
Patch — CORS wildcard hardening plus documentation fixes.
|
|
|
|
### Changed
|
|
|
|
- **`mw.CORS` now rejects `"*"` (panics at construction)** instead of silently no-op'ing it.
|
|
`"*"` matched nothing (exact-match only), so a service passing it ran with CORS effectively
|
|
off — a silent trap. Fail loud at boot; use `mw.CORSAllowAll()` (development) or list explicit origins.
|
|
- Bumped `contracts`, `core` to v1.1.2.
|
|
|
|
### Fixed
|
|
|
|
- README Go examples now compile: `mw.Recover(logger)`, `health.NewHandler(...).ServeHTTP`, and the
|
|
`mw.CORS` example no longer passes `"*"`. Corrected the `CORSAllowAll` description.
|
|
|
|
## [1.1.1] — 2026-08-07
|
|
|
|
Patch — coordinated framework version alignment.
|
|
|
|
### Changed
|
|
|
|
- Bumped `contracts` and `core` to v1.1.1 (framework version alignment). No code or API changes.
|
|
|
|
## [1.1.0] — 2026-08-07
|
|
|
|
Coordinated framework release. Documentation fixes plus the framework version bump
|
|
(which finally makes the previously-drafted `contracts` v1.1.0 pin real).
|
|
|
|
### Fixed
|
|
|
|
- **Package doc examples didn't compile.** Verified by compiling the example patterns
|
|
against the real API:
|
|
- `mw.Recover()` -> `mw.Recover(logger)` — the recover middleware takes a `logging.Logger`
|
|
(`server`, `mw` package docs and `server.go`).
|
|
- `health.NewHandler(logger, …)` -> `health.NewHandler(logger, …).ServeHTTP` — the handler
|
|
returns `http.Handler`, but chi's `Get` takes `http.HandlerFunc`; same for
|
|
`NewHandlerWithConfig` (`server`, `web`, `health` package docs).
|
|
|
|
### Changed
|
|
|
|
- Bumped `contracts` and `core` to v1.1.0 (framework version alignment).
|
|
|
|
---
|
|
|
|
## [1.0.0] — 2026-05-28
|
|
|
|
### Added
|
|
|
|
#### `server`
|
|
|
|
- `Server` interface — embeds `lifecycle.Component` (from `contracts/lifecycle`) and
|
|
`chi.Router` (from `go-chi/chi/v5`); any type that satisfies both is directly
|
|
compatible
|
|
- `Config` struct — `Host`, `Port`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout`,
|
|
`ShutdownTimeout`; all fields carry `env:"EINHERJAR_SERVER_*"` and `envDefault`
|
|
tags (`caarlos0/env` syntax)
|
|
- `New(logger logging.Logger, cfg Config, opts ...Option) Server` — constructs the
|
|
unexported `impl` struct; embeds `chi.NewRouter()`
|
|
- `Option` type + `WithMiddleware(mw ...func(http.Handler) http.Handler) Option` —
|
|
variadic option for middleware composition
|
|
- `impl.OnInit()` — applies registered middleware via `chi.Use`
|
|
- `impl.OnStart()` — binds TCP listener synchronously (`net.Listen`), starts
|
|
`http.Server.Serve` in a goroutine; port binding failure returns immediately
|
|
- `impl.OnStop(ctx)` — graceful `http.Server.Shutdown(ctx)` with `ShutdownTimeout`
|
|
(fallback: `defaultShutdownTimeout = 10s`)
|
|
- `var _ Server = (*impl)(nil)` — compile-time assertion
|
|
|
|
#### `mw`
|
|
|
|
- `StatusRecorder` struct — wraps `http.ResponseWriter`, captures written status code
|
|
- `Recover() func(http.Handler) http.Handler` — catches panics, writes 500, logs
|
|
stack trace via `runtime/debug.Stack()`
|
|
- `RequestID(generator func() string) func(http.Handler) http.Handler` — injects a
|
|
request ID via `logz.WithRequestID`; reads existing `X-Request-ID` header if present
|
|
- `RequestLogger(logger logging.Logger) func(http.Handler) http.Handler` — structured
|
|
request logging: method, path, status, latency; uses `StatusRecorder` to capture code
|
|
- `CORS(origins []string) func(http.Handler) http.Handler` — sets
|
|
`Access-Control-Allow-Origin` for listed origins; supports preflight (`OPTIONS`)
|
|
- `CORSAllowAll() func(http.Handler) http.Handler` — allows any origin by reflecting the request `Origin` (no `Access-Control-Allow-Credentials`); development only
|
|
- `RateLimiterStore` interface — `Allow(ctx context.Context, key string) (bool, error)`;
|
|
pluggable backend; `error` return allows infrastructure failures to surface; fail-open
|
|
contract: non-nil error allows the request
|
|
- `InMemoryRateLimiterStore` struct — per-key token bucket via `golang.org/x/time/rate`;
|
|
`sync.Map` for concurrent access; background goroutine evicts idle entries after 5
|
|
minutes via `time.Ticker`; `Allow` always returns `(bool, nil)`
|
|
- `NewInMemoryRateLimiterStore(rps float64, burst int) *InMemoryRateLimiterStore`
|
|
- `IPRateLimit(store RateLimiterStore, logger logging.Logger) func(http.Handler) http.Handler`
|
|
— limits by client IP (`X-Forwarded-For` → `RemoteAddr` fallback); returns 429 JSON
|
|
on exceeded limit; fails open on store error
|
|
- `UserRateLimit(store RateLimiterStore, logger logging.Logger) func(http.Handler) http.Handler`
|
|
— limits by authenticated user ID from `security.FromContext`; falls back to client IP
|
|
when no identity present; same 429 + fail-open behaviour
|
|
|
|
#### `httputil`
|
|
|
|
- `HandlerFunc` type — `func(w http.ResponseWriter, r *http.Request) error`; implements
|
|
`http.Handler` via `ServeHTTP`
|
|
- `Handle[Req, Res any](v valid.Validator, fn func(ctx context.Context, req Req) (Res, error)) http.HandlerFunc`
|
|
— decodes JSON body, validates struct, calls `fn`, encodes response; 400 on validation
|
|
failure, mapped status on `*xerrors.Err`
|
|
- `HandleNoBody[Res any](fn func(ctx context.Context) (Res, error)) http.HandlerFunc`
|
|
— no body decoding/validation; encodes response directly
|
|
- `HandleEmpty[Req any](v valid.Validator, fn func(ctx context.Context, req Req) error) http.HandlerFunc`
|
|
— decodes and validates body, calls `fn`, returns 204 on success
|
|
- `JSON(w http.ResponseWriter, status int, v any)` — writes JSON response
|
|
- `NoContent(w http.ResponseWriter)` — writes 204 with no body
|
|
- `Error(w http.ResponseWriter, err error)` — maps `*xerrors.Err` to HTTP status and
|
|
writes `{"code":"<wire_value>","message":"<msg>"}` JSON body; complete 16-code mapping
|
|
|
|
#### `health`
|
|
|
|
- `Config` struct — `CheckTimeout time.Duration` with
|
|
`env:"EINHERJAR_HEALTH_CHECK_TIMEOUT" envDefault:"5s"` (`caarlos0/env` syntax)
|
|
- `Response` struct — `Status string`, `Components map[string]ComponentStatus`
|
|
- `ComponentStatus` struct — `Status`, `Latency` (omitempty), `Error` (omitempty)
|
|
- `NewHandler(logger logging.Logger, checks ...observability.Checkable) http.Handler`
|
|
— shorthand with default 5s timeout
|
|
- `NewHandlerWithConfig(logger logging.Logger, cfg Config, checks ...observability.Checkable) http.Handler`
|
|
— all checks run concurrently in goroutines with a shared context timeout; results
|
|
collected via buffered channel; `DOWN` (critical priority) → 503; `DEGRADED`
|
|
(degraded priority) → 200; `UP` → 200
|
|
- Accepts `observability.Checkable` from `contracts` directly — no local redefinition
|
|
|
|
#### Root package (`web`)
|
|
|
|
- `Config` struct — `Server server.Config`, `AllowedOrigins []string` with
|
|
`env:"EINHERJAR_SERVER_CORS_ORIGINS" envSeparator:","`
|
|
- `New(logger logging.Logger, cfg ...Config) server.Server` — pre-wires recommended
|
|
middleware stack: Recover → RequestID (UUID v7 with v4 fallback) → RequestLogger →
|
|
CORS (only when `AllowedOrigins` non-empty)
|
|
- Unexported `newRequestID()` — uses `uuid.NewV7()` (time-ordered), falls back to
|
|
`uuid.NewString()` (v4) on generation error
|
|
|
|
### Design Notes
|
|
|
|
1. **Progressive disclosure.** `web.New` is the happy path — one call, all middleware
|
|
pre-wired, env vars respected. `server.New` is the escape hatch — every choice
|
|
explicit. Both tiers share the same config structs, env variables, and lifecycle
|
|
contract.
|
|
|
|
2. **`RateLimiterStore` interface.** The pluggable backend design lets developers start
|
|
with `InMemoryRateLimiterStore` (zero extra dependencies) and swap to a distributed
|
|
store (e.g., `cache-valkey`) at scale without touching middleware wiring. The store
|
|
satisfies the interface via Go duck typing — `cache-valkey` never imports `web/mw`.
|
|
|
|
3. **Fail-open rate limiting.** When the store returns an error (e.g., Valkey
|
|
unavailable), the request is allowed. Availability is preferred over hard
|
|
enforcement during infrastructure degradation.
|
|
|
|
4. **`observability.Checkable` from contracts.** `health.NewHandler` accepts
|
|
`observability.Checkable` directly from `contracts/observability`. Any starter
|
|
(`db-*`, `cache-*`, `storage-*`) that implements the contracts interface plugs in
|
|
without an adapter — no `web` import required by those starters.
|
|
|
|
5. **`last_seen` excluded.** Session tracking is an application-domain concern, not
|
|
transport-level middleware. It requires knowing which entity to track and where to
|
|
persist it. Developers who need it can write it in ~15 lines in their own wiring
|
|
package. Will be revisited if `einherjar/worker` provides a fire-and-forget
|
|
primitive.
|
|
|
|
6. **UUID v7 for request IDs.** Time-ordered UUIDs embed a millisecond-precision
|
|
timestamp, enabling request IDs to sort chronologically in log aggregation systems.
|
|
UUID v4 fallback ensures ID generation never fails.
|
|
|
|
---
|
|
|
|
[1.0.0]: https://code.nochebuena.dev/einherjar/web/releases/tag/v1.0.0
|