aperture_cli/
response_cache.rs

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/// Configuration for response caching
9#[derive(Debug, Clone)]
10pub struct CacheConfig {
11    /// Directory where cache files are stored
12    pub cache_dir: PathBuf,
13    /// Default TTL for cached responses
14    pub default_ttl: Duration,
15    /// Maximum number of cached responses per API
16    pub max_entries: usize,
17    /// Whether caching is enabled globally
18    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), // 5 minutes
26            max_entries: 1000,
27            enabled: true,
28        }
29    }
30}
31
32/// A cached API response
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct CachedResponse {
35    /// The HTTP response body
36    pub body: String,
37    /// HTTP status code
38    pub status_code: u16,
39    /// Response headers
40    pub headers: HashMap<String, String>,
41    /// When this response was cached (Unix timestamp)
42    pub cached_at: u64,
43    /// TTL in seconds from `cached_at`
44    pub ttl_seconds: u64,
45    /// The original request that generated this response
46    pub request_info: CachedRequestInfo,
47}
48
49/// Information about the request that generated a cached response
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct CachedRequestInfo {
52    /// HTTP method
53    pub method: String,
54    /// Full URL
55    pub url: String,
56    /// Request headers (excluding auth headers for security)
57    pub headers: HashMap<String, String>,
58    /// Request body hash (for POST/PUT requests)
59    pub body_hash: Option<String>,
60}
61
62/// Cache key components for generating cache file names
63#[derive(Debug)]
64pub struct CacheKey {
65    /// API specification name
66    pub api_name: String,
67    /// Operation ID from `OpenAPI` spec
68    pub operation_id: String,
69    /// Hash of request parameters and body
70    pub request_hash: String,
71}
72
73impl CacheKey {
74    /// Generate a cache key from request information
75    ///
76    /// # Errors
77    ///
78    /// Returns an error if hashing fails (should be rare)
79    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        // Include method, URL, and relevant headers in hash
90        hasher.update(method.as_bytes());
91        hasher.update(url.as_bytes());
92
93        // Sort headers for consistent hashing (exclude auth headers)
94        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        // Include body hash if present
106        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    /// Generate the cache file name for this key
121    #[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
136/// Response cache manager
137pub struct ResponseCache {
138    config: CacheConfig,
139}
140
141impl ResponseCache {
142    /// Creates a new response cache with the given configuration
143    ///
144    /// # Errors
145    ///
146    /// Returns an error if the cache directory cannot be created
147    pub fn new(config: CacheConfig) -> Result<Self, Error> {
148        // Ensure cache directory exists
149        std::fs::create_dir_all(&config.cache_dir).map_err(Error::Io)?;
150
151        Ok(Self { config })
152    }
153
154    /// Store a response in the cache
155    ///
156    /// # Errors
157    ///
158    /// Returns an error if:
159    /// - The cache file cannot be written
160    /// - JSON serialization fails
161    /// - Cache cleanup fails
162    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        // Clean up old entries if we exceed max_entries
199        self.cleanup_old_entries(&key.api_name).await?;
200
201        Ok(())
202    }
203
204    /// Retrieve a response from the cache if it exists and is still valid
205    ///
206    /// # Errors
207    ///
208    /// Returns an error if:
209    /// - The cache file cannot be read
210    /// - JSON deserialization fails
211    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        // Check if the cache entry is still valid
229        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            // Cache entry has expired, remove it
236            let _ = tokio::fs::remove_file(&cache_file).await;
237            return Ok(None);
238        }
239
240        Ok(Some(cached_response))
241    }
242
243    /// Check if a response is cached and valid for the given key
244    ///
245    /// # Errors
246    ///
247    /// Returns an error if cache validation fails
248    pub async fn is_cached(&self, key: &CacheKey) -> Result<bool, Error> {
249        Ok(self.get(key).await?.is_some())
250    }
251
252    /// Clear all cached responses for a specific API
253    ///
254    /// # Errors
255    ///
256    /// Returns an error if cache files cannot be removed
257    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    /// Clear all cached responses
281    ///
282    /// # Errors
283    ///
284    /// Returns an error if cache directory cannot be cleared
285    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    /// Get cache statistics for an API
307    ///
308    /// # Errors
309    ///
310    /// Returns an error if cache directory cannot be read
311    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            // Check if this entry matches the requested API
326            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            // Check if entry is expired
335            if let Ok(metadata) = entry.metadata().await {
336                stats.total_size_bytes += metadata.len();
337
338                // Try to read the cache file to check expiration
339                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    /// Clean up old cache entries for an API, keeping only the most recent `max_entries`
362    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 we have more entries than max_entries, remove the oldest ones
384        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/// Cache statistics
398#[derive(Debug, Default)]
399pub struct CacheStats {
400    /// Total number of cache entries
401    pub total_entries: usize,
402    /// Number of valid (non-expired) entries
403    pub valid_entries: usize,
404    /// Number of expired entries
405    pub expired_entries: usize,
406    /// Total size of cache files in bytes
407    pub total_size_bytes: u64,
408}
409
410/// Check if a header is an authentication header that should be excluded from caching
411fn 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()); // Should be excluded
441
442        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        // Store a response
492        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        // Retrieve the response
505        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        // Store a response with 1 second TTL
533        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        // Should be cached immediately
546        assert!(cache.is_cached(&key).await.unwrap());
547
548        // Manually create an expired cache entry by modifying the cached_at time
549        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        // Set cached_at to a time in the past that exceeds TTL
556        cached_response.cached_at = SystemTime::now()
557            .duration_since(UNIX_EPOCH)
558            .unwrap()
559            .as_secs()
560            - 2; // 2 seconds ago, which exceeds 1 second TTL
561
562        let json_content = serde_json::to_string_pretty(&cached_response).unwrap();
563        tokio::fs::write(&cache_file, json_content).await.unwrap();
564
565        // Should no longer be cached due to expiration
566        assert!(!cache.is_cached(&key).await.unwrap());
567
568        // The expired file should be removed
569        assert!(!cache_file.exists());
570    }
571}