use crate::cache::models::CachedSpec;
use crate::config::models::{ApiConfig, GlobalConfig};
use crate::config::server_variable_resolver::ServerVariableResolver;
#[allow(unused_imports)]
use crate::error::{Error, ErrorKind};
pub struct BaseUrlResolver<'a> {
spec: &'a CachedSpec,
global_config: Option<&'a GlobalConfig>,
environment_override: Option<String>,
}
impl<'a> BaseUrlResolver<'a> {
#[must_use]
pub const fn new(spec: &'a CachedSpec) -> Self {
Self {
spec,
global_config: None,
environment_override: None,
}
}
#[must_use]
#[allow(clippy::missing_const_for_fn)]
pub fn with_global_config(mut self, config: &'a GlobalConfig) -> Self {
self.global_config = Some(config);
self
}
#[must_use]
pub fn with_environment(mut self, env: Option<String>) -> Self {
self.environment_override = env;
self
}
#[must_use]
pub fn resolve(&self, explicit_url: Option<&str>) -> String {
self.resolve_with_variables(explicit_url, &[])
.unwrap_or_else(|err| {
match err {
Error::Internal {
kind: crate::error::ErrorKind::ServerVariable,
..
} => {
eprintln!(
"{} Server variable error: {err}",
crate::constants::MSG_WARNING_PREFIX
);
self.resolve_basic(explicit_url)
}
_ => self.resolve_basic(explicit_url),
}
})
}
pub fn resolve_with_variables(
&self,
explicit_url: Option<&str>,
server_var_args: &[String],
) -> Result<String, Error> {
let base_url = self.resolve_basic(explicit_url);
if !base_url.contains('{') {
return Ok(base_url);
}
if self.spec.server_variables.is_empty() {
let template_vars = extract_template_variables(&base_url);
let Some(first_var) = template_vars.first() else {
return Ok(base_url);
};
return Err(Error::unresolved_template_variable(first_var, &base_url));
}
let resolver = ServerVariableResolver::new(self.spec);
let resolved_variables = resolver.resolve_variables(server_var_args)?;
resolver.substitute_url(&base_url, &resolved_variables)
}
fn resolve_basic(&self, explicit_url: Option<&str>) -> String {
if let Some(url) = explicit_url {
return url.to_string();
}
let Some(config) = self.global_config else {
return self.resolve_env_or_spec_or_fallback();
};
let Some(api_config) = config.api_configs.get(&self.spec.name) else {
return self.resolve_env_or_spec_or_fallback();
};
let env_to_check = self.environment_override.as_ref().map_or_else(
|| std::env::var(crate::constants::ENV_APERTURE_ENV).unwrap_or_default(),
std::clone::Clone::clone,
);
if !env_to_check.is_empty() && api_config.environment_urls.contains_key(&env_to_check) {
return api_config.environment_urls[&env_to_check].clone();
}
if let Some(override_url) = &api_config.base_url_override {
return override_url.clone();
}
self.resolve_env_or_spec_or_fallback()
}
fn resolve_env_or_spec_or_fallback(&self) -> String {
if let Ok(url) = std::env::var(crate::constants::ENV_APERTURE_BASE_URL) {
return url;
}
if let Some(base_url) = &self.spec.base_url {
return base_url.clone();
}
"https://api.example.com".to_string()
}
#[must_use]
pub fn get_api_config(&self) -> Option<&ApiConfig> {
self.global_config
.and_then(|config| config.api_configs.get(&self.spec.name))
}
}
fn extract_template_variables(url: &str) -> Vec<String> {
let mut template_vars = Vec::new();
let mut start = 0;
while let Some(open) = url[start..].find('{') {
let open_pos = start + open;
let Some(close) = url[open_pos..].find('}') else {
break;
};
let close_pos = open_pos + close;
let var_name = &url[open_pos + 1..close_pos];
if !var_name.is_empty() {
template_vars.push(var_name.to_string());
}
start = close_pos + 1;
}
template_vars
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::models::{CachedSpec, ServerVariable};
use crate::error::ErrorKind;
use std::collections::HashMap;
use std::sync::Mutex;
static ENV_TEST_MUTEX: Mutex<()> = Mutex::new(());
fn create_test_spec(name: &str, base_url: Option<&str>) -> CachedSpec {
CachedSpec {
cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
name: name.to_string(),
version: "1.0.0".to_string(),
commands: vec![],
base_url: base_url.map(std::string::ToString::to_string),
servers: base_url.map(|s| vec![s.to_string()]).unwrap_or_default(),
security_schemes: HashMap::new(),
skipped_endpoints: vec![],
server_variables: HashMap::new(),
}
}
fn create_test_spec_with_variables(name: &str, base_url: Option<&str>) -> CachedSpec {
let mut server_variables = HashMap::new();
server_variables.insert(
"region".to_string(),
ServerVariable {
default: Some("us".to_string()),
enum_values: vec!["us".to_string(), "eu".to_string(), "ap".to_string()],
description: Some("API region".to_string()),
},
);
server_variables.insert(
"env".to_string(),
ServerVariable {
default: None,
enum_values: vec!["dev".to_string(), "staging".to_string(), "prod".to_string()],
description: Some("Environment".to_string()),
},
);
CachedSpec {
cache_format_version: crate::cache::models::CACHE_FORMAT_VERSION,
name: name.to_string(),
version: "1.0.0".to_string(),
commands: vec![],
base_url: base_url.map(std::string::ToString::to_string),
servers: base_url.map(|s| vec![s.to_string()]).unwrap_or_default(),
security_schemes: HashMap::new(),
skipped_endpoints: vec![],
server_variables,
}
}
fn test_with_env_isolation<F>(test_fn: F)
where
F: FnOnce() + std::panic::UnwindSafe,
{
let guard = ENV_TEST_MUTEX.lock().unwrap();
let original_value = std::env::var(crate::constants::ENV_APERTURE_BASE_URL).ok();
std::env::remove_var(crate::constants::ENV_APERTURE_BASE_URL);
let result = std::panic::catch_unwind(test_fn);
if let Some(original) = original_value {
std::env::set_var(crate::constants::ENV_APERTURE_BASE_URL, original);
} else {
std::env::remove_var(crate::constants::ENV_APERTURE_BASE_URL);
}
drop(guard);
if let Err(panic_info) = result {
std::panic::resume_unwind(panic_info);
}
}
#[test]
fn test_priority_1_explicit_url() {
test_with_env_isolation(|| {
let spec = create_test_spec("test-api", Some("https://spec.example.com"));
let resolver = BaseUrlResolver::new(&spec);
assert_eq!(
resolver.resolve(Some("https://explicit.example.com")),
"https://explicit.example.com"
);
});
}
#[test]
fn test_priority_2_api_config_override() {
test_with_env_isolation(|| {
let spec = create_test_spec("test-api", Some("https://spec.example.com"));
let mut api_configs = HashMap::new();
api_configs.insert(
"test-api".to_string(),
ApiConfig {
base_url_override: Some("https://config.example.com".to_string()),
environment_urls: HashMap::new(),
strict_mode: false,
secrets: HashMap::new(),
command_mapping: None,
},
);
let global_config = GlobalConfig {
api_configs,
..Default::default()
};
let resolver = BaseUrlResolver::new(&spec).with_global_config(&global_config);
assert_eq!(resolver.resolve(None), "https://config.example.com");
});
}
#[test]
fn test_priority_2_environment_specific() {
test_with_env_isolation(|| {
let spec = create_test_spec("test-api", Some("https://spec.example.com"));
let mut environment_urls = HashMap::new();
environment_urls.insert(
"staging".to_string(),
"https://staging.example.com".to_string(),
);
environment_urls.insert("prod".to_string(), "https://prod.example.com".to_string());
let mut api_configs = HashMap::new();
api_configs.insert(
"test-api".to_string(),
ApiConfig {
base_url_override: Some("https://config.example.com".to_string()),
environment_urls,
strict_mode: false,
secrets: HashMap::new(),
command_mapping: None,
},
);
let global_config = GlobalConfig {
api_configs,
..Default::default()
};
let resolver = BaseUrlResolver::new(&spec)
.with_global_config(&global_config)
.with_environment(Some("staging".to_string()));
assert_eq!(resolver.resolve(None), "https://staging.example.com");
});
}
#[test]
fn test_priority_config_override_beats_env_var() {
test_with_env_isolation(|| {
let spec = create_test_spec("test-api", Some("https://spec.example.com"));
std::env::set_var(
crate::constants::ENV_APERTURE_BASE_URL,
"https://env.example.com",
);
let mut api_configs = HashMap::new();
api_configs.insert(
"test-api".to_string(),
ApiConfig {
base_url_override: Some("https://config.example.com".to_string()),
environment_urls: HashMap::new(),
strict_mode: false,
secrets: HashMap::new(),
command_mapping: None,
},
);
let global_config = GlobalConfig {
api_configs,
..Default::default()
};
let resolver = BaseUrlResolver::new(&spec).with_global_config(&global_config);
assert_eq!(resolver.resolve(None), "https://config.example.com");
});
}
#[test]
fn test_priority_3_env_var() {
test_with_env_isolation(|| {
let spec = create_test_spec("test-api", Some("https://spec.example.com"));
std::env::set_var(
crate::constants::ENV_APERTURE_BASE_URL,
"https://env.example.com",
);
let resolver = BaseUrlResolver::new(&spec);
assert_eq!(resolver.resolve(None), "https://env.example.com");
});
}
#[test]
fn test_priority_4_spec_default() {
test_with_env_isolation(|| {
let spec = create_test_spec("test-api", Some("https://spec.example.com"));
let resolver = BaseUrlResolver::new(&spec);
assert_eq!(resolver.resolve(None), "https://spec.example.com");
});
}
#[test]
fn test_priority_5_fallback() {
test_with_env_isolation(|| {
let spec = create_test_spec("test-api", None);
let resolver = BaseUrlResolver::new(&spec);
assert_eq!(resolver.resolve(None), "https://api.example.com");
});
}
#[test]
fn test_server_variable_resolution_with_all_provided() {
test_with_env_isolation(|| {
let spec = create_test_spec_with_variables(
"test-api",
Some("https://{region}-{env}.api.example.com"),
);
let resolver = BaseUrlResolver::new(&spec);
let server_vars = vec!["region=eu".to_string(), "env=staging".to_string()];
let result = resolver.resolve_with_variables(None, &server_vars).unwrap();
assert_eq!(result, "https://eu-staging.api.example.com");
});
}
#[test]
fn test_server_variable_resolution_with_defaults() {
test_with_env_isolation(|| {
let spec = create_test_spec_with_variables(
"test-api",
Some("https://{region}-{env}.api.example.com"),
);
let resolver = BaseUrlResolver::new(&spec);
let server_vars = vec!["env=prod".to_string()];
let result = resolver.resolve_with_variables(None, &server_vars).unwrap();
assert_eq!(result, "https://us-prod.api.example.com");
});
}
#[test]
fn test_server_variable_resolution_missing_required() {
test_with_env_isolation(|| {
let spec = create_test_spec_with_variables(
"test-api",
Some("https://{region}-{env}.api.example.com"),
);
let resolver = BaseUrlResolver::new(&spec);
let server_vars = vec!["region=us".to_string()];
let result = resolver.resolve_with_variables(None, &server_vars);
assert!(result.is_err());
});
}
#[test]
fn test_server_variable_resolution_invalid_enum() {
test_with_env_isolation(|| {
let spec = create_test_spec_with_variables(
"test-api",
Some("https://{region}-{env}.api.example.com"),
);
let resolver = BaseUrlResolver::new(&spec);
let server_vars = vec!["region=invalid".to_string(), "env=prod".to_string()];
let result = resolver.resolve_with_variables(None, &server_vars);
assert!(result.is_err());
});
}
#[test]
fn test_non_template_url_with_server_variables() {
test_with_env_isolation(|| {
let spec = create_test_spec_with_variables("test-api", Some("https://api.example.com"));
let resolver = BaseUrlResolver::new(&spec);
let server_vars = vec!["region=eu".to_string(), "env=prod".to_string()];
let result = resolver.resolve_with_variables(None, &server_vars).unwrap();
assert_eq!(result, "https://api.example.com");
});
}
#[test]
fn test_no_server_variables_defined() {
test_with_env_isolation(|| {
let spec = create_test_spec("test-api", Some("https://{region}.api.example.com"));
let resolver = BaseUrlResolver::new(&spec);
let server_vars = vec!["region=eu".to_string()];
let result = resolver.resolve_with_variables(None, &server_vars);
assert!(result.is_err());
match result.unwrap_err() {
Error::Internal {
kind: ErrorKind::ServerVariable,
message,
..
} => {
assert!(message.contains("region"));
}
_ => panic!("Expected Internal ServerVariable error"),
}
});
}
#[test]
fn test_server_variable_fallback_compatibility() {
test_with_env_isolation(|| {
let spec = create_test_spec_with_variables(
"test-api",
Some("https://{region}-{env}.api.example.com"),
);
let resolver = BaseUrlResolver::new(&spec);
let result = resolver.resolve(None);
assert_eq!(result, "https://{region}-{env}.api.example.com");
});
}
#[test]
fn test_server_variable_with_config_override() {
test_with_env_isolation(|| {
let spec =
create_test_spec_with_variables("test-api", Some("https://{region}.original.com"));
let mut api_configs = HashMap::new();
api_configs.insert(
"test-api".to_string(),
ApiConfig {
base_url_override: Some("https://{region}-override.example.com".to_string()),
environment_urls: HashMap::new(),
strict_mode: false,
secrets: HashMap::new(),
command_mapping: None,
},
);
let global_config = GlobalConfig {
api_configs,
..Default::default()
};
let resolver = BaseUrlResolver::new(&spec).with_global_config(&global_config);
let server_vars = vec!["env=prod".to_string()]; let result = resolver.resolve_with_variables(None, &server_vars).unwrap();
assert_eq!(result, "https://us-override.example.com");
});
}
#[test]
fn test_malformed_templates_pass_through() {
test_with_env_isolation(|| {
let spec = create_test_spec("test-api", Some("https://api.example.com/path{}"));
let resolver = BaseUrlResolver::new(&spec);
let result = resolver.resolve_with_variables(None, &[]).unwrap();
assert_eq!(result, "https://api.example.com/path{}");
});
}
#[test]
fn test_backward_compatibility_no_server_vars_non_template() {
test_with_env_isolation(|| {
let spec = create_test_spec("test-api", Some("https://api.example.com"));
let resolver = BaseUrlResolver::new(&spec);
let result = resolver.resolve_with_variables(None, &[]).unwrap();
assert_eq!(result, "https://api.example.com");
});
}
}