1use crate::constants;
2use crate::error::Error;
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9#[derive(Debug, Clone)]
11pub struct CacheConfig {
12 pub cache_dir: PathBuf,
14 pub default_ttl: Duration,
16 pub max_entries: usize,
18 pub enabled: bool,
20}
21
22impl Default for CacheConfig {
23 fn default() -> Self {
24 Self {
25 cache_dir: PathBuf::from(".cache/responses"),
26 default_ttl: Duration::from_secs(300), max_entries: 1000,
28 enabled: true,
29 }
30 }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct CachedResponse {
36 pub body: String,
38 pub status_code: u16,
40 pub headers: HashMap<String, String>,
42 pub cached_at: u64,
44 pub ttl_seconds: u64,
46 pub request_info: CachedRequestInfo,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct CachedRequestInfo {
53 pub method: String,
55 pub url: String,
57 pub headers: HashMap<String, String>,
59 pub body_hash: Option<String>,
61}
62
63#[derive(Debug)]
65pub struct CacheKey {
66 pub api_name: String,
68 pub operation_id: String,
70 pub request_hash: String,
72}
73
74impl CacheKey {
75 pub fn from_request(
81 api_name: &str,
82 operation_id: &str,
83 method: &str,
84 url: &str,
85 headers: &HashMap<String, String>,
86 body: Option<&str>,
87 ) -> Result<Self, Error> {
88 let mut hasher = Sha256::new();
89
90 hasher.update(method.as_bytes());
92 hasher.update(url.as_bytes());
93
94 let mut sorted_headers: Vec<_> = headers
96 .iter()
97 .filter(|(key, _)| !is_auth_header(key))
98 .collect();
99 sorted_headers.sort_by_key(|(key, _)| *key);
100
101 for (key, value) in sorted_headers {
102 hasher.update(key.as_bytes());
103 hasher.update(value.as_bytes());
104 }
105
106 if let Some(body_content) = body {
108 hasher.update(body_content.as_bytes());
109 }
110
111 let hash = hasher.finalize();
112 let request_hash = format!("{hash:x}");
113
114 Ok(Self {
115 api_name: api_name.to_string(),
116 operation_id: operation_id.to_string(),
117 request_hash,
118 })
119 }
120
121 #[must_use]
123 pub fn to_filename(&self) -> String {
124 let hash_prefix = if self.request_hash.len() >= 16 {
125 &self.request_hash[..16]
126 } else {
127 &self.request_hash
128 };
129
130 format!(
131 "{}_{}_{}_{}{}",
132 self.api_name,
133 self.operation_id,
134 hash_prefix,
135 constants::CACHE_SUFFIX,
136 constants::FILE_EXT_JSON
137 )
138 }
139}
140
141pub struct ResponseCache {
143 config: CacheConfig,
144}
145
146impl ResponseCache {
147 pub fn new(config: CacheConfig) -> Result<Self, Error> {
153 std::fs::create_dir_all(&config.cache_dir)
155 .map_err(|e| Error::io_error(format!("Failed to create cache directory: {e}")))?;
156
157 Ok(Self { config })
158 }
159
160 pub async fn store(
169 &self,
170 key: &CacheKey,
171 body: &str,
172 status_code: u16,
173 headers: &HashMap<String, String>,
174 request_info: CachedRequestInfo,
175 ttl: Option<Duration>,
176 ) -> Result<(), Error> {
177 if !self.config.enabled {
178 return Ok(());
179 }
180
181 let now = SystemTime::now()
182 .duration_since(UNIX_EPOCH)
183 .map_err(|e| Error::invalid_config(format!("System time error: {e}")))?
184 .as_secs();
185
186 let ttl_seconds = ttl.unwrap_or(self.config.default_ttl).as_secs();
187
188 let cached_response = CachedResponse {
189 body: body.to_string(),
190 status_code,
191 headers: headers.clone(),
192 cached_at: now,
193 ttl_seconds,
194 request_info,
195 };
196
197 let cache_file = self.config.cache_dir.join(key.to_filename());
198 let json_content = serde_json::to_string_pretty(&cached_response).map_err(|e| {
199 Error::serialization_error(format!("Failed to serialize cached response: {e}"))
200 })?;
201
202 tokio::fs::write(&cache_file, json_content)
203 .await
204 .map_err(|e| Error::io_error(format!("Failed to write cache file: {e}")))?;
205
206 self.cleanup_old_entries(&key.api_name).await?;
208
209 Ok(())
210 }
211
212 pub async fn get(&self, key: &CacheKey) -> Result<Option<CachedResponse>, Error> {
220 if !self.config.enabled {
221 return Ok(None);
222 }
223
224 let cache_file = self.config.cache_dir.join(key.to_filename());
225
226 if !cache_file.exists() {
227 return Ok(None);
228 }
229
230 let json_content = tokio::fs::read_to_string(&cache_file)
231 .await
232 .map_err(|e| Error::io_error(format!("Failed to read cache file: {e}")))?;
233 let cached_response: CachedResponse = serde_json::from_str(&json_content).map_err(|e| {
234 Error::serialization_error(format!("Failed to deserialize cached response: {e}"))
235 })?;
236
237 let now = SystemTime::now()
239 .duration_since(UNIX_EPOCH)
240 .map_err(|e| Error::invalid_config(format!("System time error: {e}")))?
241 .as_secs();
242
243 if now > cached_response.cached_at + cached_response.ttl_seconds {
244 let _ = tokio::fs::remove_file(&cache_file).await;
246 return Ok(None);
247 }
248
249 Ok(Some(cached_response))
250 }
251
252 pub async fn is_cached(&self, key: &CacheKey) -> Result<bool, Error> {
258 Ok(self.get(key).await?.is_some())
259 }
260
261 pub async fn clear_api_cache(&self, api_name: &str) -> Result<usize, Error> {
267 let mut cleared_count = 0;
268 let mut entries = tokio::fs::read_dir(&self.config.cache_dir)
269 .await
270 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?;
271
272 while let Some(entry) = entries
273 .next_entry()
274 .await
275 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?
276 {
277 let filename = entry.file_name();
278 let filename_str = filename.to_string_lossy();
279
280 if filename_str.starts_with(&format!("{api_name}_"))
281 && filename_str.ends_with(constants::CACHE_FILE_SUFFIX)
282 {
283 tokio::fs::remove_file(entry.path())
284 .await
285 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?;
286 cleared_count += 1;
287 }
288 }
289
290 Ok(cleared_count)
291 }
292
293 pub async fn clear_all(&self) -> Result<usize, Error> {
299 let mut cleared_count = 0;
300 let mut entries = tokio::fs::read_dir(&self.config.cache_dir)
301 .await
302 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?;
303
304 while let Some(entry) = entries
305 .next_entry()
306 .await
307 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?
308 {
309 let filename = entry.file_name();
310 let filename_str = filename.to_string_lossy();
311
312 if filename_str.ends_with(constants::CACHE_FILE_SUFFIX) {
313 tokio::fs::remove_file(entry.path())
314 .await
315 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?;
316 cleared_count += 1;
317 }
318 }
319
320 Ok(cleared_count)
321 }
322
323 pub async fn get_stats(&self, api_name: Option<&str>) -> Result<CacheStats, Error> {
329 let mut stats = CacheStats::default();
330 let mut entries = tokio::fs::read_dir(&self.config.cache_dir)
331 .await
332 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?;
333
334 while let Some(entry) = entries
335 .next_entry()
336 .await
337 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?
338 {
339 let filename = entry.file_name();
340 let filename_str = filename.to_string_lossy();
341
342 if !filename_str.ends_with(constants::CACHE_FILE_SUFFIX) {
343 continue;
344 }
345
346 if let Some(target_api) = api_name {
348 if !filename_str.starts_with(&format!("{target_api}_")) {
349 continue;
350 }
351 }
352
353 stats.total_entries += 1;
354
355 if let Ok(metadata) = entry.metadata().await {
357 stats.total_size_bytes += metadata.len();
358
359 if let Ok(json_content) = tokio::fs::read_to_string(entry.path()).await {
361 if let Ok(cached_response) =
362 serde_json::from_str::<CachedResponse>(&json_content)
363 {
364 let now = SystemTime::now()
365 .duration_since(UNIX_EPOCH)
366 .map_err(|e| Error::invalid_config(format!("System time error: {e}")))?
367 .as_secs();
368
369 if now > cached_response.cached_at + cached_response.ttl_seconds {
370 stats.expired_entries += 1;
371 } else {
372 stats.valid_entries += 1;
373 }
374 }
375 }
376 }
377 }
378
379 Ok(stats)
380 }
381
382 async fn cleanup_old_entries(&self, api_name: &str) -> Result<(), Error> {
384 let mut entries = Vec::new();
385 let mut dir_entries = tokio::fs::read_dir(&self.config.cache_dir)
386 .await
387 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?;
388
389 while let Some(entry) = dir_entries
390 .next_entry()
391 .await
392 .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?
393 {
394 let filename = entry.file_name();
395 let filename_str = filename.to_string_lossy();
396
397 if filename_str.starts_with(&format!("{api_name}_"))
398 && filename_str.ends_with(constants::CACHE_FILE_SUFFIX)
399 {
400 if let Ok(metadata) = entry.metadata().await {
401 if let Ok(modified) = metadata.modified() {
402 entries.push((entry.path(), modified));
403 }
404 }
405 }
406 }
407
408 if entries.len() > self.config.max_entries {
410 entries.sort_by_key(|(_, modified)| *modified);
411 let to_remove = entries.len() - self.config.max_entries;
412
413 for (path, _) in entries.iter().take(to_remove) {
414 let _ = tokio::fs::remove_file(path).await;
415 }
416 }
417
418 Ok(())
419 }
420}
421
422#[derive(Debug, Default)]
424pub struct CacheStats {
425 pub total_entries: usize,
427 pub valid_entries: usize,
429 pub expired_entries: usize,
431 pub total_size_bytes: u64,
433}
434
435fn is_auth_header(header_name: &str) -> bool {
437 constants::is_auth_header(header_name)
438 || header_name
439 .to_lowercase()
440 .starts_with(constants::HEADER_PREFIX_X_AUTH)
441 || header_name
442 .to_lowercase()
443 .starts_with(constants::HEADER_PREFIX_X_API)
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449 use tempfile::TempDir;
450
451 fn create_test_cache_config() -> (CacheConfig, TempDir) {
452 let temp_dir = TempDir::new().unwrap();
453 let config = CacheConfig {
454 cache_dir: temp_dir.path().to_path_buf(),
455 default_ttl: Duration::from_secs(60),
456 max_entries: 10,
457 enabled: true,
458 };
459 (config, temp_dir)
460 }
461
462 #[test]
463 fn test_cache_key_generation() {
464 let mut headers = HashMap::new();
465 headers.insert(
466 constants::HEADER_CONTENT_TYPE_LC.to_string(),
467 constants::CONTENT_TYPE_JSON.to_string(),
468 );
469 headers.insert(
470 constants::HEADER_AUTHORIZATION_LC.to_string(),
471 "Bearer secret".to_string(),
472 ); let key = CacheKey::from_request(
475 "test_api",
476 "getUser",
477 constants::HTTP_METHOD_GET,
478 "https://api.example.com/users/123",
479 &headers,
480 None,
481 )
482 .unwrap();
483
484 assert_eq!(key.api_name, "test_api");
485 assert_eq!(key.operation_id, "getUser");
486 assert!(!key.request_hash.is_empty());
487
488 let filename = key.to_filename();
489 assert!(filename.starts_with("test_api_getUser_"));
490 assert!(filename.ends_with(constants::CACHE_FILE_SUFFIX));
491 }
492
493 #[test]
494 fn test_is_auth_header() {
495 assert!(is_auth_header(constants::HEADER_AUTHORIZATION));
496 assert!(is_auth_header("X-API-Key"));
497 assert!(is_auth_header("x-auth-token"));
498 assert!(!is_auth_header(constants::HEADER_CONTENT_TYPE));
499 assert!(!is_auth_header("User-Agent"));
500 }
501
502 #[tokio::test]
503 async fn test_cache_store_and_retrieve() {
504 let (config, _temp_dir) = create_test_cache_config();
505 let cache = ResponseCache::new(config).unwrap();
506
507 let key = CacheKey {
508 api_name: "test_api".to_string(),
509 operation_id: "getUser".to_string(),
510 request_hash: "abc123".to_string(),
511 };
512
513 let mut headers = HashMap::new();
514 headers.insert(
515 constants::HEADER_CONTENT_TYPE_LC.to_string(),
516 constants::CONTENT_TYPE_JSON.to_string(),
517 );
518
519 let request_info = CachedRequestInfo {
520 method: constants::HTTP_METHOD_GET.to_string(),
521 url: "https://api.example.com/users/123".to_string(),
522 headers: headers.clone(),
523 body_hash: None,
524 };
525
526 cache
528 .store(
529 &key,
530 r#"{"id": 123, "name": "John"}"#,
531 200,
532 &headers,
533 request_info,
534 Some(Duration::from_secs(60)),
535 )
536 .await
537 .unwrap();
538
539 let cached = cache.get(&key).await.unwrap();
541 assert!(cached.is_some());
542
543 let response = cached.unwrap();
544 assert_eq!(response.body, r#"{"id": 123, "name": "John"}"#);
545 assert_eq!(response.status_code, 200);
546 }
547
548 #[tokio::test]
549 async fn test_cache_expiration() {
550 let (config, _temp_dir) = create_test_cache_config();
551 let cache = ResponseCache::new(config).unwrap();
552
553 let key = CacheKey {
554 api_name: "test_api".to_string(),
555 operation_id: "getUser".to_string(),
556 request_hash: "abc123def456".to_string(),
557 };
558
559 let headers = HashMap::new();
560 let request_info = CachedRequestInfo {
561 method: constants::HTTP_METHOD_GET.to_string(),
562 url: "https://api.example.com/users/123".to_string(),
563 headers: headers.clone(),
564 body_hash: None,
565 };
566
567 cache
569 .store(
570 &key,
571 "test response",
572 200,
573 &headers,
574 request_info,
575 Some(Duration::from_secs(1)),
576 )
577 .await
578 .unwrap();
579
580 assert!(cache.is_cached(&key).await.unwrap());
582
583 let cache_file = cache.config.cache_dir.join(key.to_filename());
585 let mut cached_response: CachedResponse = {
586 let json_content = tokio::fs::read_to_string(&cache_file).await.unwrap();
587 serde_json::from_str(&json_content).unwrap()
588 };
589
590 cached_response.cached_at = SystemTime::now()
592 .duration_since(UNIX_EPOCH)
593 .unwrap()
594 .as_secs()
595 - 2; let json_content = serde_json::to_string_pretty(&cached_response).unwrap();
598 tokio::fs::write(&cache_file, json_content).await.unwrap();
599
600 assert!(!cache.is_cached(&key).await.unwrap());
602
603 assert!(!cache_file.exists());
605 }
606}