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    fn resolve_url(&self, logical_name: &str) -> Result<Option<String>>;
31}
32
33/// Always returns `Ok(None)`.
34#[derive(Debug, Default, Clone, Copy)]
35pub struct NoopEndpointResolver;
36
37impl DatabaseEndpointResolver for NoopEndpointResolver {
38    fn resolve_url(&self, _logical_name: &str) -> Result<Option<String>> {
39        Ok(None)
40    }
41}
42
43/// In-memory map of logical name → URL.
44#[derive(Debug, Clone)]
45pub struct StaticEndpointResolver {
46    urls: HashMap<String, String>,
47}
48
49impl StaticEndpointResolver {
50    /// Build from `(logical_name, url)` pairs with `'static` string slices.
51    pub fn new(urls: Vec<(&'static str, &'static str)>) -> Self {
52        let pairs: Vec<(&str, String)> = urls
53            .into_iter()
54            .map(|(logical, url)| (logical, url.to_string()))
55            .collect();
56        Self::from_pairs(&pairs)
57    }
58
59    /// Build from `(logical_name, url)` pairs.
60    pub fn from_pairs(pairs: &[(&str, String)]) -> Self {
61        let mut urls = HashMap::new();
62        for (logical, url) in pairs {
63            urls.insert((*logical).to_string(), url.clone());
64        }
65        Self { urls }
66    }
67}
68
69impl DatabaseEndpointResolver for StaticEndpointResolver {
70    fn resolve_url(&self, logical_name: &str) -> Result<Option<String>> {
71        Ok(self.urls.get(logical_name).cloned())
72    }
73}
74
75fn parse_env_endpoints() -> HashMap<String, String> {
76    let mut out = HashMap::new();
77
78    if let Ok(json) = std::env::var("VALENCE_ENDPOINTS_JSON") {
79        if let Ok(map) = serde_json::from_str::<HashMap<String, String>>(&json) {
80            out.extend(map);
81        }
82    }
83
84    for (key, value) in std::env::vars() {
85        let Some(rest) = key.strip_prefix("VALENCE_ENDPOINT_") else {
86            continue;
87        };
88        if rest.is_empty() {
89            continue;
90        }
91        let logical = rest.to_ascii_lowercase();
92        if !value.trim().is_empty() {
93            out.insert(logical, value);
94        }
95    }
96
97    out
98}
99
100/// Resolve physical database URLs from env at bootstrap.
101///
102/// Supported:
103/// - `VALENCE_ENDPOINTS_JSON='{"default":"http://127.0.0.1:8000"}'`
104/// - `VALENCE_ENDPOINT_<LOGICAL>=url` (logical name lowercased)
105#[derive(Debug, Default, Clone, Copy)]
106pub struct EnvEndpointResolver;
107
108impl DatabaseEndpointResolver for EnvEndpointResolver {
109    fn resolve_url(&self, logical_name: &str) -> Result<Option<String>> {
110        let key = logical_name.to_ascii_lowercase();
111        Ok(parse_env_endpoints().get(&key).cloned())
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use std::sync::{Mutex, OnceLock};
119
120    static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
121
122    fn env_test_lock() -> std::sync::MutexGuard<'static, ()> {
123        ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
124    }
125
126    #[test]
127    fn static_resolver_from_pairs() {
128        let resolver = StaticEndpointResolver::from_pairs(&[(
129            "billing",
130            "http://db.example/billing".to_string(),
131        )]);
132        assert_eq!(
133            resolver.resolve_url("billing").unwrap(),
134            Some("http://db.example/billing".to_string())
135        );
136    }
137
138    #[test]
139    fn static_resolver_new_honors_args() {
140        let resolver = StaticEndpointResolver::new(vec![
141            ("default", "postgres://localhost/valence"),
142            ("billing", "postgres://localhost/billing"),
143        ]);
144        assert_eq!(
145            resolver.resolve_url("default").unwrap().as_deref(),
146            Some("postgres://localhost/valence")
147        );
148        assert_eq!(
149            resolver.resolve_url("billing").unwrap().as_deref(),
150            Some("postgres://localhost/billing")
151        );
152        assert_eq!(resolver.resolve_url("missing").unwrap(), None);
153    }
154
155    #[test]
156    fn env_resolver_reads_prefixed_vars() {
157        let _guard = env_test_lock();
158        std::env::set_var("VALENCE_ENDPOINT_DEFAULT", "http://127.0.0.1:8000");
159        let resolver = EnvEndpointResolver;
160        assert_eq!(
161            resolver.resolve_url("default").unwrap(),
162            Some("http://127.0.0.1:8000".to_string())
163        );
164        std::env::remove_var("VALENCE_ENDPOINT_DEFAULT");
165    }
166}