Skip to main content

what_core/cache/
mod.rs

1//! Caching system for What framework
2//!
3//! Provides multi-level caching with support for:
4//! - Per-page caching
5//! - Per-user caching
6//! - Per-content caching
7//! - External API response caching
8
9use moka::future::Cache;
10use std::collections::{HashMap, HashSet};
11use std::sync::Arc;
12use std::time::Duration;
13use tokio::sync::RwLock;
14
15/// Cache key types for different caching strategies
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub enum CacheKey {
18    /// Cache by page path only (global cache)
19    Page(String),
20    /// Cache by page + user ID
21    UserPage { path: String, user_id: String },
22    /// Cache by content ID (e.g., post ID)
23    Content { content_type: String, id: String },
24    /// Cache by user + content
25    UserContent {
26        user_id: String,
27        content_type: String,
28        id: String,
29    },
30    /// Cache for external API responses
31    External { url: String },
32    /// Custom cache key
33    Custom(String),
34}
35
36impl CacheKey {
37    /// Create a page cache key
38    pub fn page(path: impl Into<String>) -> Self {
39        Self::Page(path.into())
40    }
41
42    /// Create a user-specific page cache key
43    pub fn user_page(path: impl Into<String>, user_id: impl Into<String>) -> Self {
44        Self::UserPage {
45            path: path.into(),
46            user_id: user_id.into(),
47        }
48    }
49
50    /// Create a content cache key
51    pub fn content(content_type: impl Into<String>, id: impl Into<String>) -> Self {
52        Self::Content {
53            content_type: content_type.into(),
54            id: id.into(),
55        }
56    }
57
58    /// Create a user-specific content cache key
59    pub fn user_content(
60        user_id: impl Into<String>,
61        content_type: impl Into<String>,
62        id: impl Into<String>,
63    ) -> Self {
64        Self::UserContent {
65            user_id: user_id.into(),
66            content_type: content_type.into(),
67            id: id.into(),
68        }
69    }
70
71    /// Create an external API cache key
72    pub fn external(url: impl Into<String>) -> Self {
73        Self::External { url: url.into() }
74    }
75
76    /// Create a custom cache key
77    pub fn custom(key: impl Into<String>) -> Self {
78        Self::Custom(key.into())
79    }
80
81    /// Convert to string representation for the cache
82    fn to_cache_key(&self) -> String {
83        match self {
84            Self::Page(path) => format!("page:{}", path),
85            Self::UserPage { path, user_id } => format!("user:{}:page:{}", user_id, path),
86            Self::Content { content_type, id } => format!("content:{}:{}", content_type, id),
87            Self::UserContent {
88                user_id,
89                content_type,
90                id,
91            } => format!("user:{}:content:{}:{}", user_id, content_type, id),
92            Self::External { url } => format!("external:{}", url),
93            Self::Custom(key) => format!("custom:{}", key),
94        }
95    }
96}
97
98/// Cached value with metadata
99#[derive(Debug, Clone)]
100pub struct CachedValue {
101    /// The cached content
102    pub content: String,
103    /// ETag for conditional requests
104    pub etag: Option<String>,
105    /// Content type
106    pub content_type: String,
107}
108
109impl CachedValue {
110    pub fn html(content: String) -> Self {
111        Self {
112            content,
113            etag: None,
114            content_type: "text/html".to_string(),
115        }
116    }
117
118    pub fn json(content: String) -> Self {
119        Self {
120            content,
121            etag: None,
122            content_type: "application/json".to_string(),
123        }
124    }
125
126    pub fn with_etag(mut self, etag: impl Into<String>) -> Self {
127        self.etag = Some(etag.into());
128        self
129    }
130}
131
132/// Multi-level cache for the What framework
133#[derive(Clone)]
134pub struct WhatCache {
135    /// Main content cache
136    content_cache: Cache<String, CachedValue>,
137    /// External API response cache
138    api_cache: Cache<String, String>,
139    /// Default TTL for content
140    #[allow(dead_code)]
141    default_ttl: Duration,
142    /// Default TTL for API responses
143    #[allow(dead_code)]
144    api_ttl: Duration,
145    /// Tag index: maps tag (e.g., collection name) → set of cache keys
146    /// Used for targeted invalidation instead of clearing the entire cache.
147    tag_index: Arc<RwLock<HashMap<String, HashSet<String>>>>,
148}
149
150impl WhatCache {
151    /// Create a new cache with default settings
152    pub fn new() -> Self {
153        Self::with_config(CacheConfig::default())
154    }
155
156    /// Create a cache with custom configuration
157    pub fn with_config(config: CacheConfig) -> Self {
158        let content_cache = Cache::builder()
159            .max_capacity(config.max_content_entries)
160            .time_to_live(Duration::from_secs(config.content_ttl_secs))
161            .build();
162
163        let api_cache = Cache::builder()
164            .max_capacity(config.max_api_entries)
165            .time_to_live(Duration::from_secs(config.api_ttl_secs))
166            .build();
167
168        Self {
169            content_cache,
170            api_cache,
171            default_ttl: Duration::from_secs(config.content_ttl_secs),
172            api_ttl: Duration::from_secs(config.api_ttl_secs),
173            tag_index: Arc::new(RwLock::new(HashMap::new())),
174        }
175    }
176
177    /// Get a cached page/content
178    pub async fn get(&self, key: &CacheKey) -> Option<CachedValue> {
179        self.content_cache.get(&key.to_cache_key()).await
180    }
181
182    /// Cache a page/content
183    pub async fn set(&self, key: &CacheKey, value: CachedValue) {
184        self.content_cache.insert(key.to_cache_key(), value).await;
185    }
186
187    /// Cache a page/content and associate it with tags for targeted invalidation.
188    /// Tags are typically collection names (e.g., "posts", "users").
189    pub async fn set_with_tags(&self, key: &CacheKey, value: CachedValue, tags: &[&str]) {
190        let cache_key = key.to_cache_key();
191        self.content_cache.insert(cache_key.clone(), value).await;
192
193        if !tags.is_empty() {
194            let mut index = self.tag_index.write().await;
195            for tag in tags {
196                index
197                    .entry(tag.to_string())
198                    .or_default()
199                    .insert(cache_key.clone());
200            }
201        }
202    }
203
204    /// Cache a page/content with custom TTL
205    pub async fn set_with_ttl(&self, key: &CacheKey, value: CachedValue, _ttl: Duration) {
206        // Note: moka doesn't support per-entry TTL easily,
207        // so we use the default TTL. For production, consider
208        // using a different cache backend or custom expiry logic.
209        self.content_cache.insert(key.to_cache_key(), value).await;
210    }
211
212    /// Get cached API response
213    pub async fn get_api(&self, url: &str) -> Option<String> {
214        self.api_cache.get(&format!("api:{}", url)).await
215    }
216
217    /// Cache API response
218    pub async fn set_api(&self, url: &str, response: String) {
219        self.api_cache
220            .insert(format!("api:{}", url), response)
221            .await;
222    }
223
224    /// Invalidate a specific cache entry
225    pub async fn invalidate(&self, key: &CacheKey) {
226        self.content_cache.invalidate(&key.to_cache_key()).await;
227    }
228
229    /// Invalidate all entries matching a tag.
230    /// If no entries are tagged, falls back to full cache clear.
231    pub async fn invalidate_by_tag(&self, tag: &str) {
232        let mut index = self.tag_index.write().await;
233        if let Some(keys) = index.remove(tag) {
234            for key in &keys {
235                self.content_cache.invalidate(key).await;
236            }
237            self.content_cache.run_pending_tasks().await;
238            tracing::debug!(
239                "Cache: invalidated {} entries for tag '{}'",
240                keys.len(),
241                tag
242            );
243        }
244        // Also clean up any references to this tag's keys from other tags
245        // (a key can have multiple tags)
246    }
247
248    /// Invalidate all content cache entries for a specific content type / collection.
249    /// Uses the tag index for targeted invalidation. If no entries are tagged,
250    /// falls back to full cache clear for safety.
251    pub async fn invalidate_content_type(&self, content_type: &str) {
252        let index = self.tag_index.read().await;
253        if index.contains_key(content_type) {
254            drop(index); // Release read lock before taking write lock
255            self.invalidate_by_tag(content_type).await;
256        } else {
257            drop(index);
258            // Fallback: no tagged entries, clear all content cache
259            self.content_cache.invalidate_all();
260            self.content_cache.run_pending_tasks().await;
261        }
262    }
263
264    /// Invalidate all cache entries for a specific user.
265    /// Uses prefix matching on cache keys.
266    pub async fn invalidate_user(&self, user_id: &str) {
267        let prefix = format!("user:{}:", user_id);
268        let index = self.tag_index.read().await;
269        if let Some(keys) = index.get(user_id) {
270            let keys_to_remove: Vec<String> = keys.iter().cloned().collect();
271            drop(index);
272            for key in &keys_to_remove {
273                self.content_cache.invalidate(key).await;
274            }
275        } else {
276            drop(index);
277            // Fallback: scan for keys with user prefix (best effort)
278            // Moka doesn't support prefix scanning, so we clear all
279            let _ = prefix; // Used for documentation, not runtime
280            self.content_cache.invalidate_all();
281        }
282        self.content_cache.run_pending_tasks().await;
283    }
284
285    /// Clear all caches
286    pub async fn clear_all(&self) {
287        self.content_cache.invalidate_all();
288        self.api_cache.invalidate_all();
289        self.content_cache.run_pending_tasks().await;
290        self.api_cache.run_pending_tasks().await;
291        self.tag_index.write().await.clear();
292    }
293
294    /// Get cache statistics
295    pub fn stats(&self) -> CacheStats {
296        CacheStats {
297            content_entries: self.content_cache.entry_count(),
298            api_entries: self.api_cache.entry_count(),
299        }
300    }
301}
302
303impl Default for WhatCache {
304    fn default() -> Self {
305        Self::new()
306    }
307}
308
309/// Cache configuration
310#[derive(Debug, Clone)]
311pub struct CacheConfig {
312    /// Max entries in content cache
313    pub max_content_entries: u64,
314    /// Max entries in API cache
315    pub max_api_entries: u64,
316    /// TTL for content in seconds
317    pub content_ttl_secs: u64,
318    /// TTL for API responses in seconds
319    pub api_ttl_secs: u64,
320}
321
322impl Default for CacheConfig {
323    fn default() -> Self {
324        Self {
325            max_content_entries: 10_000,
326            max_api_entries: 1_000,
327            content_ttl_secs: 300, // 5 minutes
328            api_ttl_secs: 60,      // 1 minute
329        }
330    }
331}
332
333/// Cache statistics
334#[derive(Debug, Clone)]
335pub struct CacheStats {
336    pub content_entries: u64,
337    pub api_entries: u64,
338}
339
340/// Cache control directives for responses
341#[derive(Debug, Clone, Default)]
342pub struct CacheControl {
343    /// Cache scope
344    pub scope: CacheScope,
345    /// Max age in seconds
346    pub max_age: Option<u64>,
347    /// Whether to revalidate
348    pub must_revalidate: bool,
349    /// Whether this is immutable
350    pub immutable: bool,
351}
352
353#[derive(Debug, Clone, Default)]
354pub enum CacheScope {
355    /// Can be cached by any cache (CDN, browser, etc.)
356    #[default]
357    Public,
358    /// Only cache in user's browser
359    Private,
360    /// Don't cache at all
361    NoCache,
362    /// Don't store at all
363    NoStore,
364}
365
366impl CacheControl {
367    pub fn public(max_age: u64) -> Self {
368        Self {
369            scope: CacheScope::Public,
370            max_age: Some(max_age),
371            must_revalidate: false,
372            immutable: false,
373        }
374    }
375
376    pub fn private(max_age: u64) -> Self {
377        Self {
378            scope: CacheScope::Private,
379            max_age: Some(max_age),
380            must_revalidate: false,
381            immutable: false,
382        }
383    }
384
385    pub fn no_cache() -> Self {
386        Self {
387            scope: CacheScope::NoCache,
388            max_age: None,
389            must_revalidate: true,
390            immutable: false,
391        }
392    }
393
394    pub fn immutable() -> Self {
395        Self {
396            scope: CacheScope::Public,
397            max_age: Some(31536000), // 1 year
398            must_revalidate: false,
399            immutable: true,
400        }
401    }
402
403    /// Convert to HTTP Cache-Control header value
404    pub fn to_header_value(&self) -> String {
405        let mut parts = Vec::new();
406
407        match self.scope {
408            CacheScope::Public => parts.push("public".to_string()),
409            CacheScope::Private => parts.push("private".to_string()),
410            CacheScope::NoCache => parts.push("no-cache".to_string()),
411            CacheScope::NoStore => parts.push("no-store".to_string()),
412        }
413
414        if let Some(max_age) = self.max_age {
415            parts.push(format!("max-age={}", max_age));
416        }
417
418        if self.must_revalidate {
419            parts.push("must-revalidate".to_string());
420        }
421
422        if self.immutable {
423            parts.push("immutable".to_string());
424        }
425
426        parts.join(", ")
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[tokio::test]
435    async fn test_cache_basic() {
436        let cache = WhatCache::new();
437
438        let key = CacheKey::page("/about");
439        let value = CachedValue::html("<h1>About</h1>".to_string());
440
441        cache.set(&key, value.clone()).await;
442
443        let retrieved = cache.get(&key).await.unwrap();
444        assert_eq!(retrieved.content, "<h1>About</h1>");
445    }
446
447    #[tokio::test]
448    async fn test_user_page_cache() {
449        let cache = WhatCache::new();
450
451        let key1 = CacheKey::user_page("/dashboard", "user1");
452        let key2 = CacheKey::user_page("/dashboard", "user2");
453
454        cache
455            .set(&key1, CachedValue::html("User 1 Dashboard".to_string()))
456            .await;
457        cache
458            .set(&key2, CachedValue::html("User 2 Dashboard".to_string()))
459            .await;
460
461        assert_eq!(cache.get(&key1).await.unwrap().content, "User 1 Dashboard");
462        assert_eq!(cache.get(&key2).await.unwrap().content, "User 2 Dashboard");
463    }
464
465    #[tokio::test]
466    async fn test_set_with_tags_and_invalidate_by_tag() {
467        let cache = WhatCache::new();
468
469        let key1 = CacheKey::page("/blog");
470        let key2 = CacheKey::page("/blog/post-1");
471        let key3 = CacheKey::page("/about");
472
473        // Cache pages with tags
474        cache
475            .set_with_tags(&key1, CachedValue::html("Blog list".into()), &["posts"])
476            .await;
477        cache
478            .set_with_tags(&key2, CachedValue::html("Post 1".into()), &["posts"])
479            .await;
480        cache
481            .set_with_tags(&key3, CachedValue::html("About page".into()), &["pages"])
482            .await;
483
484        // All should be cached
485        assert!(cache.get(&key1).await.is_some());
486        assert!(cache.get(&key2).await.is_some());
487        assert!(cache.get(&key3).await.is_some());
488
489        // Invalidate "posts" tag — should only remove blog pages
490        cache.invalidate_by_tag("posts").await;
491
492        assert!(cache.get(&key1).await.is_none());
493        assert!(cache.get(&key2).await.is_none());
494        assert!(cache.get(&key3).await.is_some()); // About page untouched
495    }
496
497    #[tokio::test]
498    async fn test_invalidate_content_type_targeted() {
499        let cache = WhatCache::new();
500
501        let key1 = CacheKey::page("/products");
502        let key2 = CacheKey::page("/cart");
503
504        cache
505            .set_with_tags(&key1, CachedValue::html("Products".into()), &["products"])
506            .await;
507        cache
508            .set_with_tags(&key2, CachedValue::html("Cart".into()), &["cart"])
509            .await;
510
511        // Invalidate "products" via content_type
512        cache.invalidate_content_type("products").await;
513
514        assert!(cache.get(&key1).await.is_none());
515        assert!(cache.get(&key2).await.is_some());
516    }
517
518    #[tokio::test]
519    async fn test_invalidate_content_type_fallback() {
520        let cache = WhatCache::new();
521
522        let key = CacheKey::page("/test");
523        // Set WITHOUT tags
524        cache.set(&key, CachedValue::html("Test".into())).await;
525
526        // Invalidate unknown content type — should fall back to clearing all
527        cache.invalidate_content_type("unknown").await;
528
529        assert!(cache.get(&key).await.is_none());
530    }
531
532    #[tokio::test]
533    async fn test_clear_all_clears_tag_index() {
534        let cache = WhatCache::new();
535
536        let key = CacheKey::page("/blog");
537        cache
538            .set_with_tags(&key, CachedValue::html("Blog".into()), &["posts"])
539            .await;
540
541        cache.clear_all().await;
542
543        assert!(cache.get(&key).await.is_none());
544        // Tag index should be empty
545        assert!(cache.tag_index.read().await.is_empty());
546    }
547
548    #[test]
549    fn test_cache_control_header() {
550        let cc = CacheControl::public(3600);
551        assert_eq!(cc.to_header_value(), "public, max-age=3600");
552
553        let cc = CacheControl::private(600);
554        assert_eq!(cc.to_header_value(), "private, max-age=600");
555
556        let cc = CacheControl::no_cache();
557        assert_eq!(cc.to_header_value(), "no-cache, must-revalidate");
558
559        let cc = CacheControl::immutable();
560        assert_eq!(cc.to_header_value(), "public, max-age=31536000, immutable");
561    }
562}