Skip to main content

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            let Some(target_api) = api_name else {
348                // No filter, include all entries
349                stats.total_entries += 1;
350
351                // Check if entry is expired
352                let Ok(metadata) = entry.metadata().await else {
353                    continue;
354                };
355
356                stats.total_size_bytes += metadata.len();
357
358                // Try to read the cache file to check expiration
359                let Ok(json_content) = tokio::fs::read_to_string(entry.path()).await else {
360                    continue;
361                };
362
363                let Ok(cached_response) = serde_json::from_str::<CachedResponse>(&json_content)
364                else {
365                    continue;
366                };
367
368                let now = SystemTime::now()
369                    .duration_since(UNIX_EPOCH)
370                    .map_err(|e| Error::invalid_config(format!("System time error: {e}")))?
371                    .as_secs();
372
373                if now > cached_response.cached_at + cached_response.ttl_seconds {
374                    stats.expired_entries += 1;
375                } else {
376                    stats.valid_entries += 1;
377                }
378
379                continue;
380            };
381
382            if !filename_str.starts_with(&format!("{target_api}_")) {
383                continue;
384            }
385
386            stats.total_entries += 1;
387
388            // Check if entry is expired
389            let Ok(metadata) = entry.metadata().await else {
390                continue;
391            };
392
393            stats.total_size_bytes += metadata.len();
394
395            // Try to read the cache file to check expiration
396            let Ok(json_content) = tokio::fs::read_to_string(entry.path()).await else {
397                continue;
398            };
399
400            let Ok(cached_response) = serde_json::from_str::<CachedResponse>(&json_content) else {
401                continue;
402            };
403
404            let now = SystemTime::now()
405                .duration_since(UNIX_EPOCH)
406                .map_err(|e| Error::invalid_config(format!("System time error: {e}")))?
407                .as_secs();
408
409            if now > cached_response.cached_at + cached_response.ttl_seconds {
410                stats.expired_entries += 1;
411            } else {
412                stats.valid_entries += 1;
413            }
414        }
415
416        Ok(stats)
417    }
418
419    /// Clean up old cache entries for an API, keeping only the most recent `max_entries`
420    async fn cleanup_old_entries(&self, api_name: &str) -> Result<(), Error> {
421        let mut entries = Vec::new();
422        let mut dir_entries = tokio::fs::read_dir(&self.config.cache_dir)
423            .await
424            .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?;
425
426        while let Some(entry) = dir_entries
427            .next_entry()
428            .await
429            .map_err(|e| Error::io_error(format!("I/O operation failed: {e}")))?
430        {
431            let filename = entry.file_name();
432            let filename_str = filename.to_string_lossy();
433
434            if !filename_str.starts_with(&format!("{api_name}_"))
435                || !filename_str.ends_with(constants::CACHE_FILE_SUFFIX)
436            {
437                continue;
438            }
439
440            let Ok(metadata) = entry.metadata().await else {
441                continue;
442            };
443
444            let Ok(modified) = metadata.modified() else {
445                continue;
446            };
447
448            entries.push((entry.path(), modified));
449        }
450
451        // If we have more entries than max_entries, remove the oldest ones
452        if entries.len() > self.config.max_entries {
453            entries.sort_by_key(|(_, modified)| *modified);
454            let to_remove = entries.len() - self.config.max_entries;
455
456            for (path, _) in entries.iter().take(to_remove) {
457                let _ = tokio::fs::remove_file(path).await;
458            }
459        }
460
461        Ok(())
462    }
463}
464
465/// Cache statistics
466#[derive(Debug, Default)]
467pub struct CacheStats {
468    /// Total number of cache entries
469    pub total_entries: usize,
470    /// Number of valid (non-expired) entries
471    pub valid_entries: usize,
472    /// Number of expired entries
473    pub expired_entries: usize,
474    /// Total size of cache files in bytes
475    pub total_size_bytes: u64,
476}
477
478/// Check if a header is an authentication header that should be excluded from caching
479fn is_auth_header(header_name: &str) -> bool {
480    constants::is_auth_header(header_name)
481        || header_name
482            .to_lowercase()
483            .starts_with(constants::HEADER_PREFIX_X_AUTH)
484        || header_name
485            .to_lowercase()
486            .starts_with(constants::HEADER_PREFIX_X_API)
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use tempfile::TempDir;
493
494    fn create_test_cache_config() -> (CacheConfig, TempDir) {
495        let temp_dir = TempDir::new().unwrap();
496        let config = CacheConfig {
497            cache_dir: temp_dir.path().to_path_buf(),
498            default_ttl: Duration::from_secs(60),
499            max_entries: 10,
500            enabled: true,
501        };
502        (config, temp_dir)
503    }
504
505    #[test]
506    fn test_cache_key_generation() {
507        let mut headers = HashMap::new();
508        headers.insert(
509            constants::HEADER_CONTENT_TYPE_LC.to_string(),
510            constants::CONTENT_TYPE_JSON.to_string(),
511        );
512        headers.insert(
513            constants::HEADER_AUTHORIZATION_LC.to_string(),
514            "Bearer secret".to_string(),
515        ); // Should be excluded
516
517        let key = CacheKey::from_request(
518            "test_api",
519            "getUser",
520            constants::HTTP_METHOD_GET,
521            "https://api.example.com/users/123",
522            &headers,
523            None,
524        )
525        .unwrap();
526
527        assert_eq!(key.api_name, "test_api");
528        assert_eq!(key.operation_id, "getUser");
529        assert!(!key.request_hash.is_empty());
530
531        let filename = key.to_filename();
532        assert!(filename.starts_with("test_api_getUser_"));
533        assert!(filename.ends_with(constants::CACHE_FILE_SUFFIX));
534    }
535
536    #[test]
537    fn test_is_auth_header() {
538        assert!(is_auth_header(constants::HEADER_AUTHORIZATION));
539        assert!(is_auth_header("X-API-Key"));
540        assert!(is_auth_header("x-auth-token"));
541        assert!(!is_auth_header(constants::HEADER_CONTENT_TYPE));
542        assert!(!is_auth_header("User-Agent"));
543    }
544
545    #[tokio::test]
546    async fn test_cache_store_and_retrieve() {
547        let (config, _temp_dir) = create_test_cache_config();
548        let cache = ResponseCache::new(config).unwrap();
549
550        let key = CacheKey {
551            api_name: "test_api".to_string(),
552            operation_id: "getUser".to_string(),
553            request_hash: "abc123".to_string(),
554        };
555
556        let mut headers = HashMap::new();
557        headers.insert(
558            constants::HEADER_CONTENT_TYPE_LC.to_string(),
559            constants::CONTENT_TYPE_JSON.to_string(),
560        );
561
562        let request_info = CachedRequestInfo {
563            method: constants::HTTP_METHOD_GET.to_string(),
564            url: "https://api.example.com/users/123".to_string(),
565            headers: headers.clone(),
566            body_hash: None,
567        };
568
569        // Store a response
570        cache
571            .store(
572                &key,
573                r#"{"id": 123, "name": "John"}"#,
574                200,
575                &headers,
576                request_info,
577                Some(Duration::from_secs(60)),
578            )
579            .await
580            .unwrap();
581
582        // Retrieve the response
583        let cached = cache.get(&key).await.unwrap();
584        assert!(cached.is_some());
585
586        let response = cached.unwrap();
587        assert_eq!(response.body, r#"{"id": 123, "name": "John"}"#);
588        assert_eq!(response.status_code, 200);
589    }
590
591    #[tokio::test]
592    async fn test_cache_expiration() {
593        let (config, _temp_dir) = create_test_cache_config();
594        let cache = ResponseCache::new(config).unwrap();
595
596        let key = CacheKey {
597            api_name: "test_api".to_string(),
598            operation_id: "getUser".to_string(),
599            request_hash: "abc123def456".to_string(),
600        };
601
602        let headers = HashMap::new();
603        let request_info = CachedRequestInfo {
604            method: constants::HTTP_METHOD_GET.to_string(),
605            url: "https://api.example.com/users/123".to_string(),
606            headers: headers.clone(),
607            body_hash: None,
608        };
609
610        // Store a response with 1 second TTL
611        cache
612            .store(
613                &key,
614                "test response",
615                200,
616                &headers,
617                request_info,
618                Some(Duration::from_secs(1)),
619            )
620            .await
621            .unwrap();
622
623        // Should be cached immediately
624        assert!(cache.is_cached(&key).await.unwrap());
625
626        // Manually create an expired cache entry by modifying the cached_at time
627        let cache_file = cache.config.cache_dir.join(key.to_filename());
628        let mut cached_response: CachedResponse = {
629            let json_content = tokio::fs::read_to_string(&cache_file).await.unwrap();
630            serde_json::from_str(&json_content).unwrap()
631        };
632
633        // Set cached_at to a time in the past that exceeds TTL
634        cached_response.cached_at = SystemTime::now()
635            .duration_since(UNIX_EPOCH)
636            .unwrap()
637            .as_secs()
638            - 2; // 2 seconds ago, which exceeds 1 second TTL
639
640        let json_content = serde_json::to_string_pretty(&cached_response).unwrap();
641        tokio::fs::write(&cache_file, json_content).await.unwrap();
642
643        // Should no longer be cached due to expiration
644        assert!(!cache.is_cached(&key).await.unwrap());
645
646        // The expired file should be removed
647        assert!(!cache_file.exists());
648    }
649}