Files
web/CHANGELOG.md

142 lines
7.5 KiB
Markdown
Raw Normal View History

feat(web): initial implementation — server, mw, httputil, health (v1.0.0) Introduces code.nochebuena.dev/einherjar/web — the HTTP transport layer of the Einherjar framework. Absorbs httpserver, httpmw, and httputil from micro-lib, replacing gorilla/mux with chi, adopting SecurityBag-native middleware, and centralizing error handling through a single httputil.Error function. server: - Server interface — embeds lifecycle.Component and chi.Router - Config struct (EINHERJAR_SERVER_* env vars); DefaultConfig - New(logger, cfg, opts...) Server; WithMiddleware option - Binds TCP synchronously in OnStart; logs "server: listening" on success - Graceful shutdown within ShutdownTimeout on OnStop mw: - Recover — catches panics, returns 500, logs at Error - RequestID — injects UUID v7 (UUID v4 fallback) into context and X-Request-ID header - RequestLogger — structured access log per request - CORS / CORSAllowAll — chi-based, applied only when origins non-empty - IPRateLimit / UserRateLimit — pluggable RateLimiterStore interface - InMemoryRateLimiterStore — token-bucket backed by golang.org/x/time/rate; background goroutine evicts stale entries every 5 minutes - StatusRecorder — wraps ResponseWriter to capture HTTP status code httputil: - Handle[Req, Res] / HandleNoBody[Res] / HandleEmpty[Req] — generic handler adapters - Error(logger, w, r, err) — derives log level from status (≥500→Error, 4xx→Warn, 499→Info); writes standardized JSON body; logz enriches *xerrors.Err automatically - JSON(w, status, v) / NoContent(w) — response helpers - HandlerFunc adapter type health: - NewHandler / NewHandlerWithConfig — runs all Checkable checks concurrently; returns JSON {status, components} with per-component latency and error - Config struct (EINHERJAR_HEALTH_CHECK_TIMEOUT, default 5s) Root factory: - web.New(logger, cfg...) Server — composes Recover+RequestID+RequestLogger+CORS in outermost-first order; CORS applied only when AllowedOrigins non-empty - server.Server interface and web/server/identifiable.go: embeds observability.Identifiable; ModulePath and ModuleVersion read via runtime/debug.ReadBuildInfo() — prints in launcher banner
2026-05-29 15:48:11 +00:00
# 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.0.1] — 2026-05-28
### Changed
- Bumped `code.nochebuena.dev/einherjar/contracts` dependency from v1.0.0 to v1.1.0.
No code changes — MVS selects v1.1.0 automatically when a consumer (e.g. `auth`)
requires the new `SecurityBag` API. This pin makes the minimum explicit.
---
## [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` — shorthand for `CORS([]string{"*"})`
- `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