einherjar/web

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))
lc := launcher.New(logger)
lc.Append(srv)
lc.BeforeStart(func() error {
srv.Mount("/v1", myRouter)
return nil
})
lc.Run()
With origins (CORS auto-applied):
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:
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
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
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
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
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.