2026-05-29 15:48:11 +00:00
# einherjar/web
2026-08-18 18:44:15 -06:00
[](https://code.nochebuena.dev/einherjar/web)
2026-05-29 15:48:11 +00:00
[](LICENSE)
[](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]
2026-08-08 00:43:09 -06:00
srv . Get ( "/health" , health . NewHandler ( logger , db , cache ). ServeHTTP )
2026-05-29 15:48:11 +00:00
lc := launcher . New ( logger )
lc . Append ( srv )
lc . BeforeStart ( func () error {
srv . Mount ( "/v1" , myRouter )
return nil
})
lc . Run ()
```
2026-08-08 10:48:00 -06:00
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:
2026-05-29 15:48:11 +00:00
```go
srv := web . New ( logger , web . Config {
2026-08-08 10:48:00 -06:00
Server : server . Config {
Port : 9090 ,
CORSOrigins : [] string { "https://example.com" },
},
2026-05-29 15:48:11 +00:00
})
```
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 |
2026-08-08 00:43:09 -06:00
| `EINHERJAR_SERVER_CORS_ORIGINS` | _(empty — CORS off)_ | Comma-separated allowed origins (`*` is rejected — use `mw.CORSAllowAll()` in code for allow-all) |
2026-05-29 15:48:11 +00:00
### 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 (
2026-08-08 00:43:09 -06:00
mw . Recover ( logger ),
2026-05-29 15:48:11 +00:00
mw . RequestID ( myIDGenerator ),
2026-08-08 00:43:09 -06:00
mw . CORS ([] string { "https://example.com" }),
2026-05-29 15:48:11 +00:00
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 ()
2026-08-12 17:55:28 -06:00
// 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 ) {
2026-05-29 15:48:11 +00:00
id , err := userService . Create ( ctx , req . Email , req . Name )
if err != nil {
return CreateUserRes {}, err
}
return CreateUserRes { ID : id }, nil
2026-08-12 17:55:28 -06:00
}, httputil . WithStatus ( http . StatusCreated )))
2026-05-29 15:48:11 +00:00
2026-08-12 17:55:28 -06:00
// GET /users/{id} — no request body (defaults to 200)
srv . Get ( "/users/{id}" , httputil . HandleNoBody ( logger , func ( ctx context . Context ) ( CreateUserRes , error ) {
2026-05-29 15:48:11 +00:00
// ...
}))
2026-08-12 17:55:28 -06:00
// DELETE /users/{id} — no response body (defaults to 204)
srv . Delete ( "/users/{id}" , httputil . HandleEmpty ( v , logger , func ( ctx context . Context , req DeleteReq ) error {
2026-05-29 15:48:11 +00:00
return userService . Delete ( ctx , req . ID )
}))
```
2026-08-12 17:55:28 -06:00
**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).
2026-05-29 15:48:11 +00:00
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).
---
2026-08-13 23:11:23 -06:00
### 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.
---
2026-05-29 15:48:11 +00:00
### Health endpoint
```go
import "code.nochebuena.dev/einherjar/web/health"
// db and cache implement observability.Checkable
2026-08-08 00:43:09 -06:00
srv . Get ( "/health" , health . NewHandler ( logger , db , cache ). ServeHTTP )
2026-05-29 15:48:11 +00:00
// 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.*