feat(api): auto-attach request metadata

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 14:00:04 +00:00
parent 37b7fd21ae
commit c4cbd018ac
6 changed files with 254 additions and 42 deletions

View file

@ -5,8 +5,6 @@ package api
import (
"bytes"
"container/list"
"encoding/json"
"io"
"maps"
"net/http"
"strconv"
@ -185,43 +183,5 @@ func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc {
// Non-JSON bodies, malformed JSON, and responses without a top-level object are
// returned unchanged.
func refreshCachedResponseMeta(body []byte, meta *Meta) []byte {
if meta == nil {
return body
}
var payload any
dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
if err := dec.Decode(&payload); err != nil {
return body
}
var extra any
if err := dec.Decode(&extra); err != io.EOF {
return body
}
obj, ok := payload.(map[string]any)
if !ok {
return body
}
current := map[string]any{}
if existing, ok := obj["meta"].(map[string]any); ok {
current = existing
}
if meta.RequestID != "" {
current["request_id"] = meta.RequestID
}
if meta.Duration != "" {
current["duration"] = meta.Duration
}
obj["meta"] = current
updated, err := json.Marshal(obj)
if err != nil {
return body
}
return updated
return refreshResponseMetaBody(body, meta)
}

View file

@ -151,6 +151,7 @@ They execute after `gin.Recovery()` but before any route handler. The `Option` t
| `WithAddr(addr)` | Listen address | Default `:8080` |
| `WithBearerAuth(token)` | Static bearer token authentication | Skips `/health` and `/swagger` |
| `WithRequestID()` | `X-Request-ID` propagation | Preserves client-supplied IDs; generates 16-byte hex otherwise |
| `WithResponseMeta()` | Request metadata in JSON envelopes | Merges `request_id` and `duration` into standard responses |
| `WithCORS(origins...)` | CORS policy | `"*"` enables `AllowAllOrigins`; 12-hour `MaxAge` |
| `WithRateLimit(limit)` | Per-IP token-bucket rate limiting | `429 Too Many Requests`; `Retry-After` on rejection; zero or negative disables |
| `WithMiddleware(mw...)` | Arbitrary Gin middleware | Escape hatch for custom middleware |

View file

@ -94,7 +94,7 @@ engine.Register(&Routes{service: svc})
| File | Purpose |
|------|---------|
| `api.go` | `Engine` struct, `New()`, `build()`, `Serve()`, `Handler()`, `Channels()` |
| `options.go` | All `With*()` option functions (26 options) |
| `options.go` | All `With*()` option functions (27 options) |
| `group.go` | `RouteGroup`, `StreamGroup`, `DescribableGroup` interfaces; `RouteDescription` |
| `response.go` | `Response[T]`, `Error`, `Meta`, `OK()`, `Fail()`, `FailWithDetails()`, `Paginated()` |
| `middleware.go` | `bearerAuthMiddleware()`, `requestIDMiddleware()` |

View file

@ -52,6 +52,17 @@ func (g requestMetaTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
})
}
type autoResponseMetaTestGroup struct{}
func (g autoResponseMetaTestGroup) Name() string { return "auto-response-meta" }
func (g autoResponseMetaTestGroup) BasePath() string { return "/v1" }
func (g autoResponseMetaTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/meta", func(c *gin.Context) {
time.Sleep(2 * time.Millisecond)
c.JSON(http.StatusOK, api.Paginated("classified", 1, 25, 100))
})
}
// ── Bearer auth ─────────────────────────────────────────────────────────
func TestBearerAuth_Bad_MissingToken(t *testing.T) {
@ -235,6 +246,45 @@ func TestRequestID_Good_RequestMetaHelper(t *testing.T) {
}
}
func TestResponseMeta_Good_AttachesMetaAutomatically(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(
api.WithRequestID(),
api.WithResponseMeta(),
)
e.Register(autoResponseMetaTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/meta", nil)
req.Header.Set("X-Request-ID", "client-id-auto-meta")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp api.Response[string]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Meta == nil {
t.Fatal("expected Meta to be present")
}
if resp.Meta.RequestID != "client-id-auto-meta" {
t.Fatalf("expected request_id=%q, got %q", "client-id-auto-meta", resp.Meta.RequestID)
}
if resp.Meta.Duration == "" {
t.Fatal("expected duration to be populated")
}
if resp.Meta.Page != 1 || resp.Meta.PerPage != 25 || resp.Meta.Total != 100 {
t.Fatalf("expected pagination metadata to be preserved, got %+v", resp.Meta)
}
if got := w.Header().Get("X-Request-ID"); got != "client-id-auto-meta" {
t.Fatalf("expected response header X-Request-ID=%q, got %q", "client-id-auto-meta", got)
}
}
// ── CORS ────────────────────────────────────────────────────────────────
func TestCORS_Good_PreflightAllOrigins(t *testing.T) {

View file

@ -52,6 +52,16 @@ func WithRequestID() Option {
}
}
// WithResponseMeta attaches request metadata to JSON envelope responses.
// It preserves any existing pagination metadata and merges in request_id
// and duration when available from the request context. Combine it with
// WithRequestID() to populate both fields automatically.
func WithResponseMeta() Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, responseMetaMiddleware())
}
}
// WithCORS configures Cross-Origin Resource Sharing via gin-contrib/cors.
// Pass "*" to allow all origins, or supply specific origin URLs.
// Standard methods (GET, POST, PUT, PATCH, DELETE, OPTIONS) and common

191
response_meta.go Normal file
View file

@ -0,0 +1,191 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"bufio"
"bytes"
"encoding/json"
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// responseMetaRecorder buffers JSON responses so request metadata can be
// injected into the standard envelope before the body is written to the client.
type responseMetaRecorder struct {
gin.ResponseWriter
headers http.Header
body bytes.Buffer
status int
wroteHeader bool
}
func newResponseMetaRecorder(w gin.ResponseWriter) *responseMetaRecorder {
headers := make(http.Header)
for k, vals := range w.Header() {
headers[k] = append([]string(nil), vals...)
}
return &responseMetaRecorder{
ResponseWriter: w,
headers: headers,
status: http.StatusOK,
}
}
func (w *responseMetaRecorder) Header() http.Header {
return w.headers
}
func (w *responseMetaRecorder) WriteHeader(code int) {
w.status = code
w.wroteHeader = true
}
func (w *responseMetaRecorder) WriteHeaderNow() {
w.wroteHeader = true
}
func (w *responseMetaRecorder) Write(data []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.body.Write(data)
}
func (w *responseMetaRecorder) WriteString(s string) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.body.WriteString(s)
}
func (w *responseMetaRecorder) Flush() {
}
func (w *responseMetaRecorder) Status() int {
if w.wroteHeader {
return w.status
}
return http.StatusOK
}
func (w *responseMetaRecorder) Size() int {
return w.body.Len()
}
func (w *responseMetaRecorder) Written() bool {
return w.wroteHeader
}
func (w *responseMetaRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return nil, nil, io.ErrClosedPipe
}
func (w *responseMetaRecorder) commit() {
for k := range w.ResponseWriter.Header() {
w.ResponseWriter.Header().Del(k)
}
for k, vals := range w.headers {
for _, v := range vals {
w.ResponseWriter.Header().Add(k, v)
}
}
w.ResponseWriter.WriteHeader(w.Status())
_, _ = w.ResponseWriter.Write(w.body.Bytes())
}
// responseMetaMiddleware injects request metadata into successful JSON
// envelope responses before they are written to the client.
func responseMetaMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if _, ok := c.Get(requestStartContextKey); !ok {
c.Set(requestStartContextKey, time.Now())
}
recorder := newResponseMetaRecorder(c.Writer)
c.Writer = recorder
c.Next()
body := recorder.body.Bytes()
if meta := GetRequestMeta(c); meta != nil && shouldAttachResponseMeta(recorder.Header().Get("Content-Type"), body) {
if refreshed := refreshResponseMetaBody(body, meta); refreshed != nil {
body = refreshed
}
}
recorder.body.Reset()
_, _ = recorder.body.Write(body)
recorder.Header().Set("Content-Length", strconv.Itoa(len(body)))
recorder.commit()
}
}
// refreshResponseMetaBody injects request metadata into a cached or buffered
// JSON envelope without disturbing existing pagination metadata.
func refreshResponseMetaBody(body []byte, meta *Meta) []byte {
if meta == nil {
return body
}
var payload any
dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
if err := dec.Decode(&payload); err != nil {
return body
}
var extra any
if err := dec.Decode(&extra); err != io.EOF {
return body
}
obj, ok := payload.(map[string]any)
if !ok {
return body
}
if _, ok := obj["success"]; !ok {
return body
}
current := map[string]any{}
if existing, ok := obj["meta"].(map[string]any); ok {
current = existing
}
if meta.RequestID != "" {
current["request_id"] = meta.RequestID
}
if meta.Duration != "" {
current["duration"] = meta.Duration
}
obj["meta"] = current
updated, err := json.Marshal(obj)
if err != nil {
return body
}
return updated
}
func shouldAttachResponseMeta(contentType string, body []byte) bool {
if !strings.Contains(contentType, "application/json") {
return false
}
trimmed := bytes.TrimSpace(body)
return len(trimmed) > 0 && trimmed[0] == '{'
}