commit ad2a9e465e102c3b56096e6b5c66800b217b508e Author: Rene Nochebuena Date: Thu Mar 19 00:03:24 2026 +0000 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/ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..54f5aae --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..221da82 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6bcca0c --- /dev/null +++ b/CHANGELOG.md @@ -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 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4cb1e5c --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b33b48 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/compliance_test.go b/compliance_test.go new file mode 100644 index 0000000..1c42b0c --- /dev/null +++ b/compliance_test.go @@ -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) diff --git a/cors.go b/cors.go new file mode 100644 index 0000000..a84ff88 --- /dev/null +++ b/cors.go @@ -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 +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..b7da61d --- /dev/null +++ b/doc.go @@ -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 diff --git a/docs/adr/ADR-001-logz-context-import-exception.md b/docs/adr/ADR-001-logz-context-import-exception.md new file mode 100644 index 0000000..9af3ec8 --- /dev/null +++ b/docs/adr/ADR-001-logz-context-import-exception.md @@ -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. diff --git a/docs/adr/ADR-002-requestid-via-logz-context.md b/docs/adr/ADR-002-requestid-via-logz-context.md new file mode 100644 index 0000000..b52852c --- /dev/null +++ b/docs/adr/ADR-002-requestid-via-logz-context.md @@ -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. diff --git a/docs/adr/ADR-003-cors-204-preflight.md b/docs/adr/ADR-003-cors-204-preflight.md new file mode 100644 index 0000000..067ee36 --- /dev/null +++ b/docs/adr/ADR-003-cors-204-preflight.md @@ -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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..03bf987 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module code.nochebuena.dev/go/httpmw + +go 1.25 + +require code.nochebuena.dev/go/logz v0.9.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ebe0c90 --- /dev/null +++ b/go.sum @@ -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= diff --git a/httpmw_test.go b/httpmw_test.go new file mode 100644 index 0000000..f14aca5 --- /dev/null +++ b/httpmw_test.go @@ -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) + } +} diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..70a6f3a --- /dev/null +++ b/logger.go @@ -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, + ) + } + }) + } +} diff --git a/recover.go b/recover.go new file mode 100644 index 0000000..6d734e9 --- /dev/null +++ b/recover.go @@ -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) + }) + } +} diff --git a/requestid.go b/requestid.go new file mode 100644 index 0000000..be40e37 --- /dev/null +++ b/requestid.go @@ -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) + }) + } +}