From 5bd56de0134ba6d5b2316921b23d6a24ed185d4e Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Tue, 28 Apr 2026 15:04:00 +0800 Subject: [PATCH] feat(http/mw): request id middleware with context propagation --- go.mod | 1 + go.sum | 3 ++ .../transport/http/middleware/request_id.go | 48 +++++++++++++++++++ .../http/middleware/request_id_test.go | 37 ++++++++++++++ 4 files changed, 89 insertions(+) create mode 100644 internal/transport/http/middleware/request_id.go create mode 100644 internal/transport/http/middleware/request_id_test.go diff --git a/go.mod b/go.mod index 5a81053..c2089fc 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 82cb088..a7ddd25 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/transport/http/middleware/request_id.go b/internal/transport/http/middleware/request_id.go new file mode 100644 index 0000000..c0246d1 --- /dev/null +++ b/internal/transport/http/middleware/request_id.go @@ -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 "" +} diff --git a/internal/transport/http/middleware/request_id_test.go b/internal/transport/http/middleware/request_id_test.go new file mode 100644 index 0000000..5ab2b5f --- /dev/null +++ b/internal/transport/http/middleware/request_id_test.go @@ -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")) +}