Skip to main content

featherbit/stores/
mod.rs

1//! Named shared stores (`stores:` in gateway.yaml): redis/valkey connections
2//! referenced by name from plugin config (`store: <name>`).
3//!
4//! [`StoreRegistry`] holds one client per named store. It is rebuilt by
5//! [`crate::state::SharedState::compile_routes`] on every config (re)compile,
6//! reusing the previous client when a store's *resolved* config is unchanged
7//! (so an unrelated reload does not drop connections). The registry lives in
8//! [`crate::plugins::resources::PluginResources`] behind an `ArcSwap` and is
9//! only read at plugin-construction time — never on the request path — so a
10//! bad `store:` reference fails policy compilation, not a request.
11//!
12//! Backend code is gated behind the default-on `redis-store` cargo feature;
13//! a headless `--no-default-features` build rejects any declared store at
14//! config load with a descriptive error.
15
16use 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
31/// Validates the `stores:` section: unique non-empty names, known types,
32/// and v1 topology restrictions (standalone only). Runs before any client
33/// is built, in every compile path (file, etcd, Admin API, dry-run).
34pub 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/// One client per named store, plus the counter backend built on it.
74/// Read-only after construction; replaced wholesale via the `ArcSwap` in
75/// `PluginResources` when config changes.
76#[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)] // consumed by task 5 (session backend helper)
84    sessions: HashMap<String, Arc<dyn crate::sessions::SessionStore>>,
85    // Keeps the struct non-empty (and the imports used) in headless builds.
86    #[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    /// Builds a registry for `stores`, reusing `prev`'s client wherever a
93    /// store's resolved config fingerprint is unchanged, so unrelated config
94    /// reloads keep established connections.
95    #[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    /// Headless build: any declared store is a configuration error.
135    #[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    /// True when `name` has a live client in this registry. Used by tests
152    /// (and anything else that needs a lookup without a client-typed error)
153    /// to assert what a dry-run compile did or did not durably swap in.
154    #[cfg(feature = "redis-store")]
155    #[allow(dead_code)] // exercised by mcp::tools::writes dry-run tests
156    pub fn contains(&self, name: &str) -> bool {
157        self.clients.contains_key(name)
158    }
159
160    /// The raw client for `name` (ACME storage borrows it; sessions/counters have
161    /// their own typed accessors).
162    #[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    /// Resolves the counter backend for a named store; the error carries the
180    /// declared-store list so a typo is self-explanatory.
181    #[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    /// Resolves the session backend for a named store; the error carries the
207    /// declared-store list so a typo is self-explanatory.
208    #[cfg(feature = "redis-store")]
209    #[allow(dead_code)] // consumed by task 5 (session backend helper)
210    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)] // consumed by task 5 (session backend helper)
230    #[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    /// Test-only registry holding one injected fake session store.
242    #[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        // `Arc<dyn CounterStore>` isn't `Debug`, so `unwrap_err()` doesn't
303        // compile here — match instead.
304        let err = match reg.counter_store("nope") {
305            Ok(_) => panic!("expected an error for an unknown store name"),
306            Err(e) => e,
307        };
308        // Exact wording differs by build flavor; both name the store.
309        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}