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