From c4cbd018ac07d4eea23ae74d7e9ca344f269b3f6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 14:00:04 +0000 Subject: [PATCH] feat(api): auto-attach request metadata Co-Authored-By: Virgil --- cache.go | 42 +--------- docs/architecture.md | 1 + docs/index.md | 2 +- middleware_test.go | 50 +++++++++++ options.go | 10 +++ response_meta.go | 191 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 254 insertions(+), 42 deletions(-) create mode 100644 response_meta.go diff --git a/cache.go b/cache.go index fdc041b..dd6219e 100644 --- a/cache.go +++ b/cache.go @@ -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) } diff --git a/docs/architecture.md b/docs/architecture.md index d7cd2ad..feaf595 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | diff --git a/docs/index.md b/docs/index.md index 2a27d64..d913c48 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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()` | diff --git a/middleware_test.go b/middleware_test.go index 5ee3b5c..2146b2e 100644 --- a/middleware_test.go +++ b/middleware_test.go @@ -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) { diff --git a/options.go b/options.go index 9e8f40f..dc0f4d4 100644 --- a/options.go +++ b/options.go @@ -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 diff --git a/response_meta.go b/response_meta.go new file mode 100644 index 0000000..c17b6e0 --- /dev/null +++ b/response_meta.go @@ -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] == '{' +}