wf_market/
cache.rs

1//! Caching utilities for API responses.
2//!
3//! This module provides an explicit caching mechanism for API data that
4//! rarely changes, such as the items list and riven weapons.
5//!
6//! # Why Cache?
7//!
8//! Some warframe.market endpoints return large datasets that rarely change:
9//!
10//! - **Items** (~4000 items): Only changes when new items are added to the game
11//! - **Rivens** (~300 weapons): Only changes when new weapons are added
12//!
13//! Using a cache significantly reduces API calls and improves performance.
14//!
15//! # Usage
16//!
17//! The cache is **opt-in** and **user-controlled**. You decide when to use
18//! caching and how long to keep cached data.
19//!
20//! ```ignore
21//! use wf_market::{Client, ApiCache};
22//! use std::time::Duration;
23//!
24//! async fn example() -> wf_market::Result<()> {
25//!     let client = Client::builder().build().await?;
26//!     let mut cache = ApiCache::new();
27//!
28//!     // First call fetches from API and caches
29//!     let items = client.get_items(Some(&mut cache)).await?;
30//!
31//!     // Subsequent calls use cached data
32//!     let items = client.get_items(Some(&mut cache)).await?;
33//!
34//!     // With TTL - refreshes if cache is older than specified duration
35//!     let items = client.get_items_with_ttl(
36//!         Some(&mut cache),
37//!         Duration::from_secs(24 * 60 * 60) // 24 hours
38//!     ).await?;
39//!
40//!     // Opt-out: fetch directly without caching
41//!     let items = client.get_items(None).await?;
42//!     // Or explicitly:
43//!     let items = client.fetch_items().await?;
44//!
45//!     Ok(())
46//! }
47//! ```
48//!
49//! # Cache Persistence
50//!
51//! The cache can be serialized for persistence across application restarts:
52//!
53//! ```ignore
54//! use wf_market::ApiCache;
55//!
56//! // Save cache to disk
57//! fn save_cache(cache: &ApiCache) -> std::io::Result<()> {
58//!     let json = serde_json::to_string(cache)?;
59//!     std::fs::write("cache.json", json)?;
60//!     Ok(())
61//! }
62//!
63//! // Load cache from disk
64//! fn load_cache() -> std::io::Result<ApiCache> {
65//!     let json = std::fs::read_to_string("cache.json")?;
66//!     Ok(serde_json::from_str(&json)?)
67//! }
68//! ```
69
70use serde::{Deserialize, Serialize};
71use std::time::{Duration, Instant};
72
73use crate::models::{Item, Riven};
74
75/// Cache for slowly-changing API data.
76///
77/// Use this to cache items and rivens data between requests.
78/// The cache is completely user-controlled - you decide when to
79/// use it and when to invalidate it.
80#[derive(Debug, Default)]
81pub struct ApiCache {
82    items: Option<CacheEntry<Vec<Item>>>,
83    rivens: Option<CacheEntry<Vec<Riven>>>,
84}
85
86/// A cached data entry with metadata.
87#[derive(Debug)]
88struct CacheEntry<T> {
89    data: T,
90    fetched_at: Instant,
91}
92
93impl ApiCache {
94    /// Create a new empty cache.
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    /// Check if items are cached.
100    pub fn has_items(&self) -> bool {
101        self.items.is_some()
102    }
103
104    /// Check if rivens are cached.
105    pub fn has_rivens(&self) -> bool {
106        self.rivens.is_some()
107    }
108
109    /// Get the age of the items cache.
110    ///
111    /// Returns `None` if items are not cached.
112    pub fn items_age(&self) -> Option<Duration> {
113        self.items.as_ref().map(|e| e.fetched_at.elapsed())
114    }
115
116    /// Get the age of the rivens cache.
117    ///
118    /// Returns `None` if rivens are not cached.
119    pub fn rivens_age(&self) -> Option<Duration> {
120        self.rivens.as_ref().map(|e| e.fetched_at.elapsed())
121    }
122
123    /// Clear all cached data.
124    pub fn clear(&mut self) {
125        self.items = None;
126        self.rivens = None;
127    }
128
129    /// Clear the items cache.
130    pub fn invalidate_items(&mut self) {
131        self.items = None;
132    }
133
134    /// Clear the rivens cache.
135    pub fn invalidate_rivens(&mut self) {
136        self.rivens = None;
137    }
138
139    /// Check if the cache has fresh items (younger than max_age).
140    ///
141    /// Returns `true` if items are cached and their age is less than `max_age`.
142    ///
143    /// # Example
144    ///
145    /// ```ignore
146    /// use std::time::Duration;
147    ///
148    /// let cache = ApiCache::new();
149    /// // No items yet
150    /// assert!(!cache.has_fresh_items(Duration::from_secs(3600)));
151    ///
152    /// // After setting items
153    /// cache.set_items(items);
154    /// assert!(cache.has_fresh_items(Duration::from_secs(3600)));
155    /// ```
156    pub fn has_fresh_items(&self, max_age: Duration) -> bool {
157        self.items_age().is_some_and(|age| age < max_age)
158    }
159
160    /// Invalidate items cache if older than the given duration.
161    ///
162    /// Returns `true` if the cache was invalidated.
163    pub fn invalidate_items_if_older_than(&mut self, max_age: Duration) -> bool {
164        if self.items_age().is_some_and(|age| age > max_age) {
165            self.invalidate_items();
166            true
167        } else {
168            false
169        }
170    }
171
172    /// Invalidate rivens cache if older than the given duration.
173    ///
174    /// Returns `true` if the cache was invalidated.
175    pub fn invalidate_rivens_if_older_than(&mut self, max_age: Duration) -> bool {
176        if self.rivens_age().is_some_and(|age| age > max_age) {
177            self.invalidate_rivens();
178            true
179        } else {
180            false
181        }
182    }
183
184    // Internal methods for Client use
185
186    pub(crate) fn get_items(&self) -> Option<&[Item]> {
187        self.items.as_ref().map(|e| e.data.as_slice())
188    }
189
190    pub(crate) fn set_items(&mut self, items: Vec<Item>) {
191        self.items = Some(CacheEntry {
192            data: items,
193            fetched_at: Instant::now(),
194        });
195    }
196
197    pub(crate) fn get_rivens(&self) -> Option<&[Riven]> {
198        self.rivens.as_ref().map(|e| e.data.as_slice())
199    }
200
201    pub(crate) fn set_rivens(&mut self, rivens: Vec<Riven>) {
202        self.rivens = Some(CacheEntry {
203            data: rivens,
204            fetched_at: Instant::now(),
205        });
206    }
207}
208
209/// Serializable cache for persistence.
210///
211/// This is a separate type that can be serialized/deserialized,
212/// unlike `ApiCache` which uses `Instant` (not serializable).
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct SerializableCache {
215    /// Cached items (if any)
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub items: Option<CachedItems>,
218
219    /// Cached rivens (if any)
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub rivens: Option<CachedRivens>,
222}
223
224/// Serializable cached items data.
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct CachedItems {
227    /// The cached items
228    pub data: Vec<Item>,
229
230    /// Unix timestamp when the data was fetched
231    pub fetched_at_unix: u64,
232}
233
234/// Serializable cached rivens data.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct CachedRivens {
237    /// The cached rivens
238    pub data: Vec<Riven>,
239
240    /// Unix timestamp when the data was fetched
241    pub fetched_at_unix: u64,
242}
243
244impl SerializableCache {
245    /// Create a new empty serializable cache.
246    pub fn new() -> Self {
247        Self {
248            items: None,
249            rivens: None,
250        }
251    }
252
253    /// Convert to an `ApiCache`.
254    ///
255    /// Note: The `fetched_at` times will be approximated based on the
256    /// stored Unix timestamps.
257    pub fn into_api_cache(self) -> ApiCache {
258        let now = std::time::SystemTime::now()
259            .duration_since(std::time::UNIX_EPOCH)
260            .unwrap_or_default()
261            .as_secs();
262
263        let mut cache = ApiCache::new();
264
265        if let Some(items) = self.items {
266            // Calculate how old the cache is
267            let age_secs = now.saturating_sub(items.fetched_at_unix);
268            cache.items = Some(CacheEntry {
269                data: items.data,
270                // Approximate the Instant by subtracting the age from now
271                fetched_at: Instant::now() - Duration::from_secs(age_secs),
272            });
273        }
274
275        if let Some(rivens) = self.rivens {
276            let age_secs = now.saturating_sub(rivens.fetched_at_unix);
277            cache.rivens = Some(CacheEntry {
278                data: rivens.data,
279                fetched_at: Instant::now() - Duration::from_secs(age_secs),
280            });
281        }
282
283        cache
284    }
285}
286
287impl Default for SerializableCache {
288    fn default() -> Self {
289        Self::new()
290    }
291}
292
293impl From<&ApiCache> for SerializableCache {
294    fn from(cache: &ApiCache) -> Self {
295        let now_unix = std::time::SystemTime::now()
296            .duration_since(std::time::UNIX_EPOCH)
297            .unwrap_or_default()
298            .as_secs();
299
300        Self {
301            items: cache.items.as_ref().map(|e| {
302                let age = e.fetched_at.elapsed().as_secs();
303                CachedItems {
304                    data: e.data.clone(),
305                    fetched_at_unix: now_unix.saturating_sub(age),
306                }
307            }),
308            rivens: cache.rivens.as_ref().map(|e| {
309                let age = e.fetched_at.elapsed().as_secs();
310                CachedRivens {
311                    data: e.data.clone(),
312                    fetched_at_unix: now_unix.saturating_sub(age),
313                }
314            }),
315        }
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_cache_new_is_empty() {
325        let cache = ApiCache::new();
326        assert!(!cache.has_items());
327        assert!(!cache.has_rivens());
328    }
329
330    #[test]
331    fn test_cache_set_and_get_items() {
332        let mut cache = ApiCache::new();
333        cache.set_items(vec![]);
334
335        assert!(cache.has_items());
336        assert!(cache.get_items().is_some());
337    }
338
339    #[test]
340    fn test_cache_invalidation() {
341        let mut cache = ApiCache::new();
342        cache.set_items(vec![]);
343        cache.set_rivens(vec![]);
344
345        cache.invalidate_items();
346        assert!(!cache.has_items());
347        assert!(cache.has_rivens());
348
349        cache.clear();
350        assert!(!cache.has_rivens());
351    }
352
353    #[test]
354    fn test_cache_age() {
355        let mut cache = ApiCache::new();
356        assert!(cache.items_age().is_none());
357
358        cache.set_items(vec![]);
359
360        let age = cache.items_age().unwrap();
361        assert!(age < Duration::from_secs(1));
362    }
363
364    #[test]
365    fn test_serializable_cache_roundtrip() {
366        // Create an API cache and convert to serializable
367        let mut cache = ApiCache::new();
368        cache.set_items(vec![]);
369
370        let serializable = SerializableCache::from(&cache);
371        assert!(serializable.items.is_some());
372
373        // Serialize and deserialize
374        let json = serde_json::to_string(&serializable).unwrap();
375        let restored: SerializableCache = serde_json::from_str(&json).unwrap();
376
377        assert!(restored.items.is_some());
378    }
379}