1use crate::cache::models::CachedSpec;
2use crate::config::models::{ApiConfig, GlobalConfig};
3use crate::config::server_variable_resolver::ServerVariableResolver;
4use crate::error::Error;
5
6pub struct BaseUrlResolver<'a> {
8 spec: &'a CachedSpec,
10 global_config: Option<&'a GlobalConfig>,
12 environment_override: Option<String>,
14}
15
16impl<'a> BaseUrlResolver<'a> {
17 #[must_use]
19 pub const fn new(spec: &'a CachedSpec) -> Self {
20 Self {
21 spec,
22 global_config: None,
23 environment_override: None,
24 }
25 }
26
27 #[must_use]
29 #[allow(clippy::missing_const_for_fn)]
30 pub fn with_global_config(mut self, config: &'a GlobalConfig) -> Self {
31 self.global_config = Some(config);
32 self
33 }
34
35 #[must_use]
37 pub fn with_environment(mut self, env: Option<String>) -> Self {
38 self.environment_override = env;
39 self
40 }
41
42 #[must_use]
49 pub fn resolve(&self, explicit_url: Option<&str>) -> String {
50 self.resolve_with_variables(explicit_url, &[])
51 .unwrap_or_else(|err| {
52 match err {
53 Error::InvalidServerVarFormat { .. }
56 | Error::InvalidServerVarValue { .. }
57 | Error::UnknownServerVariable { .. } => {
58 eprintln!("Warning: Server variable error: {err}");
59 self.resolve_basic(explicit_url)
60 }
61 _ => self.resolve_basic(explicit_url),
63 }
64 })
65 }
66
67 pub fn resolve_with_variables(
83 &self,
84 explicit_url: Option<&str>,
85 server_var_args: &[String],
86 ) -> Result<String, Error> {
87 let base_url = self.resolve_basic(explicit_url);
89
90 if !base_url.contains('{') {
92 return Ok(base_url);
93 }
94
95 if self.spec.server_variables.is_empty() {
99 let template_vars = extract_template_variables(&base_url);
100
101 if let Some(first_var) = template_vars.first() {
102 return Err(Error::UnresolvedTemplateVariable {
103 name: first_var.clone(),
104 url: base_url,
105 });
106 }
107
108 return Ok(base_url);
109 }
110
111 let resolver = ServerVariableResolver::new(self.spec);
113 let resolved_variables = resolver.resolve_variables(server_var_args)?;
114 resolver.substitute_url(&base_url, &resolved_variables)
115 }
116
117 fn resolve_basic(&self, explicit_url: Option<&str>) -> String {
119 if let Some(url) = explicit_url {
121 return url.to_string();
122 }
123
124 if let Some(config) = self.global_config {
126 if let Some(api_config) = config.api_configs.get(&self.spec.name) {
127 let env_to_check = self.environment_override.as_ref().map_or_else(
129 || std::env::var("APERTURE_ENV").unwrap_or_default(),
130 std::clone::Clone::clone,
131 );
132
133 if !env_to_check.is_empty() {
134 if let Some(env_url) = api_config.environment_urls.get(&env_to_check) {
135 return env_url.clone();
136 }
137 }
138
139 if let Some(override_url) = &api_config.base_url_override {
141 return override_url.clone();
142 }
143 }
144 }
145
146 if let Ok(url) = std::env::var("APERTURE_BASE_URL") {
148 return url;
149 }
150
151 if let Some(base_url) = &self.spec.base_url {
153 return base_url.clone();
154 }
155
156 "https://api.example.com".to_string()
158 }
159
160 #[must_use]
162 pub fn get_api_config(&self) -> Option<&ApiConfig> {
163 self.global_config
164 .and_then(|config| config.api_configs.get(&self.spec.name))
165 }
166}
167
168fn extract_template_variables(url: &str) -> Vec<String> {
170 let mut template_vars = Vec::new();
171 let mut start = 0;
172
173 while let Some(open) = url[start..].find('{') {
174 let open_pos = start + open;
175 if let Some(close) = url[open_pos..].find('}') {
176 let close_pos = open_pos + close;
177 let var_name = &url[open_pos + 1..close_pos];
178 if !var_name.is_empty() {
179 template_vars.push(var_name.to_string());
180 }
181 start = close_pos + 1;
182 } else {
183 break;
184 }
185 }
186
187 template_vars
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use crate::cache::models::{CachedSpec, ServerVariable};
194 use std::collections::HashMap;
195 use std::sync::Mutex;
196
197 static ENV_TEST_MUTEX: Mutex<()> = Mutex::new(());
199
200 fn create_test_spec(name: &str, base_url: Option<&str>) -> CachedSpec {
201 CachedSpec {
202 cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
203 name: name.to_string(),
204 version: "1.0.0".to_string(),
205 commands: vec![],
206 base_url: base_url.map(|s| s.to_string()),
207 servers: base_url.map(|s| vec![s.to_string()]).unwrap_or_default(),
208 security_schemes: HashMap::new(),
209 skipped_endpoints: vec![],
210 server_variables: HashMap::new(),
211 }
212 }
213
214 fn create_test_spec_with_variables(name: &str, base_url: Option<&str>) -> CachedSpec {
215 let mut server_variables = HashMap::new();
216
217 server_variables.insert(
219 "region".to_string(),
220 ServerVariable {
221 default: Some("us".to_string()),
222 enum_values: vec!["us".to_string(), "eu".to_string(), "ap".to_string()],
223 description: Some("API region".to_string()),
224 },
225 );
226
227 server_variables.insert(
228 "env".to_string(),
229 ServerVariable {
230 default: None,
231 enum_values: vec!["dev".to_string(), "staging".to_string(), "prod".to_string()],
232 description: Some("Environment".to_string()),
233 },
234 );
235
236 CachedSpec {
237 cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
238 name: name.to_string(),
239 version: "1.0.0".to_string(),
240 commands: vec![],
241 base_url: base_url.map(|s| s.to_string()),
242 servers: base_url.map(|s| vec![s.to_string()]).unwrap_or_default(),
243 security_schemes: HashMap::new(),
244 skipped_endpoints: vec![],
245 server_variables,
246 }
247 }
248
249 fn test_with_env_isolation<F>(test_fn: F)
251 where
252 F: FnOnce() + std::panic::UnwindSafe,
253 {
254 let _guard = ENV_TEST_MUTEX.lock().unwrap();
256
257 let original_value = std::env::var("APERTURE_BASE_URL").ok();
259
260 std::env::remove_var("APERTURE_BASE_URL");
262
263 let result = std::panic::catch_unwind(test_fn);
265
266 if let Some(original) = original_value {
268 std::env::set_var("APERTURE_BASE_URL", original);
269 } else {
270 std::env::remove_var("APERTURE_BASE_URL");
271 }
272
273 drop(_guard);
275
276 if let Err(panic_info) = result {
278 std::panic::resume_unwind(panic_info);
279 }
280 }
281
282 #[test]
283 fn test_priority_1_explicit_url() {
284 test_with_env_isolation(|| {
285 let spec = create_test_spec("test-api", Some("https://spec.example.com"));
286 let resolver = BaseUrlResolver::new(&spec);
287
288 assert_eq!(
289 resolver.resolve(Some("https://explicit.example.com")),
290 "https://explicit.example.com"
291 );
292 });
293 }
294
295 #[test]
296 fn test_priority_2_api_config_override() {
297 test_with_env_isolation(|| {
298 let spec = create_test_spec("test-api", Some("https://spec.example.com"));
299
300 let mut api_configs = HashMap::new();
301 api_configs.insert(
302 "test-api".to_string(),
303 ApiConfig {
304 base_url_override: Some("https://config.example.com".to_string()),
305 environment_urls: HashMap::new(),
306 strict_mode: false,
307 secrets: HashMap::new(),
308 },
309 );
310
311 let global_config = GlobalConfig {
312 api_configs,
313 ..Default::default()
314 };
315
316 let resolver = BaseUrlResolver::new(&spec).with_global_config(&global_config);
317
318 assert_eq!(resolver.resolve(None), "https://config.example.com");
319 });
320 }
321
322 #[test]
323 fn test_priority_2_environment_specific() {
324 test_with_env_isolation(|| {
325 let spec = create_test_spec("test-api", Some("https://spec.example.com"));
326
327 let mut environment_urls = HashMap::new();
328 environment_urls.insert(
329 "staging".to_string(),
330 "https://staging.example.com".to_string(),
331 );
332 environment_urls.insert("prod".to_string(), "https://prod.example.com".to_string());
333
334 let mut api_configs = HashMap::new();
335 api_configs.insert(
336 "test-api".to_string(),
337 ApiConfig {
338 base_url_override: Some("https://config.example.com".to_string()),
339 environment_urls,
340 strict_mode: false,
341 secrets: HashMap::new(),
342 },
343 );
344
345 let global_config = GlobalConfig {
346 api_configs,
347 ..Default::default()
348 };
349
350 let resolver = BaseUrlResolver::new(&spec)
351 .with_global_config(&global_config)
352 .with_environment(Some("staging".to_string()));
353
354 assert_eq!(resolver.resolve(None), "https://staging.example.com");
355 });
356 }
357
358 #[test]
359 fn test_priority_config_override_beats_env_var() {
360 test_with_env_isolation(|| {
362 let spec = create_test_spec("test-api", Some("https://spec.example.com"));
363
364 std::env::set_var("APERTURE_BASE_URL", "https://env.example.com");
366
367 let mut api_configs = HashMap::new();
368 api_configs.insert(
369 "test-api".to_string(),
370 ApiConfig {
371 base_url_override: Some("https://config.example.com".to_string()),
372 environment_urls: HashMap::new(),
373 strict_mode: false,
374 secrets: HashMap::new(),
375 },
376 );
377
378 let global_config = GlobalConfig {
379 api_configs,
380 ..Default::default()
381 };
382
383 let resolver = BaseUrlResolver::new(&spec).with_global_config(&global_config);
384
385 assert_eq!(resolver.resolve(None), "https://config.example.com");
387 });
388 }
389
390 #[test]
391 fn test_priority_3_env_var() {
392 test_with_env_isolation(|| {
394 let spec = create_test_spec("test-api", Some("https://spec.example.com"));
395
396 std::env::set_var("APERTURE_BASE_URL", "https://env.example.com");
398
399 let resolver = BaseUrlResolver::new(&spec);
400
401 assert_eq!(resolver.resolve(None), "https://env.example.com");
402 });
403 }
404
405 #[test]
406 fn test_priority_4_spec_default() {
407 test_with_env_isolation(|| {
408 let spec = create_test_spec("test-api", Some("https://spec.example.com"));
409 let resolver = BaseUrlResolver::new(&spec);
410
411 assert_eq!(resolver.resolve(None), "https://spec.example.com");
412 });
413 }
414
415 #[test]
416 fn test_priority_5_fallback() {
417 test_with_env_isolation(|| {
418 let spec = create_test_spec("test-api", None);
419 let resolver = BaseUrlResolver::new(&spec);
420
421 assert_eq!(resolver.resolve(None), "https://api.example.com");
422 });
423 }
424
425 #[test]
426 fn test_server_variable_resolution_with_all_provided() {
427 test_with_env_isolation(|| {
428 let spec = create_test_spec_with_variables(
429 "test-api",
430 Some("https://{region}-{env}.api.example.com"),
431 );
432 let resolver = BaseUrlResolver::new(&spec);
433
434 let server_vars = vec!["region=eu".to_string(), "env=staging".to_string()];
435 let result = resolver.resolve_with_variables(None, &server_vars).unwrap();
436
437 assert_eq!(result, "https://eu-staging.api.example.com");
438 });
439 }
440
441 #[test]
442 fn test_server_variable_resolution_with_defaults() {
443 test_with_env_isolation(|| {
444 let spec = create_test_spec_with_variables(
445 "test-api",
446 Some("https://{region}-{env}.api.example.com"),
447 );
448 let resolver = BaseUrlResolver::new(&spec);
449
450 let server_vars = vec!["env=prod".to_string()];
452 let result = resolver.resolve_with_variables(None, &server_vars).unwrap();
453
454 assert_eq!(result, "https://us-prod.api.example.com");
455 });
456 }
457
458 #[test]
459 fn test_server_variable_resolution_missing_required() {
460 test_with_env_isolation(|| {
461 let spec = create_test_spec_with_variables(
462 "test-api",
463 Some("https://{region}-{env}.api.example.com"),
464 );
465 let resolver = BaseUrlResolver::new(&spec);
466
467 let server_vars = vec!["region=us".to_string()];
469 let result = resolver.resolve_with_variables(None, &server_vars);
470
471 assert!(result.is_err());
472 });
473 }
474
475 #[test]
476 fn test_server_variable_resolution_invalid_enum() {
477 test_with_env_isolation(|| {
478 let spec = create_test_spec_with_variables(
479 "test-api",
480 Some("https://{region}-{env}.api.example.com"),
481 );
482 let resolver = BaseUrlResolver::new(&spec);
483
484 let server_vars = vec!["region=invalid".to_string(), "env=prod".to_string()];
485 let result = resolver.resolve_with_variables(None, &server_vars);
486
487 assert!(result.is_err());
488 });
489 }
490
491 #[test]
492 fn test_non_template_url_with_server_variables() {
493 test_with_env_isolation(|| {
494 let spec = create_test_spec_with_variables("test-api", Some("https://api.example.com"));
495 let resolver = BaseUrlResolver::new(&spec);
496
497 let server_vars = vec!["region=eu".to_string(), "env=prod".to_string()];
499 let result = resolver.resolve_with_variables(None, &server_vars).unwrap();
500
501 assert_eq!(result, "https://api.example.com");
502 });
503 }
504
505 #[test]
506 fn test_no_server_variables_defined() {
507 test_with_env_isolation(|| {
508 let spec = create_test_spec("test-api", Some("https://{region}.api.example.com"));
509 let resolver = BaseUrlResolver::new(&spec);
510
511 let server_vars = vec!["region=eu".to_string()];
513 let result = resolver.resolve_with_variables(None, &server_vars);
514
515 assert!(result.is_err());
517 match result.unwrap_err() {
518 Error::UnresolvedTemplateVariable { name, url } => {
519 assert_eq!(name, "region");
520 assert_eq!(url, "https://{region}.api.example.com");
521 }
522 _ => panic!("Expected UnresolvedTemplateVariable error"),
523 }
524 });
525 }
526
527 #[test]
528 fn test_server_variable_fallback_compatibility() {
529 test_with_env_isolation(|| {
530 let spec = create_test_spec_with_variables(
531 "test-api",
532 Some("https://{region}-{env}.api.example.com"),
533 );
534 let resolver = BaseUrlResolver::new(&spec);
535
536 let result = resolver.resolve(None);
540
541 assert_eq!(result, "https://{region}-{env}.api.example.com");
543 });
544 }
545
546 #[test]
547 fn test_server_variable_with_config_override() {
548 test_with_env_isolation(|| {
549 let spec =
550 create_test_spec_with_variables("test-api", Some("https://{region}.original.com"));
551
552 let mut api_configs = HashMap::new();
553 api_configs.insert(
554 "test-api".to_string(),
555 ApiConfig {
556 base_url_override: Some("https://{region}-override.example.com".to_string()),
557 environment_urls: HashMap::new(),
558 strict_mode: false,
559 secrets: HashMap::new(),
560 },
561 );
562
563 let global_config = GlobalConfig {
564 api_configs,
565 ..Default::default()
566 };
567
568 let resolver = BaseUrlResolver::new(&spec).with_global_config(&global_config);
569
570 let server_vars = vec!["env=prod".to_string()]; let result = resolver.resolve_with_variables(None, &server_vars).unwrap();
572
573 assert_eq!(result, "https://us-override.example.com");
575 });
576 }
577
578 #[test]
579 fn test_malformed_templates_pass_through() {
580 test_with_env_isolation(|| {
581 let spec = create_test_spec("test-api", Some("https://api.example.com/path{}"));
583 let resolver = BaseUrlResolver::new(&spec);
584
585 let result = resolver.resolve_with_variables(None, &[]).unwrap();
586 assert_eq!(result, "https://api.example.com/path{}");
588 });
589 }
590
591 #[test]
592 fn test_backward_compatibility_no_server_vars_non_template() {
593 test_with_env_isolation(|| {
594 let spec = create_test_spec("test-api", Some("https://api.example.com"));
596 let resolver = BaseUrlResolver::new(&spec);
597
598 let result = resolver.resolve_with_variables(None, &[]).unwrap();
599 assert_eq!(result, "https://api.example.com");
600 });
601 }
602}