use crate::constants;
use crate::error::Error;
use openapiv3::OpenAPI;
use regex::Regex;
fn preprocess_for_compatibility(content: &str) -> String {
const BOOLEAN_PROPERTIES: &[&str] = &[
constants::FIELD_DEPRECATED,
constants::FIELD_REQUIRED,
constants::FIELD_READ_ONLY,
constants::FIELD_WRITE_ONLY,
constants::FIELD_NULLABLE,
constants::FIELD_UNIQUE_ITEMS,
constants::FIELD_ALLOW_EMPTY_VALUE,
constants::FIELD_EXPLODE,
constants::FIELD_ALLOW_RESERVED,
constants::FIELD_EXCLUSIVE_MINIMUM,
constants::FIELD_EXCLUSIVE_MAXIMUM,
];
let is_json = content.trim_start().starts_with('{');
let mut result = content.to_string();
if is_json {
return fix_json_boolean_values(result, BOOLEAN_PROPERTIES);
}
result = fix_yaml_boolean_values(result, BOOLEAN_PROPERTIES);
if result.contains('"') {
result = fix_json_boolean_values(result, BOOLEAN_PROPERTIES);
}
result
}
fn fix_yaml_boolean_values(mut content: String, properties: &[&str]) -> String {
for property in properties {
let pattern_0 = Regex::new(&format!(r"\b{property}: 0\b"))
.expect("Regex pattern is hardcoded and valid");
let pattern_1 = Regex::new(&format!(r"\b{property}: 1\b"))
.expect("Regex pattern is hardcoded and valid");
content = pattern_0
.replace_all(&content, &format!("{property}: false"))
.to_string();
content = pattern_1
.replace_all(&content, &format!("{property}: true"))
.to_string();
}
content
}
fn fix_json_boolean_values(mut content: String, properties: &[&str]) -> String {
for property in properties {
let pattern_0 =
Regex::new(&format!(r#""{property}"\s*:\s*0\b"#)).expect("regex pattern is valid");
let pattern_1 =
Regex::new(&format!(r#""{property}"\s*:\s*1\b"#)).expect("regex pattern is valid");
content = pattern_0
.replace_all(&content, &format!(r#""{property}":false"#))
.to_string();
content = pattern_1
.replace_all(&content, &format!(r#""{property}":true"#))
.to_string();
}
content
}
fn fix_component_indentation(content: &str) -> String {
let mut result = content.to_string();
let component_sections = [
constants::COMPONENT_SCHEMAS,
constants::COMPONENT_RESPONSES,
constants::COMPONENT_EXAMPLES,
constants::COMPONENT_PARAMETERS,
constants::COMPONENT_REQUEST_BODIES,
constants::COMPONENT_HEADERS,
constants::COMPONENT_SECURITY_SCHEMES,
constants::COMPONENT_LINKS,
constants::COMPONENT_CALLBACKS,
];
for section in &component_sections {
result = result.replace(&format!("\n {section}:"), &format!("\n {section}:"));
}
result
}
pub fn parse_openapi(content: &str) -> Result<OpenAPI, Error> {
let mut preprocessed = preprocess_for_compatibility(content);
if content.contains("openapi: 3.1")
|| content.contains("openapi: \"3.1")
|| content.contains("openapi: '3.1")
|| content.contains(r#""openapi":"3.1"#)
|| content.contains(r#""openapi": "3.1"#)
{
preprocessed = fix_component_indentation(&preprocessed);
match parse_with_oas3_direct_with_original(&preprocessed, content) {
Ok(spec) => return Ok(spec),
#[cfg(not(feature = "openapi31"))]
Err(e) => return Err(e), #[cfg(feature = "openapi31")]
Err(_) => {} }
}
let trimmed = content.trim();
if trimmed.starts_with('{') {
parse_json_with_fallback(&preprocessed)
} else {
parse_yaml_with_fallback(&preprocessed)
}
}
fn parse_json_with_fallback(content: &str) -> Result<OpenAPI, Error> {
match serde_json::from_str::<OpenAPI>(content) {
Ok(spec) => Ok(spec),
Err(json_err) => {
if let Ok(spec) = serde_yaml::from_str::<OpenAPI>(content) {
return Ok(spec);
}
Err(Error::serialization_error(format!(
"Failed to parse OpenAPI spec as JSON: {json_err}"
)))
}
}
}
fn parse_yaml_with_fallback(content: &str) -> Result<OpenAPI, Error> {
match serde_yaml::from_str::<OpenAPI>(content) {
Ok(spec) => Ok(spec),
Err(yaml_err) => {
if let Ok(spec) = serde_json::from_str::<OpenAPI>(content) {
return Ok(spec);
}
Err(Error::Yaml(yaml_err))
}
}
}
#[cfg(feature = "openapi31")]
fn parse_with_oas3_direct_with_original(
preprocessed: &str,
original: &str,
) -> Result<OpenAPI, Error> {
let security_schemes_from_yaml = extract_security_schemes_from_yaml(original);
let oas3_spec = match oas3::from_yaml(preprocessed) {
Ok(spec) => spec,
Err(_yaml_err) => {
oas3::from_json(preprocessed).map_err(|e| {
Error::serialization_error(format!(
"Failed to parse OpenAPI 3.1 spec as YAML or JSON: {e}"
))
})?
}
};
eprintln!(
"{} OpenAPI 3.1 specification detected. Using compatibility mode.",
crate::constants::MSG_WARNING_PREFIX
);
eprintln!(" Some 3.1-specific features may not be available.");
let json = oas3::to_json(&oas3_spec).map_err(|e| {
Error::serialization_error(format!("Failed to serialize OpenAPI 3.1 spec: {e}"))
})?;
let mut spec = serde_json::from_str::<OpenAPI>(&json).map_err(|e| {
Error::validation_error(format!(
"OpenAPI 3.1 spec contains features incompatible with 3.0: {e}. \
Consider converting the spec to OpenAPI 3.0 format."
))
})?;
restore_security_schemes(&mut spec, security_schemes_from_yaml);
Ok(spec)
}
#[cfg(feature = "openapi31")]
fn restore_security_schemes(
spec: &mut OpenAPI,
security_schemes: Option<
indexmap::IndexMap<String, openapiv3::ReferenceOr<openapiv3::SecurityScheme>>,
>,
) {
if let Some(schemes) = security_schemes {
match spec.components {
Some(ref mut components) => {
components.security_schemes = schemes;
}
None => {
let mut components = openapiv3::Components::default();
components.security_schemes = schemes;
spec.components = Some(components);
}
}
}
}
#[cfg(feature = "openapi31")]
fn extract_security_schemes_from_yaml(
content: &str,
) -> Option<indexmap::IndexMap<String, openapiv3::ReferenceOr<openapiv3::SecurityScheme>>> {
let value = parse_content_as_value(content)?;
let security_schemes = value.get("components")?.get("securitySchemes")?;
serde_yaml::from_value(security_schemes.clone()).ok()
}
#[cfg(feature = "openapi31")]
fn parse_content_as_value(content: &str) -> Option<serde_yaml::Value> {
if let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(content) {
return Some(value);
}
serde_json::from_str::<serde_json::Value>(content)
.ok()
.and_then(|json| serde_yaml::to_value(json).ok())
}
#[cfg(not(feature = "openapi31"))]
fn parse_with_oas3_direct_with_original(
_preprocessed: &str,
_original: &str,
) -> Result<OpenAPI, Error> {
Err(Error::validation_error(
"OpenAPI 3.1 support is not enabled. Rebuild with --features openapi31 to enable 3.1 support."
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_openapi_30() {
let spec_30 = r"
openapi: 3.0.0
info:
title: Test API
version: 1.0.0
paths: {}
";
let result = parse_openapi(spec_30);
assert!(result.is_ok());
let spec = result.unwrap();
assert_eq!(spec.openapi, "3.0.0");
}
#[test]
fn test_parse_openapi_31() {
let spec_31 = r"
openapi: 3.1.0
info:
title: Test API
version: 1.0.0
paths: {}
";
let result = parse_openapi(spec_31);
#[cfg(feature = "openapi31")]
{
assert!(result.is_ok());
if let Ok(spec) = result {
assert!(spec.openapi.starts_with("3."));
}
}
#[cfg(not(feature = "openapi31"))]
{
assert!(result.is_err());
if let Err(Error::Internal {
kind: crate::error::ErrorKind::Validation,
message,
..
}) = result
{
assert!(message.contains("OpenAPI 3.1 support is not enabled"));
} else {
panic!("Expected validation error about missing 3.1 support");
}
}
}
#[test]
fn test_parse_invalid_yaml() {
let invalid_yaml = "not: valid: yaml: at: all:";
let result = parse_openapi(invalid_yaml);
assert!(result.is_err());
}
#[test]
fn test_preprocess_boolean_values() {
let input = r"
deprecated: 0
required: 1
readOnly: 0
writeOnly: 1
";
let result = preprocess_for_compatibility(input);
assert!(result.contains("deprecated: false"));
assert!(result.contains("required: true"));
assert!(result.contains("readOnly: false"));
assert!(result.contains("writeOnly: true"));
}
#[test]
fn test_preprocess_exclusive_min_max() {
let input = r"
exclusiveMinimum: 0
exclusiveMaximum: 1
exclusiveMinimum: 10
exclusiveMaximum: 18
exclusiveMinimum: 100
";
let result = preprocess_for_compatibility(input);
assert!(result.contains("exclusiveMinimum: false"));
assert!(result.contains("exclusiveMaximum: true"));
assert!(result.contains("exclusiveMinimum: 10"));
assert!(result.contains("exclusiveMaximum: 18"));
assert!(result.contains("exclusiveMinimum: 100"));
}
#[test]
fn test_preprocess_json_format() {
let input = r#"{"deprecated":0,"required":1,"exclusiveMinimum":0,"exclusiveMaximum":1,"otherValue":10}"#;
let result = preprocess_for_compatibility(input);
assert!(result.contains(r#""deprecated":false"#));
assert!(result.contains(r#""required":true"#));
assert!(result.contains(r#""exclusiveMinimum":false"#));
assert!(result.contains(r#""exclusiveMaximum":true"#));
assert!(result.contains(r#""otherValue":10"#)); }
#[test]
fn test_preprocess_preserves_multi_digit_numbers() {
let input = r"
paths:
/test:
get:
parameters:
- name: test
in: query
schema:
type: integer
minimum: 10
maximum: 100
exclusiveMinimum: 18
";
let result = preprocess_for_compatibility(input);
assert!(result.contains("minimum: 10"));
assert!(result.contains("maximum: 100"));
assert!(result.contains("exclusiveMinimum: 18"));
assert!(!result.contains("true0"));
assert!(!result.contains("true8"));
assert!(!result.contains("true00"));
assert!(!result.contains("false0"));
}
}