feat(http/mw): request id middleware with context propagation

This commit is contained in:
2026-04-28 15:04:00 +08:00
parent 98c9ce139f
commit 5bd56de013
4 changed files with 89 additions and 0 deletions
+1
View File
@@ -6,6 +6,7 @@ require (
github.com/go-chi/chi/v5 v5.2.5
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/jackc/pgx/v5 v5.9.2
github.com/oklog/ulid/v2 v2.1.1
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
+3
View File
@@ -105,10 +105,13 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -0,0 +1,48 @@
// Package middleware contains cross-cutting HTTP middleware (request_id, logging, recovery, etc.).
package middleware
import (
"context"
"crypto/rand"
"net/http"
"github.com/oklog/ulid/v2"
)
// ctxKey is an unexported type used to scope context keys to this package and
// prevent collisions with keys defined by other packages.
type ctxKey int
const requestIDKey ctxKey = 1
// RequestID is HTTP middleware that ensures every request carries a stable
// identifier propagated through the request context and the X-Request-ID
// response header.
//
// Behaviour:
// - If the incoming request has X-Client-Request-ID set, that value is
// trusted and reused (allowing clients to correlate retries).
// - Otherwise a fresh ULID is generated using crypto/rand as the entropy
// source so the value is unguessable and time-ordered.
//
// Downstream handlers can retrieve the value via RequestIDFromContext.
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Client-Request-ID")
if id == "" {
id = ulid.MustNew(ulid.Now(), rand.Reader).String()
}
w.Header().Set("X-Request-ID", id)
ctx := context.WithValue(r.Context(), requestIDKey, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// RequestIDFromContext returns the request ID stored in ctx by the RequestID
// middleware, or an empty string if none is present.
func RequestIDFromContext(ctx context.Context) string {
if v, ok := ctx.Value(requestIDKey).(string); ok {
return v
}
return ""
}
@@ -0,0 +1,37 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func TestRequestID_GeneratesWhenAbsent(t *testing.T) {
var seen string
h := RequestID(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
seen = RequestIDFromContext(r.Context())
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
require.NotEmpty(t, seen)
require.Equal(t, seen, rec.Header().Get("X-Request-ID"))
}
func TestRequestID_PreservesClientHeader(t *testing.T) {
const given = "client-supplied-id"
var seen string
h := RequestID(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
seen = RequestIDFromContext(r.Context())
}))
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Client-Request-ID", given)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, given, seen)
require.Equal(t, given, rec.Header().Get("X-Request-ID"))
}