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