1use std::collections::HashMap;
10use std::sync::{Arc, RwLock};
11use std::time::{Duration, Instant};
12
13#[derive(Clone, Debug)]
15struct CachedResponse {
16 body: Vec<u8>,
18 etag: String,
20 expires_at: Instant,
22}
23
24#[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#[derive(Debug)]
45pub struct ApiResponseCache {
46 cache: Arc<RwLock<HashMap<String, CachedResponse>>>,
47 stats: Arc<RwLock<CacheStats>>,
48}
49
50impl ApiResponseCache {
51 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 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 {
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 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 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 pub fn record_etag_hit(&self) {
114 let mut stats = self.stats.write().unwrap();
115 stats.etag_hits += 1;
116 }
117
118 pub fn stats(&self) -> CacheStats {
120 self.stats.read().unwrap().clone()
121 }
122
123 pub fn clear(&self) {
125 self.cache.write().unwrap().clear();
126 *self.stats.write().unwrap() = CacheStats::default();
127 }
128
129 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 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
161fn 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 cache
241 .get_or_compute("test", Duration::from_millis(5), || Ok(b"data1".to_vec()))
242 .unwrap();
243
244 std::thread::sleep(Duration::from_millis(50));
246
247 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}