Rene Nochebuena e76dc48688 docs(adr): generalize ADR-001 motivation — drop private consumer name and its internal ADR reference
The framework ADR must be self-contained: it described the motivating failure
modes by naming a specific downstream service and one of its internal ADRs, which
no framework reader has context for. Reworded to 'a downstream service' and to the
general authorization-boundary rationale.
2026-08-14 01:08:36 -06:00

einherjar/web

version license go

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:

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:

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:

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

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-ForRemoteAddr 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

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.

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

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

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.

S
Description
Chi-based HTTP server with middleware stack, health endpoint, and error handler
Readme AGPL-3.0
266 KiB
2026-08-12 15:50:41 -06:00
Languages
Go 100%