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>>;
31}
32
33#[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#[derive(Debug, Clone)]
45pub struct StaticEndpointResolver {
46 urls: HashMap<String, String>,
47}
48
49impl StaticEndpointResolver {
50 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 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#[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}