aperture_cli/config/
url_resolver.rs

1use crate::cache::models::CachedSpec;
2use crate::config::models::{ApiConfig, GlobalConfig};
3
4/// Resolves the base URL for an API based on a priority hierarchy
5pub struct BaseUrlResolver<'a> {
6    /// The cached API specification
7    spec: &'a CachedSpec,
8    /// Global configuration containing API overrides
9    global_config: Option<&'a GlobalConfig>,
10    /// Optional environment override (if None, reads from `APERTURE_ENV` at resolve time)
11    environment_override: Option<String>,
12}
13
14impl<'a> BaseUrlResolver<'a> {
15    /// Creates a new URL resolver for the given spec
16    #[must_use]
17    pub const fn new(spec: &'a CachedSpec) -> Self {
18        Self {
19            spec,
20            global_config: None,
21            environment_override: None,
22        }
23    }
24
25    /// Sets the global configuration for API-specific overrides
26    #[must_use]
27    #[allow(clippy::missing_const_for_fn)]
28    pub fn with_global_config(mut self, config: &'a GlobalConfig) -> Self {
29        self.global_config = Some(config);
30        self
31    }
32
33    /// Sets the environment explicitly (overrides `APERTURE_ENV`)
34    #[must_use]
35    pub fn with_environment(mut self, env: Option<String>) -> Self {
36        self.environment_override = env;
37        self
38    }
39
40    /// Resolves the base URL according to the priority hierarchy:
41    /// 1. Explicit parameter (for testing)
42    /// 2. Per-API config override with environment support
43    /// 3. Environment variable: `APERTURE_BASE_URL`
44    /// 4. Cached spec default
45    /// 5. Fallback: <https://api.example.com>
46    #[must_use]
47    pub fn resolve(&self, explicit_url: Option<&str>) -> String {
48        // Priority 1: Explicit parameter (for testing)
49        if let Some(url) = explicit_url {
50            return url.to_string();
51        }
52
53        // Priority 2: Per-API config override
54        if let Some(config) = self.global_config {
55            if let Some(api_config) = config.api_configs.get(&self.spec.name) {
56                // Check environment-specific URL first
57                let env_to_check = self.environment_override.as_ref().map_or_else(
58                    || std::env::var("APERTURE_ENV").unwrap_or_default(),
59                    std::clone::Clone::clone,
60                );
61
62                if !env_to_check.is_empty() {
63                    if let Some(env_url) = api_config.environment_urls.get(&env_to_check) {
64                        return env_url.clone();
65                    }
66                }
67
68                // Then check general override
69                if let Some(override_url) = &api_config.base_url_override {
70                    return override_url.clone();
71                }
72            }
73        }
74
75        // Priority 3: Environment variable
76        if let Ok(url) = std::env::var("APERTURE_BASE_URL") {
77            return url;
78        }
79
80        // Priority 4: Cached spec default
81        if let Some(base_url) = &self.spec.base_url {
82            return base_url.clone();
83        }
84
85        // Priority 5: Fallback
86        "https://api.example.com".to_string()
87    }
88
89    /// Gets the API config if available
90    #[must_use]
91    pub fn get_api_config(&self) -> Option<&ApiConfig> {
92        self.global_config
93            .and_then(|config| config.api_configs.get(&self.spec.name))
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::cache::models::CachedSpec;
101    use std::collections::HashMap;
102    use std::sync::Mutex;
103
104    // Static mutex to ensure only one test can modify environment variables at a time
105    static ENV_TEST_MUTEX: Mutex<()> = Mutex::new(());
106
107    fn create_test_spec(name: &str, base_url: Option<&str>) -> CachedSpec {
108        CachedSpec {
109            cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
110            name: name.to_string(),
111            version: "1.0.0".to_string(),
112            commands: vec![],
113            base_url: base_url.map(|s| s.to_string()),
114            servers: base_url.map(|s| vec![s.to_string()]).unwrap_or_default(),
115            security_schemes: HashMap::new(),
116            skipped_endpoints: vec![],
117        }
118    }
119
120    /// Test harness to isolate environment variable changes with mutual exclusion
121    fn test_with_env_isolation<F>(test_fn: F)
122    where
123        F: FnOnce() + std::panic::UnwindSafe,
124    {
125        // Acquire mutex to prevent parallel env var access
126        let _guard = ENV_TEST_MUTEX.lock().unwrap();
127
128        // Store original value
129        let original_value = std::env::var("APERTURE_BASE_URL").ok();
130
131        // Clean up first
132        std::env::remove_var("APERTURE_BASE_URL");
133
134        // Run the test with panic protection
135        let result = std::panic::catch_unwind(test_fn);
136
137        // Always restore original state, even if test panicked
138        if let Some(original) = original_value {
139            std::env::set_var("APERTURE_BASE_URL", original);
140        } else {
141            std::env::remove_var("APERTURE_BASE_URL");
142        }
143
144        // Drop the guard before re-panicking to release the mutex
145        drop(_guard);
146
147        // Re-panic if the test failed
148        if let Err(panic_info) = result {
149            std::panic::resume_unwind(panic_info);
150        }
151    }
152
153    #[test]
154    fn test_priority_1_explicit_url() {
155        test_with_env_isolation(|| {
156            let spec = create_test_spec("test-api", Some("https://spec.example.com"));
157            let resolver = BaseUrlResolver::new(&spec);
158
159            assert_eq!(
160                resolver.resolve(Some("https://explicit.example.com")),
161                "https://explicit.example.com"
162            );
163        });
164    }
165
166    #[test]
167    fn test_priority_2_api_config_override() {
168        test_with_env_isolation(|| {
169            let spec = create_test_spec("test-api", Some("https://spec.example.com"));
170
171            let mut api_configs = HashMap::new();
172            api_configs.insert(
173                "test-api".to_string(),
174                ApiConfig {
175                    base_url_override: Some("https://config.example.com".to_string()),
176                    environment_urls: HashMap::new(),
177                    strict_mode: false,
178                },
179            );
180
181            let global_config = GlobalConfig {
182                api_configs,
183                ..Default::default()
184            };
185
186            let resolver = BaseUrlResolver::new(&spec).with_global_config(&global_config);
187
188            assert_eq!(resolver.resolve(None), "https://config.example.com");
189        });
190    }
191
192    #[test]
193    fn test_priority_2_environment_specific() {
194        test_with_env_isolation(|| {
195            let spec = create_test_spec("test-api", Some("https://spec.example.com"));
196
197            let mut environment_urls = HashMap::new();
198            environment_urls.insert(
199                "staging".to_string(),
200                "https://staging.example.com".to_string(),
201            );
202            environment_urls.insert("prod".to_string(), "https://prod.example.com".to_string());
203
204            let mut api_configs = HashMap::new();
205            api_configs.insert(
206                "test-api".to_string(),
207                ApiConfig {
208                    base_url_override: Some("https://config.example.com".to_string()),
209                    environment_urls,
210                    strict_mode: false,
211                },
212            );
213
214            let global_config = GlobalConfig {
215                api_configs,
216                ..Default::default()
217            };
218
219            let resolver = BaseUrlResolver::new(&spec)
220                .with_global_config(&global_config)
221                .with_environment(Some("staging".to_string()));
222
223            assert_eq!(resolver.resolve(None), "https://staging.example.com");
224        });
225    }
226
227    #[test]
228    fn test_priority_config_override_beats_env_var() {
229        // Test that config override takes precedence over environment variable
230        test_with_env_isolation(|| {
231            let spec = create_test_spec("test-api", Some("https://spec.example.com"));
232
233            // Set env var
234            std::env::set_var("APERTURE_BASE_URL", "https://env.example.com");
235
236            let mut api_configs = HashMap::new();
237            api_configs.insert(
238                "test-api".to_string(),
239                ApiConfig {
240                    base_url_override: Some("https://config.example.com".to_string()),
241                    environment_urls: HashMap::new(),
242                    strict_mode: false,
243                },
244            );
245
246            let global_config = GlobalConfig {
247                api_configs,
248                ..Default::default()
249            };
250
251            let resolver = BaseUrlResolver::new(&spec).with_global_config(&global_config);
252
253            // Config override should win over env var
254            assert_eq!(resolver.resolve(None), "https://config.example.com");
255        });
256    }
257
258    #[test]
259    fn test_priority_3_env_var() {
260        // Use a custom test harness to isolate environment variables
261        test_with_env_isolation(|| {
262            let spec = create_test_spec("test-api", Some("https://spec.example.com"));
263
264            // Set env var
265            std::env::set_var("APERTURE_BASE_URL", "https://env.example.com");
266
267            let resolver = BaseUrlResolver::new(&spec);
268
269            assert_eq!(resolver.resolve(None), "https://env.example.com");
270        });
271    }
272
273    #[test]
274    fn test_priority_4_spec_default() {
275        test_with_env_isolation(|| {
276            let spec = create_test_spec("test-api", Some("https://spec.example.com"));
277            let resolver = BaseUrlResolver::new(&spec);
278
279            assert_eq!(resolver.resolve(None), "https://spec.example.com");
280        });
281    }
282
283    #[test]
284    fn test_priority_5_fallback() {
285        test_with_env_isolation(|| {
286            let spec = create_test_spec("test-api", None);
287            let resolver = BaseUrlResolver::new(&spec);
288
289            assert_eq!(resolver.resolve(None), "https://api.example.com");
290        });
291    }
292}