Skip to main content

chasm/api/
caching.rs

1// Copyright (c) 2024-2028 Nervosys LLC
2// SPDX-License-Identifier: AGPL-3.0-only
3//! Edge Caching Module
4//!
5//! Provides CDN integration and edge caching for API responses.
6
7use chrono::{DateTime, Duration, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::hash::Hash;
11use std::sync::Arc;
12use tokio::sync::RwLock;
13
14// ============================================================================
15// Cache Configuration
16// ============================================================================
17
18/// Cache configuration
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct CacheConfig {
21    /// Enable caching
22    pub enabled: bool,
23    /// Default TTL in seconds
24    pub default_ttl_seconds: u64,
25    /// Maximum cache size in MB
26    pub max_size_mb: u64,
27    /// Cache backend
28    pub backend: CacheBackend,
29    /// CDN configuration
30    pub cdn: Option<CdnConfig>,
31    /// Cache rules by path pattern
32    pub rules: Vec<CacheRule>,
33}
34
35impl Default for CacheConfig {
36    fn default() -> Self {
37        Self {
38            enabled: true,
39            default_ttl_seconds: 300,
40            max_size_mb: 100,
41            backend: CacheBackend::Memory,
42            cdn: None,
43            rules: vec![
44                CacheRule {
45                    pattern: "/api/stats".to_string(),
46                    ttl_seconds: 60,
47                    cache_control: "public, max-age=60".to_string(),
48                    vary: vec!["Accept".to_string()],
49                    private: false,
50                },
51                CacheRule {
52                    pattern: "/api/sessions".to_string(),
53                    ttl_seconds: 30,
54                    cache_control: "private, max-age=30".to_string(),
55                    vary: vec!["Authorization".to_string()],
56                    private: true,
57                },
58            ],
59        }
60    }
61}
62
63/// Cache backend type
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
65#[serde(rename_all = "snake_case")]
66pub enum CacheBackend {
67    /// In-memory cache
68    Memory,
69    /// Redis cache
70    Redis(String),
71    /// Memcached
72    Memcached(String),
73    /// File-based cache
74    File(String),
75}
76
77/// CDN configuration
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct CdnConfig {
80    /// CDN provider
81    pub provider: CdnProvider,
82    /// CDN base URL
83    pub base_url: String,
84    /// API key for cache invalidation
85    pub api_key: Option<String>,
86    /// Zone ID
87    pub zone_id: Option<String>,
88}
89
90/// CDN provider
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
92#[serde(rename_all = "snake_case")]
93pub enum CdnProvider {
94    Cloudflare,
95    Fastly,
96    CloudFront,
97    Akamai,
98    BunnyCDN,
99    Custom,
100}
101
102/// Cache rule
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct CacheRule {
105    /// URL pattern (glob or regex)
106    pub pattern: String,
107    /// TTL in seconds
108    pub ttl_seconds: u64,
109    /// Cache-Control header
110    pub cache_control: String,
111    /// Vary headers
112    pub vary: Vec<String>,
113    /// Whether cache is private (per-user)
114    pub private: bool,
115}
116
117// ============================================================================
118// Cache Entry
119// ============================================================================
120
121/// Cached entry
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct CacheEntry {
124    /// Cache key
125    pub key: String,
126    /// Cached value (serialized)
127    pub value: Vec<u8>,
128    /// Content type
129    pub content_type: String,
130    /// ETag
131    pub etag: String,
132    /// Created timestamp
133    pub created_at: DateTime<Utc>,
134    /// Expires timestamp
135    pub expires_at: DateTime<Utc>,
136    /// Cache headers
137    pub headers: HashMap<String, String>,
138    /// Hit count
139    pub hits: u64,
140    /// Size in bytes
141    pub size_bytes: usize,
142}
143
144impl CacheEntry {
145    /// Check if entry is expired
146    pub fn is_expired(&self) -> bool {
147        Utc::now() > self.expires_at
148    }
149
150    /// Check if entry is stale (within grace period)
151    pub fn is_stale(&self, grace_seconds: i64) -> bool {
152        let grace_time = self.expires_at + Duration::seconds(grace_seconds);
153        Utc::now() > self.expires_at && Utc::now() <= grace_time
154    }
155
156    /// Get remaining TTL in seconds
157    pub fn remaining_ttl(&self) -> i64 {
158        (self.expires_at - Utc::now()).num_seconds().max(0)
159    }
160}
161
162// ============================================================================
163// Edge Cache Manager
164// ============================================================================
165
166/// Manages edge caching operations
167pub struct EdgeCacheManager {
168    config: CacheConfig,
169    cache: Arc<RwLock<HashMap<String, CacheEntry>>>,
170    stats: Arc<RwLock<CacheStats>>,
171}
172
173/// Cache statistics
174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
175pub struct CacheStats {
176    /// Total requests
177    pub requests: u64,
178    /// Cache hits
179    pub hits: u64,
180    /// Cache misses
181    pub misses: u64,
182    /// Stale hits (served stale while revalidating)
183    pub stale_hits: u64,
184    /// Bytes served from cache
185    pub bytes_served: u64,
186    /// Current cache size
187    pub current_size_bytes: u64,
188    /// Number of entries
189    pub entry_count: usize,
190    /// Evictions
191    pub evictions: u64,
192}
193
194impl CacheStats {
195    /// Get hit rate
196    pub fn hit_rate(&self) -> f64 {
197        if self.requests == 0 {
198            0.0
199        } else {
200            self.hits as f64 / self.requests as f64
201        }
202    }
203}
204
205impl EdgeCacheManager {
206    /// Create a new edge cache manager
207    pub fn new(config: CacheConfig) -> Self {
208        Self {
209            config,
210            cache: Arc::new(RwLock::new(HashMap::new())),
211            stats: Arc::new(RwLock::new(CacheStats::default())),
212        }
213    }
214
215    /// Generate cache key from request
216    pub fn generate_key(&self, path: &str, query: Option<&str>, vary_headers: &HashMap<String, String>) -> String {
217        let mut key = path.to_string();
218        
219        if let Some(q) = query {
220            key.push('?');
221            key.push_str(q);
222        }
223
224        // Include vary headers in key
225        let rule = self.get_rule(path);
226        if let Some(rule) = rule {
227            for header in &rule.vary {
228                if let Some(value) = vary_headers.get(header) {
229                    key.push_str(&format!("|{}:{}", header, value));
230                }
231            }
232        }
233
234        // Hash for consistent key length
235        format!("cache:{:x}", md5_hash(&key))
236    }
237
238    /// Get cache rule for path
239    fn get_rule(&self, path: &str) -> Option<&CacheRule> {
240        self.config.rules.iter().find(|r| path.starts_with(&r.pattern))
241    }
242
243    /// Get entry from cache
244    pub async fn get(&self, key: &str) -> Option<CacheEntry> {
245        let mut stats = self.stats.write().await;
246        stats.requests += 1;
247
248        let cache = self.cache.read().await;
249        if let Some(entry) = cache.get(key) {
250            if !entry.is_expired() {
251                stats.hits += 1;
252                stats.bytes_served += entry.size_bytes as u64;
253                return Some(entry.clone());
254            } else if entry.is_stale(60) {
255                // Stale-while-revalidate
256                stats.stale_hits += 1;
257                stats.bytes_served += entry.size_bytes as u64;
258                return Some(entry.clone());
259            }
260        }
261
262        stats.misses += 1;
263        None
264    }
265
266    /// Set entry in cache
267    pub async fn set(&self, key: String, value: Vec<u8>, content_type: String, path: &str) {
268        let rule = self.get_rule(path);
269        let ttl = rule.map(|r| r.ttl_seconds).unwrap_or(self.config.default_ttl_seconds);
270        let cache_control = rule
271            .map(|r| r.cache_control.clone())
272            .unwrap_or_else(|| format!("public, max-age={}", ttl));
273
274        let entry = CacheEntry {
275            key: key.clone(),
276            size_bytes: value.len(),
277            value,
278            content_type,
279            etag: generate_etag(&key),
280            created_at: Utc::now(),
281            expires_at: Utc::now() + Duration::seconds(ttl as i64),
282            headers: HashMap::from([("Cache-Control".to_string(), cache_control)]),
283            hits: 0,
284        };
285
286        // Check size limits
287        self.evict_if_needed(entry.size_bytes).await;
288
289        let mut cache = self.cache.write().await;
290        let mut stats = self.stats.write().await;
291
292        stats.current_size_bytes += entry.size_bytes as u64;
293        stats.entry_count = cache.len() + 1;
294
295        cache.insert(key, entry);
296    }
297
298    /// Evict entries if cache is full
299    async fn evict_if_needed(&self, new_entry_size: usize) {
300        let max_size = self.config.max_size_mb * 1024 * 1024;
301        let stats = self.stats.read().await;
302        
303        if stats.current_size_bytes + new_entry_size as u64 <= max_size {
304            return;
305        }
306        drop(stats);
307
308        // Evict expired entries first
309        self.evict_expired().await;
310
311        // If still over limit, evict LRU entries
312        let stats = self.stats.read().await;
313        if stats.current_size_bytes + new_entry_size as u64 > max_size {
314            drop(stats);
315            self.evict_lru((max_size / 4) as usize).await; // Evict 25%
316        }
317    }
318
319    /// Evict expired entries
320    async fn evict_expired(&self) {
321        let mut cache = self.cache.write().await;
322        let mut stats = self.stats.write().await;
323
324        let expired_keys: Vec<_> = cache
325            .iter()
326            .filter(|(_, entry)| entry.is_expired() && !entry.is_stale(60))
327            .map(|(k, _)| k.clone())
328            .collect();
329
330        for key in expired_keys {
331            if let Some(entry) = cache.remove(&key) {
332                stats.current_size_bytes -= entry.size_bytes as u64;
333                stats.evictions += 1;
334            }
335        }
336        stats.entry_count = cache.len();
337    }
338
339    /// Evict LRU entries to free space
340    async fn evict_lru(&self, bytes_to_free: usize) {
341        let mut cache = self.cache.write().await;
342        let mut stats = self.stats.write().await;
343
344        // Sort by hits (ascending) and created_at (ascending)
345        let mut entries: Vec<_> = cache.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
346        entries.sort_by(|a, b| a.1.hits.cmp(&b.1.hits).then(a.1.created_at.cmp(&b.1.created_at)));
347
348        let mut freed = 0usize;
349        for (key, entry) in entries {
350            if freed >= bytes_to_free {
351                break;
352            }
353            cache.remove(&key);
354            freed += entry.size_bytes;
355            stats.current_size_bytes -= entry.size_bytes as u64;
356            stats.evictions += 1;
357        }
358        stats.entry_count = cache.len();
359    }
360
361    /// Invalidate cache entry
362    pub async fn invalidate(&self, key: &str) {
363        let mut cache = self.cache.write().await;
364        let mut stats = self.stats.write().await;
365
366        if let Some(entry) = cache.remove(key) {
367            stats.current_size_bytes -= entry.size_bytes as u64;
368            stats.entry_count = cache.len();
369        }
370
371        // Also invalidate on CDN if configured
372        if let Some(cdn) = &self.config.cdn {
373            self.invalidate_cdn(cdn, key).await;
374        }
375    }
376
377    /// Invalidate cache entries by prefix
378    pub async fn invalidate_prefix(&self, prefix: &str) {
379        let mut cache = self.cache.write().await;
380        let mut stats = self.stats.write().await;
381
382        let keys_to_remove: Vec<_> = cache
383            .keys()
384            .filter(|k| k.starts_with(prefix))
385            .cloned()
386            .collect();
387
388        for key in keys_to_remove {
389            if let Some(entry) = cache.remove(&key) {
390                stats.current_size_bytes -= entry.size_bytes as u64;
391            }
392        }
393        stats.entry_count = cache.len();
394    }
395
396    /// Clear all cache
397    pub async fn clear(&self) {
398        let mut cache = self.cache.write().await;
399        let mut stats = self.stats.write().await;
400
401        cache.clear();
402        stats.current_size_bytes = 0;
403        stats.entry_count = 0;
404    }
405
406    /// Invalidate on CDN
407    async fn invalidate_cdn(&self, cdn: &CdnConfig, key: &str) {
408        match cdn.provider {
409            CdnProvider::Cloudflare => self.invalidate_cloudflare(cdn, key).await,
410            CdnProvider::Fastly => self.invalidate_fastly(cdn, key).await,
411            CdnProvider::CloudFront => self.invalidate_cloudfront(cdn, key).await,
412            _ => {}
413        }
414    }
415
416    async fn invalidate_cloudflare(&self, _cdn: &CdnConfig, _key: &str) {
417        // In a real implementation, call Cloudflare API
418        // POST https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache
419    }
420
421    async fn invalidate_fastly(&self, _cdn: &CdnConfig, _key: &str) {
422        // In a real implementation, call Fastly API
423        // POST https://api.fastly.com/service/{service_id}/purge/{surrogate_key}
424    }
425
426    async fn invalidate_cloudfront(&self, _cdn: &CdnConfig, _key: &str) {
427        // In a real implementation, call CloudFront API
428        // CreateInvalidation
429    }
430
431    /// Get cache statistics
432    pub async fn get_stats(&self) -> CacheStats {
433        self.stats.read().await.clone()
434    }
435
436    /// Get cache headers for response
437    pub fn get_cache_headers(&self, path: &str, etag: &str) -> HashMap<String, String> {
438        let mut headers = HashMap::new();
439
440        if let Some(rule) = self.get_rule(path) {
441            headers.insert("Cache-Control".to_string(), rule.cache_control.clone());
442            if !rule.vary.is_empty() {
443                headers.insert("Vary".to_string(), rule.vary.join(", "));
444            }
445        } else {
446            headers.insert(
447                "Cache-Control".to_string(),
448                format!("public, max-age={}", self.config.default_ttl_seconds),
449            );
450        }
451
452        headers.insert("ETag".to_string(), format!("\"{}\"", etag));
453        headers
454    }
455}
456
457// ============================================================================
458// Utility Functions
459// ============================================================================
460
461fn md5_hash(input: &str) -> u128 {
462    use std::hash::Hasher;
463    let mut hasher = std::collections::hash_map::DefaultHasher::new();
464    input.hash(&mut hasher);
465    hasher.finish() as u128
466}
467
468fn generate_etag(key: &str) -> String {
469    format!("{:x}", md5_hash(&format!("{}{}", key, Utc::now().timestamp())))
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[tokio::test]
477    async fn test_cache_set_get() {
478        let config = CacheConfig::default();
479        let cache = EdgeCacheManager::new(config);
480
481        let key = cache.generate_key("/api/stats", None, &HashMap::new());
482        cache.set(key.clone(), b"test data".to_vec(), "application/json".to_string(), "/api/stats").await;
483
484        let entry = cache.get(&key).await;
485        assert!(entry.is_some());
486        assert_eq!(entry.unwrap().value, b"test data");
487    }
488
489    #[tokio::test]
490    async fn test_cache_invalidation() {
491        let config = CacheConfig::default();
492        let cache = EdgeCacheManager::new(config);
493
494        let key = cache.generate_key("/api/test", None, &HashMap::new());
495        cache.set(key.clone(), b"test".to_vec(), "text/plain".to_string(), "/api/test").await;
496
497        assert!(cache.get(&key).await.is_some());
498
499        cache.invalidate(&key).await;
500        // Entry is removed
501        let stats = cache.get_stats().await;
502        assert_eq!(stats.entry_count, 0);
503    }
504}