Files
web/CHANGELOG.md
T

13 KiB

Changelog — einherjar/web

All notable changes to this module are documented here. Format follows Keep a Changelog. This module adheres to Semantic Versioning.


[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-ForRemoteAddr 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.