1use std::collections::HashMap;
17use std::sync::Arc;
18
19use crate::config::StoreConfig;
20use crate::ratelimit::CounterStore;
21
22#[cfg(feature = "redis-store")]
23pub mod counter;
24#[cfg(feature = "redis-store")]
25pub mod namespaces;
26#[cfg(feature = "redis-store")]
27pub mod redis_cache;
28#[cfg(feature = "redis-store")]
29pub mod redis_store;
30
31pub fn validate_stores(stores: &[StoreConfig]) -> Result<(), String> {
35 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
36 for s in stores {
37 if s.name.trim().is_empty() {
38 return Err("store with empty name".to_string());
39 }
40 if !seen.insert(s.name.as_str()) {
41 return Err(format!("Duplicate store name '{}'", s.name));
42 }
43 match s.store_type.as_str() {
44 "redis" | "valkey" => {}
45 other => {
46 return Err(format!(
47 "store '{}': unknown type '{}' — supported: redis, valkey",
48 s.name, other
49 ))
50 }
51 }
52 if let Some(t) = s.topology.as_deref() {
53 if t != "standalone" {
54 return Err(format!(
55 "store '{}': topology '{}' is not yet supported (v1 supports standalone only)",
56 s.name, t
57 ));
58 }
59 }
60 if s.urls.is_some() {
61 return Err(format!(
62 "store '{}': 'urls' requires a sentinel/cluster topology, which is not yet supported — use 'url'",
63 s.name
64 ));
65 }
66 if s.url.trim().is_empty() {
67 return Err(format!("store '{}': url must not be empty", s.name));
68 }
69 }
70 Ok(())
71}
72
73#[derive(Default)]
77pub struct StoreRegistry {
78 #[cfg(feature = "redis-store")]
79 clients: HashMap<String, Arc<redis_store::RedisStoreClient>>,
80 #[cfg(feature = "redis-store")]
81 counters: HashMap<String, Arc<dyn CounterStore>>,
82 #[cfg(feature = "redis-store")]
83 #[allow(dead_code)] sessions: HashMap<String, Arc<dyn crate::sessions::SessionStore>>,
85 #[cfg(not(feature = "redis-store"))]
87 #[allow(clippy::type_complexity)]
88 _headless: std::marker::PhantomData<(HashMap<(), ()>, fn() -> Arc<dyn CounterStore>)>,
89}
90
91impl StoreRegistry {
92 #[cfg(feature = "redis-store")]
96 pub fn rebuild(
97 prev: &StoreRegistry,
98 stores: &[StoreConfig],
99 metrics: Option<Arc<crate::metrics::GatewayMetrics>>,
100 ) -> Result<StoreRegistry, String> {
101 let mut clients = HashMap::new();
102 let mut counters: HashMap<String, Arc<dyn CounterStore>> = HashMap::new();
103 let mut sessions: HashMap<String, Arc<dyn crate::sessions::SessionStore>> = HashMap::new();
104 for cfg in stores {
105 let fingerprint = redis_store::RedisStoreClient::fingerprint_of(cfg);
106 let client = match prev.clients.get(&cfg.name) {
107 Some(existing) if existing.fingerprint() == fingerprint => existing.clone(),
108 _ => Arc::new(redis_store::RedisStoreClient::build(cfg)?),
109 };
110 counters.insert(
111 cfg.name.clone(),
112 Arc::new(counter::RedisCounterStore::new(
113 client.clone(),
114 cfg.name.clone(),
115 metrics.clone(),
116 )) as Arc<dyn CounterStore>,
117 );
118 sessions.insert(
119 cfg.name.clone(),
120 Arc::new(crate::sessions::redis::RedisSessionStore::new(
121 client.clone(),
122 metrics.clone(),
123 )) as Arc<dyn crate::sessions::SessionStore>,
124 );
125 clients.insert(cfg.name.clone(), client);
126 }
127 Ok(StoreRegistry {
128 clients,
129 counters,
130 sessions,
131 })
132 }
133
134 #[cfg(not(feature = "redis-store"))]
136 pub fn rebuild(
137 _prev: &StoreRegistry,
138 stores: &[StoreConfig],
139 _metrics: Option<Arc<crate::metrics::GatewayMetrics>>,
140 ) -> Result<StoreRegistry, String> {
141 if stores.is_empty() {
142 Ok(StoreRegistry::default())
143 } else {
144 Err(format!(
145 "gateway config declares {} store(s) but this binary was built without the redis-store feature",
146 stores.len()
147 ))
148 }
149 }
150
151 #[cfg(feature = "redis-store")]
155 #[allow(dead_code)] pub fn contains(&self, name: &str) -> bool {
157 self.clients.contains_key(name)
158 }
159
160 #[cfg(feature = "redis-store")]
163 pub fn client(&self, name: &str) -> Result<Arc<redis_store::RedisStoreClient>, String> {
164 self.clients.get(name).cloned().ok_or_else(|| {
165 let mut names: Vec<&str> = self.clients.keys().map(String::as_str).collect();
166 names.sort_unstable();
167 format!(
168 "unknown store '{}' — declared stores: {}",
169 name,
170 if names.is_empty() {
171 "(none)".to_string()
172 } else {
173 names.join(", ")
174 }
175 )
176 })
177 }
178
179 #[cfg(feature = "redis-store")]
182 pub fn counter_store(&self, name: &str) -> Result<Arc<dyn CounterStore>, String> {
183 self.counters.get(name).cloned().ok_or_else(|| {
184 let mut names: Vec<&str> = self.clients.keys().map(String::as_str).collect();
185 names.sort_unstable();
186 format!(
187 "unknown store '{}' — declared stores: {}",
188 name,
189 if names.is_empty() {
190 "(none)".to_string()
191 } else {
192 names.join(", ")
193 }
194 )
195 })
196 }
197
198 #[cfg(not(feature = "redis-store"))]
199 pub fn counter_store(&self, name: &str) -> Result<Arc<dyn CounterStore>, String> {
200 Err(format!(
201 "store '{}': this binary was built without the redis-store feature",
202 name
203 ))
204 }
205
206 #[cfg(feature = "redis-store")]
209 #[allow(dead_code)] pub fn session_store(
211 &self,
212 name: &str,
213 ) -> Result<Arc<dyn crate::sessions::SessionStore>, String> {
214 self.sessions.get(name).cloned().ok_or_else(|| {
215 let mut names: Vec<&str> = self.clients.keys().map(String::as_str).collect();
216 names.sort_unstable();
217 format!(
218 "unknown store '{}' — declared stores: {}",
219 name,
220 if names.is_empty() {
221 "(none)".to_string()
222 } else {
223 names.join(", ")
224 }
225 )
226 })
227 }
228
229 #[allow(dead_code)] #[cfg(not(feature = "redis-store"))]
231 pub fn session_store(
232 &self,
233 name: &str,
234 ) -> Result<Arc<dyn crate::sessions::SessionStore>, String> {
235 Err(format!(
236 "store '{}': this binary was built without the redis-store feature",
237 name
238 ))
239 }
240
241 #[cfg(test)]
243 pub fn with_fake_session_store(
244 name: &str,
245 store: Arc<dyn crate::sessions::SessionStore>,
246 ) -> StoreRegistry {
247 #[cfg(feature = "redis-store")]
248 {
249 let mut reg = StoreRegistry::default();
250 reg.sessions.insert(name.to_string(), store);
251 reg
252 }
253 #[cfg(not(feature = "redis-store"))]
254 {
255 let _ = (name, store);
256 StoreRegistry::default()
257 }
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 fn store(name: &str, ty: &str) -> StoreConfig {
266 serde_yaml::from_str(&format!(
267 "name: {name}\ntype: {ty}\nurl: redis://127.0.0.1:6379\n"
268 ))
269 .unwrap()
270 }
271
272 #[test]
273 fn test_validate_stores_rules() {
274 assert!(validate_stores(&[]).is_ok());
275 assert!(validate_stores(&[store("a", "redis"), store("b", "valkey")]).is_ok());
276
277 let err = validate_stores(&[store("a", "redis"), store("a", "redis")]).unwrap_err();
278 assert!(err.contains("Duplicate store name 'a'"), "{err}");
279
280 let err = validate_stores(&[store("a", "memcached")]).unwrap_err();
281 assert!(err.contains("unknown type 'memcached'"), "{err}");
282
283 let mut s = store("a", "redis");
284 s.topology = Some("cluster".to_string());
285 let err = validate_stores(&[s]).unwrap_err();
286 assert!(err.contains("not yet supported"), "{err}");
287
288 let mut s = store("a", "redis");
289 s.urls = Some(vec!["redis://x".to_string()]);
290 let err = validate_stores(&[s]).unwrap_err();
291 assert!(err.contains("'urls' requires"), "{err}");
292
293 let mut s = store("a", "redis");
294 s.url = String::new();
295 let err = validate_stores(&[s]).unwrap_err();
296 assert!(err.contains("url must not be empty"), "{err}");
297 }
298
299 #[test]
300 fn test_counter_store_unknown_name_lists_declared() {
301 let reg = StoreRegistry::default();
302 let err = match reg.counter_store("nope") {
305 Ok(_) => panic!("expected an error for an unknown store name"),
306 Err(e) => e,
307 };
308 assert!(err.contains("'nope'"), "{err}");
310 }
311
312 #[tokio::test]
313 async fn test_session_store_lookup_and_fake_injection() {
314 let reg = StoreRegistry::default();
315 let err = match reg.session_store("nope") {
316 Ok(_) => panic!("expected an error for an unknown store name"),
317 Err(e) => e,
318 };
319 assert!(err.contains("'nope'"), "{err}");
320
321 let fake: std::sync::Arc<dyn crate::sessions::SessionStore> =
322 std::sync::Arc::new(crate::sessions::FakeSessionStore::default());
323 let reg = StoreRegistry::with_fake_session_store("s1", fake);
324 assert!(reg.session_store("s1").is_ok());
325 }
326}