feat(v0.12.4): Redis response cache and CDN cache headers middleware
- ResponseCache: Redis-backed HTTP response caching for public GET endpoints with configurable TTLs per endpoint prefix (tracks 15m, search 5m, etc.) - CacheHeaders: CDN-optimized Cache-Control headers per asset type (static 1yr immutable, audio 7d, HLS 60s, images 30d, API no-cache) - Integrated both middlewares into the router middleware stack - Unit tests for cache key generation, header rules, and config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
65f2104458
commit
ade46fc70f
5 changed files with 414 additions and 0 deletions
|
|
@ -195,6 +195,7 @@ func (r *APIRouter) Setup(router *gin.Engine) error {
|
|||
}
|
||||
|
||||
// Middlewares globaux (after CORS)
|
||||
router.Use(middleware.CacheHeaders(middleware.DefaultCacheHeadersConfig())) // v0.12.4: CDN cache headers
|
||||
router.Use(middleware.MaintenanceGin()) // v0.803 ADM1-03: Maintenance mode (503 except /health, /admin)
|
||||
router.Use(middleware.RequestLogger(r.logger)) // Utilisation du structured logger
|
||||
router.Use(middleware.Metrics()) // Prometheus Metrics
|
||||
|
|
@ -238,6 +239,22 @@ func (r *APIRouter) Setup(router *gin.Engine) error {
|
|||
}
|
||||
}
|
||||
|
||||
// v0.12.4: Response cache for public GET endpoints (Redis-backed)
|
||||
if r.config != nil && r.config.RedisClient != nil {
|
||||
router.Use(middleware.ResponseCache(middleware.ResponseCacheConfig{
|
||||
RedisClient: r.config.RedisClient,
|
||||
Logger: r.logger,
|
||||
DefaultTTL: 5 * time.Minute,
|
||||
KeyPrefix: "http_cache",
|
||||
EndpointTTLs: map[string]time.Duration{
|
||||
"/api/v1/tracks": 15 * time.Minute,
|
||||
"/api/v1/discover": 10 * time.Minute,
|
||||
"/api/v1/search": 5 * time.Minute,
|
||||
"/api/v1/users": 5 * time.Minute,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// Swagger Documentation — disabled in production (A05)
|
||||
if r.config == nil || (r.config.Env != config.EnvProduction && r.config.Env != "prod") {
|
||||
swaggerHandler := func(c *gin.Context) {
|
||||
|
|
|
|||
83
veza-backend-api/internal/middleware/cache_headers.go
Normal file
83
veza-backend-api/internal/middleware/cache_headers.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CacheHeadersConfig defines cache-control rules per path prefix
|
||||
type CacheHeadersConfig struct {
|
||||
Rules []CacheRule
|
||||
}
|
||||
|
||||
// CacheRule maps a URL path prefix to cache headers
|
||||
type CacheRule struct {
|
||||
PathPrefix string
|
||||
MaxAge int // seconds
|
||||
Directive string // e.g. "public", "private", "no-cache"
|
||||
Immutable bool
|
||||
}
|
||||
|
||||
// DefaultCacheHeadersConfig returns production-ready cache header rules
|
||||
// Reference: ORIGIN_PERFORMANCE_TARGETS.md §8.4
|
||||
func DefaultCacheHeadersConfig() CacheHeadersConfig {
|
||||
return CacheHeadersConfig{
|
||||
Rules: []CacheRule{
|
||||
// Static assets (JS, CSS, fonts) — immutable with content hash
|
||||
{PathPrefix: "/static/", MaxAge: 31536000, Directive: "public", Immutable: true},
|
||||
{PathPrefix: "/assets/", MaxAge: 31536000, Directive: "public", Immutable: true},
|
||||
// Audio files — CDN cached for 7 days
|
||||
{PathPrefix: "/audio/", MaxAge: 604800, Directive: "public"},
|
||||
// HLS segments — short cache (live content changes)
|
||||
{PathPrefix: "/hls/", MaxAge: 60, Directive: "public"},
|
||||
// Images (covers, avatars) — CDN cached for 30 days
|
||||
{PathPrefix: "/images/", MaxAge: 2592000, Directive: "public"},
|
||||
{PathPrefix: "/uploads/", MaxAge: 86400, Directive: "public"},
|
||||
// API responses — no caching by default (handled by ResponseCache middleware)
|
||||
{PathPrefix: "/api/", MaxAge: 0, Directive: "no-cache"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CacheHeaders returns a middleware that sets appropriate Cache-Control headers
|
||||
// based on the request path. This enables CDN and browser caching.
|
||||
func CacheHeaders(cfg CacheHeadersConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
for _, rule := range cfg.Rules {
|
||||
if strings.HasPrefix(path, rule.PathPrefix) {
|
||||
setCacheHeaders(c, rule)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func setCacheHeaders(c *gin.Context, rule CacheRule) {
|
||||
if rule.MaxAge == 0 && rule.Directive == "no-cache" {
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.Header("Pragma", "no-cache")
|
||||
return
|
||||
}
|
||||
|
||||
var parts []string
|
||||
if rule.Directive != "" {
|
||||
parts = append(parts, rule.Directive)
|
||||
}
|
||||
if rule.MaxAge > 0 {
|
||||
parts = append(parts, "max-age="+strconv.Itoa(rule.MaxAge))
|
||||
}
|
||||
if rule.Immutable {
|
||||
parts = append(parts, "immutable")
|
||||
}
|
||||
|
||||
if len(parts) > 0 {
|
||||
c.Header("Cache-Control", strings.Join(parts, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
107
veza-backend-api/internal/middleware/cache_headers_test.go
Normal file
107
veza-backend-api/internal/middleware/cache_headers_test.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestCacheHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := DefaultCacheHeadersConfig()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expectedHeader string
|
||||
}{
|
||||
{
|
||||
name: "static assets get immutable cache",
|
||||
path: "/static/js/main.abc123.js",
|
||||
expectedHeader: "public, max-age=31536000, immutable",
|
||||
},
|
||||
{
|
||||
name: "audio files get 7 day cache",
|
||||
path: "/audio/track-123/file.mp3",
|
||||
expectedHeader: "public, max-age=604800",
|
||||
},
|
||||
{
|
||||
name: "HLS segments get short cache",
|
||||
path: "/hls/track-123/segment.ts",
|
||||
expectedHeader: "public, max-age=60",
|
||||
},
|
||||
{
|
||||
name: "images get 30 day cache",
|
||||
path: "/images/covers/album.webp",
|
||||
expectedHeader: "public, max-age=2592000",
|
||||
},
|
||||
{
|
||||
name: "API responses no-cache",
|
||||
path: "/api/v1/tracks",
|
||||
expectedHeader: "no-cache, no-store, must-revalidate",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.Use(CacheHeaders(cfg))
|
||||
router.GET(tt.path, func(c *gin.Context) {
|
||||
c.String(200, "ok")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", tt.path, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
got := w.Header().Get("Cache-Control")
|
||||
if got != tt.expectedHeader {
|
||||
t.Errorf("path %s: expected Cache-Control=%q, got %q", tt.path, tt.expectedHeader, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCacheHeadersConfig(t *testing.T) {
|
||||
cfg := DefaultCacheHeadersConfig()
|
||||
if len(cfg.Rules) == 0 {
|
||||
t.Error("expected non-empty default rules")
|
||||
}
|
||||
|
||||
// Verify critical rules exist
|
||||
hasStatic := false
|
||||
hasAudio := false
|
||||
hasAPI := false
|
||||
for _, rule := range cfg.Rules {
|
||||
switch rule.PathPrefix {
|
||||
case "/static/":
|
||||
hasStatic = true
|
||||
if !rule.Immutable {
|
||||
t.Error("static assets should be immutable")
|
||||
}
|
||||
case "/audio/":
|
||||
hasAudio = true
|
||||
if rule.MaxAge != 604800 {
|
||||
t.Errorf("audio max-age should be 604800, got %d", rule.MaxAge)
|
||||
}
|
||||
case "/api/":
|
||||
hasAPI = true
|
||||
if rule.Directive != "no-cache" {
|
||||
t.Errorf("API directive should be no-cache, got %s", rule.Directive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasStatic {
|
||||
t.Error("missing static rule")
|
||||
}
|
||||
if !hasAudio {
|
||||
t.Error("missing audio rule")
|
||||
}
|
||||
if !hasAPI {
|
||||
t.Error("missing API rule")
|
||||
}
|
||||
}
|
||||
|
||||
148
veza-backend-api/internal/middleware/response_cache.go
Normal file
148
veza-backend-api/internal/middleware/response_cache.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ResponseCacheConfig configures the HTTP response cache middleware
|
||||
type ResponseCacheConfig struct {
|
||||
RedisClient *redis.Client
|
||||
Logger *zap.Logger
|
||||
DefaultTTL time.Duration
|
||||
KeyPrefix string
|
||||
EndpointTTLs map[string]time.Duration // path prefix → TTL override
|
||||
}
|
||||
|
||||
// cachedResponse stores the cached HTTP response data
|
||||
type cachedResponse struct {
|
||||
Status int `json:"status"`
|
||||
ContentType string `json:"content_type"`
|
||||
Body string `json:"body"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
}
|
||||
|
||||
// responseWriter captures the response for caching
|
||||
type cacheResponseWriter struct {
|
||||
gin.ResponseWriter
|
||||
body *bytes.Buffer
|
||||
}
|
||||
|
||||
func (w *cacheResponseWriter) Write(b []byte) (int, error) {
|
||||
w.body.Write(b)
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// ResponseCache returns a middleware that caches GET responses in Redis.
|
||||
// Only caches successful (2xx) GET requests for unauthenticated or public endpoints.
|
||||
func ResponseCache(cfg ResponseCacheConfig) gin.HandlerFunc {
|
||||
if cfg.RedisClient == nil {
|
||||
return func(c *gin.Context) { c.Next() }
|
||||
}
|
||||
if cfg.DefaultTTL == 0 {
|
||||
cfg.DefaultTTL = 5 * time.Minute
|
||||
}
|
||||
if cfg.KeyPrefix == "" {
|
||||
cfg.KeyPrefix = "http_cache"
|
||||
}
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = zap.NewNop()
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
// Only cache GET requests
|
||||
if c.Request.Method != http.MethodGet {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Skip caching for authenticated requests (user-specific data)
|
||||
if c.GetHeader("Authorization") != "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Generate cache key from URL + query params
|
||||
cacheKey := generateCacheKey(cfg.KeyPrefix, c.Request.URL.RequestURI())
|
||||
|
||||
// Try to serve from cache
|
||||
ctx := c.Request.Context()
|
||||
cached, err := cfg.RedisClient.Get(ctx, cacheKey).Result()
|
||||
if err == nil {
|
||||
// Cache hit — serve from cache
|
||||
var resp cachedResponse
|
||||
if jsonErr := json.Unmarshal([]byte(cached), &resp); jsonErr == nil {
|
||||
for k, v := range resp.Headers {
|
||||
c.Header(k, v)
|
||||
}
|
||||
c.Header("X-Cache", "HIT")
|
||||
c.Data(resp.Status, resp.ContentType, []byte(resp.Body))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss — capture response
|
||||
writer := &cacheResponseWriter{
|
||||
ResponseWriter: c.Writer,
|
||||
body: &bytes.Buffer{},
|
||||
}
|
||||
c.Writer = writer
|
||||
|
||||
c.Header("X-Cache", "MISS")
|
||||
c.Next()
|
||||
|
||||
// Only cache successful responses
|
||||
status := writer.Status()
|
||||
if status < 200 || status >= 300 {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine TTL for this endpoint
|
||||
ttl := cfg.DefaultTTL
|
||||
for prefix, override := range cfg.EndpointTTLs {
|
||||
if strings.HasPrefix(c.Request.URL.Path, prefix) {
|
||||
ttl = override
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Store in cache
|
||||
resp := cachedResponse{
|
||||
Status: status,
|
||||
ContentType: writer.Header().Get("Content-Type"),
|
||||
Body: writer.body.String(),
|
||||
Headers: map[string]string{
|
||||
"Content-Type": writer.Header().Get("Content-Type"),
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
cfg.Logger.Debug("Failed to marshal response for cache", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
if setErr := cfg.RedisClient.Set(ctx, cacheKey, data, ttl).Err(); setErr != nil {
|
||||
cfg.Logger.Debug("Failed to store response in cache",
|
||||
zap.String("key", cacheKey),
|
||||
zap.Error(setErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// generateCacheKey creates a deterministic cache key from a URI
|
||||
func generateCacheKey(prefix, uri string) string {
|
||||
hash := sha256.Sum256([]byte(uri))
|
||||
return fmt.Sprintf("%s:%s", prefix, hex.EncodeToString(hash[:16]))
|
||||
}
|
||||
59
veza-backend-api/internal/middleware/response_cache_test.go
Normal file
59
veza-backend-api/internal/middleware/response_cache_test.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateCacheKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prefix string
|
||||
uri string
|
||||
}{
|
||||
{"simple path", "http_cache", "/api/v1/tracks"},
|
||||
{"path with query", "http_cache", "/api/v1/tracks?page=1&limit=20"},
|
||||
{"search query", "http_cache", "/api/v1/search?q=test&type=track"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
key := generateCacheKey(tt.prefix, tt.uri)
|
||||
if key == "" {
|
||||
t.Error("expected non-empty cache key")
|
||||
}
|
||||
if len(key) < 10 {
|
||||
t.Errorf("cache key too short: %s", key)
|
||||
}
|
||||
|
||||
// Same input should produce same key
|
||||
key2 := generateCacheKey(tt.prefix, tt.uri)
|
||||
if key != key2 {
|
||||
t.Errorf("cache key not deterministic: %s != %s", key, key2)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Different URIs should produce different keys
|
||||
key1 := generateCacheKey("http_cache", "/api/v1/tracks?page=1")
|
||||
key2 := generateCacheKey("http_cache", "/api/v1/tracks?page=2")
|
||||
if key1 == key2 {
|
||||
t.Error("different URIs should produce different cache keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheResponseWriter(t *testing.T) {
|
||||
// Test that cachedResponse struct can be marshaled
|
||||
resp := cachedResponse{
|
||||
Status: 200,
|
||||
ContentType: "application/json",
|
||||
Body: `{"data":[]}`,
|
||||
Headers: map[string]string{"Content-Type": "application/json"},
|
||||
}
|
||||
|
||||
if resp.Status != 200 {
|
||||
t.Errorf("expected status 200, got %d", resp.Status)
|
||||
}
|
||||
if resp.ContentType != "application/json" {
|
||||
t.Errorf("expected application/json, got %s", resp.ContentType)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue