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!(
61 "{} Server variable error: {err}",
62 crate::constants::MSG_WARNING_PREFIX
63 );
64 self.resolve_basic(explicit_url)
65 }
66 _ => self.resolve_basic(explicit_url),
68 }
69 })
70 }
71
72 pub fn resolve_with_variables(
88 &self,
89 explicit_url: Option<&str>,
90 server_var_args: &[String],
91 ) -> Result<String, Error> {
92 let base_url = self.resolve_basic(explicit_url);
94
95 if !base_url.contains('{') {
97 return Ok(base_url);
98 }
99
100 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 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 fn resolve_basic(&self, explicit_url: Option<&str>) -> String {
121 if let Some(url) = explicit_url {
123 return url.to_string();
124 }
125
126 if let Some(config) = self.global_config {
128 if let Some(api_config) = config.api_configs.get(&self.spec.name) {
129 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 if let Some(override_url) = &api_config.base_url_override {
143 return override_url.clone();
144 }
145 }
146 }
147
148 if let Ok(url) = std::env::var(crate::constants::ENV_APERTURE_BASE_URL) {
150 return url;
151 }
152
153 if let Some(base_url) = &self.spec.base_url {
155 return base_url.clone();
156 }
157
158 "https://api.example.com".to_string()
160 }
161
162 #[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
170fn 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 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 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 fn test_with_env_isolation<F>(test_fn: F)
254 where
255 F: FnOnce() + std::panic::UnwindSafe,
256 {
257 let _guard = ENV_TEST_MUTEX.lock().unwrap();
259
260 let original_value = std::env::var(crate::constants::ENV_APERTURE_BASE_URL).ok();
262
263 std::env::remove_var(crate::constants::ENV_APERTURE_BASE_URL);
265
266 let result = std::panic::catch_unwind(test_fn);
268
269 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(_guard);
278
279 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_with_env_isolation(|| {
365 let spec = create_test_spec("test-api", Some("https://spec.example.com"));
366
367 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 assert_eq!(resolver.resolve(None), "https://config.example.com");
393 });
394 }
395
396 #[test]
397 fn test_priority_3_env_var() {
398 test_with_env_isolation(|| {
400 let spec = create_test_spec("test-api", Some("https://spec.example.com"));
401
402 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 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 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 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 let server_vars = vec!["region=eu".to_string()];
522 let result = resolver.resolve_with_variables(None, &server_vars);
523
524 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 let result = resolver.resolve(None);
552
553 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()]; let result = resolver.resolve_with_variables(None, &server_vars).unwrap();
584
585 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 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 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 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}