Skip to main content

armature_core/
response_cache.rs

1//! HTTP Response Caching
2//!
3//! This module provides comprehensive HTTP response caching support including:
4//!
5//! - `Cache-Control` header parsing and generation
6//! - In-memory response caching with TTL
7//! - Cache key generation from requests
8//! - Vary header support
9//! - Cache invalidation
10//!
11//! # Examples
12//!
13//! ## Cache-Control Headers
14//!
15//! ```
16//! use armature_core::response_cache::{CacheControl, CacheDirective};
17//! use std::time::Duration;
18//!
19//! // Create Cache-Control header
20//! let cache_control = CacheControl::new()
21//!     .public()
22//!     .max_age(Duration::from_secs(3600))
23//!     .must_revalidate();
24//!
25//! assert_eq!(cache_control.to_header_value(), "public, max-age=3600, must-revalidate");
26//! ```
27//!
28//! ## Response Caching
29//!
30//! ```ignore
31//! use armature_core::response_cache::{ResponseCache, CacheControl};
32//!
33//! let cache = ResponseCache::new();
34//!
35//! // Cache a response
36//! cache.store(&request, &response).await;
37//!
38//! // Retrieve cached response
39//! if let Some(cached) = cache.get(&request).await {
40//!     return Ok(cached);
41//! }
42//! ```
43
44use crate::{HttpRequest, HttpResponse};
45use std::collections::{HashMap, HashSet, VecDeque};
46use std::fmt;
47use std::sync::{Arc, Mutex};
48use std::time::{Duration, Instant, SystemTime};
49use tokio::sync::RwLock;
50
51// ============================================================================
52// Cache Directives
53// ============================================================================
54
55/// Individual cache directive from Cache-Control header.
56#[derive(Debug, Clone, PartialEq)]
57pub enum CacheDirective {
58    /// Response may be cached by any cache
59    Public,
60    /// Response is for a single user and must not be stored by shared caches
61    Private,
62    /// Response must not be stored in any cache
63    NoStore,
64    /// Response can be stored but must be validated before use
65    NoCache,
66    /// Maximum time the response is fresh (in seconds)
67    MaxAge(u64),
68    /// Maximum time a shared cache may store the response (in seconds)
69    SMaxAge(u64),
70    /// Response must be revalidated after becoming stale
71    MustRevalidate,
72    /// Shared caches must revalidate after becoming stale
73    ProxyRevalidate,
74    /// Response must not be transformed (e.g., compressed)
75    NoTransform,
76    /// Response is immutable and won't change
77    Immutable,
78    /// Client will accept stale response up to N seconds
79    MaxStale(Option<u64>),
80    /// Client wants response fresh for at least N seconds
81    MinFresh(u64),
82    /// Client will only accept cached response
83    OnlyIfCached,
84    /// Custom/unknown directive
85    Extension(String, Option<String>),
86}
87
88impl CacheDirective {
89    /// Parse a single directive from a string.
90    pub fn parse(s: &str) -> Option<Self> {
91        let s = s.trim().to_lowercase();
92
93        // Check for directives with values
94        if let Some((key, value)) = s.split_once('=') {
95            let key = key.trim();
96            let value = value.trim().trim_matches('"');
97
98            return match key {
99                "max-age" => value.parse().ok().map(CacheDirective::MaxAge),
100                "s-maxage" => value.parse().ok().map(CacheDirective::SMaxAge),
101                "max-stale" => Some(CacheDirective::MaxStale(value.parse().ok())),
102                "min-fresh" => value.parse().ok().map(CacheDirective::MinFresh),
103                _ => Some(CacheDirective::Extension(
104                    key.to_string(),
105                    Some(value.to_string()),
106                )),
107            };
108        }
109
110        // Simple directives
111        match s.as_str() {
112            "public" => Some(CacheDirective::Public),
113            "private" => Some(CacheDirective::Private),
114            "no-store" => Some(CacheDirective::NoStore),
115            "no-cache" => Some(CacheDirective::NoCache),
116            "must-revalidate" => Some(CacheDirective::MustRevalidate),
117            "proxy-revalidate" => Some(CacheDirective::ProxyRevalidate),
118            "no-transform" => Some(CacheDirective::NoTransform),
119            "immutable" => Some(CacheDirective::Immutable),
120            "max-stale" => Some(CacheDirective::MaxStale(None)),
121            "only-if-cached" => Some(CacheDirective::OnlyIfCached),
122            _ => Some(CacheDirective::Extension(s, None)),
123        }
124    }
125
126    /// Convert directive to header value string.
127    pub fn to_header_value(&self) -> String {
128        match self {
129            CacheDirective::Public => "public".to_string(),
130            CacheDirective::Private => "private".to_string(),
131            CacheDirective::NoStore => "no-store".to_string(),
132            CacheDirective::NoCache => "no-cache".to_string(),
133            CacheDirective::MaxAge(secs) => format!("max-age={}", secs),
134            CacheDirective::SMaxAge(secs) => format!("s-maxage={}", secs),
135            CacheDirective::MustRevalidate => "must-revalidate".to_string(),
136            CacheDirective::ProxyRevalidate => "proxy-revalidate".to_string(),
137            CacheDirective::NoTransform => "no-transform".to_string(),
138            CacheDirective::Immutable => "immutable".to_string(),
139            CacheDirective::MaxStale(Some(secs)) => format!("max-stale={}", secs),
140            CacheDirective::MaxStale(None) => "max-stale".to_string(),
141            CacheDirective::MinFresh(secs) => format!("min-fresh={}", secs),
142            CacheDirective::OnlyIfCached => "only-if-cached".to_string(),
143            CacheDirective::Extension(key, Some(value)) => format!("{}={}", key, value),
144            CacheDirective::Extension(key, None) => key.clone(),
145        }
146    }
147}
148
149// ============================================================================
150// Cache-Control Header
151// ============================================================================
152
153/// Parsed or constructed Cache-Control header.
154///
155/// # Examples
156///
157/// ## Parsing
158///
159/// ```
160/// use armature_core::response_cache::CacheControl;
161///
162/// let cc = CacheControl::parse("public, max-age=3600, must-revalidate");
163/// assert!(cc.is_public());
164/// assert_eq!(cc.get_max_age(), Some(3600));
165/// ```
166///
167/// ## Building
168///
169/// ```
170/// use armature_core::response_cache::CacheControl;
171/// use std::time::Duration;
172///
173/// let cc = CacheControl::new()
174///     .private()
175///     .max_age(Duration::from_secs(300))
176///     .no_transform();
177///
178/// assert!(cc.is_private());
179/// ```
180#[derive(Debug, Clone, Default)]
181pub struct CacheControl {
182    /// All directives in this Cache-Control header
183    pub directives: Vec<CacheDirective>,
184}
185
186impl CacheControl {
187    /// Create a new empty Cache-Control.
188    pub fn new() -> Self {
189        Self::default()
190    }
191
192    /// Parse a Cache-Control header value.
193    pub fn parse(header: &str) -> Self {
194        let directives: Vec<CacheDirective> = header
195            .split(',')
196            .filter_map(|s| CacheDirective::parse(s.trim()))
197            .collect();
198
199        Self { directives }
200    }
201
202    /// Convert to header value string.
203    pub fn to_header_value(&self) -> String {
204        self.directives
205            .iter()
206            .map(|d| d.to_header_value())
207            .collect::<Vec<_>>()
208            .join(", ")
209    }
210
211    // ==================== Builder Methods ====================
212
213    /// Add the `public` directive.
214    pub fn public(mut self) -> Self {
215        self.directives.push(CacheDirective::Public);
216        self
217    }
218
219    /// Add the `private` directive.
220    pub fn private(mut self) -> Self {
221        self.directives.push(CacheDirective::Private);
222        self
223    }
224
225    /// Add the `no-store` directive.
226    pub fn no_store(mut self) -> Self {
227        self.directives.push(CacheDirective::NoStore);
228        self
229    }
230
231    /// Add the `no-cache` directive.
232    pub fn no_cache(mut self) -> Self {
233        self.directives.push(CacheDirective::NoCache);
234        self
235    }
236
237    /// Add the `max-age` directive.
238    pub fn max_age(mut self, duration: Duration) -> Self {
239        self.directives
240            .push(CacheDirective::MaxAge(duration.as_secs()));
241        self
242    }
243
244    /// Add the `s-maxage` directive for shared caches.
245    pub fn s_maxage(mut self, duration: Duration) -> Self {
246        self.directives
247            .push(CacheDirective::SMaxAge(duration.as_secs()));
248        self
249    }
250
251    /// Add the `must-revalidate` directive.
252    pub fn must_revalidate(mut self) -> Self {
253        self.directives.push(CacheDirective::MustRevalidate);
254        self
255    }
256
257    /// Add the `proxy-revalidate` directive.
258    pub fn proxy_revalidate(mut self) -> Self {
259        self.directives.push(CacheDirective::ProxyRevalidate);
260        self
261    }
262
263    /// Add the `no-transform` directive.
264    pub fn no_transform(mut self) -> Self {
265        self.directives.push(CacheDirective::NoTransform);
266        self
267    }
268
269    /// Add the `immutable` directive.
270    pub fn immutable(mut self) -> Self {
271        self.directives.push(CacheDirective::Immutable);
272        self
273    }
274
275    /// Add a custom directive.
276    pub fn directive(mut self, directive: CacheDirective) -> Self {
277        self.directives.push(directive);
278        self
279    }
280
281    // ==================== Query Methods ====================
282
283    /// Check if `public` directive is present.
284    pub fn is_public(&self) -> bool {
285        self.directives
286            .iter()
287            .any(|d| matches!(d, CacheDirective::Public))
288    }
289
290    /// Check if `private` directive is present.
291    pub fn is_private(&self) -> bool {
292        self.directives
293            .iter()
294            .any(|d| matches!(d, CacheDirective::Private))
295    }
296
297    /// Check if `no-store` directive is present.
298    pub fn is_no_store(&self) -> bool {
299        self.directives
300            .iter()
301            .any(|d| matches!(d, CacheDirective::NoStore))
302    }
303
304    /// Check if `no-cache` directive is present.
305    pub fn is_no_cache(&self) -> bool {
306        self.directives
307            .iter()
308            .any(|d| matches!(d, CacheDirective::NoCache))
309    }
310
311    /// Check if `must-revalidate` directive is present.
312    pub fn is_must_revalidate(&self) -> bool {
313        self.directives
314            .iter()
315            .any(|d| matches!(d, CacheDirective::MustRevalidate))
316    }
317
318    /// Check if `immutable` directive is present.
319    pub fn is_immutable(&self) -> bool {
320        self.directives
321            .iter()
322            .any(|d| matches!(d, CacheDirective::Immutable))
323    }
324
325    /// Get the `max-age` value in seconds.
326    pub fn get_max_age(&self) -> Option<u64> {
327        self.directives.iter().find_map(|d| match d {
328            CacheDirective::MaxAge(secs) => Some(*secs),
329            _ => None,
330        })
331    }
332
333    /// Get the `s-maxage` value in seconds.
334    pub fn get_s_maxage(&self) -> Option<u64> {
335        self.directives.iter().find_map(|d| match d {
336            CacheDirective::SMaxAge(secs) => Some(*secs),
337            _ => None,
338        })
339    }
340
341    /// Check if the response is cacheable.
342    pub fn is_cacheable(&self) -> bool {
343        // Not cacheable if no-store is present
344        if self.is_no_store() {
345            return false;
346        }
347
348        // Cacheable if public, private, or has max-age/s-maxage
349        self.is_public()
350            || self.is_private()
351            || self.get_max_age().is_some()
352            || self.get_s_maxage().is_some()
353    }
354
355    /// Get the freshness lifetime in seconds.
356    ///
357    /// Returns s-maxage if present (for shared caches), otherwise max-age.
358    pub fn freshness_lifetime(&self) -> Option<u64> {
359        self.get_s_maxage().or_else(|| self.get_max_age())
360    }
361
362    // ==================== Preset Configurations ====================
363
364    /// Create a "no-store" Cache-Control (never cache).
365    pub fn never() -> Self {
366        Self::new().no_store().no_cache()
367    }
368
369    /// Create a public cache with the given max-age.
370    pub fn public_max_age(duration: Duration) -> Self {
371        Self::new().public().max_age(duration)
372    }
373
374    /// Create a private cache with the given max-age.
375    pub fn private_max_age(duration: Duration) -> Self {
376        Self::new().private().max_age(duration)
377    }
378
379    /// Create an immutable public cache (for versioned assets).
380    pub fn immutable_asset(duration: Duration) -> Self {
381        Self::new().public().max_age(duration).immutable()
382    }
383
384    /// Create a must-revalidate cache.
385    pub fn revalidate(duration: Duration) -> Self {
386        Self::new().public().max_age(duration).must_revalidate()
387    }
388}
389
390impl fmt::Display for CacheControl {
391    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
392        write!(f, "{}", self.to_header_value())
393    }
394}
395
396// ============================================================================
397// Cache Key
398// ============================================================================
399
400/// Cache key for HTTP responses.
401#[derive(Debug, Clone, PartialEq, Eq, Hash)]
402pub struct CacheKey {
403    /// HTTP method
404    pub method: crate::Method,
405    /// Request path
406    pub path: crate::ByteStr,
407    /// Canonicalized query string: the decoded pairs sorted by name, each key
408    /// and value length-prefixed as `{len}:{bytes}`.
409    ///
410    /// Not a re-rendered query string — the pairs arrive percent-decoded, so a
411    /// value may itself contain `&` or `=`. Joined naively, `?a=1%26b%3D2` (one
412    /// param whose value is `1&b=2`) and `?a=1&b=2` (two params) both render
413    /// `a=1&b=2` and would share a cache entry, letting one request be served
414    /// the other's body. The length prefix fixes where each field ends
415    /// regardless of the bytes inside it, so distinct pair sets always differ
416    /// here.
417    pub query: String,
418    /// Vary header values that affect caching
419    pub vary_values: Vec<(String, String)>,
420    /// Hash of the request body, for methods where the body identifies the
421    /// resource being queried (QUERY, draft-ietf-httpbis-safe-method-w-body).
422    /// `None` for methods whose body does not participate in the cache key.
423    pub body_hash: Option<u64>,
424}
425
426impl CacheKey {
427    /// Generate a cache key from a request.
428    pub fn from_request(request: &HttpRequest) -> Self {
429        Self::from_request_with_vary(request, &[])
430    }
431
432    /// Generate a cache key from a request with Vary headers.
433    pub fn from_request_with_vary(request: &HttpRequest, vary_headers: &[&str]) -> Self {
434        // Sort query params for consistent keys, then length-prefix each field
435        // so decoded delimiters can't merge two different pair sets into one
436        // key (see the `query` field docs).
437        use fmt::Write as _;
438        let mut query_params: Vec<_> = request.query().iter().collect();
439        query_params.sort_by(|a, b| a.0.cmp(b.0));
440        let mut query = String::new();
441        for (k, v) in &query_params {
442            let _ = write!(query, "{}:{}={}:{}&", k.len(), k, v.len(), v);
443        }
444
445        // Collect Vary header values
446        let mut vary_values: Vec<(String, String)> = vary_headers
447            .iter()
448            .filter_map(|header| {
449                // One lookup: header names intern case-insensitively, so the
450                // lowercased retry was always redundant.
451                request
452                    .headers
453                    .get(header)
454                    .map(|v| (header.to_lowercase(), v.to_owned()))
455            })
456            .collect();
457        vary_values.sort_by(|a, b| a.0.cmp(&b.0));
458
459        let method = request.method.clone();
460
461        // For QUERY the request body *is* the query, so two requests with
462        // different bodies are different cache entries.
463        let body_hash = if method == "QUERY" {
464            use std::hash::{Hash, Hasher};
465            let mut hasher = std::hash::DefaultHasher::new();
466            request.body_bytes().hash(&mut hasher);
467            Some(hasher.finish())
468        } else {
469            None
470        };
471
472        Self {
473            method,
474            // The path alone: `query` is a separate, sorted field, so folding
475            // the raw query in here would key the same request two ways.
476            path: crate::ByteStr::from(request.path_only()),
477            query,
478            vary_values,
479            body_hash,
480        }
481    }
482
483    /// Convert to a string representation suitable for use as a cache key.
484    pub fn to_string_key(&self) -> String {
485        let vary_str = if self.vary_values.is_empty() {
486            String::new()
487        } else {
488            format!(
489                "|{}",
490                self.vary_values
491                    .iter()
492                    .map(|(k, v)| format!("{}:{}", k, v))
493                    .collect::<Vec<_>>()
494                    .join(",")
495            )
496        };
497
498        let body_str = self
499            .body_hash
500            .map(|h| format!("|body:{:016x}", h))
501            .unwrap_or_default();
502
503        if self.query.is_empty() {
504            format!("{}:{}{}{}", self.method, self.path, body_str, vary_str)
505        } else {
506            format!(
507                "{}:{}?{}{}{}",
508                self.method, self.path, self.query, body_str, vary_str
509            )
510        }
511    }
512}
513
514impl fmt::Display for CacheKey {
515    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
516        write!(f, "{}", self.to_string_key())
517    }
518}
519
520// ============================================================================
521// Cached Response
522// ============================================================================
523
524/// A cached HTTP response with metadata.
525#[derive(Debug, Clone)]
526pub struct CachedResponse {
527    /// The cached response
528    pub response: CachedResponseData,
529    /// When the response was cached
530    pub cached_at: Instant,
531    /// When the response expires
532    pub expires_at: Instant,
533    /// ETag of the cached response
534    pub etag: Option<String>,
535    /// Last-Modified timestamp
536    pub last_modified: Option<SystemTime>,
537    /// Vary headers that affect this cache entry
538    pub vary: Vec<String>,
539    /// The base cache key (no Vary values) this entry was stored under.
540    ///
541    /// Recorded by [`ResponseCache::store_with_ttl`] so that eviction and TTL
542    /// purging can keep the `vary_index` in lockstep with `entries`: when the
543    /// last variant sharing a base key is removed, its `vary_index` entry can
544    /// be removed too. `None` for entries created outside the cache (e.g. via
545    /// [`CachedResponse::new`] directly), which are never tracked there.
546    pub(crate) base_key: Option<String>,
547    /// Monotonic insertion sequence used by the [`EvictionIndex`] to identify
548    /// this entry's *current* position in `order`. Each store assigns a fresh
549    /// value (see [`ResponseCache::store_with_ttl`]); a re-store (refresh)
550    /// therefore bumps it, so eviction and compaction can tell a live entry's
551    /// current `order` position from a stale duplicate left by an earlier
552    /// insert. `0` for entries created outside the cache, which never enter the
553    /// eviction index.
554    pub(crate) eviction_seq: u64,
555}
556
557/// The actual cached response data.
558#[derive(Debug, Clone)]
559pub struct CachedResponseData {
560    /// HTTP status code
561    pub status: u16,
562    /// Response headers
563    pub headers: HashMap<String, String>,
564    /// Response body
565    pub body: bytes::Bytes,
566}
567
568impl CachedResponse {
569    /// Create a new cached response.
570    pub fn new(response: &HttpResponse, ttl: Duration) -> Self {
571        let now = Instant::now();
572
573        let etag = response.headers.get("ETag").cloned();
574        let last_modified = response
575            .headers
576            .get("Last-Modified")
577            .and_then(|s| httpdate::parse_http_date(s).ok());
578        let vary = response
579            .headers
580            .get("Vary")
581            .map(|v| v.split(',').map(|s| s.trim().to_lowercase()).collect())
582            .unwrap_or_default();
583
584        Self {
585            response: CachedResponseData {
586                status: response.status,
587                headers: response.headers.clone().into(),
588                body: response.body.clone(),
589            },
590            cached_at: now,
591            expires_at: now + ttl,
592            etag,
593            last_modified,
594            vary,
595            base_key: None,
596            eviction_seq: 0,
597        }
598    }
599
600    /// Check if the cached response is still fresh.
601    pub fn is_fresh(&self) -> bool {
602        Instant::now() < self.expires_at
603    }
604
605    /// Check if the cached response is stale.
606    pub fn is_stale(&self) -> bool {
607        !self.is_fresh()
608    }
609
610    /// Get the age of the cached response.
611    pub fn age(&self) -> Duration {
612        self.cached_at.elapsed()
613    }
614
615    /// Get the remaining TTL.
616    pub fn remaining_ttl(&self) -> Option<Duration> {
617        let now = Instant::now();
618        if now < self.expires_at {
619            Some(self.expires_at - now)
620        } else {
621            None
622        }
623    }
624
625    /// Convert to an HttpResponse.
626    pub fn to_response(&self) -> HttpResponse {
627        // `with_bytes_body` rather than `from_parts`, so serving a cache hit is
628        // a refcount bump on the stored body rather than a copy of it.
629        let mut response = HttpResponse::from_parts(
630            self.response.status,
631            self.response.headers.clone(),
632            Vec::new(),
633        )
634        .with_bytes_body(self.response.body.clone());
635
636        // Add Age header
637        response
638            .headers
639            .insert("Age".to_string(), self.age().as_secs().to_string());
640
641        // Add X-Cache header
642        response
643            .headers
644            .insert("X-Cache".to_string(), "HIT".to_string());
645
646        response
647    }
648}
649
650// ============================================================================
651// In-Memory Response Cache
652// ============================================================================
653
654/// In-memory HTTP response cache.
655///
656/// # Examples
657///
658/// ```
659/// use armature_core::response_cache::{ResponseCache, ResponseCacheConfig};
660/// use std::time::Duration;
661///
662/// let cache = ResponseCache::new();
663///
664/// // Configure cache
665/// let cache = ResponseCache::with_config(
666///     ResponseCacheConfig::new()
667///         .max_entries(1000)
668///         .default_ttl(Duration::from_secs(300))
669///         .max_body_size(1024 * 1024), // 1MB
670/// );
671/// ```
672#[derive(Debug)]
673pub struct ResponseCache {
674    /// Cache configuration
675    config: ResponseCacheConfig,
676    /// Cached responses
677    entries: Arc<RwLock<HashMap<String, CachedResponse>>>,
678    /// Maps base cache keys (no Vary values) to the Vary header names the
679    /// stored response was keyed with, so `get` can rebuild the full key
680    /// from a request and `invalidate` can find all variants.
681    vary_index: Arc<RwLock<HashMap<String, Vec<String>>>>,
682    /// Insertion-order eviction bookkeeping, guarded independently but only ever
683    /// mutated while holding the `entries` write lock (so the two stay
684    /// consistent). See [`EvictionIndex`].
685    eviction: Mutex<EvictionIndex>,
686}
687
688/// Insertion-order eviction bookkeeping kept in lockstep with `entries`.
689///
690/// `order` holds variant cache keys oldest-first. It is maintained *lazily*:
691/// keys removed by invalidation or TTL purging are left in place (as
692/// tombstones) and skipped when they surface during capacity-driven eviction,
693/// so finding the oldest live entry is O(1) amortized instead of an O(n)
694/// `min_by_key` scan of the whole map on every insert once the cache is full.
695///
696/// Two things leave tombstones in `order` without ever going through
697/// `evict_oldest`, which is the only place that used to clean them up:
698///
699/// 1. Keys removed by `purge_stale`/`invalidate`/`invalidate_prefix`/expiry —
700///    in the common regime where capacity is never hit, `evict_oldest` never
701///    runs at all, so these tombstones would otherwise accumulate forever.
702/// 2. Re-inserting an already-live key pushes a *second* `order` entry for the
703///    same key (`record_insert`'s `replaced` branch only suppresses the
704///    `base_key_counts` increment, not the push), so a single hot key
705///    re-stored repeatedly (e.g. every TTL cycle) would also grow `order`
706///    without bound.
707///
708/// Each `order` element is a `(key, seq)` pair where `seq` is a monotonic
709/// counter assigned at insert time and mirrored onto the entry's
710/// [`CachedResponse::eviction_seq`]. An `order` position is the key's *current*
711/// one only when its `seq` matches the live entry's; an older `seq` marks a
712/// stale duplicate left by a refresh. This lets a re-store reset eviction
713/// recency: `evict_oldest` skips a popped position whose `seq` no longer
714/// matches (rather than evicting the refreshed entry by its original insert
715/// time), and `compact` keeps only the current-`seq` position of each key.
716///
717/// `dead` counts how many `order` entries no longer represent a live
718/// position (tombstones from removal, or superseded duplicates from a
719/// re-insert). Whenever `dead` exceeds the number of live entries,
720/// `compact_if_needed` rebuilds `order` from scratch in a single pass over
721/// the live entries, dropping the tombstones/duplicates and resetting `dead`
722/// to 0. This keeps `order` bounded to roughly `O(live entries)` regardless
723/// of how much churn (invalidation or re-insertion) has occurred, while still
724/// keeping the common insert/evict path O(1) amortized.
725///
726/// `base_key_counts` tracks how many live variants share each base key. It lets
727/// eviction decide in O(1) whether a base key's `vary_index` record is now dead
728/// (its last variant was just evicted) instead of a second O(n) scan over every
729/// entry's `base_key`.
730#[derive(Debug, Default)]
731struct EvictionIndex {
732    order: VecDeque<(String, u64)>,
733    base_key_counts: HashMap<String, usize>,
734    /// Number of `order` entries that are tombstones (their key was removed
735    /// from `entries` elsewhere) or stale duplicates (superseded by a later
736    /// re-insert of the same key). See the struct docs.
737    dead: usize,
738    /// Monotonic source for per-insert sequence numbers. Never reset (not even
739    /// by `clear`), so a sequence value is never reused for a different insert.
740    next_seq: u64,
741}
742
743impl EvictionIndex {
744    /// Allocate the next monotonic insertion sequence. The caller stamps it onto
745    /// both the new `order` position (via [`record_insert`]) and the stored
746    /// [`CachedResponse::eviction_seq`], so the two can later be cross-checked.
747    fn next_seq(&mut self) -> u64 {
748        let seq = self.next_seq;
749        self.next_seq += 1;
750        seq
751    }
752
753    /// Record a freshly inserted variant key at sequence `seq`. `replaced` is
754    /// true when an entry already existed under `key`, so its base key must not
755    /// be double-counted — and the *previous* `order` entry for `key` becomes a
756    /// dead duplicate, since the entry now carries the newer `seq`.
757    fn record_insert(&mut self, key: &str, seq: u64, base_key: &str, replaced: bool) {
758        self.order.push_back((key.to_string(), seq));
759        if replaced {
760            self.dead += 1;
761        } else {
762            *self
763                .base_key_counts
764                .entry(base_key.to_string())
765                .or_insert(0) += 1;
766        }
767    }
768
769    /// Record removal of one variant with the given base key. Returns true when
770    /// that was the last live variant for the base key (its `vary_index` record
771    /// is now dead and should be pruned).
772    ///
773    /// Callers that remove an entry from `entries` outside of `evict_oldest`
774    /// (invalidation, prefix invalidation, TTL purging) must also bump `dead`
775    /// themselves — the removed key's `order` entry is left behind as a
776    /// tombstone, and this method only tracks `base_key_counts`.
777    fn record_remove(&mut self, base_key: &str) -> bool {
778        if let Some(count) = self.base_key_counts.get_mut(base_key) {
779            *count -= 1;
780            if *count == 0 {
781                self.base_key_counts.remove(base_key);
782                return true;
783            }
784        }
785        false
786    }
787
788    /// Whether any live variant still references `base_key`.
789    fn base_key_live(&self, base_key: &str) -> bool {
790        self.base_key_counts.contains_key(base_key)
791    }
792
793    /// Rebuild `order` from `entries`, dropping tombstones (keys no longer
794    /// present) and stale duplicates left by re-inserting an already-live key
795    /// (see `record_insert`). A position is kept only when its `seq` matches the
796    /// live entry's `eviction_seq` — i.e. it is the key's *current* position —
797    /// so a refreshed entry keeps its newer (later) position and its eviction
798    /// recency, rather than reverting to the original insert time. Single
799    /// forward pass, preserving relative order; the `seen` guard is defensive
800    /// (a matching `seq` is already unique per key).
801    fn compact(&mut self, entries: &HashMap<String, CachedResponse>) {
802        let mut seen = HashSet::with_capacity(entries.len());
803        let mut compacted = VecDeque::with_capacity(entries.len());
804        for (key, seq) in self.order.drain(..) {
805            if entries.get(&key).is_some_and(|e| e.eviction_seq == seq) && seen.insert(key.clone())
806            {
807                compacted.push_back((key, seq));
808            }
809        }
810        self.order = compacted;
811        self.dead = 0;
812    }
813
814    /// Compact `order` when dead (tombstoned or superseded) entries outnumber
815    /// live ones. Keeps `order` from growing without bound under
816    /// invalidation/re-insertion churn even when capacity is never hit (so
817    /// `evict_oldest`, the only other place that trims `order`, never runs).
818    fn compact_if_needed(&mut self, entries: &HashMap<String, CachedResponse>) {
819        if self.dead > entries.len() {
820            self.compact(entries);
821        }
822    }
823
824    fn clear(&mut self) {
825        self.order.clear();
826        self.base_key_counts.clear();
827        self.dead = 0;
828    }
829}
830
831/// Configuration for the response cache.
832#[derive(Debug, Clone)]
833pub struct ResponseCacheConfig {
834    /// Maximum number of entries in the cache
835    pub max_entries: usize,
836    /// Default TTL for cached responses
837    pub default_ttl: Duration,
838    /// Maximum body size to cache (in bytes)
839    pub max_body_size: usize,
840    /// Only cache responses with these status codes
841    pub cacheable_status_codes: Vec<u16>,
842    /// Only cache these HTTP methods
843    pub cacheable_methods: Vec<String>,
844}
845
846impl Default for ResponseCacheConfig {
847    fn default() -> Self {
848        Self {
849            max_entries: 1000,
850            default_ttl: Duration::from_secs(300), // 5 minutes
851            max_body_size: 1024 * 1024,            // 1MB
852            cacheable_status_codes: vec![200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501],
853            cacheable_methods: vec![
854                "GET".to_string(),
855                "HEAD".to_string(),
856                // Safe query with a request body; the body participates in
857                // the cache key (draft-ietf-httpbis-safe-method-w-body §4).
858                "QUERY".to_string(),
859            ],
860        }
861    }
862}
863
864impl ResponseCacheConfig {
865    /// Create a new configuration with defaults.
866    pub fn new() -> Self {
867        Self::default()
868    }
869
870    /// Set the maximum number of entries.
871    pub fn max_entries(mut self, count: usize) -> Self {
872        self.max_entries = count;
873        self
874    }
875
876    /// Set the default TTL.
877    pub fn default_ttl(mut self, ttl: Duration) -> Self {
878        self.default_ttl = ttl;
879        self
880    }
881
882    /// Set the maximum body size to cache.
883    pub fn max_body_size(mut self, size: usize) -> Self {
884        self.max_body_size = size;
885        self
886    }
887}
888
889impl ResponseCache {
890    /// Create a new response cache with default configuration.
891    pub fn new() -> Self {
892        Self::with_config(ResponseCacheConfig::default())
893    }
894
895    /// Create a new response cache with custom configuration.
896    pub fn with_config(config: ResponseCacheConfig) -> Self {
897        Self {
898            config,
899            entries: Arc::new(RwLock::new(HashMap::new())),
900            vary_index: Arc::new(RwLock::new(HashMap::new())),
901            eviction: Mutex::new(EvictionIndex::default()),
902        }
903    }
904
905    /// Get a cached response for a request.
906    ///
907    /// Uses a two-phase lookup: the Vary header names recorded when the
908    /// response was stored are looked up by base key first, then used to
909    /// build the full (Vary-aware) cache key from the request's headers.
910    pub async fn get(&self, request: &HttpRequest) -> Option<HttpResponse> {
911        let base_key = CacheKey::from_request(request).to_string_key();
912        let vary_headers = {
913            let vary_index = self.vary_index.read().await;
914            vary_index.get(&base_key).cloned().unwrap_or_default()
915        };
916        let vary_refs: Vec<&str> = vary_headers.iter().map(String::as_str).collect();
917        self.get_with_vary(request, &vary_refs).await
918    }
919
920    /// Get a cached response with Vary header support.
921    pub async fn get_with_vary(
922        &self,
923        request: &HttpRequest,
924        vary_headers: &[&str],
925    ) -> Option<HttpResponse> {
926        let key = CacheKey::from_request_with_vary(request, vary_headers);
927        let key_str = key.to_string_key();
928
929        let entries = self.entries.read().await;
930        if let Some(cached) = entries.get(&key_str)
931            && cached.is_fresh()
932        {
933            return Some(cached.to_response());
934        }
935        None
936    }
937
938    /// Store a response in the cache.
939    ///
940    /// The TTL is derived from the response's `Cache-Control` header
941    /// (`s-maxage` takes precedence over `max-age`) when present, falling
942    /// back to the configured default TTL.
943    pub async fn store(&self, request: &HttpRequest, response: &HttpResponse) {
944        let ttl = response
945            .headers
946            .get("Cache-Control")
947            .map(|h| CacheControl::parse(h))
948            .and_then(|cc| cc.freshness_lifetime())
949            .map(Duration::from_secs)
950            .unwrap_or(self.config.default_ttl);
951
952        self.store_with_ttl(request, response, ttl).await
953    }
954
955    /// Store a response with a specific TTL.
956    pub async fn store_with_ttl(
957        &self,
958        request: &HttpRequest,
959        response: &HttpResponse,
960        ttl: Duration,
961    ) {
962        // Check if cacheable
963        if !self.is_cacheable(request, response) {
964            return;
965        }
966
967        // Get Vary headers from response
968        let vary_headers: Vec<&str> = response
969            .headers
970            .get("Vary")
971            .map(|v| v.split(',').map(|s| s.trim()).collect())
972            .unwrap_or_default();
973
974        let key = CacheKey::from_request_with_vary(request, &vary_headers);
975        let key_str = key.to_string_key();
976        let base_key = CacheKey::from_request(request).to_string_key();
977        let mut cached = CachedResponse::new(response, ttl);
978        cached.base_key = Some(base_key.clone());
979
980        // Base key of any entry evicted to make room, but only when that was
981        // the last variant sharing it (so its `vary_index` entry is now dead).
982        let mut evicted_base_key = None;
983        {
984            let mut entries = self.entries.write().await;
985
986            // Evict if at capacity
987            if entries.len() >= self.config.max_entries {
988                evicted_base_key = self.evict_oldest(&mut entries);
989            }
990
991            // Track insertion order and base-key multiplicity so eviction stays
992            // O(1) amortized (see `EvictionIndex`), then compact away any
993            // tombstones/duplicates that have accumulated in `order`. The
994            // sequence is stamped onto the entry *before* it is inserted so the
995            // eviction index can later match this `order` position to the live
996            // entry (and tell it apart from a stale duplicate after a refresh).
997            let mut index = self.eviction.lock().unwrap();
998            let seq = index.next_seq();
999            cached.eviction_seq = seq;
1000            let replaced = entries.insert(key_str.clone(), cached).is_some();
1001            index.record_insert(&key_str, seq, &base_key, replaced);
1002            index.compact_if_needed(&entries);
1003        }
1004
1005        // Keep `vary_index` in lockstep with `entries`.
1006        let mut vary_index = self.vary_index.write().await;
1007
1008        // Drop the evicted base key's Vary record, unless the entry we just
1009        // stored shares that base key (and therefore keeps it alive).
1010        if let Some(evicted) = evicted_base_key
1011            && evicted != base_key
1012        {
1013            vary_index.remove(&evicted);
1014        }
1015
1016        // Record the Vary header names for this base key so `get` can rebuild
1017        // the full key from a future request's headers. Merge (union) rather
1018        // than overwrite so distinct Vary sets stored at the same base key
1019        // remain reachable instead of being clobbered last-writer-wins.
1020        let entry = vary_index.entry(base_key).or_default();
1021        for header in vary_headers.iter().map(|s| s.to_string()) {
1022            if !entry.contains(&header) {
1023                entry.push(header);
1024            }
1025        }
1026    }
1027
1028    /// Check if a request/response pair is cacheable.
1029    fn is_cacheable(&self, request: &HttpRequest, response: &HttpResponse) -> bool {
1030        // Check method
1031        if !self
1032            .config
1033            .cacheable_methods
1034            .iter()
1035            .any(|m| m == request.method_str())
1036        {
1037            return false;
1038        }
1039
1040        // Check status code
1041        if !self
1042            .config
1043            .cacheable_status_codes
1044            .contains(&response.status)
1045        {
1046            return false;
1047        }
1048
1049        // Check body size
1050        if response.body.len() > self.config.max_body_size {
1051            return false;
1052        }
1053
1054        // Check response Cache-Control (RFC 9111): this is a shared cache,
1055        // so no-store, private, and no-cache responses must not be stored.
1056        let cache_control = response
1057            .headers
1058            .get("Cache-Control")
1059            .map(|h| CacheControl::parse(h));
1060        if let Some(ref cc) = cache_control
1061            && (cc.is_no_store() || cc.is_private() || cc.is_no_cache())
1062        {
1063            return false;
1064        }
1065
1066        // RFC 9111 §3.5: responses to requests with an Authorization header
1067        // must not be stored in a shared cache unless the response explicitly
1068        // allows it (public, s-maxage, or must-revalidate).
1069        let has_authorization = request
1070            .headers
1071            .get("Authorization")
1072            .or_else(|| request.headers.get("authorization"))
1073            .is_some();
1074        if has_authorization {
1075            let explicitly_allowed = cache_control.as_ref().is_some_and(|cc| {
1076                cc.is_public() || cc.get_s_maxage().is_some() || cc.is_must_revalidate()
1077            });
1078            if !explicitly_allowed {
1079                return false;
1080            }
1081        }
1082
1083        true
1084    }
1085
1086    /// Evict the oldest entry from the cache.
1087    ///
1088    /// Returns the evicted entry's base key when, after removal, no remaining
1089    /// entry shares that base key — signalling to the caller that the matching
1090    /// `vary_index` record is now dead and should be removed too. Returns
1091    /// `None` when the base key is still in use (another Vary variant remains)
1092    /// or the entry carried no tracked base key.
1093    fn evict_oldest(&self, entries: &mut HashMap<String, CachedResponse>) -> Option<String> {
1094        let mut index = self.eviction.lock().unwrap();
1095
1096        // Pop insertion-ordered positions, skipping any that are no longer the
1097        // key's *current* position, until we reach the oldest live entry. This
1098        // replaces the previous O(n) `min_by_key` scan on the insert path.
1099        //
1100        // A popped `(key, seq)` is the genuine oldest live entry only when the
1101        // live entry's `eviction_seq` still equals `seq`. Otherwise it is either
1102        // a tombstone (key removed elsewhere) or a stale duplicate superseded by
1103        // a later re-store (refresh) — in which case the refreshed entry has a
1104        // newer position further back in `order`, so evicting here would wrongly
1105        // drop a recently-touched entry. Such positions are simply skipped.
1106        while let Some((oldest_key, seq)) = index.order.pop_front() {
1107            let is_current = entries
1108                .get(&oldest_key)
1109                .is_some_and(|e| e.eviction_seq == seq);
1110            if is_current {
1111                let removed = entries.remove(&oldest_key);
1112                // Report the base key as dead only once its last variant is gone,
1113                // decided in O(1) from the tracked counts rather than a second
1114                // O(n) scan of every entry.
1115                return match removed.and_then(|r| r.base_key) {
1116                    Some(base_key) => index.record_remove(&base_key).then_some(base_key),
1117                    None => None,
1118                };
1119            }
1120            // Tombstone or stale duplicate — it is leaving `order` right now, so
1121            // it stops counting as dead.
1122            index.dead = index.dead.saturating_sub(1);
1123        }
1124        None
1125    }
1126
1127    /// Remove a specific entry from the cache, including all Vary variants.
1128    pub async fn invalidate(&self, request: &HttpRequest) {
1129        let base_key = CacheKey::from_request(request).to_string_key();
1130        // Variant keys are the base key followed by a `|`-separated list of
1131        // Vary header values (see `CacheKey::to_string_key`).
1132        let variant_prefix = format!("{}|", base_key);
1133
1134        {
1135            let mut entries = self.entries.write().await;
1136            let mut removed_count = 0usize;
1137            entries.retain(|key, _| {
1138                if key == &base_key || key.starts_with(&variant_prefix) {
1139                    removed_count += 1;
1140                    false
1141                } else {
1142                    true
1143                }
1144            });
1145            let mut index = self.eviction.lock().unwrap();
1146            // Every removed variant shared this base key, so drop its count.
1147            index.base_key_counts.remove(&base_key);
1148            // Each removed variant leaves its `order` entry behind as a
1149            // tombstone (see `EvictionIndex` docs).
1150            index.dead += removed_count;
1151            index.compact_if_needed(&entries);
1152        }
1153
1154        let mut vary_index = self.vary_index.write().await;
1155        vary_index.remove(&base_key);
1156    }
1157
1158    /// Remove all entries matching a path prefix.
1159    pub async fn invalidate_prefix(&self, path_prefix: &str) {
1160        let needle = format!(":{}", path_prefix);
1161        let mut entries = self.entries.write().await;
1162        let mut index = self.eviction.lock().unwrap();
1163        entries.retain(|key, v| {
1164            if key.contains(&needle) {
1165                if let Some(bk) = &v.base_key {
1166                    index.record_remove(bk);
1167                }
1168                // The removed key's `order` entry is left behind as a
1169                // tombstone (see `EvictionIndex` docs).
1170                index.dead += 1;
1171                false
1172            } else {
1173                true
1174            }
1175        });
1176        index.compact_if_needed(&entries);
1177    }
1178
1179    /// Clear all cached responses.
1180    pub async fn clear(&self) {
1181        {
1182            let mut entries = self.entries.write().await;
1183            entries.clear();
1184            self.eviction.lock().unwrap().clear();
1185        }
1186        let mut vary_index = self.vary_index.write().await;
1187        vary_index.clear();
1188    }
1189
1190    /// Remove all stale entries.
1191    ///
1192    /// Keeps `vary_index` in lockstep: any base key whose last variant is
1193    /// purged here is also removed from `vary_index`, so TTL expiry cannot leak
1194    /// `vary_index` entries.
1195    pub async fn purge_stale(&self) {
1196        let dead_base_keys = {
1197            let mut entries = self.entries.write().await;
1198            let mut index = self.eviction.lock().unwrap();
1199
1200            // Collect base keys of the stale entries being removed, decrementing
1201            // their live-variant counts as we go.
1202            let mut removed_base_keys: Vec<String> = Vec::new();
1203            entries.retain(|_, v| {
1204                if v.is_fresh() {
1205                    true
1206                } else {
1207                    if let Some(bk) = &v.base_key {
1208                        removed_base_keys.push(bk.clone());
1209                        index.record_remove(bk);
1210                    }
1211                    // The removed key's `order` entry is left behind as a
1212                    // tombstone (see `EvictionIndex` docs).
1213                    index.dead += 1;
1214                    false
1215                }
1216            });
1217
1218            // Keep only base keys that no surviving entry still references —
1219            // decided in O(1) from the tracked counts instead of scanning every
1220            // remaining entry.
1221            removed_base_keys.retain(|bk| !index.base_key_live(bk));
1222            index.compact_if_needed(&entries);
1223            removed_base_keys
1224        };
1225
1226        if !dead_base_keys.is_empty() {
1227            let mut vary_index = self.vary_index.write().await;
1228            for bk in dead_base_keys {
1229                vary_index.remove(&bk);
1230            }
1231        }
1232    }
1233
1234    /// Get cache statistics.
1235    pub async fn stats(&self) -> CacheStats {
1236        let entries = self.entries.read().await;
1237        let fresh_count = entries.values().filter(|e| e.is_fresh()).count();
1238        let stale_count = entries.len() - fresh_count;
1239        let total_size: usize = entries.values().map(|e| e.response.body.len()).sum();
1240
1241        CacheStats {
1242            total_entries: entries.len(),
1243            fresh_entries: fresh_count,
1244            stale_entries: stale_count,
1245            total_size_bytes: total_size,
1246            max_entries: self.config.max_entries,
1247        }
1248    }
1249}
1250
1251impl Default for ResponseCache {
1252    fn default() -> Self {
1253        Self::new()
1254    }
1255}
1256
1257/// Cache statistics.
1258#[derive(Debug, Clone)]
1259pub struct CacheStats {
1260    /// Total number of entries
1261    pub total_entries: usize,
1262    /// Number of fresh entries
1263    pub fresh_entries: usize,
1264    /// Number of stale entries
1265    pub stale_entries: usize,
1266    /// Total size of cached bodies in bytes
1267    pub total_size_bytes: usize,
1268    /// Maximum entries allowed
1269    pub max_entries: usize,
1270}
1271
1272// ============================================================================
1273// Request/Response Extensions
1274// ============================================================================
1275
1276/// Extension methods for HttpRequest related to caching.
1277impl HttpRequest {
1278    /// Get the Cache-Control header from the request.
1279    pub fn cache_control(&self) -> Option<CacheControl> {
1280        // One lookup: header names intern case-insensitively.
1281        self.headers.get("Cache-Control").map(CacheControl::parse)
1282    }
1283
1284    /// Check if the request allows cached responses.
1285    pub fn allows_cached(&self) -> bool {
1286        if let Some(cc) = self.cache_control() {
1287            // Check for no-cache or no-store
1288            !cc.is_no_cache() && !cc.is_no_store()
1289        } else {
1290            true
1291        }
1292    }
1293
1294    /// Get the max-stale tolerance from the request.
1295    pub fn max_stale(&self) -> Option<u64> {
1296        self.cache_control().and_then(|cc| {
1297            cc.directives.iter().find_map(|d| match d {
1298                CacheDirective::MaxStale(secs) => Some(secs.unwrap_or(u64::MAX)),
1299                _ => None,
1300            })
1301        })
1302    }
1303
1304    /// Generate a cache key for this request.
1305    pub fn cache_key(&self) -> CacheKey {
1306        CacheKey::from_request(self)
1307    }
1308
1309    /// Generate a cache key with Vary headers.
1310    pub fn cache_key_with_vary(&self, vary_headers: &[&str]) -> CacheKey {
1311        CacheKey::from_request_with_vary(self, vary_headers)
1312    }
1313}
1314
1315/// Extension methods for HttpResponse related to caching.
1316impl HttpResponse {
1317    /// Set the Cache-Control header.
1318    pub fn with_cache_control(mut self, cache_control: CacheControl) -> Self {
1319        self.headers
1320            .insert("Cache-Control".to_string(), cache_control.to_header_value());
1321        self
1322    }
1323
1324    /// Set a public cache with max-age.
1325    pub fn cache_public(self, max_age: Duration) -> Self {
1326        self.with_cache_control(CacheControl::public_max_age(max_age))
1327    }
1328
1329    /// Set a private cache with max-age.
1330    pub fn cache_private(self, max_age: Duration) -> Self {
1331        self.with_cache_control(CacheControl::private_max_age(max_age))
1332    }
1333
1334    /// Set cache for immutable assets.
1335    pub fn cache_immutable(self, max_age: Duration) -> Self {
1336        self.with_cache_control(CacheControl::immutable_asset(max_age))
1337    }
1338
1339    /// Add Vary header.
1340    pub fn with_vary(mut self, headers: &[&str]) -> Self {
1341        let vary = headers.join(", ");
1342        self.headers.insert("Vary".to_string(), vary);
1343        self
1344    }
1345
1346    /// Get the Cache-Control header from the response.
1347    pub fn get_cache_control(&self) -> Option<CacheControl> {
1348        self.headers
1349            .get("Cache-Control")
1350            .map(|h| CacheControl::parse(h))
1351    }
1352
1353    /// Check if the response is cacheable based on Cache-Control.
1354    pub fn is_cacheable(&self) -> bool {
1355        if let Some(cc) = self.get_cache_control() {
1356            cc.is_cacheable()
1357        } else {
1358            // Default: only cache 200 OK without explicit Cache-Control
1359            self.status == 200
1360        }
1361    }
1362}
1363
1364// ============================================================================
1365// Tests
1366// ============================================================================
1367
1368#[cfg(test)]
1369mod tests {
1370    use super::*;
1371    use bytes::Bytes;
1372
1373    #[test]
1374    fn test_cache_directive_parse() {
1375        assert_eq!(
1376            CacheDirective::parse("public"),
1377            Some(CacheDirective::Public)
1378        );
1379        assert_eq!(
1380            CacheDirective::parse("private"),
1381            Some(CacheDirective::Private)
1382        );
1383        assert_eq!(
1384            CacheDirective::parse("no-store"),
1385            Some(CacheDirective::NoStore)
1386        );
1387        assert_eq!(
1388            CacheDirective::parse("max-age=3600"),
1389            Some(CacheDirective::MaxAge(3600))
1390        );
1391    }
1392
1393    #[test]
1394    fn test_cache_control_parse() {
1395        let cc = CacheControl::parse("public, max-age=3600, must-revalidate");
1396        assert!(cc.is_public());
1397        assert_eq!(cc.get_max_age(), Some(3600));
1398        assert!(cc.is_must_revalidate());
1399    }
1400
1401    #[test]
1402    fn test_cache_control_builder() {
1403        let cc = CacheControl::new()
1404            .public()
1405            .max_age(Duration::from_secs(3600))
1406            .must_revalidate();
1407
1408        assert_eq!(
1409            cc.to_header_value(),
1410            "public, max-age=3600, must-revalidate"
1411        );
1412    }
1413
1414    #[test]
1415    fn test_cache_control_presets() {
1416        let never = CacheControl::never();
1417        assert!(never.is_no_store());
1418        assert!(never.is_no_cache());
1419
1420        let public = CacheControl::public_max_age(Duration::from_secs(3600));
1421        assert!(public.is_public());
1422        assert_eq!(public.get_max_age(), Some(3600));
1423
1424        let immutable = CacheControl::immutable_asset(Duration::from_secs(31536000));
1425        assert!(immutable.is_immutable());
1426    }
1427
1428    #[test]
1429    fn test_cache_control_is_cacheable() {
1430        assert!(CacheControl::public_max_age(Duration::from_secs(3600)).is_cacheable());
1431        assert!(CacheControl::private_max_age(Duration::from_secs(3600)).is_cacheable());
1432        assert!(!CacheControl::never().is_cacheable());
1433    }
1434
1435    #[test]
1436    fn test_cache_key_from_request() {
1437        let request = HttpRequest::new("GET", "/api/users?page=1&limit=10");
1438
1439        let key = CacheKey::from_request(&request);
1440        assert_eq!(key.method, "GET");
1441        assert_eq!(key.path, "/api/users");
1442        // Sorted by name, with each field length-prefixed.
1443        assert_eq!(key.query, "5:limit=2:10&4:page=1:1&");
1444    }
1445
1446    #[test]
1447    fn cache_key_query_does_not_collide_across_decoded_delimiters() {
1448        // One param whose decoded value is `1&b=2` …
1449        let one = HttpRequest::new("GET", "/search?a=1%26b%3D2");
1450        // … versus two params that render the same way once decoded.
1451        let two = HttpRequest::new("GET", "/search?a=1&b=2");
1452
1453        assert_ne!(
1454            CacheKey::from_request(&one),
1455            CacheKey::from_request(&two),
1456            "distinct requests must not share a cache entry"
1457        );
1458    }
1459
1460    #[test]
1461    fn test_cache_key_with_vary() {
1462        let mut request = HttpRequest::new("GET", "/api/users".to_string());
1463        request
1464            .headers
1465            .insert("Accept", "application/json".to_string());
1466
1467        let key = CacheKey::from_request_with_vary(&request, &["Accept"]);
1468        assert_eq!(key.vary_values.len(), 1);
1469        assert_eq!(
1470            key.vary_values[0],
1471            ("accept".to_string(), "application/json".to_string())
1472        );
1473    }
1474
1475    #[test]
1476    fn test_cached_response() {
1477        let mut response = HttpResponse::ok();
1478        response.body = Bytes::from_static(b"Hello, World!");
1479        response
1480            .headers
1481            .insert("ETag".to_string(), "\"abc123\"".to_string());
1482
1483        let cached = CachedResponse::new(&response, Duration::from_secs(300));
1484        assert!(cached.is_fresh());
1485        assert_eq!(cached.etag, Some("\"abc123\"".to_string()));
1486    }
1487
1488    #[tokio::test]
1489    async fn test_response_cache_store_and_get() {
1490        let cache = ResponseCache::new();
1491        let request = HttpRequest::new("GET", "/api/users".to_string());
1492        let mut response = HttpResponse::ok();
1493        response.body = Bytes::from_static(b"cached content");
1494
1495        cache.store(&request, &response).await;
1496
1497        let cached = cache.get(&request).await;
1498        assert!(cached.is_some());
1499        assert_eq!(cached.unwrap().body, Bytes::from_static(b"cached content"));
1500    }
1501
1502    #[tokio::test]
1503    async fn test_query_method_cached_with_body_in_key() {
1504        let cache = ResponseCache::new();
1505
1506        let mut search_a = HttpRequest::new("QUERY", "/search".to_string());
1507        search_a.body = Bytes::from_static(b"name=alice");
1508        let mut response_a = HttpResponse::ok();
1509        response_a.body = Bytes::from_static(b"results for alice");
1510
1511        cache.store(&search_a, &response_a).await;
1512
1513        // Same path + same body: hit
1514        let cached = cache.get(&search_a).await;
1515        assert!(cached.is_some());
1516        assert_eq!(
1517            cached.unwrap().body,
1518            Bytes::from_static(b"results for alice")
1519        );
1520
1521        // Same path, different body: distinct entry, must miss
1522        let mut search_b = HttpRequest::new("QUERY", "/search".to_string());
1523        search_b.body = Bytes::from_static(b"name=bob");
1524        assert!(cache.get(&search_b).await.is_none());
1525
1526        // The two bodies produce different keys, so both can coexist
1527        let mut response_b = HttpResponse::ok();
1528        response_b.body = Bytes::from_static(b"results for bob");
1529        cache.store(&search_b, &response_b).await;
1530        assert_eq!(
1531            cache.get(&search_a).await.unwrap().body,
1532            Bytes::from_static(b"results for alice")
1533        );
1534        assert_eq!(
1535            cache.get(&search_b).await.unwrap().body,
1536            Bytes::from_static(b"results for bob")
1537        );
1538    }
1539
1540    #[test]
1541    fn test_query_cache_key_includes_body_hash() {
1542        let mut req_a = HttpRequest::new("QUERY", "/search".to_string());
1543        req_a.body = Bytes::from_static(b"a");
1544        let mut req_b = HttpRequest::new("QUERY", "/search".to_string());
1545        req_b.body = Bytes::from_static(b"b");
1546
1547        let key_a = CacheKey::from_request(&req_a);
1548        let key_b = CacheKey::from_request(&req_b);
1549        assert!(key_a.body_hash.is_some());
1550        assert_ne!(key_a, key_b);
1551        assert_ne!(key_a.to_string_key(), key_b.to_string_key());
1552
1553        // GET keys are unaffected by the body
1554        let mut get_req = HttpRequest::new("GET", "/search".to_string());
1555        get_req.body = Bytes::from_static(b"ignored");
1556        assert!(CacheKey::from_request(&get_req).body_hash.is_none());
1557    }
1558
1559    #[tokio::test]
1560    async fn test_response_cache_invalidate() {
1561        let cache = ResponseCache::new();
1562        let request = HttpRequest::new("GET", "/api/users".to_string());
1563        let response = HttpResponse::ok();
1564
1565        cache.store(&request, &response).await;
1566        assert!(cache.get(&request).await.is_some());
1567
1568        cache.invalidate(&request).await;
1569        assert!(cache.get(&request).await.is_none());
1570    }
1571
1572    #[tokio::test]
1573    async fn test_response_cache_respects_no_store() {
1574        let cache = ResponseCache::new();
1575        let request = HttpRequest::new("GET", "/api/users".to_string());
1576        let response = HttpResponse::ok().no_cache();
1577
1578        cache.store(&request, &response).await;
1579
1580        // Should not be cached due to no-store
1581        assert!(cache.get(&request).await.is_none());
1582    }
1583
1584    #[tokio::test]
1585    async fn test_response_cache_respects_private() {
1586        let cache = ResponseCache::new();
1587        let request = HttpRequest::new("GET", "/api/users".to_string());
1588        let response = HttpResponse::ok().cache_private(Duration::from_secs(300));
1589
1590        cache.store(&request, &response).await;
1591
1592        // private responses must not be stored in a shared cache
1593        assert!(cache.get(&request).await.is_none());
1594    }
1595
1596    #[tokio::test]
1597    async fn test_response_cache_respects_no_cache_directive() {
1598        let cache = ResponseCache::new();
1599        let request = HttpRequest::new("GET", "/api/users".to_string());
1600        let response = HttpResponse::ok().with_cache_control(CacheControl::new().no_cache());
1601
1602        cache.store(&request, &response).await;
1603
1604        assert!(cache.get(&request).await.is_none());
1605    }
1606
1607    #[tokio::test]
1608    async fn test_response_cache_authorization_not_stored() {
1609        let cache = ResponseCache::new();
1610        let mut request = HttpRequest::new("GET", "/api/me".to_string());
1611        request
1612            .headers
1613            .insert("Authorization", "Bearer user-a".to_string());
1614        let response = HttpResponse::ok();
1615
1616        cache.store(&request, &response).await;
1617
1618        // Responses to authorized requests must not be replayed from a
1619        // shared cache without explicit permission.
1620        assert!(cache.get(&request).await.is_none());
1621    }
1622
1623    #[tokio::test]
1624    async fn test_response_cache_authorization_stored_when_public() {
1625        let cache = ResponseCache::new();
1626        let mut request = HttpRequest::new("GET", "/api/assets".to_string());
1627        request
1628            .headers
1629            .insert("Authorization", "Bearer user-a".to_string());
1630        let response = HttpResponse::ok().cache_public(Duration::from_secs(60));
1631
1632        cache.store(&request, &response).await;
1633
1634        assert!(cache.get(&request).await.is_some());
1635    }
1636
1637    #[tokio::test]
1638    async fn test_response_cache_ttl_from_max_age() {
1639        let cache = ResponseCache::new();
1640        let request = HttpRequest::new("GET", "/api/users".to_string());
1641        // max-age=0 must override the 5-minute default TTL.
1642        let response = HttpResponse::ok().cache_public(Duration::from_secs(0));
1643
1644        cache.store(&request, &response).await;
1645
1646        assert!(cache.get(&request).await.is_none());
1647    }
1648
1649    #[tokio::test]
1650    async fn test_response_cache_vary_two_phase_lookup() {
1651        let cache = ResponseCache::new();
1652        let mut request = HttpRequest::new("GET", "/api/data".to_string());
1653        request
1654            .headers
1655            .insert("Accept", "application/json".to_string());
1656
1657        let mut response = HttpResponse::ok().with_vary(&["Accept"]);
1658        response.body = Bytes::from_static(b"json");
1659
1660        cache.store(&request, &response).await;
1661
1662        // A plain get (no explicit vary list) must find the varied entry.
1663        let hit = cache.get(&request).await;
1664        assert!(hit.is_some());
1665        assert_eq!(hit.unwrap().body, Bytes::from_static(b"json"));
1666
1667        // A request with a different Accept value is a different variant.
1668        let mut other = HttpRequest::new("GET", "/api/data".to_string());
1669        other.headers.insert("Accept", "text/xml".to_string());
1670        assert!(cache.get(&other).await.is_none());
1671    }
1672
1673    #[tokio::test]
1674    async fn test_response_cache_invalidate_removes_vary_variants() {
1675        let cache = ResponseCache::new();
1676        let mut request = HttpRequest::new("GET", "/api/data".to_string());
1677        request
1678            .headers
1679            .insert("Accept", "application/json".to_string());
1680
1681        let response = HttpResponse::ok().with_vary(&["Accept"]);
1682        cache.store(&request, &response).await;
1683        assert!(cache.get(&request).await.is_some());
1684
1685        // Invalidating with a plain request must remove all variants.
1686        let plain = HttpRequest::new("GET", "/api/data".to_string());
1687        cache.invalidate(&plain).await;
1688        assert!(cache.get(&request).await.is_none());
1689    }
1690
1691    #[test]
1692    fn test_response_cache_control_methods() {
1693        let response = HttpResponse::ok().cache_public(Duration::from_secs(3600));
1694
1695        let cc = response.get_cache_control().unwrap();
1696        assert!(cc.is_public());
1697        assert_eq!(cc.get_max_age(), Some(3600));
1698    }
1699
1700    #[test]
1701    fn test_response_with_vary() {
1702        let response = HttpResponse::ok().with_vary(&["Accept", "Accept-Encoding"]);
1703
1704        assert_eq!(
1705            response.headers.get("Vary"),
1706            Some(&"Accept, Accept-Encoding".to_string())
1707        );
1708    }
1709
1710    #[test]
1711    fn test_request_allows_cached() {
1712        let request = HttpRequest::new("GET", "/api/users".to_string());
1713        assert!(request.allows_cached());
1714
1715        let mut request_no_cache = HttpRequest::new("GET", "/api/users".to_string());
1716        request_no_cache
1717            .headers
1718            .insert("Cache-Control", "no-cache".to_string());
1719        assert!(!request_no_cache.allows_cached());
1720    }
1721
1722    /// Regression: with QUERY body-hash keying, every distinct request body
1723    /// produces a distinct base key and therefore a `vary_index` entry. If
1724    /// eviction does not prune `vary_index`, it grows without bound while
1725    /// `entries` stays capped. After eviction, `vary_index` must never exceed
1726    /// the number of live entries.
1727    #[tokio::test]
1728    async fn test_vary_index_bounded_after_eviction() {
1729        let cache = ResponseCache::with_config(ResponseCacheConfig::new().max_entries(8));
1730
1731        for i in 0..100 {
1732            let mut req = HttpRequest::new("QUERY", "/search".to_string());
1733            req.body = Bytes::from(format!("q={}", i).into_bytes());
1734            let mut resp = HttpResponse::ok();
1735            resp.body = Bytes::from(format!("result {}", i).into_bytes());
1736            cache.store(&req, &resp).await;
1737        }
1738
1739        let entries_len = cache.entries.read().await.len();
1740        let vary_len = cache.vary_index.read().await.len();
1741
1742        assert!(
1743            entries_len <= 8,
1744            "entries ({}) exceeded max_entries",
1745            entries_len
1746        );
1747        assert!(
1748            vary_len <= entries_len,
1749            "vary_index ({}) must not exceed entries ({}) after eviction",
1750            vary_len,
1751            entries_len,
1752        );
1753    }
1754
1755    /// Eviction removes entries in insertion order: once at capacity, the
1756    /// oldest-inserted entry is the one dropped to make room, and newer entries
1757    /// survive.
1758    #[tokio::test]
1759    async fn test_evicts_in_insertion_order() {
1760        let cache = ResponseCache::with_config(ResponseCacheConfig::new().max_entries(3));
1761
1762        for i in 0..3 {
1763            let req = HttpRequest::new("GET", format!("/p{}", i));
1764            cache.store(&req, &HttpResponse::ok()).await;
1765        }
1766        for i in 0..3 {
1767            let req = HttpRequest::new("GET", format!("/p{}", i));
1768            assert!(cache.get(&req).await.is_some(), "/p{} should be cached", i);
1769        }
1770
1771        // A fourth insert must evict the oldest (/p0), not any newer entry.
1772        let req = HttpRequest::new("GET", "/p3".to_string());
1773        cache.store(&req, &HttpResponse::ok()).await;
1774
1775        let p0 = HttpRequest::new("GET", "/p0".to_string());
1776        assert!(
1777            cache.get(&p0).await.is_none(),
1778            "oldest entry (/p0) must be evicted first"
1779        );
1780        for i in 1..4 {
1781            let req = HttpRequest::new("GET", format!("/p{}", i));
1782            assert!(cache.get(&req).await.is_some(), "/p{} must remain", i);
1783        }
1784
1785        // vary_index must not outgrow the live entry set after eviction.
1786        let entries_len = cache.entries.read().await.len();
1787        let vary_len = cache.vary_index.read().await.len();
1788        assert!(entries_len <= 3);
1789        assert!(vary_len <= entries_len);
1790    }
1791
1792    /// Regression: refreshing an entry while still under capacity must reset its
1793    /// eviction recency, so a later capacity-driven eviction drops the genuinely
1794    /// oldest *untouched* entry — not the refreshed one by its original insert
1795    /// time. Repro: max_entries(3), store A, store B, re-store A (refresh, under
1796    /// cap), store C, store D (at cap → eviction). B must be evicted and A must
1797    /// survive. The old index evicted A because a refresh left the stale
1798    /// front-most `order` position live.
1799    #[tokio::test]
1800    async fn test_eviction_refresh_resets_recency() {
1801        async fn store(cache: &ResponseCache, path: &str, body: &[u8]) {
1802            let req = HttpRequest::new("GET", path.to_string());
1803            let mut resp = HttpResponse::ok();
1804            resp.body = Bytes::copy_from_slice(body);
1805            cache.store(&req, &resp).await;
1806        }
1807        async fn present(cache: &ResponseCache, path: &str) -> bool {
1808            cache
1809                .get(&HttpRequest::new("GET", path.to_string()))
1810                .await
1811                .is_some()
1812        }
1813
1814        let cache = ResponseCache::with_config(ResponseCacheConfig::new().max_entries(3));
1815
1816        store(&cache, "/a", b"a").await;
1817        store(&cache, "/b", b"b").await;
1818        store(&cache, "/a", b"a2").await; // refresh while under capacity
1819        store(&cache, "/c", b"c").await;
1820        store(&cache, "/d", b"d").await; // at capacity → one eviction
1821
1822        assert!(
1823            !present(&cache, "/b").await,
1824            "B (the oldest untouched entry) must be evicted"
1825        );
1826        assert!(
1827            present(&cache, "/a").await,
1828            "A was refreshed under capacity and must survive"
1829        );
1830        assert!(present(&cache, "/c").await, "/c must remain");
1831        assert!(present(&cache, "/d").await, "/d was just stored");
1832    }
1833
1834    /// Regression: TTL purging must also prune `vary_index` so expired entries
1835    /// do not leak their base-key records.
1836    #[tokio::test]
1837    async fn test_purge_stale_shrinks_vary_index() {
1838        let cache = ResponseCache::new();
1839
1840        let mut stale_req = HttpRequest::new("QUERY", "/search".to_string());
1841        stale_req.body = Bytes::from_static(b"q=stale");
1842        let mut fresh_req = HttpRequest::new("QUERY", "/search".to_string());
1843        fresh_req.body = Bytes::from_static(b"q=fresh");
1844        let resp = HttpResponse::ok();
1845
1846        cache
1847            .store_with_ttl(&stale_req, &resp, Duration::from_secs(0))
1848            .await;
1849        cache
1850            .store_with_ttl(&fresh_req, &resp, Duration::from_secs(300))
1851            .await;
1852
1853        assert_eq!(cache.vary_index.read().await.len(), 2);
1854
1855        // Ensure the zero-TTL entry is observably stale.
1856        tokio::time::sleep(Duration::from_millis(5)).await;
1857        cache.purge_stale().await;
1858
1859        assert_eq!(
1860            cache.entries.read().await.len(),
1861            1,
1862            "only the fresh entry should survive purge",
1863        );
1864        assert_eq!(
1865            cache.vary_index.read().await.len(),
1866            1,
1867            "vary_index must shrink in lockstep with purged entries",
1868        );
1869    }
1870
1871    /// Regression: repeatedly storing then invalidating keys (capacity never
1872    /// hit, so `evict_oldest` never runs) must not let `order` grow without
1873    /// bound. Keys removed via `invalidate` were previously left in `order`
1874    /// as tombstones forever.
1875    #[tokio::test]
1876    async fn test_eviction_order_bounded_under_store_invalidate_churn() {
1877        let cache = ResponseCache::with_config(ResponseCacheConfig::new().max_entries(10_000));
1878
1879        for i in 0..2000 {
1880            let req = HttpRequest::new("GET", format!("/churn/{}", i % 5));
1881            cache.store(&req, &HttpResponse::ok()).await;
1882            cache.invalidate(&req).await;
1883        }
1884
1885        let live = cache.entries.read().await.len();
1886        let order_len = cache.eviction.lock().unwrap().order.len();
1887        assert!(
1888            order_len <= 2 * live + 16,
1889            "order ({}) grew unbounded relative to live entries ({}) under store/invalidate churn",
1890            order_len,
1891            live
1892        );
1893    }
1894
1895    /// Regression: re-storing the *same* key repeatedly (e.g. a hot endpoint
1896    /// refreshed every TTL cycle) pushes a new `order` entry each time
1897    /// (`EvictionIndex::record_insert`'s `replaced` branch only suppresses the
1898    /// `base_key_counts` increment, not the push). Without compaction this
1899    /// grows without bound even though only one entry is ever live.
1900    #[tokio::test]
1901    async fn test_eviction_order_bounded_under_repeated_restore() {
1902        let cache = ResponseCache::with_config(ResponseCacheConfig::new().max_entries(10_000));
1903        let req = HttpRequest::new("GET", "/hot".to_string());
1904
1905        for i in 0..2000 {
1906            let mut resp = HttpResponse::ok();
1907            resp.body = Bytes::from(format!("v{}", i).into_bytes());
1908            cache.store(&req, &resp).await;
1909        }
1910
1911        let live = cache.entries.read().await.len();
1912        assert_eq!(live, 1, "only the latest write for the key should be live");
1913
1914        let order_len = cache.eviction.lock().unwrap().order.len();
1915        assert!(
1916            order_len <= 2 * live + 16,
1917            "order ({}) grew unbounded across repeated re-stores of one key (live={})",
1918            order_len,
1919            live
1920        );
1921
1922        // The live value must be the most recent store, not a stale one.
1923        let cached = cache.get(&req).await;
1924        assert!(cached.is_some());
1925        assert_eq!(cached.unwrap().body, Bytes::from_static(b"v1999"));
1926    }
1927
1928    /// Regression: two responses stored at the same path with *different* Vary
1929    /// header sets must both remain retrievable. Overwriting the `vary_index`
1930    /// record last-writer-wins would make the earlier variant unreachable;
1931    /// merging (union) keeps both reachable.
1932    #[tokio::test]
1933    async fn test_vary_index_merges_distinct_vary_sets() {
1934        let cache = ResponseCache::new();
1935
1936        // Variant 1: keyed on Accept.
1937        let mut req_accept = HttpRequest::new("GET", "/api/data".to_string());
1938        req_accept
1939            .headers
1940            .insert("Accept", "application/json".to_string());
1941        let mut resp_accept = HttpResponse::ok().with_vary(&["Accept"]);
1942        resp_accept.body = Bytes::from_static(b"json-body");
1943        cache.store(&req_accept, &resp_accept).await;
1944
1945        // Variant 2: same path, keyed on Accept-Encoding.
1946        let mut req_enc = HttpRequest::new("GET", "/api/data".to_string());
1947        req_enc
1948            .headers
1949            .insert("Accept-Encoding", "gzip".to_string());
1950        let mut resp_enc = HttpResponse::ok().with_vary(&["Accept-Encoding"]);
1951        resp_enc.body = Bytes::from_static(b"gzip-body");
1952        cache.store(&req_enc, &resp_enc).await;
1953
1954        // Both variants must survive the second store's `vary_index` update.
1955        let hit_accept = cache.get(&req_accept).await;
1956        assert!(
1957            hit_accept.is_some(),
1958            "Accept variant lost after second store"
1959        );
1960        assert_eq!(hit_accept.unwrap().body, Bytes::from_static(b"json-body"));
1961
1962        let hit_enc = cache.get(&req_enc).await;
1963        assert!(
1964            hit_enc.is_some(),
1965            "Accept-Encoding variant lost after second store",
1966        );
1967        assert_eq!(hit_enc.unwrap().body, Bytes::from_static(b"gzip-body"));
1968    }
1969}