aperture_cli/config/
url_resolver.rs

1use crate::cache::models::CachedSpec;
2use crate::config::models::{ApiConfig, GlobalConfig};
3use crate::config::server_variable_resolver::ServerVariableResolver;
4use crate::error::Error;
5
6/// Resolves the base URL for an API based on a priority hierarchy
7pub struct BaseUrlResolver<'a> {
8    /// The cached API specification
9    spec: &'a CachedSpec,
10    /// Global configuration containing API overrides
11    global_config: Option<&'a GlobalConfig>,
12    /// Optional environment override (if None, reads from `APERTURE_ENV` at resolve time)
13    environment_override: Option<String>,
14}
15
16impl<'a> BaseUrlResolver<'a> {
17    /// Creates a new URL resolver for the given spec
18    #[must_use]
19    pub const fn new(spec: &'a CachedSpec) -> Self {
20        Self {
21            spec,
22            global_config: None,
23            environment_override: None,
24        }
25    }
26
27    /// Sets the global configuration for API-specific overrides
28    #[must_use]
29    #[allow(clippy::missing_const_for_fn)]
30    pub fn with_global_config(mut self, config: &'a GlobalConfig) -> Self {
31        self.global_config = Some(config);
32        self
33    }
34
35    /// Sets the environment explicitly (overrides `APERTURE_ENV`)
36    #[must_use]
37    pub fn with_environment(mut self, env: Option<String>) -> Self {
38        self.environment_override = env;
39        self
40    }
41
42    /// Resolves the base URL according to the priority hierarchy:
43    /// 1. Explicit parameter (for testing)
44    /// 2. Per-API config override with environment support
45    /// 3. Environment variable: `APERTURE_BASE_URL`
46    /// 4. Cached spec default
47    /// 5. Fallback: <https://api.example.com>
48    #[must_use]
49    pub fn resolve(&self, explicit_url: Option<&str>) -> String {
50        self.resolve_with_variables(explicit_url, &[])
51            .unwrap_or_else(|err| {
52                match err {
53                    // For validation errors, log and fallback to basic resolution
54                    // This maintains backward compatibility while providing visibility
55                    Error::InvalidServerVarFormat { .. }
56                    | Error::InvalidServerVarValue { .. }
57                    | Error::UnknownServerVariable { .. } => {
58                        eprintln!("Warning: Server variable error: {err}");
59                        self.resolve_basic(explicit_url)
60                    }
61                    // Fallback for all other errors (template resolution, missing variables, etc.)
62                    _ => self.resolve_basic(explicit_url),
63                }
64            })
65    }
66
67    /// Resolves the base URL with server variable substitution support
68    ///
69    /// # Arguments
70    /// * `explicit_url` - Explicit URL override (for testing)
71    /// * `server_var_args` - Server variable arguments from CLI (e.g., `["region=us", "env=prod"]`)
72    ///
73    /// # Returns
74    /// * `Ok(String)` - Resolved URL with variables substituted
75    /// * `Err(Error)` - Server variable validation or substitution errors
76    ///
77    /// # Errors
78    /// Returns errors for:
79    /// - Invalid server variable format or values
80    /// - Missing required server variables
81    /// - URL template substitution failures
82    pub fn resolve_with_variables(
83        &self,
84        explicit_url: Option<&str>,
85        server_var_args: &[String],
86    ) -> Result<String, Error> {
87        // First resolve the base URL using the standard priority hierarchy
88        let base_url = self.resolve_basic(explicit_url);
89
90        // If the URL doesn't contain template variables, return as-is
91        if !base_url.contains('{') {
92            return Ok(base_url);
93        }
94
95        // If no server variables are defined in the spec but URL has templates,
96        // this indicates a backward compatibility issue - the spec has template
97        // URLs but no server variable definitions
98        if self.spec.server_variables.is_empty() {
99            let template_vars = extract_template_variables(&base_url);
100
101            if let Some(first_var) = template_vars.first() {
102                return Err(Error::UnresolvedTemplateVariable {
103                    name: first_var.clone(),
104                    url: base_url,
105                });
106            }
107
108            return Ok(base_url);
109        }
110
111        // Resolve server variables and apply template substitution
112        let resolver = ServerVariableResolver::new(self.spec);
113        let resolved_variables = resolver.resolve_variables(server_var_args)?;
114        resolver.substitute_url(&base_url, &resolved_variables)
115    }
116
117    /// Basic URL resolution without server variable processing (internal helper)
118    fn resolve_basic(&self, explicit_url: Option<&str>) -> String {
119        // Priority 1: Explicit parameter (for testing)
120        if let Some(url) = explicit_url {
121            return url.to_string();
122        }
123
124        // Priority 2: Per-API config override
125        if let Some(config) = self.global_config {
126            if let Some(api_config) = config.api_configs.get(&self.spec.name) {
127                // Check environment-specific URL first
128                let env_to_check = self.environment_override.as_ref().map_or_else(
129                    || std::env::var("APERTURE_ENV").unwrap_or_default(),
130                    std::clone::Clone::clone,
131                );
132
133                if !env_to_check.is_empty() {
134                    if let Some(env_url) = api_config.environment_urls.get(&env_to_check) {
135                        return env_url.clone();
136                    }
137                }
138
139                // Then check general override
140                if let Some(override_url) = &api_config.base_url_override {
141                    return override_url.clone();
142                }
143            }
144        }
145
146        // Priority 3: Environment variable
147        if let Ok(url) = std::env::var("APERTURE_BASE_URL") {
148            return url;
149        }
150
151        // Priority 4: Cached spec default
152        if let Some(base_url) = &self.spec.base_url {
153            return base_url.clone();
154        }
155
156        // Priority 5: Fallback
157        "https://api.example.com".to_string()
158    }
159
160    /// Gets the API config if available
161    #[must_use]
162    pub fn get_api_config(&self) -> Option<&ApiConfig> {
163        self.global_config
164            .and_then(|config| config.api_configs.get(&self.spec.name))
165    }
166}
167
168/// Extracts template variable names from a URL string
169fn extract_template_variables(url: &str) -> Vec<String> {
170    let mut template_vars = Vec::new();
171    let mut start = 0;
172
173    while let Some(open) = url[start..].find('{') {
174        let open_pos = start + open;
175        if let Some(close) = url[open_pos..].find('}') {
176            let close_pos = open_pos + close;
177            let var_name = &url[open_pos + 1..close_pos];
178            if !var_name.is_empty() {
179                template_vars.push(var_name.to_string());
180            }
181            start = close_pos + 1;
182        } else {
183            break;
184        }
185    }
186
187    template_vars
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::cache::models::{CachedSpec, ServerVariable};
194    use std::collections::HashMap;
195    use std::sync::Mutex;
196
197    // Static mutex to ensure only one test can modify environment variables at a time
198    static ENV_TEST_MUTEX: Mutex<()> = Mutex::new(());
199
200    fn create_test_spec(name: &str, base_url: Option<&str>) -> CachedSpec {
201        CachedSpec {
202            cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
203            name: name.to_string(),
204            version: "1.0.0".to_string(),
205            commands: vec![],
206            base_url: base_url.map(|s| s.to_string()),
207            servers: base_url.map(|s| vec![s.to_string()]).unwrap_or_default(),
208            security_schemes: HashMap::new(),
209            skipped_endpoints: vec![],
210            server_variables: HashMap::new(),
211        }
212    }
213
214    fn create_test_spec_with_variables(name: &str, base_url: Option<&str>) -> CachedSpec {
215        let mut server_variables = HashMap::new();
216
217        // Add test server variables
218        server_variables.insert(
219            "region".to_string(),
220            ServerVariable {
221                default: Some("us".to_string()),
222                enum_values: vec!["us".to_string(), "eu".to_string(), "ap".to_string()],
223                description: Some("API region".to_string()),
224            },
225        );
226
227        server_variables.insert(
228            "env".to_string(),
229            ServerVariable {
230                default: None,
231                enum_values: vec!["dev".to_string(), "staging".to_string(), "prod".to_string()],
232                description: Some("Environment".to_string()),
233            },
234        );
235
236        CachedSpec {
237            cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
238            name: name.to_string(),
239            version: "1.0.0".to_string(),
240            commands: vec![],
241            base_url: base_url.map(|s| s.to_string()),
242            servers: base_url.map(|s| vec![s.to_string()]).unwrap_or_default(),
243            security_schemes: HashMap::new(),
244            skipped_endpoints: vec![],
245            server_variables,
246        }
247    }
248
249    /// Test harness to isolate environment variable changes with mutual exclusion
250    fn test_with_env_isolation<F>(test_fn: F)
251    where
252        F: FnOnce() + std::panic::UnwindSafe,
253    {
254        // Acquire mutex to prevent parallel env var access
255        let _guard = ENV_TEST_MUTEX.lock().unwrap();
256
257        // Store original value
258        let original_value = std::env::var("APERTURE_BASE_URL").ok();
259
260        // Clean up first
261        std::env::remove_var("APERTURE_BASE_URL");
262
263        // Run the test with panic protection
264        let result = std::panic::catch_unwind(test_fn);
265
266        // Always restore original state, even if test panicked
267        if let Some(original) = original_value {
268            std::env::set_var("APERTURE_BASE_URL", original);
269        } else {
270            std::env::remove_var("APERTURE_BASE_URL");
271        }
272
273        // Drop the guard before re-panicking to release the mutex
274        drop(_guard);
275
276        // Re-panic if the test failed
277        if let Err(panic_info) = result {
278            std::panic::resume_unwind(panic_info);
279        }
280    }
281
282    #[test]
283    fn test_priority_1_explicit_url() {
284        test_with_env_isolation(|| {
285            let spec = create_test_spec("test-api", Some("https://spec.example.com"));
286            let resolver = BaseUrlResolver::new(&spec);
287
288            assert_eq!(
289                resolver.resolve(Some("https://explicit.example.com")),
290                "https://explicit.example.com"
291            );
292        });
293    }
294
295    #[test]
296    fn test_priority_2_api_config_override() {
297        test_with_env_isolation(|| {
298            let spec = create_test_spec("test-api", Some("https://spec.example.com"));
299
300            let mut api_configs = HashMap::new();
301            api_configs.insert(
302                "test-api".to_string(),
303                ApiConfig {
304                    base_url_override: Some("https://config.example.com".to_string()),
305                    environment_urls: HashMap::new(),
306                    strict_mode: false,
307                    secrets: HashMap::new(),
308                },
309            );
310
311            let global_config = GlobalConfig {
312                api_configs,
313                ..Default::default()
314            };
315
316            let resolver = BaseUrlResolver::new(&spec).with_global_config(&global_config);
317
318            assert_eq!(resolver.resolve(None), "https://config.example.com");
319        });
320    }
321
322    #[test]
323    fn test_priority_2_environment_specific() {
324        test_with_env_isolation(|| {
325            let spec = create_test_spec("test-api", Some("https://spec.example.com"));
326
327            let mut environment_urls = HashMap::new();
328            environment_urls.insert(
329                "staging".to_string(),
330                "https://staging.example.com".to_string(),
331            );
332            environment_urls.insert("prod".to_string(), "https://prod.example.com".to_string());
333
334            let mut api_configs = HashMap::new();
335            api_configs.insert(
336                "test-api".to_string(),
337                ApiConfig {
338                    base_url_override: Some("https://config.example.com".to_string()),
339                    environment_urls,
340                    strict_mode: false,
341                    secrets: HashMap::new(),
342                },
343            );
344
345            let global_config = GlobalConfig {
346                api_configs,
347                ..Default::default()
348            };
349
350            let resolver = BaseUrlResolver::new(&spec)
351                .with_global_config(&global_config)
352                .with_environment(Some("staging".to_string()));
353
354            assert_eq!(resolver.resolve(None), "https://staging.example.com");
355        });
356    }
357
358    #[test]
359    fn test_priority_config_override_beats_env_var() {
360        // Test that config override takes precedence over environment variable
361        test_with_env_isolation(|| {
362            let spec = create_test_spec("test-api", Some("https://spec.example.com"));
363
364            // Set env var
365            std::env::set_var("APERTURE_BASE_URL", "https://env.example.com");
366
367            let mut api_configs = HashMap::new();
368            api_configs.insert(
369                "test-api".to_string(),
370                ApiConfig {
371                    base_url_override: Some("https://config.example.com".to_string()),
372                    environment_urls: HashMap::new(),
373                    strict_mode: false,
374                    secrets: HashMap::new(),
375                },
376            );
377
378            let global_config = GlobalConfig {
379                api_configs,
380                ..Default::default()
381            };
382
383            let resolver = BaseUrlResolver::new(&spec).with_global_config(&global_config);
384
385            // Config override should win over env var
386            assert_eq!(resolver.resolve(None), "https://config.example.com");
387        });
388    }
389
390    #[test]
391    fn test_priority_3_env_var() {
392        // Use a custom test harness to isolate environment variables
393        test_with_env_isolation(|| {
394            let spec = create_test_spec("test-api", Some("https://spec.example.com"));
395
396            // Set env var
397            std::env::set_var("APERTURE_BASE_URL", "https://env.example.com");
398
399            let resolver = BaseUrlResolver::new(&spec);
400
401            assert_eq!(resolver.resolve(None), "https://env.example.com");
402        });
403    }
404
405    #[test]
406    fn test_priority_4_spec_default() {
407        test_with_env_isolation(|| {
408            let spec = create_test_spec("test-api", Some("https://spec.example.com"));
409            let resolver = BaseUrlResolver::new(&spec);
410
411            assert_eq!(resolver.resolve(None), "https://spec.example.com");
412        });
413    }
414
415    #[test]
416    fn test_priority_5_fallback() {
417        test_with_env_isolation(|| {
418            let spec = create_test_spec("test-api", None);
419            let resolver = BaseUrlResolver::new(&spec);
420
421            assert_eq!(resolver.resolve(None), "https://api.example.com");
422        });
423    }
424
425    #[test]
426    fn test_server_variable_resolution_with_all_provided() {
427        test_with_env_isolation(|| {
428            let spec = create_test_spec_with_variables(
429                "test-api",
430                Some("https://{region}-{env}.api.example.com"),
431            );
432            let resolver = BaseUrlResolver::new(&spec);
433
434            let server_vars = vec!["region=eu".to_string(), "env=staging".to_string()];
435            let result = resolver.resolve_with_variables(None, &server_vars).unwrap();
436
437            assert_eq!(result, "https://eu-staging.api.example.com");
438        });
439    }
440
441    #[test]
442    fn test_server_variable_resolution_with_defaults() {
443        test_with_env_isolation(|| {
444            let spec = create_test_spec_with_variables(
445                "test-api",
446                Some("https://{region}-{env}.api.example.com"),
447            );
448            let resolver = BaseUrlResolver::new(&spec);
449
450            // Only provide required variable, let region use default
451            let server_vars = vec!["env=prod".to_string()];
452            let result = resolver.resolve_with_variables(None, &server_vars).unwrap();
453
454            assert_eq!(result, "https://us-prod.api.example.com");
455        });
456    }
457
458    #[test]
459    fn test_server_variable_resolution_missing_required() {
460        test_with_env_isolation(|| {
461            let spec = create_test_spec_with_variables(
462                "test-api",
463                Some("https://{region}-{env}.api.example.com"),
464            );
465            let resolver = BaseUrlResolver::new(&spec);
466
467            // Missing required 'env' variable
468            let server_vars = vec!["region=us".to_string()];
469            let result = resolver.resolve_with_variables(None, &server_vars);
470
471            assert!(result.is_err());
472        });
473    }
474
475    #[test]
476    fn test_server_variable_resolution_invalid_enum() {
477        test_with_env_isolation(|| {
478            let spec = create_test_spec_with_variables(
479                "test-api",
480                Some("https://{region}-{env}.api.example.com"),
481            );
482            let resolver = BaseUrlResolver::new(&spec);
483
484            let server_vars = vec!["region=invalid".to_string(), "env=prod".to_string()];
485            let result = resolver.resolve_with_variables(None, &server_vars);
486
487            assert!(result.is_err());
488        });
489    }
490
491    #[test]
492    fn test_non_template_url_with_server_variables() {
493        test_with_env_isolation(|| {
494            let spec = create_test_spec_with_variables("test-api", Some("https://api.example.com"));
495            let resolver = BaseUrlResolver::new(&spec);
496
497            // Non-template URL should be returned as-is even with server variables defined
498            let server_vars = vec!["region=eu".to_string(), "env=prod".to_string()];
499            let result = resolver.resolve_with_variables(None, &server_vars).unwrap();
500
501            assert_eq!(result, "https://api.example.com");
502        });
503    }
504
505    #[test]
506    fn test_no_server_variables_defined() {
507        test_with_env_isolation(|| {
508            let spec = create_test_spec("test-api", Some("https://{region}.api.example.com"));
509            let resolver = BaseUrlResolver::new(&spec);
510
511            // Template URL but no server variables defined in spec should error
512            let server_vars = vec!["region=eu".to_string()];
513            let result = resolver.resolve_with_variables(None, &server_vars);
514
515            // Should fail with UnresolvedTemplateVariable error
516            assert!(result.is_err());
517            match result.unwrap_err() {
518                Error::UnresolvedTemplateVariable { name, url } => {
519                    assert_eq!(name, "region");
520                    assert_eq!(url, "https://{region}.api.example.com");
521                }
522                _ => panic!("Expected UnresolvedTemplateVariable error"),
523            }
524        });
525    }
526
527    #[test]
528    fn test_server_variable_fallback_compatibility() {
529        test_with_env_isolation(|| {
530            let spec = create_test_spec_with_variables(
531                "test-api",
532                Some("https://{region}-{env}.api.example.com"),
533            );
534            let resolver = BaseUrlResolver::new(&spec);
535
536            // resolve() method should gracefully fallback when server variables fail
537            // This tests backward compatibility - when server variables are missing
538            // required values, it should fallback to basic resolution
539            let result = resolver.resolve(None);
540
541            // Should return the basic URL resolution (original template URL)
542            assert_eq!(result, "https://{region}-{env}.api.example.com");
543        });
544    }
545
546    #[test]
547    fn test_server_variable_with_config_override() {
548        test_with_env_isolation(|| {
549            let spec =
550                create_test_spec_with_variables("test-api", Some("https://{region}.original.com"));
551
552            let mut api_configs = HashMap::new();
553            api_configs.insert(
554                "test-api".to_string(),
555                ApiConfig {
556                    base_url_override: Some("https://{region}-override.example.com".to_string()),
557                    environment_urls: HashMap::new(),
558                    strict_mode: false,
559                    secrets: HashMap::new(),
560                },
561            );
562
563            let global_config = GlobalConfig {
564                api_configs,
565                ..Default::default()
566            };
567
568            let resolver = BaseUrlResolver::new(&spec).with_global_config(&global_config);
569
570            let server_vars = vec!["env=prod".to_string()]; // region should use default 'us'
571            let result = resolver.resolve_with_variables(None, &server_vars).unwrap();
572
573            // Should use config override as base, then apply server variable substitution
574            assert_eq!(result, "https://us-override.example.com");
575        });
576    }
577
578    #[test]
579    fn test_malformed_templates_pass_through() {
580        test_with_env_isolation(|| {
581            // Test URLs with empty braces or malformed templates
582            let spec = create_test_spec("test-api", Some("https://api.example.com/path{}"));
583            let resolver = BaseUrlResolver::new(&spec);
584
585            let result = resolver.resolve_with_variables(None, &[]).unwrap();
586            // Empty braces should pass through as they're not valid template variables
587            assert_eq!(result, "https://api.example.com/path{}");
588        });
589    }
590
591    #[test]
592    fn test_backward_compatibility_no_server_vars_non_template() {
593        test_with_env_isolation(|| {
594            // Non-template URL with no server variables should work normally
595            let spec = create_test_spec("test-api", Some("https://api.example.com"));
596            let resolver = BaseUrlResolver::new(&spec);
597
598            let result = resolver.resolve_with_variables(None, &[]).unwrap();
599            assert_eq!(result, "https://api.example.com");
600        });
601    }
602}