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/
This commit is contained in:
2026-03-19 00:03:24 +00:00
commit ad2a9e465e
17 changed files with 739 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
# ADR-001: Direct logz Import for Context Helpers (Exception to Global ADR-001)
**Status:** Accepted
**Date:** 2026-03-18
## Context
Global ADR-001 states that modules receiving a logger from the application layer
should duck-type the logger injection point with a private interface (e.g.
`type Logger interface { Info(...); Error(...) }`), so the application is not forced
to import `logz` just to satisfy a concrete type.
`httpmw` follows this rule for its `Logger` interface in `logger.go`. Any struct
with the right method set satisfies it.
However, `httpmw` also calls `logz.WithRequestID` (in `requestid.go`) and
`logz.GetRequestID` (in `logger.go`) to store and read the request ID from context.
These functions use an **unexported context key type** defined inside `logz`:
```go
// inside logz — not exported
type contextKey string
const requestIDKey contextKey = "request_id"
```
A context value stored with that key can only be read back with the same key. If
`httpmw` tried to replicate the storage using its own unexported key, the values set
by `logz.WithRequestID` would be invisible to `logz.GetRequestID`, and vice-versa —
breaking the downstream `logz.Logger.WithContext` integration.
## Decision
`httpmw` imports `logz` directly for context helper access (`logz.WithRequestID`,
`logz.GetRequestID`). This is an **explicit, documented exception** to the
duck-typed Logger rule in global ADR-001.
The Logger injection point remains duck-typed (`httpmw.Logger` interface in
`logger.go`). The exception applies only to the two context functions, not to the
logger parameter of `RequestLogger`.
## Consequences
- `httpmw` has a direct module dependency on `logz` in its `go.mod`. Upgrading
`logz` is a breaking change for `httpmw` if the context helper signatures change.
- `logz.WithRequestID` and `logz.GetRequestID` form a shared context contract
between `httpmw` (which stores the ID) and `logz.Logger` (which reads it when
enriching log records). Both sides of the contract must be the same package.
- Applications that use `httpmw` without `logz` as their logger will still get
request IDs injected into the context correctly — `RequestID` middleware works
independently. The ID simply won't be picked up automatically by a non-logz logger.
- This exception must not be used as a precedent for importing logz elsewhere without
justification. The rule remains: duck-type logger injection; import logz only when
the unexported context key contract requires it.

View File

@@ -0,0 +1,50 @@
# ADR-002: Request ID Injected via logz Context Helpers
**Status:** Accepted
**Date:** 2026-03-18
## Context
Each HTTP request should carry a unique identifier that appears in log records,
error responses, and the `X-Request-ID` response header, so that a single request
can be traced across log lines.
There are two sub-problems:
1. **Generation and storage**: the middleware must generate an ID and make it
available to downstream code via the request context.
2. **Retrieval for logging**: `RequestLogger` must be able to read the ID from the
context to include it in log records.
The naive approach — store the ID under a locally-defined context key — breaks
interoperability with `logz.Logger.WithContext`, which enriches log records with
values stored under `logz`'s own unexported key. If a different key is used, logz
cannot find the ID, and it does not appear automatically in structured log output.
## Decision
The `RequestID` middleware calls `logz.WithRequestID(ctx, id)` to store the
generated ID in context. `RequestLogger` calls `logz.GetRequestID(r.Context())` to
retrieve it.
Both functions use the same unexported key inside the `logz` package, guaranteeing
that the value stored by `RequestID` is the same value retrieved by `RequestLogger`
and, more importantly, by any `logz.Logger` downstream that calls `WithContext`.
The ID is also written to the `X-Request-ID` response header at the middleware
level, so clients can correlate responses to requests without parsing log files.
## Consequences
- Any `logz.Logger` in the request chain that calls `.WithContext(ctx)` automatically
inherits the request ID as a structured log field — no manual plumbing required.
- The generator function is injected by the caller (`RequestID(generator func() string)`),
keeping the ID format flexible (UUID, ULID, or any string).
- If the same request ID is sent in the `X-Request-ID` request header, it is ignored
by `RequestID` — the middleware always generates a fresh ID. Preserving inbound
IDs is a separate concern that should be handled explicitly if required.
- This design requires `httpmw` to import `logz` directly (see ADR-001 for the
justification of that exception).
- Global ADR-003 (context helpers live with data owners) is directly applied here:
`logz` owns the request ID context key because the request ID is a logging
concern, not an auth or business concern.

View File

@@ -0,0 +1,46 @@
# ADR-003: CORS Preflight Returns 204 No Content
**Status:** Accepted
**Date:** 2026-03-18
## Context
CORS preflight requests use the HTTP `OPTIONS` method. Browsers send them before
cross-origin requests that carry non-simple headers or methods, and they expect a
response with `Access-Control-Allow-*` headers. The response status code of a
preflight is not consumed by the browser's fetch algorithm — only the headers
matter — but the choice of status code affects certain clients and proxies.
Common practice uses either 200 OK or 204 No Content for OPTIONS responses.
RFC 9110 specifies that 204 indicates "the server has successfully fulfilled the
request and there is no additional content to send". This is semantically more
accurate for a preflight: there is no payload, only permission headers.
## Decision
The `CORS` middleware checks `r.Method == http.MethodOptions` after writing the
CORS headers. If the method is OPTIONS, it calls `w.WriteHeader(http.StatusNoContent)`
and returns immediately without calling `next.ServeHTTP`. The downstream handler is
never reached for preflight requests.
Non-OPTIONS requests that pass the origin check continue to `next` with the
`Access-Control-Allow-Origin` and related headers already set on the response.
If the request origin is not in the allowed list, CORS headers are not set and the
request continues to `next` unchanged — the browser will block the response at its
end, but the server does not return an explicit rejection.
## Consequences
- 204 is semantically cleaner than 200 for no-body responses, and avoids some
proxy caches treating the OPTIONS response as cacheable content.
- Some legacy clients or API testing tools expect 200 for OPTIONS. If this is a
concern, the response status can be changed at the application level by wrapping
the `CORS` middleware, but 204 is the default and documented contract.
- The `allowedMethods` constant (`GET, HEAD, PUT, PATCH, POST, DELETE, OPTIONS`)
and `allowedHeaders` constant (`Content-Type, Authorization, X-Request-ID`) are
package-level constants, not configurable per route. Applications with fine-grained
per-route CORS requirements should configure their router's CORS support instead.
- For local development, passing `[]string{"*"}` as `origins` is supported: any
origin header is treated as allowed, and CORS headers are set with the actual
origin value (not `*`) to support credentials if needed.