1use std::time::{Duration, SystemTime};
13
14use crate::CamelError;
15
16#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
18pub struct CacheEntry {
19 pub bytes: Vec<u8>,
21 #[serde(default)]
23 pub payload_path: Option<String>,
24 pub content_type: ContentType,
26 pub expires_at: Option<SystemTime>,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35pub enum ContentType {
36 Bytes,
38 Text,
40 Json,
42 Xml,
44}
45
46#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
50pub struct CacheStats {
51 pub hits: u64,
53 pub misses: u64,
55 pub evictions: u64,
57 pub entries: u64,
59 pub peek_stale_served: u64,
61 pub invalidations: u64,
63 pub bytes: Option<u64>,
65}
66
67#[async_trait::async_trait]
72pub trait CacheRepository: Send + Sync + std::fmt::Debug + 'static {
73 fn name(&self) -> &str;
75
76 async fn get(&self, key: &str) -> Result<Option<CacheEntry>, CamelError>;
81
82 async fn set(
87 &self,
88 key: &str,
89 value: CacheEntry,
90 ttl: Option<Duration>,
91 ) -> Result<(), CamelError>;
92
93 async fn peek_stale(&self, key: &str) -> Result<Option<CacheEntry>, CamelError>;
97
98 async fn invalidate(&self, key: &str) -> Result<(), CamelError>;
100
101 async fn clear(&self) -> Result<(), CamelError>;
103
104 async fn invalidate_prefix(&self, prefix: &str) -> Result<u64, CamelError> {
110 let _ = prefix;
111 Err(CamelError::Config(format!(
112 "cache backend '{}' does not support invalidate_prefix (no key iteration)",
113 self.name()
114 )))
115 }
116
117 async fn stats(&self) -> CacheStats {
121 CacheStats::default()
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn cache_entry_construction() {
131 let entry = CacheEntry {
132 bytes: vec![b'x'],
133 payload_path: None,
134 content_type: ContentType::Bytes,
135 expires_at: None,
136 };
137 assert_eq!(entry.bytes.len(), 1);
138 assert_eq!(entry.content_type, ContentType::Bytes);
139 }
140
141 #[test]
142 fn legacy_json_without_payload_path_deserializes_as_none() {
143 let json = r#"{"bytes":[1,2,3],"content_type":"Bytes","expires_at":null}"#;
144 let entry: CacheEntry = serde_json::from_str(json).unwrap();
145 assert_eq!(entry.payload_path, None);
146 assert_eq!(entry.bytes, vec![1, 2, 3]);
147 }
148
149 #[test]
150 fn payload_path_round_trips_through_serde() {
151 let entry = CacheEntry {
152 bytes: vec![1, 2, 3],
153 payload_path: Some("abc.blob".into()),
154 content_type: ContentType::Bytes,
155 expires_at: None,
156 };
157 let json = serde_json::to_string(&entry).unwrap();
158 assert!(json.contains("\"payload_path\":\"abc.blob\""));
159 let back: CacheEntry = serde_json::from_str(&json).unwrap();
160 assert_eq!(back.payload_path, Some("abc.blob".to_string()));
161 }
162
163 #[test]
164 fn cache_stats_default() {
165 assert_eq!(
166 CacheStats::default(),
167 CacheStats {
168 hits: 0,
169 misses: 0,
170 evictions: 0,
171 entries: 0,
172 peek_stale_served: 0,
173 invalidations: 0,
174 bytes: None,
175 }
176 );
177 }
178
179 #[test]
180 fn cache_stats_serialize_round_trip() {
181 let stats = CacheStats {
182 hits: 2,
183 misses: 1,
184 evictions: 0,
185 entries: 3,
186 peek_stale_served: 4,
187 invalidations: 1,
188 bytes: None,
189 };
190 let json = serde_json::to_string(&stats).unwrap();
191 assert!(json.contains("\"peek_stale_served\""));
192 assert!(json.contains("\"bytes\":null"));
193 let back: CacheStats = serde_json::from_str(&json).unwrap();
194 assert_eq!(stats, back);
195 }
196
197 struct NoIter;
200
201 impl std::fmt::Debug for NoIter {
202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203 f.write_str("NoIter")
204 }
205 }
206
207 #[async_trait::async_trait]
208 impl CacheRepository for NoIter {
209 fn name(&self) -> &str {
210 "noiter"
211 }
212
213 async fn get(&self, _key: &str) -> Result<Option<CacheEntry>, CamelError> {
214 Ok(None)
215 }
216
217 async fn set(
218 &self,
219 _key: &str,
220 _value: CacheEntry,
221 _ttl: Option<Duration>,
222 ) -> Result<(), CamelError> {
223 Ok(())
224 }
225
226 async fn peek_stale(&self, _key: &str) -> Result<Option<CacheEntry>, CamelError> {
227 Ok(None)
228 }
229
230 async fn invalidate(&self, _key: &str) -> Result<(), CamelError> {
231 Ok(())
232 }
233
234 async fn clear(&self) -> Result<(), CamelError> {
235 Ok(())
236 }
237 }
238
239 #[tokio::test]
240 async fn default_invalidate_prefix_returns_err_naming_backend() {
241 let repo = NoIter;
242 let err = repo.invalidate_prefix("ns:").await.unwrap_err();
243 assert!(
244 format!("{err}").contains("noiter"),
245 "error must name the backend, got: {err}"
246 );
247 }
248
249 #[tokio::test]
250 async fn default_async_stats_returns_zeroed() {
251 let stats = NoIter.stats().await;
252 assert_eq!(stats.hits, 0);
253 assert_eq!(stats.misses, 0);
254 assert_eq!(stats.evictions, 0);
255 assert_eq!(stats.entries, 0);
256 assert_eq!(stats.peek_stale_served, 0);
257 assert_eq!(stats.invalidations, 0);
258 assert_eq!(stats.bytes, None);
259 }
260}