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:
53
docs/adr/ADR-001-logz-context-import-exception.md
Normal file
53
docs/adr/ADR-001-logz-context-import-exception.md
Normal 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.
|
||||
50
docs/adr/ADR-002-requestid-via-logz-context.md
Normal file
50
docs/adr/ADR-002-requestid-via-logz-context.md
Normal 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.
|
||||
46
docs/adr/ADR-003-cors-204-preflight.md
Normal file
46
docs/adr/ADR-003-cors-204-preflight.md
Normal 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.
|
||||
Reference in New Issue
Block a user