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}