Bind and BindEmpty fill Req from path/query/json struct tags and validate once, extending the typed decode->validate->call->encode pipeline to routes with identifiers and filters. Conversion via builtins + encoding.TextUnmarshaler (uuid.UUID, time.Time); malformed value -> 400 naming the parameter; default: applies only when absent; repeated query -> slice; mis-tagged struct panics at wiring. Purely additive; existing adapters unchanged. Coordinated lockstep v1.6.0.
328 lines
11 KiB
Markdown
328 lines
11 KiB
Markdown
# einherjar/web
|
|
|
|
[](https://code.nochebuena.dev/einherjar/web)
|
|
[](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]
|
|
|
|
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.*
|