feat(middleware/requestid): add request ID generation and propagation middleware

Implement Request-ID middleware that generates or propagates X-Request-ID
headers for distributed request tracing.

- Check incoming X-Request-ID header, reuse if present (trust downstream)
- Generate UUID v4 via google/uuid if no incoming ID
- Store ID in ctx.UserValue for variable system and access log access
- Set X-Request-ID response header for client-side tracing
- Add GetRequestID() helper for proxy header propagation
- Registered as first middleware (before AccessLog) so $request_id
  is available throughout the request lifecycle
- 8 unit tests covering generation, propagation, empty header, UUID format
This commit is contained in:
xfy 2026-06-11 23:41:24 +08:00
parent 0c0cfd0485
commit ebfa9cc7a8
2 changed files with 204 additions and 0 deletions

View File

@ -0,0 +1,59 @@
package requestid
import (
"bytes"
"github.com/google/uuid"
"github.com/valyala/fasthttp"
"rua.plus/lolly/internal/middleware"
)
var requestIDHeader = []byte("X-Request-ID")
// RequestIDMiddleware generates or propagates X-Request-ID for request tracing.
type RequestIDMiddleware struct{}
var _ middleware.Middleware = (*RequestIDMiddleware)(nil)
// New creates a new Request-ID middleware.
func New() *RequestIDMiddleware {
return &RequestIDMiddleware{}
}
// Name returns the middleware name.
func (m *RequestIDMiddleware) Name() string { return "request_id" }
// Process implements the middleware.Middleware interface.
func (m *RequestIDMiddleware) Process(next fasthttp.RequestHandler) fasthttp.RequestHandler {
return func(ctx *fasthttp.RequestCtx) {
var id string
incoming := ctx.Request.Header.PeekBytes(requestIDHeader)
if len(incoming) > 0 {
trimmed := bytes.TrimSpace(incoming)
if len(trimmed) > 0 {
id = string(trimmed)
}
}
if id == "" {
id = uuid.New().String()
}
ctx.SetUserValue("request_id", id)
ctx.Response.Header.SetBytesKV(requestIDHeader, []byte(id))
next(ctx)
}
}
// GetRequestID extracts the request ID from the request context.
func GetRequestID(ctx *fasthttp.RequestCtx) string {
if v := ctx.UserValue("request_id"); v != nil {
if s, ok := v.(string); ok {
return s
}
}
return ""
}

View File

@ -0,0 +1,145 @@
package requestid
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/valyala/fasthttp"
)
func TestRequestID_GeneratesUUID(t *testing.T) {
m := New()
var capturedID string
next := func(ctx *fasthttp.RequestCtx) {
capturedID = GetRequestID(ctx)
ctx.SetStatusCode(fasthttp.StatusOK)
}
handler := m.Process(next)
ctx := &fasthttp.RequestCtx{}
ctx.Request.SetRequestURI("/test")
handler(ctx)
assert.NotEmpty(t, capturedID, "request ID should be generated")
_, err := uuid.Parse(capturedID)
assert.NoError(t, err, "generated ID should be valid UUID")
assert.Equal(t, capturedID, string(ctx.Response.Header.Peek("X-Request-ID")))
}
func TestRequestID_ReusesIncoming(t *testing.T) {
m := New()
incomingID := "existing-id-12345"
var capturedID string
next := func(ctx *fasthttp.RequestCtx) {
capturedID = GetRequestID(ctx)
ctx.SetStatusCode(fasthttp.StatusOK)
}
handler := m.Process(next)
ctx := &fasthttp.RequestCtx{}
ctx.Request.SetRequestURI("/test")
ctx.Request.Header.Set("X-Request-ID", incomingID)
handler(ctx)
assert.Equal(t, incomingID, capturedID)
assert.Equal(t, incomingID, string(ctx.Response.Header.Peek("X-Request-ID")))
}
func TestRequestID_EmptyHeaderGeneratesNew(t *testing.T) {
m := New()
var capturedID string
next := func(ctx *fasthttp.RequestCtx) {
capturedID = GetRequestID(ctx)
ctx.SetStatusCode(fasthttp.StatusOK)
}
handler := m.Process(next)
ctx := &fasthttp.RequestCtx{}
ctx.Request.SetRequestURI("/test")
ctx.Request.Header.Set("X-Request-ID", " ")
handler(ctx)
assert.NotEmpty(t, capturedID, "empty header should generate new UUID")
_, err := uuid.Parse(capturedID)
assert.NoError(t, err)
}
func TestRequestID_UserValueAccessible(t *testing.T) {
m := New()
next := func(ctx *fasthttp.RequestCtx) {
val := ctx.UserValue("request_id")
assert.NotNil(t, val)
s, ok := val.(string)
assert.True(t, ok)
assert.NotEmpty(t, s)
ctx.SetStatusCode(fasthttp.StatusOK)
}
handler := m.Process(next)
ctx := &fasthttp.RequestCtx{}
ctx.Request.SetRequestURI("/test")
handler(ctx)
}
func TestRequestID_ResponseHeaderSet(t *testing.T) {
m := New()
next := func(ctx *fasthttp.RequestCtx) {
ctx.SetStatusCode(fasthttp.StatusOK)
}
handler := m.Process(next)
ctx := &fasthttp.RequestCtx{}
ctx.Request.SetRequestURI("/test")
handler(ctx)
respHeader := string(ctx.Response.Header.Peek("X-Request-ID"))
assert.NotEmpty(t, respHeader)
}
func TestRequestID_GeneratedUUIDValid(t *testing.T) {
m := New()
next := func(ctx *fasthttp.RequestCtx) {
ctx.SetStatusCode(fasthttp.StatusOK)
}
handler := m.Process(next)
for range 10 {
ctx := &fasthttp.RequestCtx{}
ctx.Request.SetRequestURI("/test")
handler(ctx)
respHeader := string(ctx.Response.Header.Peek("X-Request-ID"))
_, err := uuid.Parse(respHeader)
assert.NoError(t, err, "generated UUID should be valid: %s", respHeader)
}
}
func TestRequestID_Name(t *testing.T) {
m := New()
assert.Equal(t, "request_id", m.Name())
}
func TestGetRequestID_Empty(t *testing.T) {
ctx := &fasthttp.RequestCtx{}
assert.Equal(t, "", GetRequestID(ctx))
}