aperture_cli/
response_cache.rs

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/// Configuration for response caching
10#[derive(Debug, Clone)]
11pub struct CacheConfig {
12    /// Directory where cache files are stored
13    pub cache_dir: PathBuf,
14    /// Default TTL for cached responses
15    pub default_ttl: Duration,
16    /// Maximum number of cached responses per API
17    pub max_entries: usize,
18    /// Whether caching is enabled globally
19    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), // 5 minutes
27            max_entries: 1000,
28            enabled: true,
29        }
30    }
31}
32
33/// A cached API response
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct CachedResponse {
36    /// The HTTP response body
37    pub body: String,
38    /// HTTP status code
39    pub status_code: u16,
40    /// Response headers
41    pub headers: HashMap<String, String>,
42    /// When this response was cached (Unix timestamp)
43    pub cached_at: u64,
44    /// TTL in seconds from `cached_at`
45    pub ttl_seconds: u64,
46    /// The original request that generated this response
47    pub request_info: CachedRequestInfo,
48}
49
50/// Information about the request that generated a cached response
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct CachedRequestInfo {
53    /// HTTP method
54    pub method: String,
55    /// Full URL
56    pub url: String,
57    /// Request headers (excluding auth headers for security)
58    pub headers: HashMap<String, String>,
59    /// Request body hash (for POST/PUT requests)
60    pub body_hash: Option<String>,
61}
62
63/// Cache key components for generating cache file names
64#[derive(Debug)]
65pub struct CacheKey {
66    /// API specification name
67    pub api_name: String,
68    /// Operation ID from `OpenAPI` spec
69    pub operation_id: String,
70    /// Hash of request parameters and body
71    pub request_hash: String,
72}
73
74impl CacheKey {
75    /// Generate a cache key from request information
76    ///
77    /// # Errors
78    ///
79    /// Returns an error if hashing fails (should be rare)
80    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        // Include method, URL, and relevant headers in hash
91        hasher.update(method.as_bytes());
92        hasher.update(url.as_bytes());
93
94        // Sort headers for consistent hashing (exclude auth headers)
95        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        // Include body hash if present
107        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    /// Generate the cache file name for this key
122    #[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
141/// Response cache manager
142pub struct ResponseCache {
143    config: CacheConfig,
144}
145
146impl ResponseCache {
147    /// Creates a new response cache with the given configuration
148    ///
149    /// # Errors
150    ///
151    /// Returns an error if the cache directory cannot be created
152    pub fn new(config: CacheConfig) -> Result<Self, Error> {
153        // Ensure cache directory exists
154        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    /// Store a response in the cache
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if:
165    /// - The cache file cannot be written
166    /// - JSON serialization fails
167    /// - Cache cleanup fails
168    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        // Clean up old entries if we exceed max_entries
207        self.cleanup_old_entries(&key.api_name).await?;
208
209        Ok(())
210    }
211
212    /// Retrieve a response from the cache if it exists and is still valid
213    ///
214    /// # Errors
215    ///
216    /// Returns an error if:
217    /// - The cache file cannot be read
218    /// - JSON deserialization fails
219    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        // Check if the cache entry is still valid
238        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            // Cache entry has expired, remove it
245            let _ = tokio::fs::remove_file(&cache_file).await;
246            return Ok(None);
247        }
248
249        Ok(Some(cached_response))
250    }
251
252    /// Check if a response is cached and valid for the given key
253    ///
254    /// # Errors
255    ///
256    /// Returns an error if cache validation fails
257    pub async fn is_cached(&self, key: &CacheKey) -> Result<bool, Error> {
258        Ok(self.get(key).await?.is_some())
259    }
260
261    /// Clear all cached responses for a specific API
262    ///
263    /// # Errors
264    ///
265    /// Returns an error if cache files cannot be removed
266    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    /// Clear all cached responses
294    ///
295    /// # Errors
296    ///
297    /// Returns an error if cache directory cannot be cleared
298    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    /// Get cache statistics for an API
324    ///
325    /// # Errors
326    ///
327    /// Returns an error if cache directory cannot be read
328    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            // Check if this entry matches the requested API
347            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            // Check if entry is expired
356            if let Ok(metadata) = entry.metadata().await {
357                stats.total_size_bytes += metadata.len();
358
359                // Try to read the cache file to check expiration
360                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    /// Clean up old cache entries for an API, keeping only the most recent `max_entries`
383    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 we have more entries than max_entries, remove the oldest ones
409        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/// Cache statistics
423#[derive(Debug, Default)]
424pub struct CacheStats {
425    /// Total number of cache entries
426    pub total_entries: usize,
427    /// Number of valid (non-expired) entries
428    pub valid_entries: usize,
429    /// Number of expired entries
430    pub expired_entries: usize,
431    /// Total size of cache files in bytes
432    pub total_size_bytes: u64,
433}
434
435/// Check if a header is an authentication header that should be excluded from caching
436fn 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        ); // Should be excluded
473
474        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        // Store a response
527        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        // Retrieve the response
540        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        // Store a response with 1 second TTL
568        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        // Should be cached immediately
581        assert!(cache.is_cached(&key).await.unwrap());
582
583        // Manually create an expired cache entry by modifying the cached_at time
584        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        // Set cached_at to a time in the past that exceeds TTL
591        cached_response.cached_at = SystemTime::now()
592            .duration_since(UNIX_EPOCH)
593            .unwrap()
594            .as_secs()
595            - 2; // 2 seconds ago, which exceeds 1 second TTL
596
597        let json_content = serde_json::to_string_pretty(&cached_response).unwrap();
598        tokio::fs::write(&cache_file, json_content).await.unwrap();
599
600        // Should no longer be cached due to expiration
601        assert!(!cache.is_cached(&key).await.unwrap());
602
603        // The expired file should be removed
604        assert!(!cache_file.exists());
605    }
606}