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
7pub struct BaseUrlResolver<'a> {
9 spec: &'a CachedSpec,
11 global_config: Option<&'a GlobalConfig>,
13 environment_override: Option<String>,
15}
16
17impl<'a> BaseUrlResolver<'a> {
18 #[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 #[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 #[must_use]
38 pub fn with_environment(mut self, env: Option<String>) -> Self {
39 self.environment_override = env;
40 self
41 }
42
43 #[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 Error::Internal {
57 kind: crate::error::ErrorKind::ServerVariable,
58 ..
59 } => {
60 eprintln!(
62 "{} Server variable error: {err}",
63 crate::constants::MSG_WARNING_PREFIX
64 );
65 self.resolve_basic(explicit_url)
66 }
67 _ => self.resolve_basic(explicit_url),
69 }
70 })
71 }
72
73 pub fn resolve_with_variables(
89 &self,
90 explicit_url: Option<&str>,
91 server_var_args: &[String],
92 ) -> Result<String, Error> {
93 let base_url = self.resolve_basic(explicit_url);
95
96 if !base_url.contains('{') {
98 return Ok(base_url);
99 }
100
101 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 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 fn resolve_basic(&self, explicit_url: Option<&str>) -> String {
122 if let Some(url) = explicit_url {
124 return url.to_string();
125 }
126
127 let Some(config) = self.global_config else {
129 return self.resolve_env_or_spec_or_fallback();
131 };
132
133 let Some(api_config) = config.api_configs.get(&self.spec.name) else {
134 return self.resolve_env_or_spec_or_fallback();
136 };
137
138 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 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 if let Some(override_url) = &api_config.base_url_override {
151 return override_url.clone();
152 }
153
154 self.resolve_env_or_spec_or_fallback()
156 }
157
158 fn resolve_env_or_spec_or_fallback(&self) -> String {
160 if let Ok(url) = std::env::var(crate::constants::ENV_APERTURE_BASE_URL) {
162 return url;
163 }
164
165 if let Some(base_url) = &self.spec.base_url {
167 return base_url.clone();
168 }
169
170 "https://api.example.com".to_string()
172 }
173
174 #[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
182fn 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 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 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 fn test_with_env_isolation<F>(test_fn: F)
269 where
270 F: FnOnce() + std::panic::UnwindSafe,
271 {
272 let guard = ENV_TEST_MUTEX.lock().unwrap();
274
275 let original_value = std::env::var(crate::constants::ENV_APERTURE_BASE_URL).ok();
277
278 std::env::remove_var(crate::constants::ENV_APERTURE_BASE_URL);
280
281 let result = std::panic::catch_unwind(test_fn);
283
284 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(guard);
293
294 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 },
327 );
328
329 let global_config = GlobalConfig {
330 api_configs,
331 ..Default::default()
332 };
333
334 let resolver = BaseUrlResolver::new(&spec).with_global_config(&global_config);
335
336 assert_eq!(resolver.resolve(None), "https://config.example.com");
337 });
338 }
339
340 #[test]
341 fn test_priority_2_environment_specific() {
342 test_with_env_isolation(|| {
343 let spec = create_test_spec("test-api", Some("https://spec.example.com"));
344
345 let mut environment_urls = HashMap::new();
346 environment_urls.insert(
347 "staging".to_string(),
348 "https://staging.example.com".to_string(),
349 );
350 environment_urls.insert("prod".to_string(), "https://prod.example.com".to_string());
351
352 let mut api_configs = HashMap::new();
353 api_configs.insert(
354 "test-api".to_string(),
355 ApiConfig {
356 base_url_override: Some("https://config.example.com".to_string()),
357 environment_urls,
358 strict_mode: false,
359 secrets: HashMap::new(),
360 },
361 );
362
363 let global_config = GlobalConfig {
364 api_configs,
365 ..Default::default()
366 };
367
368 let resolver = BaseUrlResolver::new(&spec)
369 .with_global_config(&global_config)
370 .with_environment(Some("staging".to_string()));
371
372 assert_eq!(resolver.resolve(None), "https://staging.example.com");
373 });
374 }
375
376 #[test]
377 fn test_priority_config_override_beats_env_var() {
378 test_with_env_isolation(|| {
380 let spec = create_test_spec("test-api", Some("https://spec.example.com"));
381
382 std::env::set_var(
384 crate::constants::ENV_APERTURE_BASE_URL,
385 "https://env.example.com",
386 );
387
388 let mut api_configs = HashMap::new();
389 api_configs.insert(
390 "test-api".to_string(),
391 ApiConfig {
392 base_url_override: Some("https://config.example.com".to_string()),
393 environment_urls: HashMap::new(),
394 strict_mode: false,
395 secrets: HashMap::new(),
396 },
397 );
398
399 let global_config = GlobalConfig {
400 api_configs,
401 ..Default::default()
402 };
403
404 let resolver = BaseUrlResolver::new(&spec).with_global_config(&global_config);
405
406 assert_eq!(resolver.resolve(None), "https://config.example.com");
408 });
409 }
410
411 #[test]
412 fn test_priority_3_env_var() {
413 test_with_env_isolation(|| {
415 let spec = create_test_spec("test-api", Some("https://spec.example.com"));
416
417 std::env::set_var(
419 crate::constants::ENV_APERTURE_BASE_URL,
420 "https://env.example.com",
421 );
422
423 let resolver = BaseUrlResolver::new(&spec);
424
425 assert_eq!(resolver.resolve(None), "https://env.example.com");
426 });
427 }
428
429 #[test]
430 fn test_priority_4_spec_default() {
431 test_with_env_isolation(|| {
432 let spec = create_test_spec("test-api", Some("https://spec.example.com"));
433 let resolver = BaseUrlResolver::new(&spec);
434
435 assert_eq!(resolver.resolve(None), "https://spec.example.com");
436 });
437 }
438
439 #[test]
440 fn test_priority_5_fallback() {
441 test_with_env_isolation(|| {
442 let spec = create_test_spec("test-api", None);
443 let resolver = BaseUrlResolver::new(&spec);
444
445 assert_eq!(resolver.resolve(None), "https://api.example.com");
446 });
447 }
448
449 #[test]
450 fn test_server_variable_resolution_with_all_provided() {
451 test_with_env_isolation(|| {
452 let spec = create_test_spec_with_variables(
453 "test-api",
454 Some("https://{region}-{env}.api.example.com"),
455 );
456 let resolver = BaseUrlResolver::new(&spec);
457
458 let server_vars = vec!["region=eu".to_string(), "env=staging".to_string()];
459 let result = resolver.resolve_with_variables(None, &server_vars).unwrap();
460
461 assert_eq!(result, "https://eu-staging.api.example.com");
462 });
463 }
464
465 #[test]
466 fn test_server_variable_resolution_with_defaults() {
467 test_with_env_isolation(|| {
468 let spec = create_test_spec_with_variables(
469 "test-api",
470 Some("https://{region}-{env}.api.example.com"),
471 );
472 let resolver = BaseUrlResolver::new(&spec);
473
474 let server_vars = vec!["env=prod".to_string()];
476 let result = resolver.resolve_with_variables(None, &server_vars).unwrap();
477
478 assert_eq!(result, "https://us-prod.api.example.com");
479 });
480 }
481
482 #[test]
483 fn test_server_variable_resolution_missing_required() {
484 test_with_env_isolation(|| {
485 let spec = create_test_spec_with_variables(
486 "test-api",
487 Some("https://{region}-{env}.api.example.com"),
488 );
489 let resolver = BaseUrlResolver::new(&spec);
490
491 let server_vars = vec!["region=us".to_string()];
493 let result = resolver.resolve_with_variables(None, &server_vars);
494
495 assert!(result.is_err());
496 });
497 }
498
499 #[test]
500 fn test_server_variable_resolution_invalid_enum() {
501 test_with_env_isolation(|| {
502 let spec = create_test_spec_with_variables(
503 "test-api",
504 Some("https://{region}-{env}.api.example.com"),
505 );
506 let resolver = BaseUrlResolver::new(&spec);
507
508 let server_vars = vec!["region=invalid".to_string(), "env=prod".to_string()];
509 let result = resolver.resolve_with_variables(None, &server_vars);
510
511 assert!(result.is_err());
512 });
513 }
514
515 #[test]
516 fn test_non_template_url_with_server_variables() {
517 test_with_env_isolation(|| {
518 let spec = create_test_spec_with_variables("test-api", Some("https://api.example.com"));
519 let resolver = BaseUrlResolver::new(&spec);
520
521 let server_vars = vec!["region=eu".to_string(), "env=prod".to_string()];
523 let result = resolver.resolve_with_variables(None, &server_vars).unwrap();
524
525 assert_eq!(result, "https://api.example.com");
526 });
527 }
528
529 #[test]
530 fn test_no_server_variables_defined() {
531 test_with_env_isolation(|| {
532 let spec = create_test_spec("test-api", Some("https://{region}.api.example.com"));
533 let resolver = BaseUrlResolver::new(&spec);
534
535 let server_vars = vec!["region=eu".to_string()];
537 let result = resolver.resolve_with_variables(None, &server_vars);
538
539 assert!(result.is_err());
541 match result.unwrap_err() {
542 Error::Internal {
543 kind: ErrorKind::ServerVariable,
544 message,
545 ..
546 } => {
547 assert!(message.contains("region"));
548 }
549 _ => panic!("Expected Internal ServerVariable error"),
550 }
551 });
552 }
553
554 #[test]
555 fn test_server_variable_fallback_compatibility() {
556 test_with_env_isolation(|| {
557 let spec = create_test_spec_with_variables(
558 "test-api",
559 Some("https://{region}-{env}.api.example.com"),
560 );
561 let resolver = BaseUrlResolver::new(&spec);
562
563 let result = resolver.resolve(None);
567
568 assert_eq!(result, "https://{region}-{env}.api.example.com");
570 });
571 }
572
573 #[test]
574 fn test_server_variable_with_config_override() {
575 test_with_env_isolation(|| {
576 let spec =
577 create_test_spec_with_variables("test-api", Some("https://{region}.original.com"));
578
579 let mut api_configs = HashMap::new();
580 api_configs.insert(
581 "test-api".to_string(),
582 ApiConfig {
583 base_url_override: Some("https://{region}-override.example.com".to_string()),
584 environment_urls: HashMap::new(),
585 strict_mode: false,
586 secrets: HashMap::new(),
587 },
588 );
589
590 let global_config = GlobalConfig {
591 api_configs,
592 ..Default::default()
593 };
594
595 let resolver = BaseUrlResolver::new(&spec).with_global_config(&global_config);
596
597 let server_vars = vec!["env=prod".to_string()]; let result = resolver.resolve_with_variables(None, &server_vars).unwrap();
599
600 assert_eq!(result, "https://us-override.example.com");
602 });
603 }
604
605 #[test]
606 fn test_malformed_templates_pass_through() {
607 test_with_env_isolation(|| {
608 let spec = create_test_spec("test-api", Some("https://api.example.com/path{}"));
610 let resolver = BaseUrlResolver::new(&spec);
611
612 let result = resolver.resolve_with_variables(None, &[]).unwrap();
613 assert_eq!(result, "https://api.example.com/path{}");
615 });
616 }
617
618 #[test]
619 fn test_backward_compatibility_no_server_vars_non_template() {
620 test_with_env_isolation(|| {
621 let spec = create_test_spec("test-api", Some("https://api.example.com"));
623 let resolver = BaseUrlResolver::new(&spec);
624
625 let result = resolver.resolve_with_variables(None, &[]).unwrap();
626 assert_eq!(result, "https://api.example.com");
627 });
628 }
629}