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 9dad291c33
17 changed files with 739 additions and 0 deletions

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

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.

5
go.mod Normal file
View 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
View 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
View 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
View 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
View 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
View 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)
})
}
}