# einherjar/web [![version](https://img.shields.io/badge/version-v1.7.1-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/web) [![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE) [![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white)](https://go.dev) > The gate is not a barrier. It is the point where the outside world meets order. `code.nochebuena.dev/einherjar/web` is the HTTP layer of the Einherjar framework. It sits above `core` in the dependency graph and provides everything a service needs to receive, process, and respond to HTTP requests: a lifecycle-aware server, a composable middleware stack, type-safe generic handlers, and a concurrent health endpoint. --- ## Sub-packages | Package | Import path | Purpose | |---|---|---| | `server` | `.../web/server` | Lifecycle-aware HTTP server (chi router + `lifecycle.Component`) | | `mw` | `.../web/mw` | Middleware: Recover, RequestID, RequestLogger, CORS, rate limiting | | `httputil` | `.../web/httputil` | Generic handler adapters: decode → validate → call → encode | | `health` | `.../web/health` | Concurrent health-check endpoint consuming `observability.Checkable` | All four are in one module because they compose together and ship in every Einherjar HTTP service. --- ## Usage ### Tier 1 — Happy path (`web.New`) Zero config, safe defaults, all env vars respected: ```go import ( "code.nochebuena.dev/einherjar/core/logz" "code.nochebuena.dev/einherjar/web" "code.nochebuena.dev/einherjar/web/health" ) logger := logz.New(logz.Config{JSON: true, StaticArgs: []any{"service", "api"}}) srv := web.New(logger) // Pre-wired stack: Recover → RequestID (UUID v7/v4) → RequestLogger → [CORS] srv.Get("/health", health.NewHandler(logger, db, cache).ServeHTTP) lc := launcher.New(logger) lc.Append(srv) lc.BeforeStart(func() error { srv.Mount("/v1", myRouter) return nil }) lc.Run() ``` With origins set in code (CORS auto-applied). `Server.CORSOrigins` is the single source of truth — normally it loads from `EINHERJAR_SERVER_CORS_ORIGINS`, but you can set it directly to override without the env var: ```go srv := web.New(logger, web.Config{ Server: server.Config{ Port: 9090, CORSOrigins: []string{"https://example.com"}, }, }) ``` Environment variables for `web.New`: | Variable | Default | Effect | |---|---|---| | `EINHERJAR_SERVER_HOST` | `0.0.0.0` | Bind address | | `EINHERJAR_SERVER_PORT` | `8080` | Listen port | | `EINHERJAR_SERVER_READ_TIMEOUT` | `5s` | HTTP read timeout | | `EINHERJAR_SERVER_WRITE_TIMEOUT` | `10s` | HTTP write timeout | | `EINHERJAR_SERVER_IDLE_TIMEOUT` | `120s` | Keep-alive idle timeout | | `EINHERJAR_SERVER_SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown budget | | `EINHERJAR_SERVER_CORS_ORIGINS` | _(empty — CORS off)_ | Comma-separated allowed origins (`*` is rejected — use `mw.CORSAllowAll()` in code for allow-all) | ### Tier 2 — Full control (`server.New`) Explicit middleware composition: ```go import ( "code.nochebuena.dev/einherjar/web/mw" "code.nochebuena.dev/einherjar/web/server" ) srv := server.New(logger, server.Config{Port: 9090}, server.WithMiddleware( mw.Recover(logger), mw.RequestID(myIDGenerator), mw.CORS([]string{"https://example.com"}), mw.RequestLogger(logger), myOwnMiddleware, ), ) ``` Both tiers share the same `server.Config`, env variables, and `lifecycle.Component` contract. The only difference is how much wiring is automated. --- ### Middleware ```go import "code.nochebuena.dev/einherjar/web/mw" // Rate limiting — in-memory token bucket (swap for distributed store at scale) limiter := mw.NewInMemoryRateLimiterStore(100, 20) // rps=100, burst=20 srv.Use(mw.IPRateLimit(limiter, logger)) srv.Use(mw.UserRateLimit(limiter, logger)) // Scale: swap store without changing middleware // valkeyLimiter := valkeymw.NewRateLimiterStore(valkey, 100, 20) // implements mw.RateLimiterStore // srv.Use(mw.IPRateLimit(valkeyLimiter, logger)) ``` `IPRateLimit` uses `X-Forwarded-For` → `RemoteAddr` as the rate-limit key. `UserRateLimit` uses the authenticated user ID from `security.Identity`; falls back to client IP when no identity is present in context. Both middlewares fail **open** on store error — the request is allowed, the error is logged. This keeps the service available when the rate-limit store is degraded. --- ### Generic handlers ```go import ( "code.nochebuena.dev/einherjar/core/valid" "code.nochebuena.dev/einherjar/web/httputil" ) type CreateUserReq struct { Email string `json:"email" validate:"required,email"` Name string `json:"name" validate:"required"` } type CreateUserRes struct { ID string `json:"id"` } v := valid.New() // POST /users — decode → validate → call → encode. WithStatus makes it 201 Created. srv.Post("/users", httputil.Handle(v, logger, func(ctx context.Context, req CreateUserReq) (CreateUserRes, error) { id, err := userService.Create(ctx, req.Email, req.Name) if err != nil { return CreateUserRes{}, err } return CreateUserRes{ID: id}, nil }, httputil.WithStatus(http.StatusCreated))) // GET /users/{id} — no request body (defaults to 200) srv.Get("/users/{id}", httputil.HandleNoBody(logger, func(ctx context.Context) (CreateUserRes, error) { // ... })) // DELETE /users/{id} — no response body (defaults to 204) srv.Delete("/users/{id}", httputil.HandleEmpty(v, logger, func(ctx context.Context, req DeleteReq) error { return userService.Delete(ctx, req.ID) })) ``` **Success status** defaults to 200 for the body-returning adapters and 204 for `HandleEmpty`; override it with `httputil.WithStatus(code)` — e.g. `WithStatus(http.StatusCreated)` on a POST, or `WithStatus(http.StatusAccepted)` for an async `HandleEmpty`. The code must be 2xx (these adapters own only the success path); a non-2xx code panics at wiring, so the service fails to start rather than emit a wrong status at runtime. **Error status is separate** — return the right `*xerrors.Err` and `Error` maps it (full 16-code table below). Validation failures return 400 with a structured JSON error. All `*xerrors.Err` values are mapped to their canonical HTTP status codes (full 16-code table below). --- ### Request binding (`Bind` / `BindEmpty`) `Handle` and friends fill `Req` from the **JSON body only**. A route with an identifier or a filter needs more, and hand-rolling the parse via `HandlerFunc` is the one path that reaches production with **no validation**. `Bind` closes that gap: each field declares its source with a struct tag — `path:`, `query:` or `json:` — and the assembled struct is validated once by the same `valid.Validator`. ```go type listRolesReq struct { Page int `query:"page" default:"1" validate:"min=1"` PerPage int `query:"per_page" default:"50" validate:"min=1,max=200"` Q string `query:"q" validate:"omitempty,max=100"` Kind []string `query:"kind" validate:"omitempty,dive,oneof=POS KDS"` } // GET /roles?page=2&per_page=50&q=turno&kind=POS&kind=KDS → all validated, 200. srv.Get("/roles", httputil.Bind(v, logger, func(ctx context.Context, req listRolesReq) (ListRes, error) { return roleService.List(ctx, req) })) type updateRoleReq struct { RoleID uuid.UUID `path:"roleID" validate:"required"` // from the path, already typed Name string `json:"name" validate:"omitempty,max=200"` // from the body } // PATCH /roles/{roleID} — path + body in one struct; WithStatus still applies. srv.Patch("/roles/{roleID}", httputil.Bind(v, logger, func(ctx context.Context, req updateRoleReq) (RoleRes, error) { return roleService.Update(ctx, req) })) type deleteRoleReq struct { RoleID uuid.UUID `path:"roleID" validate:"required"` } // DELETE /roles/{roleID} — path only, no body. BindEmpty writes 204 and does not // fail on the empty body the way HandleEmpty would. srv.Delete("/roles/{roleID}", httputil.BindEmpty(v, logger, func(ctx context.Context, req deleteRoleReq) error { return roleService.Delete(ctx, req.RoleID) })) ``` Binding rules: - **One source per field.** A field carries at most one of `path:` / `query:` / `json:`; declaring two fails at **wiring** (the service does not boot). - **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 import in `web`. - **A malformed value is a 400** naming the parameter (`ErrInvalidInput`), never a 500. - **`default:` applies only when the parameter is absent** — a present-but-empty `?q=` is the caller clearing a filter and is left as the zero value. Pair `default:` with a plain `min=1` (not `omitempty,min=1`): the default guarantees presence, and `omitempty` would let an explicit `?page=0` skip the bound. - **Repeated query parameters bind to a slice** (`?kind=POS&kind=KDS` → `[]string{…}`); a comma inside a single value is **not** split. - **No body is not an error** — a bodiless `GET`/`DELETE` binds path/query directly. - The struct is reflected over **once per type and cached**; per-request work does not re-parse tags. `HandlerFunc` remains for genuinely custom responses (streaming, file downloads, non-JSON) — not for parameters. --- ### Health endpoint ```go import "code.nochebuena.dev/einherjar/web/health" // db and cache implement observability.Checkable srv.Get("/health", health.NewHandler(logger, db, cache).ServeHTTP) // Response shape: // {"status":"UP","components":{"db":{"status":"UP","latency":"1.2ms"}}} // {"status":"DEGRADED","components":{"cache":{"status":"DEGRADED","latency":"50ms","error":"timeout"}}} // {"status":"DOWN","components":{"db":{"status":"DOWN","latency":"5s","error":"connection refused"}}} ``` All checks run concurrently within a configurable timeout (default 5s). `DOWN` (critical priority failure) → HTTP 503. `DEGRADED` (degraded priority failure) → HTTP 200. Environment variables: | Variable | Default | Effect | |---|---|---| | `EINHERJAR_HEALTH_CHECK_TIMEOUT` | `5s` | Maximum time to wait for all checks | --- ## HTTP Status Code Mapping `httputil.Error` maps `*xerrors.Err` codes to HTTP status: | Code | HTTP | |---|---| | `ErrInvalidInput`, `ErrOutOfRange` | 400 | | `ErrUnauthorized` | 401 | | `ErrPermissionDenied` | 403 | | `ErrNotFound` | 404 | | `ErrAlreadyExists`, `ErrAborted` | 409 | | `ErrGone` | 410 | | `ErrPreconditionFailed` | 412 | | `ErrRateLimited` | 429 | | `ErrCancelled` | 499 | | `ErrInternal`, `ErrDataLoss` | 500 | | `ErrNotImplemented` | 501 | | `ErrUnavailable` | 503 | | `ErrDeadlineExceeded` | 504 | --- ## Dependency Graph ``` contracts (zero dependencies) ↑ core (contracts) ↑ web (contracts, core, chi/v5, uuid, x/time) ↑ your app ``` `db-*`, `cache-*`, and `storage-*` starters never import `web` — they only need `contracts` and `core`. Repositories do not know HTTP exists. --- ## Verification ```bash cd web/ go build ./... # must compile clean go vet ./... # no warnings go test ./... # structural + behavioural compliance passes gofmt -l . # no output ``` --- > *The gate does not decide who passes.* > *It decides that passing has consequences.*