// SPDX-License-Identifier: EUPL-1.2 package api import ( "bytes" "container/list" "maps" "net/http" "strconv" "sync" "time" "github.com/gin-gonic/gin" ) // cacheEntry holds a cached response body, status code, headers, and expiry. type cacheEntry struct { status int headers http.Header body []byte size int expires time.Time } // cacheStore is a simple thread-safe in-memory cache keyed by request URL. type cacheStore struct { mu sync.RWMutex entries map[string]*cacheEntry order *list.List index map[string]*list.Element maxEntries int maxBytes int currentBytes int } // newCacheStore creates an empty cache store. func newCacheStore(maxEntries, maxBytes int) *cacheStore { return &cacheStore{ entries: make(map[string]*cacheEntry), order: list.New(), index: make(map[string]*list.Element), maxEntries: maxEntries, maxBytes: maxBytes, } } // get retrieves a non-expired entry for the given key. // Returns nil if the key is missing or expired. func (s *cacheStore) get(key string) *cacheEntry { s.mu.Lock() entry, ok := s.entries[key] if ok { if elem, exists := s.index[key]; exists { s.order.MoveToFront(elem) } } s.mu.Unlock() if !ok { return nil } if time.Now().After(entry.expires) { s.mu.Lock() if elem, exists := s.index[key]; exists { s.order.Remove(elem) delete(s.index, key) } s.currentBytes -= entry.size if s.currentBytes < 0 { s.currentBytes = 0 } delete(s.entries, key) s.mu.Unlock() return nil } return entry } // set stores a cache entry with the given TTL. func (s *cacheStore) set(key string, entry *cacheEntry) { s.mu.Lock() if entry.size <= 0 { entry.size = cacheEntrySize(entry.headers, entry.body) } if elem, ok := s.index[key]; ok { if existing, exists := s.entries[key]; exists { s.currentBytes -= existing.size if s.currentBytes < 0 { s.currentBytes = 0 } } s.order.MoveToFront(elem) s.entries[key] = entry s.currentBytes += entry.size s.evictBySizeLocked() s.mu.Unlock() return } if s.maxBytes > 0 && entry.size > s.maxBytes { s.mu.Unlock() return } for (s.maxEntries > 0 && len(s.entries) >= s.maxEntries) || s.wouldExceedBytesLocked(entry.size) { if !s.evictOldestLocked() { break } } if s.maxBytes > 0 && s.wouldExceedBytesLocked(entry.size) { s.mu.Unlock() return } s.entries[key] = entry elem := s.order.PushFront(key) s.index[key] = elem s.currentBytes += entry.size s.mu.Unlock() } func (s *cacheStore) wouldExceedBytesLocked(nextSize int) bool { if s.maxBytes <= 0 { return false } return s.currentBytes+nextSize > s.maxBytes } func (s *cacheStore) evictBySizeLocked() { for s.maxBytes > 0 && s.currentBytes > s.maxBytes { if !s.evictOldestLocked() { return } } } func (s *cacheStore) evictOldestLocked() bool { back := s.order.Back() if back == nil { return false } oldKey := back.Value.(string) if existing, ok := s.entries[oldKey]; ok { s.currentBytes -= existing.size if s.currentBytes < 0 { s.currentBytes = 0 } } delete(s.entries, oldKey) delete(s.index, oldKey) s.order.Remove(back) return true } // cacheWriter intercepts writes to capture the response body and status. type cacheWriter struct { gin.ResponseWriter body *bytes.Buffer } func (w *cacheWriter) Write(data []byte) (int, error) { w.body.Write(data) return w.ResponseWriter.Write(data) } func (w *cacheWriter) WriteString(s string) (int, error) { w.body.WriteString(s) return w.ResponseWriter.WriteString(s) } // cacheMiddleware returns Gin middleware that caches GET responses in memory. // Only successful responses (2xx) are cached. Non-GET methods pass through. func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc { return func(c *gin.Context) { // Only cache GET requests. if c.Request.Method != http.MethodGet { c.Next() return } key := c.Request.URL.RequestURI() // 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().Add(k, v) } } if requestID := GetRequestID(c); requestID != "" { c.Writer.Header().Set("X-Request-ID", requestID) } else if requestID := c.GetHeader("X-Request-ID"); requestID != "" { 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(body) c.Abort() return } // Wrap the writer to capture the response. cw := &cacheWriter{ ResponseWriter: c.Writer, body: &bytes.Buffer{}, } c.Writer = cw c.Next() // Only cache successful responses. status := cw.ResponseWriter.Status() if status >= 200 && status < 300 { headers := make(http.Header) maps.Copy(headers, cw.ResponseWriter.Header()) store.set(key, &cacheEntry{ status: status, headers: headers, body: cw.body.Bytes(), size: cacheEntrySize(headers, cw.body.Bytes()), expires: time.Now().Add(ttl), }) } } } // 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 { return refreshResponseMetaBody(body, meta) } func cacheEntrySize(headers http.Header, body []byte) int { size := len(body) for key, vals := range headers { size += len(key) for _, val := range vals { size += len(val) } } return size }