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
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/contractsdependency 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 newSecurityBagAPI. This pin makes the minimum explicit.
1.0.0 — 2026-05-28
Added
server
Serverinterface — embedslifecycle.Component(fromcontracts/lifecycle) andchi.Router(fromgo-chi/chi/v5); any type that satisfies both is directly compatibleConfigstruct —Host,Port,ReadTimeout,WriteTimeout,IdleTimeout,ShutdownTimeout; all fields carryenv:"EINHERJAR_SERVER_*"andenvDefaulttags (caarlos0/envsyntax)New(logger logging.Logger, cfg Config, opts ...Option) Server— constructs the unexportedimplstruct; embedschi.NewRouter()Optiontype +WithMiddleware(mw ...func(http.Handler) http.Handler) Option— variadic option for middleware compositionimpl.OnInit()— applies registered middleware viachi.Useimpl.OnStart()— binds TCP listener synchronously (net.Listen), startshttp.Server.Servein a goroutine; port binding failure returns immediatelyimpl.OnStop(ctx)— gracefulhttp.Server.Shutdown(ctx)withShutdownTimeout(fallback:defaultShutdownTimeout = 10s)var _ Server = (*impl)(nil)— compile-time assertion
mw
StatusRecorderstruct — wrapshttp.ResponseWriter, captures written status codeRecover() func(http.Handler) http.Handler— catches panics, writes 500, logs stack trace viaruntime/debug.Stack()RequestID(generator func() string) func(http.Handler) http.Handler— injects a request ID vialogz.WithRequestID; reads existingX-Request-IDheader if presentRequestLogger(logger logging.Logger) func(http.Handler) http.Handler— structured request logging: method, path, status, latency; usesStatusRecorderto capture codeCORS(origins []string) func(http.Handler) http.Handler— setsAccess-Control-Allow-Originfor listed origins; supports preflight (OPTIONS)CORSAllowAll() func(http.Handler) http.Handler— shorthand forCORS([]string{"*"})RateLimiterStoreinterface —Allow(ctx context.Context, key string) (bool, error); pluggable backend;errorreturn allows infrastructure failures to surface; fail-open contract: non-nil error allows the requestInMemoryRateLimiterStorestruct — per-key token bucket viagolang.org/x/time/rate;sync.Mapfor concurrent access; background goroutine evicts idle entries after 5 minutes viatime.Ticker;Allowalways returns(bool, nil)NewInMemoryRateLimiterStore(rps float64, burst int) *InMemoryRateLimiterStoreIPRateLimit(store RateLimiterStore, logger logging.Logger) func(http.Handler) http.Handler— limits by client IP (X-Forwarded-For→RemoteAddrfallback); returns 429 JSON on exceeded limit; fails open on store errorUserRateLimit(store RateLimiterStore, logger logging.Logger) func(http.Handler) http.Handler— limits by authenticated user ID fromsecurity.FromContext; falls back to client IP when no identity present; same 429 + fail-open behaviour
httputil
HandlerFunctype —func(w http.ResponseWriter, r *http.Request) error; implementshttp.HandlerviaServeHTTPHandle[Req, Res any](v valid.Validator, fn func(ctx context.Context, req Req) (Res, error)) http.HandlerFunc— decodes JSON body, validates struct, callsfn, encodes response; 400 on validation failure, mapped status on*xerrors.ErrHandleNoBody[Res any](fn func(ctx context.Context) (Res, error)) http.HandlerFunc— no body decoding/validation; encodes response directlyHandleEmpty[Req any](v valid.Validator, fn func(ctx context.Context, req Req) error) http.HandlerFunc— decodes and validates body, callsfn, returns 204 on successJSON(w http.ResponseWriter, status int, v any)— writes JSON responseNoContent(w http.ResponseWriter)— writes 204 with no bodyError(w http.ResponseWriter, err error)— maps*xerrors.Errto HTTP status and writes{"code":"<wire_value>","message":"<msg>"}JSON body; complete 16-code mapping
health
Configstruct —CheckTimeout time.Durationwithenv:"EINHERJAR_HEALTH_CHECK_TIMEOUT" envDefault:"5s"(caarlos0/envsyntax)Responsestruct —Status string,Components map[string]ComponentStatusComponentStatusstruct —Status,Latency(omitempty),Error(omitempty)NewHandler(logger logging.Logger, checks ...observability.Checkable) http.Handler— shorthand with default 5s timeoutNewHandlerWithConfig(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.Checkablefromcontractsdirectly — no local redefinition
Root package (web)
Configstruct —Server server.Config,AllowedOrigins []stringwithenv:"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 whenAllowedOriginsnon-empty)- Unexported
newRequestID()— usesuuid.NewV7()(time-ordered), falls back touuid.NewString()(v4) on generation error
Design Notes
-
Progressive disclosure.
web.Newis the happy path — one call, all middleware pre-wired, env vars respected.server.Newis the escape hatch — every choice explicit. Both tiers share the same config structs, env variables, and lifecycle contract. -
RateLimiterStoreinterface. The pluggable backend design lets developers start withInMemoryRateLimiterStore(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-valkeynever importsweb/mw. -
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.
-
observability.Checkablefrom contracts.health.NewHandleracceptsobservability.Checkabledirectly fromcontracts/observability. Any starter (db-*,cache-*,storage-*) that implements the contracts interface plugs in without an adapter — nowebimport required by those starters. -
last_seenexcluded. 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 ifeinherjar/workerprovides a fire-and-forget primitive. -
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.