Skip to main content

valence_core/ports/
endpoints.rs

1//! Bootstrap-only physical URL resolution — not per-query schema routing.
2//!
3//! Schema `database:` evaluators ([`crate::DatabaseFromEngine`]) select a **router key**.
4//! [`DatabaseEndpointResolver`] resolves a **physical URL** when a host bootstraps a
5//! remote engine. Do not conflate the two.
6
7use std::collections::HashMap;
8
9use crate::error::Result;
10
11/// Resolve a physical connection URL for a logical database name at bootstrap.
12///
13/// Wire with [`crate::ValenceBuilder::endpoint_resolver`].
14///
15/// # Examples
16///
17/// ```
18/// use valence_core::{DatabaseEndpointResolver, StaticEndpointResolver};
19///
20/// let resolver = StaticEndpointResolver::from_pairs(&[
21///     ("billing", "postgres://db.example/billing".to_string()),
22/// ]);
23/// assert_eq!(
24///     resolver.resolve_url("billing").unwrap().as_deref(),
25///     Some("postgres://db.example/billing")
26/// );
27/// ```
28pub trait DatabaseEndpointResolver: Send + Sync {
29    /// Return `Ok(None)` when the logical name has no mapped URL.
30    /// # Errors
31    ///
32    /// Returns an error when the requested operation cannot be completed.
33    fn resolve_url(&self, logical_name: &str) -> Result<Option<String>>;
34}
35
36/// Always returns `Ok(None)`.
37#[derive(Debug, Default, Clone, Copy)]
38pub struct NoopEndpointResolver;
39
40impl DatabaseEndpointResolver for NoopEndpointResolver {
41    fn resolve_url(&self, _logical_name: &str) -> Result<Option<String>> {
42        Ok(None)
43    }
44}
45
46/// In-memory map of logical name → URL.
47#[derive(Debug, Clone)]
48pub struct StaticEndpointResolver {
49    urls: HashMap<String, String>,
50}
51
52impl StaticEndpointResolver {
53    /// Build from `(logical_name, url)` pairs with `'static` string slices.
54    #[must_use]
55    pub fn new(urls: Vec<(&'static str, &'static str)>) -> Self {
56        let pairs: Vec<(&str, String)> = urls
57            .into_iter()
58            .map(|(logical, url)| (logical, url.to_string()))
59            .collect();
60        Self::from_pairs(&pairs)
61    }
62
63    /// Build from `(logical_name, url)` pairs.
64    #[must_use]
65    pub fn from_pairs(pairs: &[(&str, String)]) -> Self {
66        let mut urls = HashMap::new();
67        for (logical, url) in pairs {
68            urls.insert((*logical).to_string(), url.clone());
69        }
70        Self { urls }
71    }
72}
73
74impl DatabaseEndpointResolver for StaticEndpointResolver {
75    fn resolve_url(&self, logical_name: &str) -> Result<Option<String>> {
76        Ok(self.urls.get(logical_name).cloned())
77    }
78}
79
80fn parse_env_endpoints() -> HashMap<String, String> {
81    let mut out = HashMap::new();
82
83    if let Ok(json) = std::env::var("VALENCE_ENDPOINTS_JSON") {
84        if let Ok(map) = serde_json::from_str::<HashMap<String, String>>(&json) {
85            out.extend(map);
86        }
87    }
88
89    for (key, value) in std::env::vars() {
90        let Some(rest) = key.strip_prefix("VALENCE_ENDPOINT_") else {
91            continue;
92        };
93        if rest.is_empty() {
94            continue;
95        }
96        let logical = rest.to_ascii_lowercase();
97        if !value.trim().is_empty() {
98            out.insert(logical, value);
99        }
100    }
101
102    out
103}
104
105/// Resolve physical database URLs from env at bootstrap.
106///
107/// Supported:
108/// - `VALENCE_ENDPOINTS_JSON='{"default":"http://127.0.0.1:8000"}'`
109/// - `VALENCE_ENDPOINT_<LOGICAL>=url` (logical name lowercased)
110#[derive(Debug, Default, Clone, Copy)]
111pub struct EnvEndpointResolver;
112
113impl DatabaseEndpointResolver for EnvEndpointResolver {
114    fn resolve_url(&self, logical_name: &str) -> Result<Option<String>> {
115        let key = logical_name.to_ascii_lowercase();
116        Ok(parse_env_endpoints().get(&key).cloned())
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use std::sync::{Mutex, OnceLock};
124
125    static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
126
127    fn env_test_lock() -> std::sync::MutexGuard<'static, ()> {
128        ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
129    }
130
131    #[test]
132    fn static_resolver_from_pairs() {
133        let resolver = StaticEndpointResolver::from_pairs(&[(
134            "billing",
135            "http://db.example/billing".to_string(),
136        )]);
137        assert_eq!(
138            resolver.resolve_url("billing").unwrap(),
139            Some("http://db.example/billing".to_string())
140        );
141    }
142
143    #[test]
144    fn static_resolver_new_honors_args() {
145        let resolver = StaticEndpointResolver::new(vec![
146            ("default", "postgres://localhost/valence"),
147            ("billing", "postgres://localhost/billing"),
148        ]);
149        assert_eq!(
150            resolver.resolve_url("default").unwrap().as_deref(),
151            Some("postgres://localhost/valence")
152        );
153        assert_eq!(
154            resolver.resolve_url("billing").unwrap().as_deref(),
155            Some("postgres://localhost/billing")
156        );
157        assert_eq!(resolver.resolve_url("missing").unwrap(), None);
158    }
159
160    #[test]
161    fn env_resolver_reads_prefixed_vars() {
162        let _guard = env_test_lock();
163        std::env::set_var("VALENCE_ENDPOINT_DEFAULT", "http://127.0.0.1:8000");
164        let resolver = EnvEndpointResolver;
165        assert_eq!(
166            resolver.resolve_url("default").unwrap(),
167            Some("http://127.0.0.1:8000".to_string())
168        );
169        std::env::remove_var("VALENCE_ENDPOINT_DEFAULT");
170    }
171}