Files
web/CHANGELOG.md
Rene Nochebuena c4ef1948f6 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

7.5 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.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-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.