pub fn is_remote_spec(input: &str) -> bool {
url::Url::parse(input).is_ok_and(|url| matches!(url.scheme(), "https" | "http"))
}
pub fn validate_remote_spec_url(input: &str) -> Result<url::Url, String> {
let url =
url::Url::parse(input).map_err(|error| format!("invalid remote OpenAPI URL: {error}"))?;
if !url.username().is_empty() || url.password().is_some() {
return Err("remote OpenAPI URLs must not contain embedded credentials".to_string());
}
match url.scheme() {
"https" => Ok(url),
"http" if is_loopback_host(url.host_str()) => Ok(url),
"http" => Err(
"remote OpenAPI URLs must use HTTPS (plain HTTP is allowed only for localhost/loopback)"
.to_string(),
),
scheme => Err(format!(
"unsupported OpenAPI URL scheme `{scheme}`; use HTTPS or a local file path"
)),
}
}
pub fn sanitize_source_provenance(input: &str) -> String {
let sanitize_controls = |value: &str| {
value
.chars()
.map(|character| {
if character.is_control() {
'�'
} else {
character
}
})
.collect::<String>()
};
let Ok(mut url) = url::Url::parse(input) else {
return sanitize_controls(input);
};
if !matches!(url.scheme(), "https" | "http") {
return sanitize_controls(input);
}
let query_was_redacted = url.query().is_some();
let _ = url.set_username("");
let _ = url.set_password(None);
url.set_query(None);
url.set_fragment(None);
let mut label = url.to_string();
if query_was_redacted {
label.push_str(" (query redacted)");
}
sanitize_controls(&label)
}
fn is_loopback_host(host: Option<&str>) -> bool {
match host {
Some("localhost") => true,
Some(host) => host
.parse::<std::net::IpAddr>()
.is_ok_and(|address| address.is_loopback()),
None => false,
}
}
pub fn parse_oas_version(s: &str) -> Option<(u32, u32)> {
let mut parts = s.split('.');
let major = parts.next()?.parse().ok()?;
let minor_raw = parts.next()?;
let minor_digits: String = minor_raw
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
let minor = minor_digits.parse().ok()?;
Some((major, minor))
}
pub fn parse_spec(
content: &str,
input: &str,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let is_yaml = input.ends_with(".yaml")
|| input.ends_with(".yml")
|| content.trim_start().starts_with("openapi:")
|| content.trim_start().starts_with("swagger:");
if is_yaml {
let value = yaml_to_json_value(content)?;
Ok(value)
} else {
let value = json_from_str_lossy(content)?;
Ok(value)
}
}
pub fn yaml_to_json_value(content: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let preprocessed = sanitize_large_yaml_integers(content);
let yaml_value: serde_yaml::Value = serde_yaml::from_str(&preprocessed)?;
Ok(yaml_value_to_json(yaml_value))
}
pub fn json_from_str_lossy(content: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
match serde_json::from_str::<serde_json::Value>(content) {
Ok(v) => Ok(v),
Err(e) => {
let err_msg = e.to_string();
if err_msg.contains("number out of range") {
let yaml_value: serde_yaml::Value = serde_yaml::from_str(content)?;
Ok(yaml_value_to_json(yaml_value))
} else {
Err(e.into())
}
}
}
}
fn yaml_value_to_json(yaml: serde_yaml::Value) -> serde_json::Value {
match yaml {
serde_yaml::Value::Null => serde_json::Value::Null,
serde_yaml::Value::Bool(b) => serde_json::Value::Bool(b),
serde_yaml::Value::Number(n) => {
if let Some(i) = n.as_i64() {
serde_json::Value::Number(i.into())
} else if let Some(u) = n.as_u64() {
serde_json::Value::Number(u.into())
} else if let Some(f) = n.as_f64() {
serde_json::json!(f)
} else {
serde_json::json!(0.0)
}
}
serde_yaml::Value::String(s) => serde_json::Value::String(s),
serde_yaml::Value::Sequence(seq) => {
serde_json::Value::Array(seq.into_iter().map(yaml_value_to_json).collect())
}
serde_yaml::Value::Mapping(map) => {
let obj = map
.into_iter()
.filter_map(|(k, v)| {
let key = match k {
serde_yaml::Value::String(s) => s,
serde_yaml::Value::Number(n) => n.to_string(),
serde_yaml::Value::Bool(b) => b.to_string(),
_ => return None,
};
Some((key, yaml_value_to_json(v)))
})
.collect();
serde_json::Value::Object(obj)
}
serde_yaml::Value::Tagged(tagged) => yaml_value_to_json(tagged.value),
}
}
fn sanitize_large_yaml_integers(content: &str) -> String {
let mut result = String::with_capacity(content.len());
for line in content.lines() {
if let Some(sanitized) = try_sanitize_integer_line(line) {
result.push_str(&sanitized);
} else {
result.push_str(line);
}
result.push('\n');
}
result
}
fn try_sanitize_integer_line(line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
let colon_pos = line.find(": ")?;
let value_start = colon_pos + 2;
let value_str = line[value_start..].trim();
if value_str.is_empty() {
return None;
}
let (is_negative, digit_part) = if let Some(rest) = value_str.strip_prefix('-') {
(true, rest)
} else {
(false, value_str)
};
if !digit_part.chars().all(|c| c.is_ascii_digit()) || digit_part.is_empty() {
return None;
}
let overflows = if is_negative {
digit_part.len() > 19 || (digit_part.len() == 19 && digit_part > "9223372036854775808")
} else {
digit_part.len() > 20 || (digit_part.len() == 20 && digit_part > "18446744073709551615")
};
if overflows {
let mut sanitized = line[..value_start].to_string();
sanitized.push_str(value_str);
sanitized.push_str(".0");
Some(sanitized)
} else {
None
}
}
pub fn validate_oas_document(value: &serde_json::Value) -> Result<Option<String>, String> {
let version = value
.get("openapi")
.and_then(|value| value.as_str())
.unwrap_or("");
match parse_oas_version(version) {
Some((3, 0 | 1)) => Ok(None),
Some((3, 2)) => Ok(Some(format!(
"OpenAPI {version} support is experimental; some 3.2-only features are not generated"
))),
Some((major, minor)) => Err(format!(
"unsupported OpenAPI version {major}.{minor} ({version:?}); expected 3.0, 3.1, or experimental 3.2"
)),
None => {
let hint = if value.get("swagger").is_some() {
" (the document appears to be Swagger 2.0)"
} else {
""
};
Err(format!("missing or unrecognized `openapi` version{hint}"))
}
}
}