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:
26
.devcontainer/devcontainer.json
Normal file
26
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "Go",
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie",
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers-extra/features/claude-code:1": {}
|
||||||
|
},
|
||||||
|
"forwardPorts": [],
|
||||||
|
"postCreateCommand": "go version",
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"settings": {
|
||||||
|
"files.autoSave": "afterDelay",
|
||||||
|
"files.autoSaveDelay": 1000,
|
||||||
|
"explorer.compactFolders": false,
|
||||||
|
"explorer.showEmptyFolders": true
|
||||||
|
},
|
||||||
|
"extensions": [
|
||||||
|
"golang.go",
|
||||||
|
"eamodio.golang-postfix-completion",
|
||||||
|
"quicktype.quicktype",
|
||||||
|
"usernamehw.errorlens"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"remoteUser": "vscode"
|
||||||
|
}
|
||||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Binaries
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with go test -c
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of go build
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directory
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Editor / IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# VCS files
|
||||||
|
COMMIT.md
|
||||||
|
RELEASE.md
|
||||||
25
CHANGELOG.md
Normal file
25
CHANGELOG.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this module will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-03-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `Recover() func(http.Handler) http.Handler` — catches panics from inner handlers via `defer/recover`, captures the stack trace with `debug.Stack`, and writes HTTP 500; requires no configuration or logger injection
|
||||||
|
- `CORS(origins []string) func(http.Handler) http.Handler` — sets `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods` (`GET, HEAD, PUT, PATCH, POST, DELETE, OPTIONS`), and `Access-Control-Allow-Headers` (`Content-Type, Authorization, X-Request-ID`) for matching origins; short-circuits OPTIONS preflight requests with HTTP 204; pass `[]string{"*"}` for development environments
|
||||||
|
- `RequestID(generator func() string) func(http.Handler) http.Handler` — calls `generator` (e.g. `uuid.NewString`) to produce a unique ID per request, stores it in the context via `logz.WithRequestID`, and writes it to the `X-Request-ID` response header
|
||||||
|
- `RequestLogger(logger Logger) func(http.Handler) http.Handler` — logs method, path, status code, latency, and request ID after the inner handler returns; uses `logger.Error` for 5xx responses and `logger.Info` for all others; reads the request ID from context via `logz.GetRequestID`
|
||||||
|
- `Logger` interface — duck-typed logger accepted by `RequestLogger`; satisfied by `logz.Logger`: `Info(msg string, args ...any)`, `Error(msg string, err error, args ...any)`, `With(args ...any) Logger`
|
||||||
|
- `StatusRecorder` struct — exported `http.ResponseWriter` wrapper that captures the written status code in its `Status` field; used internally by `RequestLogger` and available for custom logging middleware
|
||||||
|
|
||||||
|
### Design Notes
|
||||||
|
|
||||||
|
- `RequestID` and `RequestLogger` use `logz.WithRequestID` and `logz.GetRequestID` directly rather than a locally defined context key; this ensures the request ID is visible to any `logz.Logger` that calls `.WithContext(ctx)` downstream, which would break if the key were re-implemented here.
|
||||||
|
- `Recover` intentionally requires no logger injection in this release: it captures the stack trace but does not log it, keeping the middleware usable with zero configuration; logger injection is deferred to a future release.
|
||||||
|
- No middleware is installed by default; the package exports independent functions and the application chooses the chain and order (recommended: `Recover` → `RequestID` → `RequestLogger` → `CORS`).
|
||||||
|
|
||||||
|
[0.9.0]: https://code.nochebuena.dev/go/httpmw/releases/tag/v0.9.0
|
||||||
106
CLAUDE.md
Normal file
106
CLAUDE.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
**Tier:** 3 (transport layer; depends on Tier 0 `logz`)
|
||||||
|
**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.
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 NOCHEBUENADEV
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
19
compliance_test.go
Normal file
19
compliance_test.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package httpmw_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.nochebuena.dev/go/httpmw"
|
||||||
|
)
|
||||||
|
|
||||||
|
type complianceLogger struct{}
|
||||||
|
|
||||||
|
func (c *complianceLogger) Info(msg string, args ...any) {}
|
||||||
|
func (c *complianceLogger) Error(msg string, err error, args ...any) {}
|
||||||
|
func (c *complianceLogger) With(args ...any) httpmw.Logger { return c }
|
||||||
|
|
||||||
|
// Compile-time check: complianceLogger satisfies httpmw.Logger.
|
||||||
|
var _ httpmw.Logger = (*complianceLogger)(nil)
|
||||||
|
|
||||||
|
// Compile-time check: StatusRecorder satisfies http.ResponseWriter.
|
||||||
|
var _ http.ResponseWriter = (*httpmw.StatusRecorder)(nil)
|
||||||
51
cors.go
Normal file
51
cors.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package httpmw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
allowedMethods = "GET, HEAD, PUT, PATCH, POST, DELETE, OPTIONS"
|
||||||
|
allowedHeaders = "Content-Type, Authorization, X-Request-ID"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CORS applies Cross-Origin Resource Sharing headers.
|
||||||
|
// origins is the allowed origins list. Pass []string{"*"} for development.
|
||||||
|
func CORS(origins []string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
allowed := false
|
||||||
|
for _, o := range origins {
|
||||||
|
if o == "*" || o == origin {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allowed {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", allowedMethods)
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", allowedHeaders)
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// originAllowed is a helper for tests.
|
||||||
|
func originAllowed(origins []string, origin string) bool {
|
||||||
|
for _, o := range origins {
|
||||||
|
if o == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.EqualFold(o, origin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
5
doc.go
Normal file
5
doc.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Package httpmw provides stdlib net/http middleware for transport concerns:
|
||||||
|
// panic recovery, CORS, request ID injection, and structured request logging.
|
||||||
|
//
|
||||||
|
// No authentication or identity logic lives here — see httpauth-firebase for that.
|
||||||
|
package httpmw
|
||||||
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.
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module code.nochebuena.dev/go/httpmw
|
||||||
|
|
||||||
|
go 1.25
|
||||||
|
|
||||||
|
require code.nochebuena.dev/go/logz v0.9.0
|
||||||
2
go.sum
Normal file
2
go.sum
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
code.nochebuena.dev/go/logz v0.9.0 h1:wfV7vtI4V/8ED7Hm31Fbql7Y5iOGrlHN4X8Z5ajTZZE=
|
||||||
|
code.nochebuena.dev/go/logz v0.9.0/go.mod h1:qODhSbKb+tWE7rdhHLcKweiP5CgwIaWoZxadCT3bQV8=
|
||||||
183
httpmw_test.go
Normal file
183
httpmw_test.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package httpmw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.nochebuena.dev/go/logz"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func newLogger() logz.Logger { return logz.New(logz.Options{}) }
|
||||||
|
|
||||||
|
type testLogger struct {
|
||||||
|
last string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testLogger) Info(msg string, args ...any) { t.last = "info:" + msg }
|
||||||
|
func (t *testLogger) Error(msg string, err error, args ...any) { t.last = "error:" + msg }
|
||||||
|
func (t *testLogger) With(args ...any) Logger { return t }
|
||||||
|
|
||||||
|
func chain(mw func(http.Handler) http.Handler, h http.HandlerFunc) http.Handler {
|
||||||
|
return mw(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Recover ---
|
||||||
|
|
||||||
|
func TestRecover_NoPanic(t *testing.T) {
|
||||||
|
h := chain(Recover(), func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("want 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecover_Panic(t *testing.T) {
|
||||||
|
h := chain(Recover(), func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
panic("test panic")
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("want 500, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CORS ---
|
||||||
|
|
||||||
|
func TestCORS_AllowedOrigin(t *testing.T) {
|
||||||
|
h := chain(CORS([]string{"https://example.com"}), func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Origin", "https://example.com")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Header().Get("Access-Control-Allow-Origin") != "https://example.com" {
|
||||||
|
t.Errorf("expected CORS header, got %q", rec.Header().Get("Access-Control-Allow-Origin"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORS_AllowAll(t *testing.T) {
|
||||||
|
h := chain(CORS([]string{"*"}), func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set("Origin", "https://any.com")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Header().Get("Access-Control-Allow-Origin") == "" {
|
||||||
|
t.Error("expected CORS header for wildcard origin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORS_OPTIONS_Preflight(t *testing.T) {
|
||||||
|
h := chain(CORS([]string{"*"}), func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Error("handler should not be called for OPTIONS")
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/", nil)
|
||||||
|
req.Header.Set("Origin", "https://any.com")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("want 204, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RequestID ---
|
||||||
|
|
||||||
|
func TestRequestID_Generated(t *testing.T) {
|
||||||
|
var capturedID string
|
||||||
|
h := chain(RequestID(func() string { return "test-id-123" }),
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
capturedID = logz.GetRequestID(r.Context())
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
if capturedID != "test-id-123" {
|
||||||
|
t.Errorf("want test-id-123 in context, got %q", capturedID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestID_CustomGenerator(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
h := chain(RequestID(func() string { called = true; return "custom" }),
|
||||||
|
func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
if !called {
|
||||||
|
t.Error("custom generator not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestID_ContextReadable(t *testing.T) {
|
||||||
|
var id string
|
||||||
|
h := chain(RequestID(func() string { return "abc" }),
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id = logz.GetRequestID(r.Context())
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
if id != "abc" {
|
||||||
|
t.Errorf("logz.GetRequestID: want abc, got %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestID_HeaderSet(t *testing.T) {
|
||||||
|
h := chain(RequestID(func() string { return "hdr-id" }),
|
||||||
|
func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
if rec.Header().Get("X-Request-ID") != "hdr-id" {
|
||||||
|
t.Errorf("want X-Request-ID=hdr-id, got %q", rec.Header().Get("X-Request-ID"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RequestLogger ---
|
||||||
|
|
||||||
|
func TestRequestLogger_Success(t *testing.T) {
|
||||||
|
lg := &testLogger{}
|
||||||
|
h := chain(RequestLogger(lg), func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/foo", nil))
|
||||||
|
if lg.last != "info:http: request" {
|
||||||
|
t.Errorf("expected info log, got %q", lg.last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestLogger_Error(t *testing.T) {
|
||||||
|
lg := &testLogger{}
|
||||||
|
h := chain(RequestLogger(lg), func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/err", nil))
|
||||||
|
if lg.last != "error:http: request" {
|
||||||
|
t.Errorf("expected error log, got %q", lg.last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- StatusRecorder ---
|
||||||
|
|
||||||
|
func TestStatusRecorder_Default(t *testing.T) {
|
||||||
|
rec := &StatusRecorder{ResponseWriter: httptest.NewRecorder(), Status: http.StatusOK}
|
||||||
|
if rec.Status != http.StatusOK {
|
||||||
|
t.Errorf("default status: want 200, got %d", rec.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusRecorder_Capture(t *testing.T) {
|
||||||
|
rec := &StatusRecorder{ResponseWriter: httptest.NewRecorder(), Status: http.StatusOK}
|
||||||
|
rec.WriteHeader(http.StatusCreated)
|
||||||
|
if rec.Status != http.StatusCreated {
|
||||||
|
t.Errorf("want 201, got %d", rec.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
logger.go
Normal file
64
logger.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package httpmw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.nochebuena.dev/go/logz"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Logger is the minimal interface httpmw needs — satisfied by logz.Logger via duck typing.
|
||||||
|
// httpmw does NOT duck-type the Logger for context purposes (it imports logz for
|
||||||
|
// logz.WithRequestID / logz.GetRequestID context helpers).
|
||||||
|
type Logger interface {
|
||||||
|
Info(msg string, args ...any)
|
||||||
|
Error(msg string, err error, args ...any)
|
||||||
|
With(args ...any) Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusRecorder wraps http.ResponseWriter to expose the written status code.
|
||||||
|
type StatusRecorder struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
Status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StatusRecorder) WriteHeader(code int) {
|
||||||
|
r.Status = code
|
||||||
|
r.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// logzLogger is the subset we need from logz.Logger for RequestLogger.
|
||||||
|
// We accept the Logger interface (duck-typed) but need to also use logz.GetRequestID.
|
||||||
|
// Those two concerns are separate: Logger injection = duck-typed; context helpers = logz import.
|
||||||
|
|
||||||
|
// RequestLogger logs each request with method, path, status code, latency, and request ID.
|
||||||
|
func RequestLogger(logger Logger) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rec := &StatusRecorder{ResponseWriter: w, Status: http.StatusOK}
|
||||||
|
start := time.Now()
|
||||||
|
next.ServeHTTP(rec, r)
|
||||||
|
latency := time.Since(start)
|
||||||
|
|
||||||
|
reqID := logz.GetRequestID(r.Context())
|
||||||
|
if rec.Status >= 500 {
|
||||||
|
logger.Error("http: request",
|
||||||
|
nil,
|
||||||
|
"method", r.Method,
|
||||||
|
"path", r.URL.Path,
|
||||||
|
"status", rec.Status,
|
||||||
|
"latency", latency.String(),
|
||||||
|
"request_id", reqID,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.Info("http: request",
|
||||||
|
"method", r.Method,
|
||||||
|
"path", r.URL.Path,
|
||||||
|
"status", rec.Status,
|
||||||
|
"latency", latency.String(),
|
||||||
|
"request_id", reqID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
23
recover.go
Normal file
23
recover.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package httpmw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Recover returns a middleware that catches panics, logs them, and returns 500.
|
||||||
|
func Recover() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
stack := debug.Stack()
|
||||||
|
http.Error(w, http.StatusText(http.StatusInternalServerError),
|
||||||
|
http.StatusInternalServerError)
|
||||||
|
_ = stack // available for future logger injection
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
22
requestid.go
Normal file
22
requestid.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package httpmw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.nochebuena.dev/go/logz"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestID injects a unique request ID into the context (via logz.WithRequestID)
|
||||||
|
// and the X-Request-ID response header.
|
||||||
|
// generator is typically uuid.NewString.
|
||||||
|
func RequestID(generator func() string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := generator()
|
||||||
|
ctx := logz.WithRequestID(r.Context(), id)
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
w.Header().Set("X-Request-ID", id)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user