feat(httpmw): initial stable release v0.9.0
Standalone net/http middleware for panic recovery, CORS, request ID injection, and request logging.
What's included:
- Recover(): panic -> 500, captures debug.Stack, no logger required
- CORS(origins): OPTIONS 204 preflight, origin allowlist, package-wide method/header constants
- RequestID(generator): injects ID via logz.WithRequestID, sets X-Request-ID response header
- RequestLogger(logger): logs method/path/status/latency/request_id; Error for 5xx, Info otherwise
- Logger interface: Info, Error, With — duck-typed; satisfied by logz.Logger
- StatusRecorder: exported http.ResponseWriter wrapper that captures written status code
- Direct logz import for context helpers (documented exception to ADR-001)
Tested-via: todo-api POC integration
Reviewed-against: docs/adr/
2026-03-19 00:03:24 +00:00
|
|
|
# httpmw
|
|
|
|
|
|
|
|
|
|
`net/http` middleware for transport-layer concerns: panic recovery, CORS, request ID
|
|
|
|
|
injection, and structured request logging.
|
|
|
|
|
|
|
|
|
|
## Purpose
|
|
|
|
|
|
|
|
|
|
`httpmw` provides standalone middleware functions that wrap `http.Handler`. Each
|
|
|
|
|
function addresses one transport concern and is independent of the others. No
|
|
|
|
|
authentication or identity logic lives here — see `httpauth-firebase` for that.
|
|
|
|
|
|
|
|
|
|
## Tier & Dependencies
|
|
|
|
|
|
2026-03-19 06:55:59 -06:00
|
|
|
**Tier:** 2 (transport layer; depends on Tier 1 `logz`)
|
feat(httpmw): initial stable release v0.9.0
Standalone net/http middleware for panic recovery, CORS, request ID injection, and request logging.
What's included:
- Recover(): panic -> 500, captures debug.Stack, no logger required
- CORS(origins): OPTIONS 204 preflight, origin allowlist, package-wide method/header constants
- RequestID(generator): injects ID via logz.WithRequestID, sets X-Request-ID response header
- RequestLogger(logger): logs method/path/status/latency/request_id; Error for 5xx, Info otherwise
- Logger interface: Info, Error, With — duck-typed; satisfied by logz.Logger
- StatusRecorder: exported http.ResponseWriter wrapper that captures written status code
- Direct logz import for context helpers (documented exception to ADR-001)
Tested-via: todo-api POC integration
Reviewed-against: docs/adr/
2026-03-19 00:03:24 +00:00
|
|
|
**Module:** `code.nochebuena.dev/go/httpmw`
|
|
|
|
|
**Direct imports:** `code.nochebuena.dev/go/logz`
|
|
|
|
|
|
|
|
|
|
The `logz` import is required for context helpers (`logz.WithRequestID`,
|
|
|
|
|
`logz.GetRequestID`). The `Logger` injection point remains duck-typed. See ADR-001
|
|
|
|
|
for the full justification.
|
|
|
|
|
|
|
|
|
|
## Key Design Decisions
|
|
|
|
|
|
|
|
|
|
- **Direct logz import for context helpers** (ADR-001): `logz.WithRequestID` and
|
|
|
|
|
`logz.GetRequestID` use an unexported key. Re-implementing the key in `httpmw`
|
|
|
|
|
would break interoperability with `logz.Logger.WithContext` downstream. This is
|
|
|
|
|
a documented exception to the global ADR-001 duck-type rule.
|
|
|
|
|
- **Request ID via logz context** (ADR-002): `RequestID` middleware stores the
|
|
|
|
|
generated ID with `logz.WithRequestID` so it is automatically picked up by any
|
|
|
|
|
`logz.Logger` that calls `.WithContext(ctx)`. The ID is also written to the
|
|
|
|
|
`X-Request-ID` response header.
|
|
|
|
|
- **CORS returns 204 for OPTIONS** (ADR-003): Preflight requests are short-circuited
|
|
|
|
|
with 204 No Content after setting the CORS headers. Non-OPTIONS requests continue
|
|
|
|
|
to the next handler.
|
|
|
|
|
- **`Recover` requires no logger injection**: It writes 500 and captures the stack
|
|
|
|
|
trace (via `debug.Stack`) but does not log. The stack is available for future
|
|
|
|
|
logger injection if needed. This keeps `Recover` usable with zero configuration.
|
|
|
|
|
- **No middleware is installed by default**: The package exports functions, not a
|
|
|
|
|
pre-configured chain. The application chooses which middleware to use and in what
|
|
|
|
|
order.
|
|
|
|
|
|
|
|
|
|
## Patterns
|
|
|
|
|
|
|
|
|
|
**Recommended middleware order (outermost first):**
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
// 1. Recover — must be outermost to catch panics from all inner middleware
|
|
|
|
|
// 2. RequestID — generates ID early so it is available to logger
|
|
|
|
|
// 3. RequestLogger — reads ID from context; logs after inner handlers complete
|
|
|
|
|
// 4. CORS — sets headers before business logic runs
|
|
|
|
|
|
|
|
|
|
mux.Use(httpmw.Recover())
|
|
|
|
|
mux.Use(httpmw.RequestID(uuid.NewString))
|
|
|
|
|
mux.Use(httpmw.RequestLogger(logger))
|
|
|
|
|
mux.Use(httpmw.CORS([]string{"https://example.com"}))
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**Logger interface** (duck-typed; satisfied by `logz.Logger`):
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
type Logger interface {
|
|
|
|
|
Info(msg string, args ...any)
|
|
|
|
|
Error(msg string, err error, args ...any)
|
|
|
|
|
With(args ...any) Logger
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**StatusRecorder** is exported for use by custom logging middleware that needs to
|
|
|
|
|
inspect the written status code after the inner handler returns:
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
rec := &httpmw.StatusRecorder{ResponseWriter: w, Status: http.StatusOK}
|
|
|
|
|
next.ServeHTTP(rec, r)
|
|
|
|
|
fmt.Println(rec.Status)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## What to Avoid
|
|
|
|
|
|
|
|
|
|
- Do not put authentication, identity checks, or RBAC logic in this package. Those
|
|
|
|
|
belong in `httpauth-firebase` and operate on `rbac.Identity`.
|
|
|
|
|
- Do not define your own context key for the request ID. Use `logz.WithRequestID`
|
|
|
|
|
and `logz.GetRequestID` so the ID is visible to logz-enriched log records.
|
|
|
|
|
- Do not rely on `Recover` to log panics — it currently only writes a 500 response.
|
|
|
|
|
If panic logging is required, wrap `Recover` with a custom middleware that also
|
|
|
|
|
logs, or wait for logger injection to be added.
|
|
|
|
|
- Do not configure per-route CORS using this middleware. The allowed methods and
|
|
|
|
|
headers constants are package-wide. Use your router's built-in CORS support if
|
|
|
|
|
per-route configuration is needed.
|
|
|
|
|
- Do not change the order so that `RequestID` comes after `RequestLogger`. The
|
|
|
|
|
logger reads the request ID from context; if `RequestID` has not run yet, the ID
|
|
|
|
|
will be empty in log records.
|
|
|
|
|
|
|
|
|
|
## Testing Notes
|
|
|
|
|
|
|
|
|
|
- `compliance_test.go` verifies at compile time that any struct implementing
|
|
|
|
|
`Info`, `Error`, and `With` satisfies `httpmw.Logger`, and that `StatusRecorder`
|
|
|
|
|
satisfies `http.ResponseWriter`.
|
|
|
|
|
- `httpmw_test.go` uses `httptest.NewRecorder()` and `httptest.NewRequest()` for
|
|
|
|
|
all tests — no real network connections.
|
|
|
|
|
- `Recover` can be tested by constructing a handler that panics and asserting the
|
|
|
|
|
response is 500.
|
|
|
|
|
- `RequestLogger` depends on `logz.GetRequestID`; tests should run the handler
|
|
|
|
|
through a `RequestID` middleware first, or call `logz.WithRequestID` on the
|
|
|
|
|
request context manually.
|
|
|
|
|
- `CORS` with `[]string{"*"}` matches any origin — useful for table-driven tests
|
|
|
|
|
covering allowed vs. rejected origins.
|