1use crate::error::Error;
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::collections::HashMap;
5use std::path::PathBuf;
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8#[derive(Debug, Clone)]
10pub struct CacheConfig {
11 pub cache_dir: PathBuf,
13 pub default_ttl: Duration,
15 pub max_entries: usize,
17 pub enabled: bool,
19}
20
21impl Default for CacheConfig {
22 fn default() -> Self {
23 Self {
24 cache_dir: PathBuf::from(".cache/responses"),
25 default_ttl: Duration::from_secs(300), max_entries: 1000,
27 enabled: true,
28 }
29 }
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct CachedResponse {
35 pub body: String,
37 pub status_code: u16,
39 pub headers: HashMap<String, String>,
41 pub cached_at: u64,
43 pub ttl_seconds: u64,
45 pub request_info: CachedRequestInfo,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct CachedRequestInfo {
52 pub method: String,
54 pub url: String,
56 pub headers: HashMap<String, String>,
58 pub body_hash: Option<String>,
60}
61
62#[derive(Debug)]
64pub struct CacheKey {
65 pub api_name: String,
67 pub operation_id: String,
69 pub request_hash: String,
71}
72
73impl CacheKey {
74 pub fn from_request(
80 api_name: &str,
81 operation_id: &str,
82 method: &str,
83 url: &str,
84 headers: &HashMap<String, String>,
85 body: Option<&str>,
86 ) -> Result<Self, Error> {
87 let mut hasher = Sha256::new();
88
89 hasher.update(method.as_bytes());
91 hasher.update(url.as_bytes());
92
93 let mut sorted_headers: Vec<_> = headers
95 .iter()
96 .filter(|(key, _)| !is_auth_header(key))
97 .collect();
98 sorted_headers.sort_by_key(|(key, _)| *key);
99
100 for (key, value) in sorted_headers {
101 hasher.update(key.as_bytes());
102 hasher.update(value.as_bytes());
103 }
104
105 if let Some(body_content) = body {
107 hasher.update(body_content.as_bytes());
108 }
109
110 let hash = hasher.finalize();
111 let request_hash = format!("{hash:x}");
112
113 Ok(Self {
114 api_name: api_name.to_string(),
115 operation_id: operation_id.to_string(),
116 request_hash,
117 })
118 }
119
120 #[must_use]
122 pub fn to_filename(&self) -> String {
123 let hash_prefix = if self.request_hash.len() >= 16 {
124 &self.request_hash[..16]
125 } else {
126 &self.request_hash
127 };
128
129 format!(
130 "{}_{}_{}_{}.json",
131 self.api_name, self.operation_id, hash_prefix, "cache"
132 )
133 }
134}
135
136pub struct ResponseCache {
138 config: CacheConfig,
139}
140
141impl ResponseCache {
142 pub fn new(config: CacheConfig) -> Result<Self, Error> {
148 std::fs::create_dir_all(&config.cache_dir).map_err(Error::Io)?;
150
151 Ok(Self { config })
152 }
153
154 pub async fn store(
163 &self,
164 key: &CacheKey,
165 body: &str,
166 status_code: u16,
167 headers: &HashMap<String, String>,
168 request_info: CachedRequestInfo,
169 ttl: Option<Duration>,
170 ) -> Result<(), Error> {
171 if !self.config.enabled {
172 return Ok(());
173 }
174
175 let now = SystemTime::now()
176 .duration_since(UNIX_EPOCH)
177 .map_err(|e| Error::Config(format!("System time error: {e}")))?
178 .as_secs();
179
180 let ttl_seconds = ttl.unwrap_or(self.config.default_ttl).as_secs();
181
182 let cached_response = CachedResponse {
183 body: body.to_string(),
184 status_code,
185 headers: headers.clone(),
186 cached_at: now,
187 ttl_seconds,
188 request_info,
189 };
190
191 let cache_file = self.config.cache_dir.join(key.to_filename());
192 let json_content = serde_json::to_string_pretty(&cached_response).map_err(Error::Json)?;
193
194 tokio::fs::write(&cache_file, json_content)
195 .await
196 .map_err(Error::Io)?;
197
198 self.cleanup_old_entries(&key.api_name).await?;
200
201 Ok(())
202 }
203
204 pub async fn get(&self, key: &CacheKey) -> Result<Option<CachedResponse>, Error> {
212 if !self.config.enabled {
213 return Ok(None);
214 }
215
216 let cache_file = self.config.cache_dir.join(key.to_filename());
217
218 if !cache_file.exists() {
219 return Ok(None);
220 }
221
222 let json_content = tokio::fs::read_to_string(&cache_file)
223 .await
224 .map_err(Error::Io)?;
225 let cached_response: CachedResponse =
226 serde_json::from_str(&json_content).map_err(Error::Json)?;
227
228 let now = SystemTime::now()
230 .duration_since(UNIX_EPOCH)
231 .map_err(|e| Error::Config(format!("System time error: {e}")))?
232 .as_secs();
233
234 if now > cached_response.cached_at + cached_response.ttl_seconds {
235 let _ = tokio::fs::remove_file(&cache_file).await;
237 return Ok(None);
238 }
239
240 Ok(Some(cached_response))
241 }
242
243 pub async fn is_cached(&self, key: &CacheKey) -> Result<bool, Error> {
249 Ok(self.get(key).await?.is_some())
250 }
251
252 pub async fn clear_api_cache(&self, api_name: &str) -> Result<usize, Error> {
258 let mut cleared_count = 0;
259 let mut entries = tokio::fs::read_dir(&self.config.cache_dir)
260 .await
261 .map_err(Error::Io)?;
262
263 while let Some(entry) = entries.next_entry().await.map_err(Error::Io)? {
264 let filename = entry.file_name();
265 let filename_str = filename.to_string_lossy();
266
267 if filename_str.starts_with(&format!("{api_name}_"))
268 && filename_str.ends_with("_cache.json")
269 {
270 tokio::fs::remove_file(entry.path())
271 .await
272 .map_err(Error::Io)?;
273 cleared_count += 1;
274 }
275 }
276
277 Ok(cleared_count)
278 }
279
280 pub async fn clear_all(&self) -> Result<usize, Error> {
286 let mut cleared_count = 0;
287 let mut entries = tokio::fs::read_dir(&self.config.cache_dir)
288 .await
289 .map_err(Error::Io)?;
290
291 while let Some(entry) = entries.next_entry().await.map_err(Error::Io)? {
292 let filename = entry.file_name();
293 let filename_str = filename.to_string_lossy();
294
295 if filename_str.ends_with("_cache.json") {
296 tokio::fs::remove_file(entry.path())
297 .await
298 .map_err(Error::Io)?;
299 cleared_count += 1;
300 }
301 }
302
303 Ok(cleared_count)
304 }
305
306 pub async fn get_stats(&self, api_name: Option<&str>) -> Result<CacheStats, Error> {
312 let mut stats = CacheStats::default();
313 let mut entries = tokio::fs::read_dir(&self.config.cache_dir)
314 .await
315 .map_err(Error::Io)?;
316
317 while let Some(entry) = entries.next_entry().await.map_err(Error::Io)? {
318 let filename = entry.file_name();
319 let filename_str = filename.to_string_lossy();
320
321 if !filename_str.ends_with("_cache.json") {
322 continue;
323 }
324
325 if let Some(target_api) = api_name {
327 if !filename_str.starts_with(&format!("{target_api}_")) {
328 continue;
329 }
330 }
331
332 stats.total_entries += 1;
333
334 if let Ok(metadata) = entry.metadata().await {
336 stats.total_size_bytes += metadata.len();
337
338 if let Ok(json_content) = tokio::fs::read_to_string(entry.path()).await {
340 if let Ok(cached_response) =
341 serde_json::from_str::<CachedResponse>(&json_content)
342 {
343 let now = SystemTime::now()
344 .duration_since(UNIX_EPOCH)
345 .map_err(|e| Error::Config(format!("System time error: {e}")))?
346 .as_secs();
347
348 if now > cached_response.cached_at + cached_response.ttl_seconds {
349 stats.expired_entries += 1;
350 } else {
351 stats.valid_entries += 1;
352 }
353 }
354 }
355 }
356 }
357
358 Ok(stats)
359 }
360
361 async fn cleanup_old_entries(&self, api_name: &str) -> Result<(), Error> {
363 let mut entries = Vec::new();
364 let mut dir_entries = tokio::fs::read_dir(&self.config.cache_dir)
365 .await
366 .map_err(Error::Io)?;
367
368 while let Some(entry) = dir_entries.next_entry().await.map_err(Error::Io)? {
369 let filename = entry.file_name();
370 let filename_str = filename.to_string_lossy();
371
372 if filename_str.starts_with(&format!("{api_name}_"))
373 && filename_str.ends_with("_cache.json")
374 {
375 if let Ok(metadata) = entry.metadata().await {
376 if let Ok(modified) = metadata.modified() {
377 entries.push((entry.path(), modified));
378 }
379 }
380 }
381 }
382
383 if entries.len() > self.config.max_entries {
385 entries.sort_by_key(|(_, modified)| *modified);
386 let to_remove = entries.len() - self.config.max_entries;
387
388 for (path, _) in entries.iter().take(to_remove) {
389 let _ = tokio::fs::remove_file(path).await;
390 }
391 }
392
393 Ok(())
394 }
395}
396
397#[derive(Debug, Default)]
399pub struct CacheStats {
400 pub total_entries: usize,
402 pub valid_entries: usize,
404 pub expired_entries: usize,
406 pub total_size_bytes: u64,
408}
409
410fn is_auth_header(header_name: &str) -> bool {
412 let lower_name = header_name.to_lowercase();
413 matches!(
414 lower_name.as_str(),
415 "authorization" | "x-api-key" | "api-key" | "token" | "bearer" | "cookie"
416 ) || lower_name.starts_with("x-auth-")
417 || lower_name.starts_with("x-api-")
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use tempfile::TempDir;
424
425 fn create_test_cache_config() -> (CacheConfig, TempDir) {
426 let temp_dir = TempDir::new().unwrap();
427 let config = CacheConfig {
428 cache_dir: temp_dir.path().to_path_buf(),
429 default_ttl: Duration::from_secs(60),
430 max_entries: 10,
431 enabled: true,
432 };
433 (config, temp_dir)
434 }
435
436 #[test]
437 fn test_cache_key_generation() {
438 let mut headers = HashMap::new();
439 headers.insert("content-type".to_string(), "application/json".to_string());
440 headers.insert("authorization".to_string(), "Bearer secret".to_string()); let key = CacheKey::from_request(
443 "test_api",
444 "getUser",
445 "GET",
446 "https://api.example.com/users/123",
447 &headers,
448 None,
449 )
450 .unwrap();
451
452 assert_eq!(key.api_name, "test_api");
453 assert_eq!(key.operation_id, "getUser");
454 assert!(!key.request_hash.is_empty());
455
456 let filename = key.to_filename();
457 assert!(filename.starts_with("test_api_getUser_"));
458 assert!(filename.ends_with("_cache.json"));
459 }
460
461 #[test]
462 fn test_is_auth_header() {
463 assert!(is_auth_header("Authorization"));
464 assert!(is_auth_header("X-API-Key"));
465 assert!(is_auth_header("x-auth-token"));
466 assert!(!is_auth_header("Content-Type"));
467 assert!(!is_auth_header("User-Agent"));
468 }
469
470 #[tokio::test]
471 async fn test_cache_store_and_retrieve() {
472 let (config, _temp_dir) = create_test_cache_config();
473 let cache = ResponseCache::new(config).unwrap();
474
475 let key = CacheKey {
476 api_name: "test_api".to_string(),
477 operation_id: "getUser".to_string(),
478 request_hash: "abc123".to_string(),
479 };
480
481 let mut headers = HashMap::new();
482 headers.insert("content-type".to_string(), "application/json".to_string());
483
484 let request_info = CachedRequestInfo {
485 method: "GET".to_string(),
486 url: "https://api.example.com/users/123".to_string(),
487 headers: headers.clone(),
488 body_hash: None,
489 };
490
491 cache
493 .store(
494 &key,
495 r#"{"id": 123, "name": "John"}"#,
496 200,
497 &headers,
498 request_info,
499 Some(Duration::from_secs(60)),
500 )
501 .await
502 .unwrap();
503
504 let cached = cache.get(&key).await.unwrap();
506 assert!(cached.is_some());
507
508 let response = cached.unwrap();
509 assert_eq!(response.body, r#"{"id": 123, "name": "John"}"#);
510 assert_eq!(response.status_code, 200);
511 }
512
513 #[tokio::test]
514 async fn test_cache_expiration() {
515 let (config, _temp_dir) = create_test_cache_config();
516 let cache = ResponseCache::new(config).unwrap();
517
518 let key = CacheKey {
519 api_name: "test_api".to_string(),
520 operation_id: "getUser".to_string(),
521 request_hash: "abc123def456".to_string(),
522 };
523
524 let headers = HashMap::new();
525 let request_info = CachedRequestInfo {
526 method: "GET".to_string(),
527 url: "https://api.example.com/users/123".to_string(),
528 headers: headers.clone(),
529 body_hash: None,
530 };
531
532 cache
534 .store(
535 &key,
536 "test response",
537 200,
538 &headers,
539 request_info,
540 Some(Duration::from_secs(1)),
541 )
542 .await
543 .unwrap();
544
545 assert!(cache.is_cached(&key).await.unwrap());
547
548 let cache_file = cache.config.cache_dir.join(key.to_filename());
550 let mut cached_response: CachedResponse = {
551 let json_content = tokio::fs::read_to_string(&cache_file).await.unwrap();
552 serde_json::from_str(&json_content).unwrap()
553 };
554
555 cached_response.cached_at = SystemTime::now()
557 .duration_since(UNIX_EPOCH)
558 .unwrap()
559 .as_secs()
560 - 2; let json_content = serde_json::to_string_pretty(&cached_response).unwrap();
563 tokio::fs::write(&cache_file, json_content).await.unwrap();
564
565 assert!(!cache.is_cached(&key).await.unwrap());
567
568 assert!(!cache_file.exists());
570 }
571}