diff --git a/veza-backend-api/internal/api/router.go b/veza-backend-api/internal/api/router.go index f2ed74a43..0d43ba588 100644 --- a/veza-backend-api/internal/api/router.go +++ b/veza-backend-api/internal/api/router.go @@ -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) { diff --git a/veza-backend-api/internal/middleware/cache_headers.go b/veza-backend-api/internal/middleware/cache_headers.go new file mode 100644 index 000000000..da00e0170 --- /dev/null +++ b/veza-backend-api/internal/middleware/cache_headers.go @@ -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, ", ")) + } +} + diff --git a/veza-backend-api/internal/middleware/cache_headers_test.go b/veza-backend-api/internal/middleware/cache_headers_test.go new file mode 100644 index 000000000..09121e837 --- /dev/null +++ b/veza-backend-api/internal/middleware/cache_headers_test.go @@ -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") + } +} + diff --git a/veza-backend-api/internal/middleware/response_cache.go b/veza-backend-api/internal/middleware/response_cache.go new file mode 100644 index 000000000..c8b492345 --- /dev/null +++ b/veza-backend-api/internal/middleware/response_cache.go @@ -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])) +} diff --git a/veza-backend-api/internal/middleware/response_cache_test.go b/veza-backend-api/internal/middleware/response_cache_test.go new file mode 100644 index 000000000..c298ca2f5 --- /dev/null +++ b/veza-backend-api/internal/middleware/response_cache_test.go @@ -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) + } +}