From 37b7fd21aea72f7a9e94881f27f48f981b92ad47 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 13:53:37 +0000 Subject: [PATCH] feat(cache): refresh request meta on cache hits Co-Authored-By: Virgil --- cache.go | 60 +++++++++++++++++++++++++++++++++++++++- cache_test.go | 76 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 133 insertions(+), 3 deletions(-) diff --git a/cache.go b/cache.go index 01fdd91..fdc041b 100644 --- a/cache.go +++ b/cache.go @@ -5,8 +5,11 @@ package api import ( "bytes" "container/list" + "encoding/json" + "io" "maps" "net/http" + "strconv" "sync" "time" @@ -124,10 +127,18 @@ func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc { // Serve from cache if a valid entry exists. if entry := store.get(key); entry != nil { + body := entry.body + if meta := GetRequestMeta(c); meta != nil { + body = refreshCachedResponseMeta(entry.body, meta) + } + for k, vals := range entry.headers { if http.CanonicalHeaderKey(k) == "X-Request-ID" { continue } + if http.CanonicalHeaderKey(k) == "Content-Length" { + continue + } for _, v := range vals { c.Writer.Header().Set(k, v) } @@ -138,8 +149,9 @@ func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc { c.Writer.Header().Set("X-Request-ID", requestID) } c.Writer.Header().Set("X-Cache", "HIT") + c.Writer.Header().Set("Content-Length", strconv.Itoa(len(body))) c.Writer.WriteHeader(entry.status) - _, _ = c.Writer.Write(entry.body) + _, _ = c.Writer.Write(body) c.Abort() return } @@ -167,3 +179,49 @@ func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc { } } } + +// refreshCachedResponseMeta updates the meta envelope in a cached JSON body so +// request-scoped metadata reflects the current request instead of the cache fill. +// 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 +} diff --git a/cache_test.go b/cache_test.go index fa88610..1c05f22 100644 --- a/cache_test.go +++ b/cache_test.go @@ -250,8 +250,80 @@ func TestWithCache_Good_PreservesCurrentRequestIDOnHit(t *testing.T) { if got := w2.Header().Get("X-Cache"); got != "HIT" { t.Fatalf("expected X-Cache=HIT, got %q", got) } - if body1, body2 := w1.Body.String(), w2.Body.String(); body1 != body2 { - t.Fatalf("expected cached body %q, got %q", body1, body2) + + var resp2 api.Response[string] + if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp2.Data != "call-1" { + t.Fatalf("expected cached response data %q, got %q", "call-1", resp2.Data) + } + if resp2.Meta == nil { + t.Fatal("expected cached response meta to be attached") + } + if resp2.Meta.RequestID != "second-request-id" { + t.Fatalf("expected cached response request_id=%q, got %q", "second-request-id", resp2.Meta.RequestID) + } + if resp2.Meta.Duration == "" { + t.Fatal("expected cached response duration to be refreshed") + } +} + +func TestWithCache_Good_PreservesCurrentRequestMetaOnHit(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New( + api.WithRequestID(), + api.WithCache(5*time.Second), + ) + e.Register(requestMetaTestGroup{}) + + h := e.Handler() + + w1 := httptest.NewRecorder() + req1, _ := http.NewRequest(http.MethodGet, "/v1/meta", nil) + req1.Header.Set("X-Request-ID", "first-request-id") + h.ServeHTTP(w1, req1) + if w1.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w1.Code) + } + + var resp1 api.Response[string] + if err := json.Unmarshal(w1.Body.Bytes(), &resp1); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp1.Meta == nil { + t.Fatal("expected meta on first response") + } + if resp1.Meta.RequestID != "first-request-id" { + t.Fatalf("expected first response request_id=%q, got %q", "first-request-id", resp1.Meta.RequestID) + } + + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest(http.MethodGet, "/v1/meta", nil) + req2.Header.Set("X-Request-ID", "second-request-id") + h.ServeHTTP(w2, req2) + if w2.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w2.Code) + } + + var resp2 api.Response[string] + if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp2.Meta == nil { + t.Fatal("expected meta on cached response") + } + if resp2.Meta.RequestID != "second-request-id" { + t.Fatalf("expected cached response request_id=%q, got %q", "second-request-id", resp2.Meta.RequestID) + } + if resp2.Meta.Duration == "" { + t.Fatal("expected cached response duration to be refreshed") + } + if resp2.Meta.Page != 1 || resp2.Meta.PerPage != 25 || resp2.Meta.Total != 100 { + t.Fatalf("expected pagination metadata to remain intact, got %+v", resp2.Meta) + } + if got := w2.Header().Get("X-Request-ID"); got != "second-request-id" { + t.Fatalf("expected response header X-Request-ID=%q, got %q", "second-request-id", got) } }