Skip to main content

ghostlink_core/
api_response_cache.rs

1//! API Response Caching Layer with ETag Support
2//!
3//! Reduces serialization overhead and bandwidth by caching API responses with:
4//! - TTL-based expiration
5//! - ETag support for conditional requests (304 Not Modified)
6//! - Hit ratio tracking and statistics
7//! - Optional pre-warming on startup
8
9use std::collections::HashMap;
10use std::sync::{Arc, RwLock};
11use std::time::{Duration, Instant};
12
13/// Cached API response
14#[derive(Clone, Debug)]
15struct CachedResponse {
16    /// Response body
17    body: Vec<u8>,
18    /// ETag hash
19    etag: String,
20    /// Expiration time
21    expires_at: Instant,
22}
23
24/// Cache statistics for observability
25#[derive(Clone, Debug, Default)]
26pub struct CacheStats {
27    pub total_requests: usize,
28    pub cache_hits: usize,
29    pub cache_misses: usize,
30    pub etag_hits: usize,
31}
32
33impl CacheStats {
34    pub fn hit_ratio(&self) -> f64 {
35        if self.total_requests == 0 {
36            0.0
37        } else {
38            (self.cache_hits + self.etag_hits) as f64 / self.total_requests as f64
39        }
40    }
41}
42
43/// Thread-safe API response cache
44#[derive(Debug)]
45pub struct ApiResponseCache {
46    cache: Arc<RwLock<HashMap<String, CachedResponse>>>,
47    stats: Arc<RwLock<CacheStats>>,
48}
49
50impl ApiResponseCache {
51    /// Create a new cache
52    pub fn new() -> Self {
53        Self {
54            cache: Arc::new(RwLock::new(HashMap::new())),
55            stats: Arc::new(RwLock::new(CacheStats::default())),
56        }
57    }
58
59    /// Get cached response or compute new one
60    pub fn get_or_compute<F>(
61        &self,
62        key: &str,
63        ttl: Duration,
64        compute_fn: F,
65    ) -> Result<(Vec<u8>, String), String>
66    where
67        F: FnOnce() -> Result<Vec<u8>, String>,
68    {
69        let mut stats = self.stats.write().unwrap();
70        stats.total_requests += 1;
71
72        // Check cache
73        {
74            let cache = self.cache.read().unwrap();
75            if let Some(cached) = cache.get(key) {
76                if cached.expires_at > Instant::now() {
77                    stats.cache_hits += 1;
78                    return Ok((cached.body.clone(), cached.etag.clone()));
79                }
80            }
81        }
82
83        // Cache miss or expired
84        stats.cache_misses += 1;
85        let body = compute_fn()?;
86        let etag = compute_etag(&body);
87
88        let mut cache = self.cache.write().unwrap();
89        cache.insert(
90            key.to_string(),
91            CachedResponse {
92                body: body.clone(),
93                etag: etag.clone(),
94                expires_at: Instant::now() + ttl,
95            },
96        );
97
98        Ok((body, etag))
99    }
100
101    /// Check if client's ETag matches (for 304 responses)
102    pub fn etag_matches(&self, key: &str, client_etag: &str) -> bool {
103        let cache = self.cache.read().unwrap();
104        if let Some(cached) = cache.get(key) {
105            if cached.expires_at > Instant::now() {
106                return cached.etag == client_etag;
107            }
108        }
109        false
110    }
111
112    /// Record ETag hit (304 response sent)
113    pub fn record_etag_hit(&self) {
114        let mut stats = self.stats.write().unwrap();
115        stats.etag_hits += 1;
116    }
117
118    /// Get current cache statistics
119    pub fn stats(&self) -> CacheStats {
120        self.stats.read().unwrap().clone()
121    }
122
123    /// Clear all cache entries
124    pub fn clear(&self) {
125        self.cache.write().unwrap().clear();
126        *self.stats.write().unwrap() = CacheStats::default();
127    }
128
129    /// Pre-warm cache with known endpoints
130    pub fn pre_warm<F>(&self, endpoints: Vec<(&str, Duration)>, compute_fn: F) -> Result<(), String>
131    where
132        F: Fn(&str) -> Result<Vec<u8>, String>,
133    {
134        for (key, ttl) in endpoints {
135            self.get_or_compute(key, ttl, || compute_fn(key))?;
136        }
137        Ok(())
138    }
139
140    /// Get cache size (number of entries)
141    pub fn size(&self) -> usize {
142        self.cache.read().unwrap().len()
143    }
144}
145
146impl Default for ApiResponseCache {
147    fn default() -> Self {
148        Self::new()
149    }
150}
151
152impl Clone for ApiResponseCache {
153    fn clone(&self) -> Self {
154        Self {
155            cache: Arc::clone(&self.cache),
156            stats: Arc::clone(&self.stats),
157        }
158    }
159}
160
161/// Compute a weak ETag (RFC 7232 `W/` prefix — a content fingerprint for
162/// conditional-GET purposes, not a claim of byte-exact equivalence) from a
163/// response body. Mixes the body length into the hash state and appends it
164/// to the digest so two different-length bodies that happen to collide in
165/// the 64-bit hash are still distinguishable — plain `DefaultHasher::finish()`
166/// alone shares this any-fixed-width-hash's theoretical collision risk, but
167/// unlike a 32-bit CRC (already used elsewhere in this crate for wire-frame
168/// corruption detection, a different threat model) this doesn't halve the
169/// effective space to make it worse.
170fn compute_etag(body: &[u8]) -> String {
171    use std::collections::hash_map::DefaultHasher;
172    use std::hash::{Hash, Hasher};
173
174    let mut hasher = DefaultHasher::new();
175    body.len().hash(&mut hasher);
176    body.hash(&mut hasher);
177    format!("W/\"{:x}-{:x}\"", hasher.finish(), body.len())
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn cache_miss_computes_value() {
186        let cache = ApiResponseCache::new();
187        let body = b"response data".to_vec();
188        let body_clone = body.clone();
189
190        let (result, etag) = cache
191            .get_or_compute("test", Duration::from_secs(60), || Ok(body_clone))
192            .unwrap();
193
194        assert_eq!(result, body);
195        assert!(!etag.is_empty());
196    }
197
198    #[test]
199    fn cache_hit_reuses() {
200        let cache = ApiResponseCache::new();
201        let body1 = b"response1".to_vec();
202        let body1_clone = body1.clone();
203
204        let (result1, etag1) = cache
205            .get_or_compute("test", Duration::from_secs(60), || Ok(body1_clone))
206            .unwrap();
207
208        let (result2, etag2) = cache
209            .get_or_compute(
210                "test",
211                Duration::from_secs(60),
212                || Ok(b"response2".to_vec()),
213            )
214            .unwrap();
215
216        assert_eq!(result1, result2);
217        assert_eq!(etag1, etag2);
218        assert_eq!(cache.stats().cache_hits, 1);
219    }
220
221    #[test]
222    fn etag_match_detection() {
223        let cache = ApiResponseCache::new();
224        let body = b"data".to_vec();
225        let body_clone = body.clone();
226
227        let (_result, etag) = cache
228            .get_or_compute("test", Duration::from_secs(60), || Ok(body_clone))
229            .unwrap();
230
231        assert!(cache.etag_matches("test", &etag));
232        assert!(!cache.etag_matches("test", "wrong-etag"));
233    }
234
235    #[test]
236    fn expiration_and_recompute() {
237        let cache = ApiResponseCache::new();
238
239        // First request: cache miss
240        cache
241            .get_or_compute("test", Duration::from_millis(5), || Ok(b"data1".to_vec()))
242            .unwrap();
243
244        // Wait for expiration
245        std::thread::sleep(Duration::from_millis(50));
246
247        // Second request on expired key: should recompute
248        let (result2, _etag2) = cache
249            .get_or_compute("test", Duration::from_secs(60), || Ok(b"data2".to_vec()))
250            .unwrap();
251
252        assert_eq!(result2, b"data2");
253    }
254
255    #[test]
256    fn statistics_tracking() {
257        let cache = ApiResponseCache::new();
258
259        for i in 0..5 {
260            let body = format!("response{}", i).into_bytes();
261            cache
262                .get_or_compute(&format!("endpoint{}", i), Duration::from_secs(60), || {
263                    Ok(body)
264                })
265                .unwrap();
266        }
267
268        let stats = cache.stats();
269        assert_eq!(stats.total_requests, 5);
270        assert_eq!(stats.cache_misses, 5);
271        assert_eq!(stats.cache_hits, 0);
272    }
273
274    #[test]
275    fn hit_ratio_calculation() {
276        let cache = ApiResponseCache::new();
277
278        for _ in 0..3 {
279            cache
280                .get_or_compute("test", Duration::from_secs(60), || Ok(b"data".to_vec()))
281                .unwrap();
282            cache
283                .get_or_compute("test", Duration::from_secs(60), || Ok(b"data".to_vec()))
284                .unwrap();
285        }
286
287        let stats = cache.stats();
288        let ratio = stats.hit_ratio();
289        assert!(ratio > 0.5);
290    }
291
292    #[test]
293    fn pre_warm_populates() {
294        let cache = ApiResponseCache::new();
295
296        cache
297            .pre_warm(
298                vec![
299                    ("models", Duration::from_secs(60)),
300                    ("runtime", Duration::from_secs(300)),
301                ],
302                |_key| Ok(b"warm data".to_vec()),
303            )
304            .unwrap();
305
306        assert_eq!(cache.size(), 2);
307    }
308
309    #[test]
310    fn clone_shares_state() {
311        let cache1 = ApiResponseCache::new();
312        cache1
313            .get_or_compute("test", Duration::from_secs(60), || Ok(b"data".to_vec()))
314            .unwrap();
315
316        let cache2 = cache1.clone();
317        assert_eq!(cache1.size(), cache2.size());
318    }
319}