Files
web/README.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

252 lines
7.2 KiB
Markdown

# einherjar/web
[![version](https://img.shields.io/badge/version-v1.0.0-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))
lc := launcher.New(logger)
lc.Append(srv)
lc.BeforeStart(func() error {
srv.Mount("/v1", myRouter)
return nil
})
lc.Run()
```
With origins (CORS auto-applied):
```go
srv := web.New(logger, web.Config{
Server: server.Config{Port: 9090},
AllowedOrigins: []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 |
### 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(),
mw.RequestID(myIDGenerator),
mw.CORS([]string{"*"}),
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 body → validate → call service → encode response
srv.Post("/users", httputil.Handle(v, 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
}))
// GET /users/{id} — no request body
srv.Get("/users/{id}", httputil.HandleNoBody(func(ctx context.Context) (CreateUserRes, error) {
// ...
}))
// DELETE /users/{id} — no response body
srv.Delete("/users/{id}", httputil.HandleEmpty(v, func(ctx context.Context, req DeleteReq) error {
return userService.Delete(ctx, req.ID)
}))
```
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).
---
### Health endpoint
```go
import "code.nochebuena.dev/einherjar/web/health"
// db and cache implement observability.Checkable
srv.Get("/health", health.NewHandler(logger, db, cache))
// 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.*