Skip to main content

aperture_cli/config/
url_resolver.rs

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