valence_core/ports/
endpoints.rs1use std::collections::HashMap;
8
9use crate::error::Result;
10
11pub trait DatabaseEndpointResolver: Send + Sync {
29 fn resolve_url(&self, logical_name: &str) -> Result<Option<String>>;
34}
35
36#[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#[derive(Debug, Clone)]
48pub struct StaticEndpointResolver {
49 urls: HashMap<String, String>,
50}
51
52impl StaticEndpointResolver {
53 #[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 #[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#[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}