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 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_with_env_isolation(|| {
382 let spec = create_test_spec("test-api", Some("https://spec.example.com"));
383
384 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 assert_eq!(resolver.resolve(None), "https://config.example.com");
411 });
412 }
413
414 #[test]
415 fn test_priority_3_env_var() {
416 test_with_env_isolation(|| {
418 let spec = create_test_spec("test-api", Some("https://spec.example.com"));
419
420 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 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 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 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 let server_vars = vec!["region=eu".to_string()];
540 let result = resolver.resolve_with_variables(None, &server_vars);
541
542 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 let result = resolver.resolve(None);
570
571 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()]; let result = resolver.resolve_with_variables(None, &server_vars).unwrap();
603
604 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 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 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 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}