use std::{collections::{BTreeMap, BTreeSet}, fs, path::{Path, PathBuf}};
use serde_json::Value;
use crate::{
contract::{
ContractDiscriminatorBranch, ContractDocument, ContractOperation, ContractParameter,
ContractParameterShape, ContractRequestBody, ContractResponseBody,
ContractResponseSchema, ContractScalarDefinition, ContractSchemaDefinition,
ContractSchemaField, ContractSchemaShape,
ContractSecurityRequirement, ContractSecuritySchemeKind, ContractTypeRef,
},
error::{HenError, HenErrorKind, HenResult},
};
use super::{
OpenApiCapabilityDiagnostic, OpenApiImportBoundary, OpenApiImportPlan, OpenApiSupportLevel,
};
const HTTP_METHODS: &[&str] = &[
"delete", "get", "head", "options", "patch", "post", "put", "trace",
];
enum ComponentDefinition {
Scalar(ContractScalarDefinition),
Schema(ContractSchemaDefinition),
}
#[derive(Default)]
struct SyntheticScalarRegistry {
names: BTreeSet<String>,
expression_names: BTreeMap<String, String>,
generated: Vec<ContractScalarDefinition>,
generated_schemas: Vec<ContractSchemaDefinition>,
}
impl SyntheticScalarRegistry {
fn for_root(root: &Value) -> Self {
let mut names = BTreeSet::new();
if let Some(component_schemas) = root
.get("components")
.and_then(|components| components.get("schemas"))
.and_then(Value::as_object)
{
names.extend(component_schemas.keys().cloned());
}
Self {
names,
expression_names: BTreeMap::new(),
generated: Vec::new(),
generated_schemas: Vec::new(),
}
}
fn register_existing(&mut self, definition: &ContractScalarDefinition) {
self.names.insert(definition.name.clone());
self.expression_names
.entry(definition.expression.clone())
.or_insert_with(|| definition.name.clone());
}
fn synthesize(&mut self, context: &str, expression: String) -> String {
if let Some(existing) = self.expression_names.get(expression.as_str()) {
return existing.clone();
}
let root = synthetic_scalar_base_name(context);
let root = if root.is_empty() {
"ImportedScalar".to_string()
} else {
root
};
let mut candidate = root.clone();
let mut counter = 2;
while self.names.contains(&candidate) {
candidate = format!("{}{}", root, counter);
counter += 1;
}
self.names.insert(candidate.clone());
self.expression_names.insert(expression.clone(), candidate.clone());
self.generated.push(ContractScalarDefinition {
name: candidate.clone(),
expression,
});
candidate
}
fn synthesize_schema(&mut self, context: &str, shape: ContractSchemaShape) -> String {
let root = synthetic_scalar_base_name(context);
let root = if root.is_empty() {
"ImportedSchema".to_string()
} else {
root
};
let mut candidate = root.clone();
let mut counter = 2;
while self.names.contains(&candidate) {
candidate = format!("{}{}", root, counter);
counter += 1;
}
self.names.insert(candidate.clone());
self.generated_schemas.push(ContractSchemaDefinition {
name: candidate.clone(),
shape,
});
candidate
}
fn into_generated_parts(self) -> (Vec<ContractScalarDefinition>, Vec<ContractSchemaDefinition>) {
(self.generated, self.generated_schemas)
}
}
#[derive(Debug, Clone)]
enum ResolvedSecurityScheme {
ApiKeyHeader { name: String },
ApiKeyQuery { name: String },
ApiKeyCookie { name: String },
HttpBasic,
HttpBearer,
OpenIdConnect { discovery_url: String },
OAuthBearerPlaceholder { flow_name: String },
OAuthClientCredentials { token_url: String },
Unsupported { message: String },
}
pub fn load_import_plan(path: &Path) -> HenResult<OpenApiImportPlan> {
let source = fs::read_to_string(path).map_err(|err| {
HenError::new(
HenErrorKind::Io,
format!("Failed to read OpenAPI spec {}", path.display()),
)
.with_detail(err.to_string())
})?;
let root = parse_root(&source, path)?;
build_import_plan(&root, path)
}
fn parse_root(source: &str, path: &Path) -> HenResult<Value> {
let root = parse_openapi_value(source, path)?;
let normalized_path = normalize_doc_path(path);
let mut documents = BTreeMap::new();
documents.insert(normalized_path.clone(), root.clone());
resolve_external_refs(root, &normalized_path, &mut documents, &mut Vec::new(), false)
}
fn parse_openapi_value(source: &str, path: &Path) -> HenResult<Value> {
serde_json::from_str(source)
.or_else(|json_err| {
serde_yaml::from_str(source).map_err(|yaml_err| {
HenError::new(HenErrorKind::Parse, "Failed to parse OpenAPI spec")
.with_detail(format!("Path: {}", path.display()))
.with_detail(format!("JSON parse error: {json_err}"))
.with_detail(format!("YAML parse error: {yaml_err}"))
})
})
}
fn resolve_external_refs(
value: Value,
current_doc_path: &Path,
documents: &mut BTreeMap<PathBuf, Value>,
resolution_stack: &mut Vec<(PathBuf, String)>,
inline_internal_refs: bool,
) -> HenResult<Value> {
match value {
Value::Array(values) => Ok(Value::Array(
values
.into_iter()
.map(|entry| {
resolve_external_refs(
entry,
current_doc_path,
documents,
resolution_stack,
inline_internal_refs,
)
})
.collect::<HenResult<Vec<_>>>()?,
)),
Value::Object(object) => {
if let Some(reference) = object
.get("$ref")
.and_then(Value::as_str)
.map(str::to_owned)
{
if reference.starts_with('#') {
if inline_internal_refs {
return resolve_reference_object(
object,
&reference,
current_doc_path,
current_doc_path,
documents,
resolution_stack,
inline_internal_refs,
);
}
} else {
let target_doc_path = resolve_external_ref_path(current_doc_path, &reference);
return resolve_reference_object(
object,
&reference,
current_doc_path,
&target_doc_path,
documents,
resolution_stack,
inline_internal_refs,
);
}
}
let mut resolved = serde_json::Map::new();
for (key, value) in object {
resolved.insert(
key,
resolve_external_refs(
value,
current_doc_path,
documents,
resolution_stack,
inline_internal_refs,
)?,
);
}
Ok(Value::Object(resolved))
}
other => Ok(other),
}
}
fn resolve_reference_object(
object: serde_json::Map<String, Value>,
reference: &str,
source_doc_path: &Path,
target_doc_path: &Path,
documents: &mut BTreeMap<PathBuf, Value>,
resolution_stack: &mut Vec<(PathBuf, String)>,
inline_internal_refs: bool,
) -> HenResult<Value> {
ensure_document_loaded(target_doc_path, documents)?;
let normalized_target_path = normalize_doc_path(target_doc_path);
let document = documents.get(&normalized_target_path).cloned().ok_or_else(|| {
HenError::new(
HenErrorKind::Input,
"Referenced OpenAPI document cache was unexpectedly missing",
)
})?;
let stack_key = (normalized_target_path.clone(), reference.to_string());
if resolution_stack.contains(&stack_key) {
return Err(
HenError::new(
HenErrorKind::Input,
format!("Cyclic OpenAPI $ref is not supported: {reference}"),
)
.with_detail(format!("While resolving {}", normalized_target_path.display())),
);
}
resolution_stack.push(stack_key);
let target_value = resolve_reference_target(&document, reference, &normalized_target_path)?;
let resolved_target = resolve_external_refs(
target_value,
&normalized_target_path,
documents,
resolution_stack,
true,
)?;
resolution_stack.pop();
let sibling_entries = object
.into_iter()
.filter(|(key, _)| key != "$ref")
.map(|(key, value)| {
Ok((
key,
resolve_external_refs(
value,
source_doc_path,
documents,
resolution_stack,
inline_internal_refs,
)?,
))
})
.collect::<HenResult<Vec<_>>>()?;
if sibling_entries.is_empty() {
return Ok(resolved_target);
}
let Value::Object(mut resolved_object) = resolved_target else {
return Err(
HenError::new(
HenErrorKind::Input,
format!("OpenAPI $ref with sibling fields must resolve to an object: {reference}"),
)
.with_detail(format!("In {}", source_doc_path.display())),
);
};
for (key, value) in sibling_entries {
resolved_object.insert(key, value);
}
Ok(Value::Object(resolved_object))
}
fn ensure_document_loaded(path: &Path, documents: &mut BTreeMap<PathBuf, Value>) -> HenResult<()> {
let normalized_path = normalize_doc_path(path);
if documents.contains_key(&normalized_path) {
return Ok(());
}
let source = fs::read_to_string(&normalized_path).map_err(|error| {
HenError::new(
HenErrorKind::Input,
format!(
"Failed to read referenced OpenAPI document {}",
normalized_path.display()
),
)
.with_detail(error.to_string())
})?;
let document = parse_openapi_value(&source, &normalized_path)?;
documents.insert(normalized_path, document);
Ok(())
}
fn resolve_reference_target(document: &Value, reference: &str, path: &Path) -> HenResult<Value> {
let Some((_, fragment)) = reference.split_once('#') else {
return Ok(document.clone());
};
if fragment.is_empty() {
return Ok(document.clone());
}
if !fragment.starts_with('/') {
return Err(
HenError::new(
HenErrorKind::Input,
format!("Unsupported OpenAPI $ref fragment: {reference}"),
)
.with_detail(format!("In {}", path.display())),
);
}
resolve_document_fragment(document, fragment).map_err(|detail| {
HenError::new(
HenErrorKind::Input,
format!("Unsupported OpenAPI $ref target: {reference}"),
)
.with_detail(format!("In {}", path.display()))
.with_detail(format!("Document shape: {}", describe_value_shape(document)))
.with_detail(detail)
})
}
fn describe_value_shape(value: &Value) -> String {
match value {
Value::Object(entries) => format!(
"object with keys [{}]",
entries.keys().cloned().collect::<Vec<_>>().join(", ")
),
Value::Array(entries) => format!("array with {} entries", entries.len()),
Value::String(_) => "string".to_string(),
Value::Number(_) => "number".to_string(),
Value::Bool(_) => "bool".to_string(),
Value::Null => "null".to_string(),
}
}
fn resolve_document_fragment(document: &Value, fragment: &str) -> Result<Value, String> {
let mut current = document;
for segment in fragment.trim_start_matches('/').split('/') {
let token = segment.replace("~1", "/").replace("~0", "~");
current = match current {
Value::Object(entries) => entries.get(token.as_str()).ok_or_else(|| {
format!(
"Missing object key '{token}' while resolving fragment {fragment}; available keys: [{}]",
entries.keys().cloned().collect::<Vec<_>>().join(", ")
)
})?,
Value::Array(entries) => {
let index = token.parse::<usize>().map_err(|_| {
format!(
"Expected array index while resolving fragment {fragment}, got '{token}'"
)
})?;
entries.get(index).ok_or_else(|| {
format!(
"Array index {index} out of bounds while resolving fragment {fragment}"
)
})?
}
other => {
return Err(format!(
"Cannot descend through {} while resolving fragment {fragment}",
describe_value_shape(other)
));
}
};
}
Ok(current.clone())
}
fn resolve_external_ref_path(current_doc_path: &Path, reference: &str) -> PathBuf {
let relative_path = reference
.split_once('#')
.map(|(path, _)| path)
.unwrap_or(reference);
let base_directory = current_doc_path.parent().unwrap_or_else(|| Path::new("."));
normalize_doc_path(&base_directory.join(relative_path))
}
fn normalize_doc_path(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn build_import_plan(root: &Value, path: &Path) -> HenResult<OpenApiImportPlan> {
let version = root
.get("openapi")
.and_then(Value::as_str)
.ok_or_else(|| {
HenError::new(HenErrorKind::Input, "OpenAPI spec is missing an 'openapi' version")
.with_detail(format!("Path: {}", path.display()))
})?;
if !(version.starts_with("3.0") || version.starts_with("3.1")) {
return Err(
HenError::new(HenErrorKind::Input, "Only OpenAPI 3.0.x and 3.1.x are supported")
.with_detail(format!("Path: {}", path.display()))
.with_detail(format!("Received version: {version}")),
);
}
let paths = root
.get("paths")
.and_then(Value::as_object)
.ok_or_else(|| {
HenError::new(HenErrorKind::Input, "OpenAPI spec is missing a 'paths' object")
.with_detail(format!("Path: {}", path.display()))
})?;
let mut operations = Vec::new();
let mut diagnostics = vec![OpenApiCapabilityDiagnostic {
code: "openapi_contract_only",
level: OpenApiSupportLevel::Supported,
message:
"OpenAPI import is scoped to contract-only materialization into plain .hen requests and schemas."
.to_string(),
}];
let mut synthetic_scalars = SyntheticScalarRegistry::for_root(root);
let (mut scalars, mut schemas) = collect_component_definitions(
root,
&mut diagnostics,
&mut synthetic_scalars,
);
let security_schemes = collect_security_schemes(root);
let document_servers = collect_servers(root.get("servers"));
let default_security = collect_security_requirements(
root.get("security"),
&security_schemes,
&mut diagnostics,
"document",
);
for path_key in sorted_path_keys(paths) {
let Some(raw_path_item) = paths.get(path_key) else {
continue;
};
let Some(resolved_path_item) =
resolve_root_component_alias(raw_path_item, root, &["#/components/pathItems/"])
else {
diagnostics.push(OpenApiCapabilityDiagnostic {
code: "openapi_non_object_path_item",
level: OpenApiSupportLevel::Warning,
message: format!(
"Skipping path '{}' because its value is not an object.",
path_key
),
});
continue;
};
let Some(path_item) = resolved_path_item.as_object() else {
diagnostics.push(OpenApiCapabilityDiagnostic {
code: "openapi_non_object_path_item",
level: OpenApiSupportLevel::Warning,
message: format!(
"Skipping path '{}' because its value is not an object.",
path_key
),
});
continue;
};
let shared_parameters = collect_parameters(root, path_item.get("parameters"), path_key, None);
let path_servers = if path_item.contains_key("servers") {
collect_servers(path_item.get("servers"))
} else {
document_servers.clone()
};
let path_security = if path_item.contains_key("security") {
collect_security_requirements(
path_item.get("security"),
&security_schemes,
&mut diagnostics,
&format!("path {}", path_key),
)
} else {
default_security.clone()
};
for method in HTTP_METHODS {
let Some(operation_value) = path_item.get(*method) else {
continue;
};
let Some(operation) = operation_value.as_object() else {
diagnostics.push(OpenApiCapabilityDiagnostic {
code: "openapi_non_object_operation",
level: OpenApiSupportLevel::Warning,
message: format!(
"Skipping {} {} because the operation value is not an object.",
method.to_uppercase(),
path_key
),
});
continue;
};
let mut parameters = shared_parameters.clone();
parameters.extend(collect_parameters(
root,
operation.get("parameters"),
path_key,
Some(method),
));
let servers = if operation.contains_key("servers") {
collect_servers(operation.get("servers"))
} else {
path_servers.clone()
};
let security = if operation.contains_key("security") {
collect_security_requirements(
operation.get("security"),
&security_schemes,
&mut diagnostics,
&format!("{} {}", method.to_uppercase(), path_key),
)
} else {
path_security.clone()
};
operations.push(ContractOperation {
operation_id: operation
.get("operationId")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
summary: operation
.get("summary")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| {
operation
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}),
method: method.to_uppercase(),
path: path_key.to_string(),
servers,
tags: operation
.get("tags")
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default(),
parameters,
request_body: collect_request_body(
root,
operation.get("requestBody"),
path_key,
method,
&mut diagnostics,
&mut synthetic_scalars,
),
response_body: collect_response_body(
root,
operation.get("responses"),
path_key,
method,
&mut diagnostics,
&mut synthetic_scalars,
),
security,
source_span: None,
});
}
}
operations.sort_by(|left, right| {
normalized_path(&left.path)
.cmp(&normalized_path(&right.path))
.then_with(|| left.method.cmp(&right.method))
.then_with(|| left.operation_id.cmp(&right.operation_id))
});
let (generated_scalars, generated_schemas) = synthetic_scalars.into_generated_parts();
scalars.extend(generated_scalars);
schemas.extend(generated_schemas);
Ok(OpenApiImportPlan {
boundary: Some(OpenApiImportBoundary::ContractOnly),
contract: ContractDocument {
servers: document_servers,
scalars,
schemas,
operations,
},
selected_operations: Vec::new(),
diagnostics,
})
}
fn resolve_root_component_alias(value: &Value, root: &Value, prefixes: &[&str]) -> Option<Value> {
resolve_root_component_alias_inner(value, root, prefixes, &mut Vec::new()).ok()
}
fn resolve_root_component_alias_inner(
value: &Value,
root: &Value,
prefixes: &[&str],
stack: &mut Vec<String>,
) -> HenResult<Value> {
let Some(object) = value.as_object() else {
return Ok(value.clone());
};
let Some(reference) = object.get("$ref").and_then(Value::as_str) else {
return Ok(value.clone());
};
if !prefixes.iter().any(|prefix| reference.starts_with(prefix)) {
return Ok(value.clone());
}
if stack.iter().any(|entry| entry == reference) {
return Err(HenError::new(
HenErrorKind::Input,
format!("Cyclic OpenAPI $ref is not supported: {reference}"),
));
}
stack.push(reference.to_string());
let resolved_target = resolve_reference_target(root, reference, Path::new("<root>"))?;
let resolved_target = resolve_root_component_alias_inner(&resolved_target, root, prefixes, stack)?;
stack.pop();
let sibling_entries = object
.iter()
.filter(|(key, _)| key.as_str() != "$ref")
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<Vec<_>>();
if sibling_entries.is_empty() {
return Ok(resolved_target);
}
let Value::Object(mut resolved_object) = resolved_target else {
return Err(HenError::new(
HenErrorKind::Input,
format!("OpenAPI $ref with sibling fields must resolve to an object: {reference}"),
));
};
for (key, value) in sibling_entries {
resolved_object.insert(key, value);
}
Ok(Value::Object(resolved_object))
}
fn collect_servers(value: Option<&Value>) -> Vec<String> {
value
.and_then(Value::as_array)
.map(|servers| {
servers
.iter()
.filter_map(|server| server.as_object())
.filter_map(|server| server.get("url"))
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn sorted_path_keys<'a>(paths: &'a serde_json::Map<String, Value>) -> Vec<&'a str> {
let mut keys = paths.keys().map(String::as_str).collect::<Vec<_>>();
keys.sort_by_key(|path| normalized_path(path));
keys
}
fn normalized_path(path: &str) -> String {
path.trim().to_string()
}
fn collect_parameters(
root: &Value,
value: Option<&Value>,
_path: &str,
_method: Option<&str>,
) -> Vec<ContractParameter> {
let Some(parameters) = value.and_then(Value::as_array) else {
return Vec::new();
};
parameters
.iter()
.filter_map(|parameter| {
let resolved = resolve_root_component_alias(parameter, root, &["#/components/parameters/"])?;
let object = resolved.as_object()?;
let name = object.get("name")?.as_str()?.to_string();
let location = object.get("in")?.as_str()?.to_string();
let style = object
.get("style")
.and_then(Value::as_str)
.unwrap_or_else(|| default_parameter_style(&location))
.to_string();
let required = object
.get("required")
.and_then(Value::as_bool)
.unwrap_or(location == "path");
let explode = object
.get("explode")
.and_then(Value::as_bool)
.unwrap_or(style == "form");
let allow_reserved = object
.get("allowReserved")
.and_then(Value::as_bool)
.unwrap_or(false);
let shape = object
.get("schema")
.map(parameter_shape_from_schema)
.unwrap_or(ContractParameterShape::Scalar);
let value = parameter_concrete_value(object);
Some(ContractParameter {
name,
location,
required,
style,
explode,
allow_reserved,
shape,
value,
})
})
.collect::<Vec<_>>()
}
fn default_parameter_style(location: &str) -> &'static str {
match location {
"path" | "header" => "simple",
"query" | "cookie" => "form",
_ => "form",
}
}
fn parameter_shape_from_schema(value: &Value) -> ContractParameterShape {
let Some(object) = value.as_object() else {
return ContractParameterShape::Scalar;
};
if schema_is_object(object) {
ContractParameterShape::Object
} else if schema_is_array(object) {
ContractParameterShape::Array
} else {
ContractParameterShape::Scalar
}
}
fn parameter_concrete_value(object: &serde_json::Map<String, Value>) -> Option<String> {
object
.get("example")
.map(example_to_string)
.or_else(|| {
object
.get("examples")
.and_then(Value::as_object)
.and_then(|examples| examples.values().next())
.and_then(Value::as_object)
.and_then(|example| example.get("value"))
.map(example_to_string)
})
.or_else(|| {
object
.get("schema")
.and_then(Value::as_object)
.and_then(|schema| schema.get("default"))
.map(example_to_string)
})
}
fn collect_request_body(
root: &Value,
value: Option<&Value>,
path: &str,
method: &str,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
synthetic_scalars: &mut SyntheticScalarRegistry,
) -> Option<ContractRequestBody> {
let resolved = resolve_root_component_alias(value?, root, &["#/components/requestBodies/"])?;
let object = resolved.as_object()?;
let required = object
.get("required")
.and_then(Value::as_bool)
.unwrap_or(false);
let content = object.get("content")?.as_object()?;
if let Some((content_type, media_type)) = first_json_like_media_type(content) {
let example = media_type
.get("example")
.map(example_to_string)
.or_else(|| {
media_type
.get("examples")
.and_then(Value::as_object)
.and_then(|examples| examples.values().next())
.and_then(Value::as_object)
.and_then(|example| example.get("value"))
.map(example_to_string)
});
let schema = media_type.get("schema").and_then(|schema| {
parse_request_body_schema(
schema,
root,
diagnostics,
&format!("{} {} request body", method.to_uppercase(), path),
synthetic_scalars,
)
});
return Some(ContractRequestBody {
content_type: content_type.to_string(),
example,
schema,
required,
});
}
for supported_content_type in ["application/x-www-form-urlencoded", "multipart/form-data", "text/plain"] {
let Some(media_type) = content.get(supported_content_type).and_then(Value::as_object) else {
continue;
};
let example = media_type
.get("example")
.map(example_to_string)
.or_else(|| {
media_type
.get("examples")
.and_then(Value::as_object)
.and_then(|examples| examples.values().next())
.and_then(Value::as_object)
.and_then(|example| example.get("value"))
.map(example_to_string)
});
let schema = if matches!(supported_content_type, "application/x-www-form-urlencoded" | "multipart/form-data") {
media_type.get("schema").and_then(|schema| {
parse_request_body_schema(
schema,
root,
diagnostics,
&format!("{} {} request body", method.to_uppercase(), path),
synthetic_scalars,
)
})
} else {
None
};
return Some(ContractRequestBody {
content_type: supported_content_type.to_string(),
example,
schema,
required,
});
}
None
}
fn parse_request_body_schema(
value: &Value,
root: &Value,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
context: &str,
synthetic_scalars: &mut SyntheticScalarRegistry,
) -> Option<ContractResponseSchema> {
let object = value.as_object()?;
if object.contains_key("discriminator") || has_unimplemented_schema_composition(object) {
let target = synthesize_inline_schema_target(value, root, diagnostics, context, synthetic_scalars)?;
return Some(ContractResponseSchema::Type(ContractTypeRef::new(target)));
}
if object.contains_key("allOf") {
let fields = parse_all_of_object_fields(value, root, diagnostics, context, synthetic_scalars)?;
return Some(ContractResponseSchema::Object { fields });
}
if schema_is_object(object) {
if schema_nullable(object) {
diagnostics.push(schema_warning(
"openapi_nullable_object_unimplemented",
format!(
"Request body schema for {} uses a nullable top-level object, which is not materialized yet.",
context
),
));
return None;
}
let fields = parse_object_fields(object, diagnostics, context, synthetic_scalars)?;
return Some(ContractResponseSchema::Object { fields });
}
if let Some(expression) = parse_scalar_expression(value, diagnostics, context) {
return Some(ContractResponseSchema::Scalar(expression));
}
parse_type_ref(value, diagnostics, context, synthetic_scalars).map(ContractResponseSchema::Type)
}
fn collect_response_body(
root: &Value,
value: Option<&Value>,
path: &str,
method: &str,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
synthetic_scalars: &mut SyntheticScalarRegistry,
) -> Option<ContractResponseBody> {
let responses = value?.as_object()?;
let mut status_codes = responses.keys().map(String::as_str).collect::<Vec<_>>();
status_codes.sort();
for status_code in status_codes {
if !(status_code == "default"
|| (status_code.len() == 3
&& status_code.starts_with('2')
&& status_code.chars().all(|ch| ch.is_ascii_digit())))
{
continue;
}
let Some(raw_response) = responses.get(status_code) else {
continue;
};
let Some(resolved_response) =
resolve_root_component_alias(raw_response, root, &["#/components/responses/"])
else {
continue;
};
let Some(response) = resolved_response.as_object() else {
continue;
};
let Some(content) = response.get("content").and_then(Value::as_object) else {
continue;
};
let Some((content_type, media_type)) = first_json_like_media_type(content) else {
continue;
};
let Some(schema_value) = media_type.get("schema") else {
continue;
};
let context = format!("{} {} response {}", method.to_uppercase(), path, status_code);
let schema = parse_response_schema(
schema_value,
root,
diagnostics,
&context,
synthetic_scalars,
);
return Some(ContractResponseBody {
content_type: content_type.to_string(),
schema,
});
}
None
}
fn first_json_like_media_type<'a>(
content: &'a serde_json::Map<String, Value>,
) -> Option<(&'a str, &'a serde_json::Map<String, Value>)> {
if let Some(media_type) = content.get("application/json").and_then(Value::as_object) {
return Some(("application/json", media_type));
}
let mut content_types = content.keys().map(String::as_str).collect::<Vec<_>>();
content_types.sort();
for content_type in content_types {
if !is_json_media_type(content_type) {
continue;
}
let Some(media_type) = content.get(content_type).and_then(Value::as_object) else {
continue;
};
return Some((content_type, media_type));
}
None
}
fn is_json_media_type(content_type: &str) -> bool {
let normalized = content_type.trim().to_ascii_lowercase();
normalized == "application/json" || normalized.ends_with("+json")
}
fn collect_component_definitions(
root: &Value,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
synthetic_scalars: &mut SyntheticScalarRegistry,
) -> (Vec<ContractScalarDefinition>, Vec<ContractSchemaDefinition>) {
let Some(component_schemas) = root
.get("components")
.and_then(|components| components.get("schemas"))
.and_then(Value::as_object)
else {
return (Vec::new(), Vec::new());
};
let mut names = component_schemas.keys().map(String::as_str).collect::<Vec<_>>();
names.sort();
let mut scalars = Vec::new();
let mut schemas = Vec::new();
for name in names {
let Some(value) = component_schemas.get(name) else {
continue;
};
match parse_component_definition(name, value, root, diagnostics, synthetic_scalars) {
Some(ComponentDefinition::Scalar(definition)) => {
synthetic_scalars.register_existing(&definition);
scalars.push(definition);
}
Some(ComponentDefinition::Schema(definition)) => schemas.push(definition),
None => {}
}
}
(scalars, schemas)
}
fn collect_security_schemes(root: &Value) -> BTreeMap<String, ResolvedSecurityScheme> {
let Some(component_schemes) = root
.get("components")
.and_then(|components| components.get("securitySchemes"))
.and_then(Value::as_object)
else {
return BTreeMap::new();
};
let mut names = component_schemes.keys().map(String::as_str).collect::<Vec<_>>();
names.sort();
let mut schemes = BTreeMap::new();
for name in names {
let Some(value) = component_schemes.get(name) else {
continue;
};
let resolved = resolve_root_component_alias(
value,
root,
&["#/components/securitySchemes/"],
)
.unwrap_or_else(|| value.clone());
schemes.insert(name.to_string(), parse_security_scheme(&resolved));
}
schemes
}
fn parse_security_scheme(value: &Value) -> ResolvedSecurityScheme {
let Some(object) = value.as_object() else {
return ResolvedSecurityScheme::Unsupported {
message: "the security scheme value is not an object".to_string(),
};
};
match object.get("type").and_then(Value::as_str) {
Some("apiKey") => {
let Some(name) = object.get("name").and_then(Value::as_str) else {
return ResolvedSecurityScheme::Unsupported {
message: "apiKey security schemes must declare a name".to_string(),
};
};
match object.get("in").and_then(Value::as_str) {
Some("header") => ResolvedSecurityScheme::ApiKeyHeader {
name: name.to_string(),
},
Some("query") => ResolvedSecurityScheme::ApiKeyQuery {
name: name.to_string(),
},
Some("cookie") => ResolvedSecurityScheme::ApiKeyCookie {
name: name.to_string(),
},
Some(location) => ResolvedSecurityScheme::Unsupported {
message: format!("apiKey auth location '{}' is not materialized yet", location),
},
None => ResolvedSecurityScheme::Unsupported {
message: "apiKey security schemes must declare an 'in' location".to_string(),
},
}
}
Some("http") => match object
.get("scheme")
.and_then(Value::as_str)
.map(|scheme| scheme.to_ascii_lowercase())
{
Some(scheme) if scheme == "basic" => ResolvedSecurityScheme::HttpBasic,
Some(scheme) if scheme == "bearer" => ResolvedSecurityScheme::HttpBearer,
Some(scheme) => ResolvedSecurityScheme::Unsupported {
message: format!("http auth scheme '{}' is not materialized yet", scheme),
},
None => ResolvedSecurityScheme::Unsupported {
message: "http security schemes must declare a scheme".to_string(),
},
},
Some("oauth2") => parse_oauth2_security_scheme(object),
Some("openIdConnect") => {
let Some(discovery_url) = object.get("openIdConnectUrl").and_then(Value::as_str) else {
return ResolvedSecurityScheme::Unsupported {
message: "openIdConnect security schemes must declare openIdConnectUrl".to_string(),
};
};
ResolvedSecurityScheme::OpenIdConnect {
discovery_url: discovery_url.to_string(),
}
}
Some(other) => ResolvedSecurityScheme::Unsupported {
message: format!("security scheme type '{}' is not materialized yet", other),
},
None => ResolvedSecurityScheme::Unsupported {
message: "security schemes must declare a type".to_string(),
},
}
}
fn parse_oauth2_security_scheme(
object: &serde_json::Map<String, Value>,
) -> ResolvedSecurityScheme {
let Some(flows) = object.get("flows").and_then(Value::as_object) else {
return ResolvedSecurityScheme::Unsupported {
message: "oauth2 security schemes must declare flows".to_string(),
};
};
let Some(flow) = flows.get("clientCredentials").and_then(Value::as_object) else {
for flow_name in ["authorizationCode", "implicit", "password"] {
if flows.get(flow_name).and_then(Value::as_object).is_some() {
return ResolvedSecurityScheme::OAuthBearerPlaceholder {
flow_name: flow_name.to_string(),
};
}
}
return ResolvedSecurityScheme::Unsupported {
message: "oauth2 flows without a supported flow declaration are not materialized yet"
.to_string(),
};
};
let Some(token_url) = flow.get("tokenUrl").and_then(Value::as_str) else {
return ResolvedSecurityScheme::Unsupported {
message: "oauth2 clientCredentials flows must declare tokenUrl".to_string(),
};
};
ResolvedSecurityScheme::OAuthClientCredentials {
token_url: token_url.to_string(),
}
}
fn collect_security_requirements(
value: Option<&Value>,
security_schemes: &BTreeMap<String, ResolvedSecurityScheme>,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
context: &str,
) -> Vec<Vec<ContractSecurityRequirement>> {
let Some(value) = value else {
return Vec::new();
};
let Some(requirements) = value.as_array() else {
diagnostics.push(security_warning(
"openapi_invalid_security_requirement",
format!(
"Ignoring security requirements for {} because the security value is not an array.",
context
),
));
return Vec::new();
};
let mut resolved = Vec::new();
for requirement in requirements {
let Some(object) = requirement.as_object() else {
diagnostics.push(security_warning(
"openapi_invalid_security_requirement",
format!(
"Ignoring one security alternative for {} because the requirement value is not an object.",
context
),
));
continue;
};
let mut scheme_names = object.keys().map(String::as_str).collect::<Vec<_>>();
scheme_names.sort();
let mut alternative = Vec::new();
let mut supported = true;
for scheme_name in scheme_names {
let Some(raw_requirement) = object.get(scheme_name) else {
supported = false;
break;
};
let Some(scheme) = security_schemes.get(scheme_name) else {
diagnostics.push(security_warning(
"openapi_unknown_security_scheme",
format!(
"Ignoring one security alternative for {} because it references unknown scheme '{}'.",
context, scheme_name
),
));
supported = false;
break;
};
match scheme {
ResolvedSecurityScheme::ApiKeyHeader { name } => {
alternative.push(ContractSecurityRequirement {
scheme_name: scheme_name.to_string(),
kind: ContractSecuritySchemeKind::ApiKeyHeader {
name: name.clone(),
},
});
}
ResolvedSecurityScheme::ApiKeyQuery { name } => {
alternative.push(ContractSecurityRequirement {
scheme_name: scheme_name.to_string(),
kind: ContractSecuritySchemeKind::ApiKeyQuery {
name: name.clone(),
},
});
}
ResolvedSecurityScheme::ApiKeyCookie { name } => {
alternative.push(ContractSecurityRequirement {
scheme_name: scheme_name.to_string(),
kind: ContractSecuritySchemeKind::ApiKeyCookie {
name: name.clone(),
},
});
}
ResolvedSecurityScheme::HttpBasic => {
alternative.push(ContractSecurityRequirement {
scheme_name: scheme_name.to_string(),
kind: ContractSecuritySchemeKind::HttpBasic,
});
}
ResolvedSecurityScheme::HttpBearer => {
alternative.push(ContractSecurityRequirement {
scheme_name: scheme_name.to_string(),
kind: ContractSecuritySchemeKind::HttpBearer,
});
}
ResolvedSecurityScheme::OpenIdConnect { discovery_url } => {
diagnostics.push(security_warning(
"openapi_openidconnect_reduced",
format!(
"Materialized openIdConnect scheme '{}' for {} as a bearer token placeholder and omitted discovery metadata from {}.",
scheme_name, context, discovery_url
),
));
alternative.push(ContractSecurityRequirement {
scheme_name: scheme_name.to_string(),
kind: ContractSecuritySchemeKind::HttpBearer,
});
}
ResolvedSecurityScheme::OAuthBearerPlaceholder { flow_name } => {
diagnostics.push(security_warning(
"openapi_oauth2_reduced",
format!(
"Materialized oauth2 scheme '{}' for {} as a bearer token placeholder because flow '{}' is not imported as an executable OAuth profile in this slice.",
scheme_name, context, flow_name
),
));
alternative.push(ContractSecurityRequirement {
scheme_name: scheme_name.to_string(),
kind: ContractSecuritySchemeKind::HttpBearer,
});
}
ResolvedSecurityScheme::OAuthClientCredentials { token_url } => {
let Some(scopes) = parse_security_scope_list(raw_requirement, diagnostics, context, scheme_name) else {
supported = false;
break;
};
alternative.push(ContractSecurityRequirement {
scheme_name: scheme_name.to_string(),
kind: ContractSecuritySchemeKind::OAuthClientCredentials {
token_url: token_url.clone(),
scopes,
},
});
}
ResolvedSecurityScheme::Unsupported { message } => {
diagnostics.push(security_warning(
"openapi_security_scheme_unimplemented",
format!(
"Ignoring one security alternative for {} because scheme '{}' is unsupported: {}.",
context, scheme_name, message
),
));
supported = false;
break;
}
}
}
if supported {
resolved.push(alternative);
}
}
resolved
}
fn parse_security_scope_list(
value: &Value,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
context: &str,
scheme_name: &str,
) -> Option<Vec<String>> {
let Some(values) = value.as_array() else {
diagnostics.push(security_warning(
"openapi_invalid_security_requirement",
format!(
"Ignoring one security alternative for {} because scheme '{}' does not use an array scope list.",
context, scheme_name
),
));
return None;
};
let mut scopes = Vec::with_capacity(values.len());
for entry in values {
let Some(scope) = entry.as_str() else {
diagnostics.push(security_warning(
"openapi_invalid_security_requirement",
format!(
"Ignoring one security alternative for {} because scheme '{}' includes a non-string scope value.",
context, scheme_name
),
));
return None;
};
scopes.push(scope.to_string());
}
Some(scopes)
}
fn parse_component_definition(
name: &str,
value: &Value,
root: &Value,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
synthetic_scalars: &mut SyntheticScalarRegistry,
) -> Option<ComponentDefinition> {
let object = value.as_object()?;
let context = format!("components.schemas.{name}");
if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
if object.len() == 1 {
if let Some(target) = resolve_local_schema_ref(reference) {
return Some(ComponentDefinition::Schema(ContractSchemaDefinition {
name: name.to_string(),
shape: ContractSchemaShape::Alias(ContractTypeRef::new(target)),
}));
}
}
let resolved = resolve_root_component_alias(value, root, &["#/components/schemas/"])?;
return parse_component_definition(name, &resolved, root, diagnostics, synthetic_scalars).or_else(|| {
diagnostics.push(schema_warning(
"openapi_component_ref_alias_unimplemented",
format!(
"Skipping {} because plain component alias '{}' resolves to a schema shape that is not materialized yet.",
context, reference
),
));
None
});
}
if let Some(shape) = parse_inline_schema_shape(value, root, diagnostics, &context, synthetic_scalars) {
return Some(ComponentDefinition::Schema(ContractSchemaDefinition {
name: name.to_string(),
shape,
}));
}
parse_scalar_expression(value, diagnostics, &context).map(|expression| {
ComponentDefinition::Scalar(ContractScalarDefinition {
name: name.to_string(),
expression,
})
})
}
fn parse_response_schema(
value: &Value,
root: &Value,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
context: &str,
synthetic_scalars: &mut SyntheticScalarRegistry,
) -> Option<ContractResponseSchema> {
let object = value.as_object()?;
if object.contains_key("discriminator") || has_unimplemented_schema_composition(object) {
let target = synthesize_inline_schema_target(value, root, diagnostics, context, synthetic_scalars)?;
return Some(ContractResponseSchema::Type(ContractTypeRef::new(target)));
}
if object.contains_key("allOf") {
let fields = parse_all_of_object_fields(value, root, diagnostics, context, synthetic_scalars)?;
return Some(ContractResponseSchema::Object { fields });
}
if schema_is_object(object) {
if schema_nullable(object) {
diagnostics.push(schema_warning(
"openapi_nullable_object_unimplemented",
format!(
"Omitted a response assertion for {} because nullable top-level object schemas are not materialized yet.",
context
),
));
return None;
}
let fields = parse_object_fields(object, diagnostics, context, synthetic_scalars)?;
return Some(ContractResponseSchema::Object { fields });
}
if let Some(expression) = parse_scalar_expression(value, diagnostics, context) {
return Some(ContractResponseSchema::Scalar(expression));
}
parse_type_ref(value, diagnostics, context, synthetic_scalars).map(ContractResponseSchema::Type)
}
fn parse_inline_schema_shape(
value: &Value,
root: &Value,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
context: &str,
synthetic_scalars: &mut SyntheticScalarRegistry,
) -> Option<ContractSchemaShape> {
let object = value.as_object()?;
if object.contains_key("discriminator") {
return parse_discriminator_shape(value, root, diagnostics, context, synthetic_scalars);
}
if let Some(branches) = object.get("oneOf").and_then(Value::as_array) {
return parse_branch_targets(branches, root, diagnostics, &format!("{}.oneOf", context), synthetic_scalars)
.map(ContractSchemaShape::OneOf);
}
if let Some(branches) = object.get("anyOf").and_then(Value::as_array) {
return parse_branch_targets(branches, root, diagnostics, &format!("{}.anyOf", context), synthetic_scalars)
.map(ContractSchemaShape::AnyOf);
}
if object.contains_key("allOf") {
let fields = parse_all_of_object_fields(value, root, diagnostics, context, synthetic_scalars)?;
return Some(ContractSchemaShape::Object { fields });
}
if schema_is_object(object) {
if schema_nullable(object) {
diagnostics.push(schema_warning(
"openapi_nullable_object_unimplemented",
format!(
"Skipping {} because nullable top-level object schemas are not materialized yet.",
context
),
));
return None;
}
let fields = parse_object_fields(object, diagnostics, context, synthetic_scalars)?;
return Some(ContractSchemaShape::Object { fields });
}
if schema_is_array(object) {
let value_type = parse_type_ref(value, diagnostics, context, synthetic_scalars)?;
return Some(ContractSchemaShape::Array(value_type));
}
None
}
fn synthesize_inline_schema_target(
value: &Value,
root: &Value,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
context: &str,
synthetic_scalars: &mut SyntheticScalarRegistry,
) -> Option<String> {
let shape = parse_inline_schema_shape(value, root, diagnostics, context, synthetic_scalars)?;
Some(synthetic_scalars.synthesize_schema(context, shape))
}
fn parse_branch_targets(
branches: &[Value],
root: &Value,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
context: &str,
synthetic_scalars: &mut SyntheticScalarRegistry,
) -> Option<Vec<String>> {
let mut targets = Vec::with_capacity(branches.len());
for (index, branch) in branches.iter().enumerate() {
let branch_context = format!("{}[{}]", context, index);
targets.push(parse_branch_target(branch, root, diagnostics, &branch_context, synthetic_scalars)?);
}
(!targets.is_empty()).then_some(targets)
}
fn parse_branch_target(
branch: &Value,
root: &Value,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
context: &str,
synthetic_scalars: &mut SyntheticScalarRegistry,
) -> Option<String> {
let object = branch.as_object()?;
if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
if let Some(target) = resolve_local_schema_ref(reference) {
return Some(target);
}
}
if let Some(value_type) = parse_type_ref(branch, diagnostics, context, synthetic_scalars) {
if value_type.array_depth == 0 && !value_type.nullable {
return Some(value_type.target);
}
return Some(
synthetic_scalars.synthesize_schema(context, ContractSchemaShape::Alias(value_type)),
);
}
let shape = parse_inline_schema_shape(branch, root, diagnostics, context, synthetic_scalars)?;
Some(synthetic_scalars.synthesize_schema(context, shape))
}
fn parse_discriminator_shape(
value: &Value,
root: &Value,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
context: &str,
synthetic_scalars: &mut SyntheticScalarRegistry,
) -> Option<ContractSchemaShape> {
let object = value.as_object()?;
let discriminator = object.get("discriminator")?.as_object()?;
let field = discriminator.get("propertyName")?.as_str()?.to_string();
let branch_values = object
.get("oneOf")
.or_else(|| object.get("anyOf"))
.and_then(Value::as_array)?;
let branch_targets = branch_values
.iter()
.enumerate()
.map(|(index, branch)| {
let branch_context = format!("{}.discriminator[{}]", context, index);
let local_name = branch
.as_object()
.and_then(|object| object.get("$ref"))
.and_then(Value::as_str)
.and_then(resolve_local_schema_ref);
let target = parse_branch_target(branch, root, diagnostics, &branch_context, synthetic_scalars)?;
Some((local_name, target))
})
.collect::<Option<Vec<_>>>()?;
let mut branches = Vec::new();
if let Some(mapping) = discriminator.get("mapping").and_then(Value::as_object) {
let mut tags = mapping.keys().cloned().collect::<Vec<_>>();
tags.sort();
for tag in tags {
let Some(target_ref) = mapping.get(tag.as_str()).and_then(Value::as_str) else {
diagnostics.push(schema_warning(
"openapi_discriminator_mapping_unimplemented",
format!(
"Omitted {} because discriminator mapping entry '{}' is not a string schema ref.",
context, tag
),
));
return None;
};
let Some(target) = resolve_local_schema_ref(target_ref) else {
diagnostics.push(schema_warning(
"openapi_discriminator_mapping_unimplemented",
format!(
"Omitted {} because discriminator mapping entry '{}' does not point at a local component schema.",
context, tag
),
));
return None;
};
branches.push(ContractDiscriminatorBranch { tag, target });
}
} else {
for (index, (local_name, target)) in branch_targets.into_iter().enumerate() {
let inferred_tag = branch_values
.get(index)
.and_then(|branch| infer_discriminator_tag(branch, field.as_str()));
branches.push(ContractDiscriminatorBranch {
tag: local_name.or(inferred_tag).unwrap_or_else(|| target.clone()),
target,
});
}
}
(!branches.is_empty()).then_some(ContractSchemaShape::Discriminator { field, branches })
}
fn infer_discriminator_tag(branch: &Value, field: &str) -> Option<String> {
let object = branch.as_object()?;
let property = object
.get("properties")
.and_then(Value::as_object)?
.get(field)?
.as_object()?;
if let Some(const_value) = property.get("const").and_then(Value::as_str) {
return Some(const_value.to_string());
}
property
.get("enum")
.and_then(Value::as_array)
.filter(|values| values.len() == 1)
.and_then(|values| values[0].as_str())
.map(ToOwned::to_owned)
}
fn parse_object_fields(
object: &serde_json::Map<String, Value>,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
context: &str,
synthetic_scalars: &mut SyntheticScalarRegistry,
) -> Option<Vec<ContractSchemaField>> {
let required = object
.get("required")
.and_then(Value::as_array)
.map(|required| {
required
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let properties = object
.get("properties")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let mut property_names = properties.keys().map(String::as_str).collect::<Vec<_>>();
property_names.sort();
let mut fields = Vec::new();
for property_name in property_names {
let Some(property_value) = properties.get(property_name) else {
continue;
};
let required_field = required.iter().any(|name| *name == property_name);
let field_context = format!("{}.{}", context, property_name);
match parse_type_ref(property_value, diagnostics, &field_context, synthetic_scalars) {
Some(value_type) => {
if required_field {
fields.push(ContractSchemaField::required(property_name, value_type));
} else {
fields.push(ContractSchemaField::optional(property_name, value_type));
}
}
None if required_field => {
diagnostics.push(schema_warning(
"openapi_required_schema_field_unimplemented",
format!(
"Skipping {} because required field '{}' uses an unsupported schema shape.",
context, property_name
),
));
return None;
}
None => {
diagnostics.push(schema_warning(
"openapi_optional_schema_field_omitted",
format!(
"Omitted optional schema field '{}' from {} because its schema shape is not materialized yet.",
property_name, context
),
));
}
}
}
Some(fields)
}
fn parse_all_of_object_fields(
value: &Value,
root: &Value,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
context: &str,
synthetic_scalars: &mut SyntheticScalarRegistry,
) -> Option<Vec<ContractSchemaField>> {
let object = value.as_object()?;
let branches = object.get("allOf")?.as_array()?;
let mut merged = BTreeMap::<String, ContractSchemaField>::new();
for (index, branch) in branches.iter().enumerate() {
let resolved = resolve_root_component_alias(branch, root, &["#/components/schemas/"])?;
let branch_object = resolved.as_object()?;
let branch_context = format!("{}.allOf[{}]", context, index);
if has_unimplemented_schema_composition(branch_object) {
diagnostics.push(schema_warning(
"openapi_allof_branch_unimplemented",
format!(
"Omitted {} because {} uses anyOf, oneOf, or an unsupported nested allOf composition.",
context, branch_context
),
));
return None;
}
let branch_fields = if branch_object.contains_key("allOf") {
parse_all_of_object_fields(&resolved, root, diagnostics, &branch_context, synthetic_scalars)?
} else if schema_is_object(branch_object) {
if schema_nullable(branch_object) {
diagnostics.push(schema_warning(
"openapi_nullable_object_unimplemented",
format!(
"Omitted {} because {} uses a nullable object branch inside allOf, which is not materialized yet.",
context, branch_context
),
));
return None;
}
parse_object_fields(branch_object, diagnostics, &branch_context, synthetic_scalars)?
} else {
diagnostics.push(schema_warning(
"openapi_allof_branch_unimplemented",
format!(
"Omitted {} because {} is not an object-shaped allOf branch.",
context, branch_context
),
));
return None;
};
merge_schema_fields(&mut merged, branch_fields, diagnostics, context, &branch_context)?;
}
if schema_is_object(object) {
if schema_nullable(object) {
diagnostics.push(schema_warning(
"openapi_nullable_object_unimplemented",
format!(
"Omitted {} because its top-level object schema is nullable, which is not materialized yet.",
context
),
));
return None;
}
let direct_fields = parse_object_fields(object, diagnostics, context, synthetic_scalars)?;
merge_schema_fields(&mut merged, direct_fields, diagnostics, context, context)?;
}
Some(merged.into_values().collect())
}
fn merge_schema_fields(
merged: &mut BTreeMap<String, ContractSchemaField>,
fields: Vec<ContractSchemaField>,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
context: &str,
branch_context: &str,
) -> Option<()> {
for field in fields {
match merged.get_mut(field.name.as_str()) {
Some(existing) => {
if existing.value_type != field.value_type {
diagnostics.push(schema_warning(
"openapi_allof_field_conflict_unimplemented",
format!(
"Omitted {} because field '{}' has conflicting allOf shapes in {}.",
context, field.name, branch_context
),
));
return None;
}
existing.required |= field.required;
}
None => {
merged.insert(field.name.clone(), field);
}
}
}
Some(())
}
fn parse_type_ref(
value: &Value,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
context: &str,
synthetic_scalars: &mut SyntheticScalarRegistry,
) -> Option<ContractTypeRef> {
let object = value.as_object()?;
if has_schema_composition(object) {
return None;
}
if schema_is_object(object) {
return None;
}
if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
let mut type_ref = ContractTypeRef::new(resolve_local_schema_ref(reference)?);
if schema_nullable(object) {
type_ref = type_ref.nullable();
}
return Some(type_ref);
}
let (schema_type, nullable) = schema_type_and_nullable(object, diagnostics, context)?;
match schema_type.as_str() {
"array" => {
let item_context = format!("{}[]", context);
let items = object.get("items")?;
let item_ref = parse_type_ref(items, diagnostics, &item_context, synthetic_scalars)?;
if item_ref.nullable {
diagnostics.push(schema_warning(
"openapi_nullable_array_items_unimplemented",
format!(
"Omitted {} because arrays with nullable item types are not materialized yet.",
context
),
));
return None;
}
let mut type_ref = item_ref.array();
if nullable {
type_ref = type_ref.nullable();
}
Some(type_ref)
}
"string" | "integer" | "number" | "boolean" => {
let simple_target = scalar_base_target(&schema_type, object)
.unwrap_or_else(|| schema_type.clone());
let target = match parse_scalar_expression(value, diagnostics, context) {
Some(expression) if expression != simple_target => {
synthetic_scalars.synthesize(context, expression)
}
_ => simple_target,
};
let mut type_ref = ContractTypeRef::new(target);
if nullable {
type_ref = type_ref.nullable();
}
Some(type_ref)
}
_ => None,
}
}
fn parse_scalar_expression(
value: &Value,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
context: &str,
) -> Option<String> {
let object = value.as_object()?;
if has_schema_composition(object) || schema_is_object(object) || schema_is_array(object) {
return None;
}
let (schema_type, _) = schema_type_and_nullable(object, diagnostics, context)?;
if !matches!(schema_type.as_str(), "string" | "integer" | "number" | "boolean") {
return None;
}
let mut terms = Vec::new();
if let Some(base) = scalar_base_target(&schema_type, object) {
terms.push(base);
} else {
terms.push(schema_type.clone());
}
if let Some(enum_values) = object.get("enum").and_then(Value::as_array) {
let rendered = enum_values
.iter()
.map(render_scalar_literal)
.collect::<Option<Vec<_>>>()?;
terms.push(format!("enum({})", rendered.join(", ")));
}
if let Some(const_value) = object.get("const") {
let rendered = render_scalar_literal(const_value)?;
terms.push(format!("const({rendered})"));
}
if schema_type == "string" {
if let Some(pattern) = object.get("pattern").and_then(Value::as_str) {
terms.push(format!("pattern(/{}/)", pattern.replace('/', "\\/")));
}
let min = object
.get("minLength")
.and_then(Value::as_u64)
.map(|value| value.to_string())
.unwrap_or_default();
let max = object
.get("maxLength")
.and_then(Value::as_u64)
.map(|value| value.to_string())
.unwrap_or_default();
if !min.is_empty() || !max.is_empty() {
terms.push(format!("len({min}..{max})"));
}
if object.get("format").is_some() && scalar_base_target(&schema_type, object).is_none() {
diagnostics.push(schema_warning(
"openapi_scalar_format_omitted",
format!(
"Omitted the unsupported string format on {} during scalar materialization.",
context
),
));
}
}
if matches!(schema_type.as_str(), "integer" | "number") {
if object.get("exclusiveMinimum").is_some() || object.get("exclusiveMaximum").is_some() {
diagnostics.push(schema_warning(
"openapi_exclusive_bounds_reduced",
format!(
"Reduced exclusive numeric bounds on {} to inclusive range checks because Hen scalar range predicates are inclusive in this slice.",
context
),
));
}
let min = object
.get("minimum")
.or_else(|| object.get("exclusiveMinimum"))
.and_then(numeric_value_to_string);
let max = object
.get("maximum")
.or_else(|| object.get("exclusiveMaximum"))
.and_then(numeric_value_to_string);
if min.is_some() || max.is_some() {
terms.push(format!(
"range({}..{})",
min.unwrap_or_default(),
max.unwrap_or_default()
));
}
}
Some(terms.join(" & "))
}
fn schema_is_object(object: &serde_json::Map<String, Value>) -> bool {
matches!(schema_type_and_nullable(object, &mut Vec::new(), ""), Some((kind, _)) if kind == "object")
|| object.contains_key("properties")
}
fn schema_is_array(object: &serde_json::Map<String, Value>) -> bool {
matches!(schema_type_and_nullable(object, &mut Vec::new(), ""), Some((kind, _)) if kind == "array")
|| object.contains_key("items")
}
fn schema_type_and_nullable(
object: &serde_json::Map<String, Value>,
diagnostics: &mut Vec<OpenApiCapabilityDiagnostic>,
context: &str,
) -> Option<(String, bool)> {
let explicit_nullable = object
.get("nullable")
.and_then(Value::as_bool)
.unwrap_or(false);
match object.get("type") {
Some(Value::String(value)) => Some((value.to_string(), explicit_nullable)),
Some(Value::Array(values)) => {
let mut non_null = values
.iter()
.filter_map(Value::as_str)
.filter(|value| *value != "null")
.collect::<Vec<_>>();
non_null.sort();
non_null.dedup();
let includes_null = values.iter().any(|value| value.as_str() == Some("null"));
if non_null.len() == 1 {
Some((non_null[0].to_string(), explicit_nullable || includes_null))
} else {
diagnostics.push(schema_warning(
"openapi_union_type_unimplemented",
format!(
"Skipping {} because OpenAPI union types with multiple non-null branches are not materialized yet.",
context
),
));
None
}
}
Some(_) => None,
None if object.contains_key("properties") => Some(("object".to_string(), explicit_nullable)),
None if object.contains_key("items") => Some(("array".to_string(), explicit_nullable)),
None => None,
}
}
fn scalar_base_target(schema_type: &str, object: &serde_json::Map<String, Value>) -> Option<String> {
if schema_type != "string" {
return None;
}
match object.get("format").and_then(Value::as_str) {
Some("uuid") => Some("UUID".to_string()),
Some("email") => Some("EMAIL".to_string()),
Some("date") => Some("DATE".to_string()),
Some("date-time") => Some("DATE_TIME".to_string()),
Some("time") => Some("TIME".to_string()),
Some("uri") => Some("URI".to_string()),
_ => None,
}
}
fn has_schema_composition(object: &serde_json::Map<String, Value>) -> bool {
object.contains_key("allOf") || object.contains_key("anyOf") || object.contains_key("oneOf")
}
fn has_unimplemented_schema_composition(object: &serde_json::Map<String, Value>) -> bool {
object.contains_key("anyOf") || object.contains_key("oneOf")
}
fn schema_nullable(object: &serde_json::Map<String, Value>) -> bool {
object
.get("nullable")
.and_then(Value::as_bool)
.unwrap_or(false)
|| object
.get("type")
.and_then(Value::as_array)
.is_some_and(|values| values.iter().any(|value| value.as_str() == Some("null")))
}
fn synthetic_scalar_base_name(context: &str) -> String {
let mut segments = context
.split('.')
.filter_map(|segment| {
let sanitized = sanitize_type_name(segment);
if sanitized.is_empty() || sanitized.starts_with("allOf") {
None
} else {
Some(sanitized)
}
})
.collect::<Vec<_>>();
if segments.len() > 2 {
segments = segments.split_off(segments.len() - 2);
}
let joined = segments.join("_");
if joined.is_empty() {
sanitize_type_name(context)
} else {
joined
}
}
fn sanitize_type_name(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut last_was_underscore = false;
for ch in input.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' {
output.push(ch);
last_was_underscore = ch == '_';
} else if !last_was_underscore && !output.is_empty() {
output.push('_');
last_was_underscore = true;
}
}
while output.ends_with('_') {
output.pop();
}
if output.is_empty() {
return output;
}
if output.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
output.insert(0, '_');
}
output
}
fn resolve_local_schema_ref(reference: &str) -> Option<String> {
reference
.strip_prefix("#/components/schemas/")
.map(ToOwned::to_owned)
}
fn render_scalar_literal(value: &Value) -> Option<String> {
match value {
Value::String(text) => Some(format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))),
Value::Bool(value) => Some(value.to_string()),
Value::Null => Some("null".to_string()),
Value::Number(number) => Some(number.to_string()),
_ => None,
}
}
fn numeric_value_to_string(value: &Value) -> Option<String> {
match value {
Value::Number(number) => Some(number.to_string()),
_ => None,
}
}
fn schema_warning(code: &'static str, message: String) -> OpenApiCapabilityDiagnostic {
OpenApiCapabilityDiagnostic {
code,
level: OpenApiSupportLevel::Warning,
message,
}
}
fn security_warning(code: &'static str, message: String) -> OpenApiCapabilityDiagnostic {
OpenApiCapabilityDiagnostic {
code,
level: OpenApiSupportLevel::Warning,
message,
}
}
fn example_to_string(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
_ => serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::{tempdir, NamedTempFile};
fn write_temp(contents: &str, suffix: &str) -> NamedTempFile {
let file = tempfile::Builder::new()
.suffix(suffix)
.tempfile()
.expect("tempfile should be created");
std::fs::write(file.path(), contents).expect("tempfile should be written");
file
}
#[test]
fn loads_yaml_openapi_operations_into_contract_document() {
let file = write_temp(
r#"
openapi: 3.1.0
paths:
/pets:
get:
operationId: listPets
summary: List pets
tags: [pets]
parameters:
- name: limit
in: query
required: false
/pets/{petId}:
get:
operationId: getPet
parameters:
- name: petId
in: path
required: true
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("plan should load");
assert!(plan.contract.servers.is_empty());
assert!(plan.contract.scalars.is_empty());
assert!(plan.contract.schemas.is_empty());
assert_eq!(plan.contract.operations.len(), 2);
assert_eq!(plan.contract.operations[0].operation_id.as_deref(), Some("listPets"));
assert_eq!(plan.contract.operations[0].method, "GET");
assert_eq!(plan.contract.operations[0].path, "/pets");
assert_eq!(plan.contract.operations[0].summary.as_deref(), Some("List pets"));
assert_eq!(plan.contract.operations[0].tags, vec!["pets"]);
assert_eq!(plan.contract.operations[1].operation_id.as_deref(), Some("getPet"));
assert_eq!(plan.contract.operations[1].path, "/pets/{petId}");
assert_eq!(plan.contract.operations[1].parameters[0].name, "petId");
}
#[test]
fn loads_json_openapi_operations_into_contract_document() {
let file = write_temp(
r#"{
"openapi": "3.0.3",
"paths": {
"/pets": {
"post": {
"operationId": "createPet"
}
}
}
}"#,
".json",
);
let plan = load_import_plan(file.path()).expect("plan should load");
assert_eq!(plan.contract.operations.len(), 1);
assert_eq!(plan.contract.operations[0].operation_id.as_deref(), Some("createPet"));
assert_eq!(plan.contract.operations[0].method, "POST");
assert_eq!(plan.contract.operations[0].path, "/pets");
}
#[test]
fn loads_parameter_concrete_values_from_examples_and_defaults() {
let file = write_temp(
r#"
openapi: 3.1.0
paths:
"/pets/{petId}":
get:
operationId: getPet
parameters:
- name: petId
in: path
required: true
schema:
type: string
example: pet-123
- name: trace-id
in: header
required: true
schema:
type: string
examples:
request:
value: trace-123
- name: session
in: cookie
required: true
schema:
type: string
default: cookie-123
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("plan should load");
assert_eq!(plan.contract.operations.len(), 1);
assert_eq!(plan.contract.operations[0].parameters[0].value.as_deref(), Some("pet-123"));
assert_eq!(plan.contract.operations[0].parameters[1].value.as_deref(), Some("trace-123"));
assert_eq!(plan.contract.operations[0].parameters[2].value.as_deref(), Some("cookie-123"));
}
#[test]
fn loads_parameter_serialization_metadata() {
let file = write_temp(
r#"
openapi: 3.1.0
paths:
"/pets/{petId}":
get:
operationId: getPet
parameters:
- name: petId
in: path
style: label
schema:
type: string
- name: filters
in: query
required: true
style: deepObject
explode: true
allowReserved: true
schema:
type: object
properties:
species:
type: string
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("plan should load");
assert_eq!(plan.contract.operations.len(), 1);
assert_eq!(plan.contract.operations[0].parameters.len(), 2);
assert_eq!(plan.contract.operations[0].parameters[0].style, "label");
assert!(!plan.contract.operations[0].parameters[0].explode);
assert!(!plan.contract.operations[0].parameters[0].allow_reserved);
assert_eq!(
plan.contract.operations[0].parameters[0].shape,
ContractParameterShape::Scalar
);
assert_eq!(plan.contract.operations[0].parameters[1].style, "deepObject");
assert!(plan.contract.operations[0].parameters[1].explode);
assert!(plan.contract.operations[0].parameters[1].allow_reserved);
assert_eq!(
plan.contract.operations[0].parameters[1].shape,
ContractParameterShape::Object
);
}
#[test]
fn loads_servers_and_json_request_body_example() {
let file = write_temp(
r#"
openapi: 3.1.0
servers:
- url: https://api.example.com
paths:
/pets:
post:
operationId: createPet
requestBody:
required: true
content:
application/json:
example:
name: Pickles
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("plan should load");
assert_eq!(plan.contract.servers, vec!["https://api.example.com"]);
let body = plan.contract.operations[0]
.request_body
.as_ref()
.expect("request body should be parsed");
assert_eq!(body.content_type, "application/json");
assert!(body.required);
assert!(body.example.as_ref().is_some_and(|value| value.contains("Pickles")));
assert!(body.schema.is_none());
}
#[test]
fn loads_form_urlencoded_request_body_example() {
let file = write_temp(
r#"
openapi: 3.1.0
paths:
/pets:
post:
operationId: createPet
requestBody:
required: true
content:
application/x-www-form-urlencoded:
example:
name: Pickles
age: 3
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("plan should load");
let body = plan.contract.operations[0]
.request_body
.as_ref()
.expect("request body should be parsed");
assert_eq!(body.content_type, "application/x-www-form-urlencoded");
assert!(body.required);
assert!(body.example.as_ref().is_some_and(|value| value.contains("Pickles")));
assert!(body.schema.is_none());
}
#[test]
fn loads_form_urlencoded_request_body_schema_without_example() {
let file = write_temp(
r#"
openapi: 3.1.0
paths:
/pets:
post:
operationId: createPet
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
required: [name]
properties:
name:
type: string
age:
type: integer
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("plan should load");
let body = plan.contract.operations[0]
.request_body
.as_ref()
.expect("request body should be parsed");
assert!(body.example.is_none());
match body.schema.as_ref().expect("request body schema should exist") {
ContractResponseSchema::Object { fields } => {
assert!(fields.iter().any(|field| field.name == "name" && field.required));
assert!(fields.iter().any(|field| field.name == "age" && !field.required));
}
schema => panic!("expected object request body schema, got {schema:?}"),
}
}
#[test]
fn loads_multipart_request_body_example() {
let file = write_temp(
r#"
openapi: 3.1.0
paths:
/pets:
post:
operationId: createPet
requestBody:
required: true
content:
multipart/form-data:
example:
name: Pickles
age: 3
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("plan should load");
let body = plan.contract.operations[0]
.request_body
.as_ref()
.expect("request body should be parsed");
assert_eq!(body.content_type, "multipart/form-data");
assert!(body.required);
assert!(body.example.as_ref().is_some_and(|value| value.contains("Pickles")));
assert!(body.schema.is_none());
}
#[test]
fn loads_multipart_request_body_schema_without_example() {
let file = write_temp(
r#"
openapi: 3.1.0
paths:
/pets:
post:
operationId: createPet
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [name]
properties:
name:
type: string
age:
type: integer
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("plan should load");
let body = plan.contract.operations[0]
.request_body
.as_ref()
.expect("request body should be parsed");
assert!(body.example.is_none());
match body.schema.as_ref().expect("request body schema should exist") {
ContractResponseSchema::Object { fields } => {
assert!(fields.iter().any(|field| field.name == "name" && field.required));
assert!(fields.iter().any(|field| field.name == "age" && !field.required));
}
schema => panic!("expected object request body schema, got {schema:?}"),
}
}
#[test]
fn inherits_path_and_operation_level_servers() {
let file = write_temp(
r#"
openapi: 3.1.0
servers:
- url: https://api.example.com
paths:
/pets:
servers:
- url: https://path.example.com/v2
get:
operationId: listPets
post:
operationId: createPet
servers:
- url: https://write.example.com/v3
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("plan should load");
assert_eq!(plan.contract.servers, vec!["https://api.example.com"]);
assert_eq!(plan.contract.operations.len(), 2);
assert_eq!(plan.contract.operations[0].operation_id.as_deref(), Some("listPets"));
assert_eq!(plan.contract.operations[0].servers, vec!["https://path.example.com/v2"]);
assert_eq!(plan.contract.operations[1].operation_id.as_deref(), Some("createPet"));
assert_eq!(plan.contract.operations[1].servers, vec!["https://write.example.com/v3"]);
}
#[test]
fn loads_json_request_body_schema_without_example() {
let file = write_temp(
r#"
openapi: 3.1.0
paths:
/pets:
post:
operationId: createPet
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name:
type: string
age:
type: integer
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("plan should load");
let body = plan.contract.operations[0]
.request_body
.as_ref()
.expect("request body should be parsed");
assert!(body.example.is_none());
match body.schema.as_ref().expect("request body schema should exist") {
ContractResponseSchema::Object { fields } => {
assert!(fields.iter().any(|field| field.name == "name" && field.required));
assert!(fields.iter().any(|field| field.name == "age" && !field.required));
}
schema => panic!("expected object request body schema, got {schema:?}"),
}
}
#[test]
fn loads_component_schemas_and_response_schema() {
let file = write_temp(
r#"
openapi: 3.1.0
components:
schemas:
Pet:
type: object
required: [id, name]
properties:
id:
type: integer
name:
type: string
tag:
type: string
nullable: true
PetList:
type: array
items:
$ref: '#/components/schemas/Pet'
paths:
/pets:
get:
operationId: listPets
responses:
'200':
description: ok
content:
application/json:
schema:
$ref: '#/components/schemas/PetList'
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("plan should load");
assert!(plan.contract.scalars.is_empty());
assert_eq!(plan.contract.schemas.len(), 2);
assert_eq!(plan.contract.schemas[0].name, "Pet");
assert_eq!(plan.contract.schemas[1].name, "PetList");
match &plan.contract.schemas[0].shape {
ContractSchemaShape::Object { fields } => {
assert_eq!(fields.len(), 3);
assert!(fields.iter().any(|field| field.name == "tag" && !field.required && field.value_type.nullable));
}
shape => panic!("expected object schema, got {shape:?}"),
}
match &plan.contract.schemas[1].shape {
ContractSchemaShape::Array(value_type) => {
assert_eq!(value_type.target, "Pet");
assert_eq!(value_type.array_depth, 1);
}
shape => panic!("expected array schema, got {shape:?}"),
}
let response_body = plan.contract.operations[0]
.response_body
.as_ref()
.expect("response body should be parsed");
assert_eq!(response_body.content_type, "application/json");
match response_body.schema.as_ref().expect("response schema should exist") {
ContractResponseSchema::Type(value_type) => {
assert_eq!(value_type.target, "PetList");
assert_eq!(value_type.array_depth, 0);
}
schema => panic!("expected named response target, got {schema:?}"),
}
}
#[test]
fn loads_supported_security_requirements() {
let file = write_temp(
r#"
openapi: 3.1.0
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
headerKey:
type: apiKey
in: header
name: X-API-Key
oauth:
type: oauth2
flows: {}
security:
- bearerAuth: []
- oauth: []
paths:
/pets:
get:
operationId: listPets
post:
operationId: createPet
security:
- headerKey: []
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("plan should load");
assert_eq!(plan.contract.operations.len(), 2);
assert_eq!(plan.contract.operations[0].operation_id.as_deref(), Some("listPets"));
assert_eq!(plan.contract.operations[0].security.len(), 1);
assert_eq!(plan.contract.operations[0].security[0].len(), 1);
assert_eq!(plan.contract.operations[0].security[0][0].scheme_name, "bearerAuth");
assert_eq!(plan.contract.operations[1].operation_id.as_deref(), Some("createPet"));
assert_eq!(plan.contract.operations[1].security.len(), 1);
match &plan.contract.operations[1].security[0][0].kind {
ContractSecuritySchemeKind::ApiKeyHeader { name } => {
assert_eq!(name, "X-API-Key");
}
kind => panic!("expected header apiKey auth, got {kind:?}"),
}
assert!(plan.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "openapi_security_scheme_unimplemented"
&& diagnostic.message.contains("oauth")
}));
}
#[test]
fn loads_openid_connect_security_requirements_as_bearer() {
let file = write_temp(
r#"openapi: 3.1.0
components:
securitySchemes:
oidc:
type: openIdConnect
openIdConnectUrl: https://login.example.com/.well-known/openid-configuration
paths:
/pets:
get:
operationId: listPets
security:
- oidc: []
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("oidc security should load");
assert_eq!(plan.contract.operations.len(), 1);
assert_eq!(plan.contract.operations[0].security.len(), 1);
assert_eq!(plan.contract.operations[0].security[0].len(), 1);
match &plan.contract.operations[0].security[0][0].kind {
ContractSecuritySchemeKind::HttpBearer => {}
kind => panic!("expected oidc auth to reduce to bearer, got {kind:?}"),
}
assert!(plan.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "openapi_openidconnect_reduced"
&& diagnostic.message.contains("oidc")
}));
}
#[test]
fn loads_oauth_client_credentials_security_requirements() {
let file = write_temp(
r#"
openapi: 3.1.0
components:
securitySchemes:
oauthClient:
type: oauth2
flows:
clientCredentials:
tokenUrl: https://login.example.com/oauth/token
scopes:
read:pets: Read pets
paths:
/pets:
get:
operationId: listPets
security:
- oauthClient:
- read:pets
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("plan should load");
assert_eq!(plan.contract.operations.len(), 1);
assert_eq!(plan.contract.operations[0].security.len(), 1);
assert_eq!(plan.contract.operations[0].security[0].len(), 1);
match &plan.contract.operations[0].security[0][0].kind {
ContractSecuritySchemeKind::OAuthClientCredentials { token_url, scopes } => {
assert_eq!(token_url, "https://login.example.com/oauth/token");
assert_eq!(scopes, &vec!["read:pets".to_string()]);
}
kind => panic!("expected oauth client credentials auth, got {kind:?}"),
}
}
#[test]
fn loads_oauth_authorization_code_security_requirements_as_bearer() {
let file = write_temp(
r#"openapi: 3.1.0
components:
securitySchemes:
oauthAuthCode:
type: oauth2
flows:
authorizationCode:
authorizationUrl: https://login.example.com/oauth/authorize
tokenUrl: https://login.example.com/oauth/token
scopes:
read:pets: Read pets
paths:
/pets:
get:
operationId: listPets
security:
- oauthAuthCode: []
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("oauth authorization code should load");
assert_eq!(plan.contract.operations.len(), 1);
assert_eq!(plan.contract.operations[0].security.len(), 1);
assert_eq!(plan.contract.operations[0].security[0].len(), 1);
match &plan.contract.operations[0].security[0][0].kind {
ContractSecuritySchemeKind::HttpBearer => {}
kind => panic!("expected oauth authorization code to reduce to bearer, got {kind:?}"),
}
assert!(plan.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "openapi_oauth2_reduced"
&& diagnostic.message.contains("authorizationCode")
}));
}
#[test]
fn loads_cookie_api_key_security_requirements() {
let file = write_temp(
r#"
openapi: 3.1.0
components:
securitySchemes:
sessionCookie:
type: apiKey
in: cookie
name: session
paths:
/pets:
get:
operationId: listPets
security:
- sessionCookie: []
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("plan should load");
assert_eq!(plan.contract.operations.len(), 1);
assert_eq!(plan.contract.operations[0].security.len(), 1);
assert_eq!(plan.contract.operations[0].security[0].len(), 1);
match &plan.contract.operations[0].security[0][0].kind {
ContractSecuritySchemeKind::ApiKeyCookie { name } => {
assert_eq!(name, "session");
}
kind => panic!("expected cookie apiKey auth, got {kind:?}"),
}
}
#[test]
fn inherits_path_level_security_with_operation_override() {
let file = write_temp(
r#"
openapi: 3.1.0
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
headerKey:
type: apiKey
in: header
name: X-API-Key
sessionCookie:
type: apiKey
in: cookie
name: session
security:
- bearerAuth: []
paths:
/pets:
security:
- headerKey: []
get:
operationId: listPets
post:
operationId: createPet
security:
- sessionCookie: []
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("plan should load");
assert_eq!(plan.contract.operations.len(), 2);
assert_eq!(plan.contract.operations[0].operation_id.as_deref(), Some("listPets"));
assert_eq!(plan.contract.operations[0].security.len(), 1);
match &plan.contract.operations[0].security[0][0].kind {
ContractSecuritySchemeKind::ApiKeyHeader { name } => {
assert_eq!(name, "X-API-Key");
}
kind => panic!("expected path-level header apiKey auth, got {kind:?}"),
}
assert_eq!(plan.contract.operations[1].operation_id.as_deref(), Some("createPet"));
assert_eq!(plan.contract.operations[1].security.len(), 1);
match &plan.contract.operations[1].security[0][0].kind {
ContractSecuritySchemeKind::ApiKeyCookie { name } => {
assert_eq!(name, "session");
}
kind => panic!("expected operation-level cookie apiKey auth, got {kind:?}"),
}
}
#[test]
fn loads_local_multifile_response_schema_refs() {
let dir = tempdir().expect("tempdir should be created");
let spec_path = dir.path().join("spec.yaml");
let schemas_path = dir.path().join("schemas.yaml");
fs::write(
&schemas_path,
r#"components:
schemas:
Pet:
type: object
required: [id]
properties:
id:
type: string
format: uuid
name:
type: string
"#,
)
.expect("schema file should be written");
fs::write(
&spec_path,
r#"openapi: 3.1.0
paths:
/pets:
get:
operationId: listPets
responses:
'200':
description: ok
content:
application/json:
schema:
$ref: './schemas.yaml#/components/schemas/Pet'
"#,
)
.expect("spec file should be written");
let plan = load_import_plan(&spec_path).expect("external schema ref should load");
let response = plan.contract.operations[0]
.response_body
.as_ref()
.expect("response schema should be present");
let ContractResponseSchema::Object { fields } = response
.schema
.as_ref()
.expect("response schema should be materialized")
else {
panic!("expected object response schema");
};
assert_eq!(fields.len(), 2);
assert_eq!(fields[0].name, "id");
assert_eq!(fields[0].value_type.target, "UUID");
assert!(fields[0].required);
assert_eq!(fields[1].name, "name");
assert_eq!(fields[1].value_type.target, "string");
assert!(!fields[1].required);
}
#[test]
fn loads_root_component_parameter_request_body_and_response_refs() {
let file = write_temp(
r#"openapi: 3.1.0
components:
schemas:
Pet:
type: object
required: [id]
properties:
id:
type: string
format: uuid
name:
type: string
parameters:
TraceId:
name: trace-id
in: header
required: true
schema:
type: string
examples:
request:
value: trace-123
requestBodies:
CreatePet:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name:
type: string
responses:
PetCreated:
description: ok
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
paths:
/pets:
post:
operationId: createPet
parameters:
- $ref: '#/components/parameters/TraceId'
requestBody:
$ref: '#/components/requestBodies/CreatePet'
responses:
'201':
$ref: '#/components/responses/PetCreated'
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("component refs should load");
let operation = &plan.contract.operations[0];
assert_eq!(operation.parameters.len(), 1);
assert_eq!(operation.parameters[0].name, "trace-id");
assert_eq!(operation.parameters[0].location, "header");
assert_eq!(operation.parameters[0].value.as_deref(), Some("trace-123"));
let request_body = operation
.request_body
.as_ref()
.expect("request body should be parsed");
assert_eq!(request_body.content_type, "application/json");
assert!(request_body.required);
match request_body.schema.as_ref().expect("request body schema should exist") {
ContractResponseSchema::Object { fields } => {
assert!(fields.iter().any(|field| field.name == "name" && field.required));
}
schema => panic!("expected object request body schema, got {schema:?}"),
}
let response_body = operation
.response_body
.as_ref()
.expect("response body should be parsed");
match response_body.schema.as_ref().expect("response schema should exist") {
ContractResponseSchema::Type(value_type) => {
assert_eq!(value_type.target, "Pet");
}
schema => panic!("expected named response target, got {schema:?}"),
}
}
#[test]
fn loads_root_component_path_item_refs() {
let file = write_temp(
r#"openapi: 3.1.0
components:
parameters:
TraceId:
name: trace-id
in: header
required: true
schema:
type: string
example: trace-123
pathItems:
Pets:
get:
operationId: listPets
parameters:
- $ref: '#/components/parameters/TraceId'
paths:
/pets:
$ref: '#/components/pathItems/Pets'
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("path item refs should load");
let operation = &plan.contract.operations[0];
assert_eq!(operation.operation_id.as_deref(), Some("listPets"));
assert_eq!(operation.method, "GET");
assert_eq!(operation.path, "/pets");
assert_eq!(operation.parameters.len(), 1);
assert_eq!(operation.parameters[0].name, "trace-id");
assert_eq!(operation.parameters[0].value.as_deref(), Some("trace-123"));
}
#[test]
fn loads_json_like_media_types() {
let file = write_temp(
r#"openapi: 3.1.0
paths:
/pets:
post:
operationId: createPet
requestBody:
required: true
content:
application/merge-patch+json:
schema:
type: object
required: [name]
properties:
name:
type: string
responses:
'200':
description: ok
content:
application/problem+json:
schema:
type: object
required: [detail]
properties:
detail:
type: string
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("json-like media types should load");
let operation = &plan.contract.operations[0];
let request_body = operation.request_body.as_ref().expect("request body should exist");
assert_eq!(request_body.content_type, "application/merge-patch+json");
match request_body.schema.as_ref().expect("request body schema should exist") {
ContractResponseSchema::Object { fields } => {
assert!(fields.iter().any(|field| field.name == "name" && field.required));
}
schema => panic!("expected object request body schema, got {schema:?}"),
}
let response_body = operation.response_body.as_ref().expect("response body should exist");
assert_eq!(response_body.content_type, "application/problem+json");
match response_body.schema.as_ref().expect("response schema should exist") {
ContractResponseSchema::Object { fields } => {
assert!(fields.iter().any(|field| field.name == "detail" && field.required));
}
schema => panic!("expected object response schema, got {schema:?}"),
}
}
#[test]
fn loads_object_allof_component_request_and_response_schemas() {
let file = write_temp(
r#"openapi: 3.1.0
components:
schemas:
PetBase:
type: object
required: [id]
properties:
id:
type: string
format: uuid
Pet:
allOf:
- $ref: '#/components/schemas/PetBase'
- type: object
properties:
name:
type: string
paths:
/pets:
post:
operationId: createPet
requestBody:
required: true
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/PetBase'
- type: object
required: [name]
properties:
name:
type: string
responses:
'200':
description: ok
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("object allOf should load");
assert_eq!(plan.contract.schemas.len(), 2);
let pet_schema = plan
.contract
.schemas
.iter()
.find(|schema| schema.name == "Pet")
.expect("Pet schema should exist");
match &pet_schema.shape {
ContractSchemaShape::Object { fields } => {
assert!(fields.iter().any(|field| field.name == "id" && field.required && field.value_type.target == "UUID"));
assert!(fields.iter().any(|field| field.name == "name" && !field.required && field.value_type.target == "string"));
}
shape => panic!("expected composed object schema, got {shape:?}"),
}
let operation = &plan.contract.operations[0];
let request_body = operation.request_body.as_ref().expect("request body should exist");
match request_body.schema.as_ref().expect("request body schema should exist") {
ContractResponseSchema::Object { fields } => {
assert!(fields.iter().any(|field| field.name == "id" && field.required));
assert!(fields.iter().any(|field| field.name == "name" && field.required));
}
schema => panic!("expected object request body schema, got {schema:?}"),
}
let response_body = operation.response_body.as_ref().expect("response body should exist");
match response_body.schema.as_ref().expect("response schema should exist") {
ContractResponseSchema::Type(value_type) => {
assert_eq!(value_type.target, "Pet");
}
schema => panic!("expected named response target, got {schema:?}"),
}
}
#[test]
fn loads_component_oneof_and_discriminator_schemas() {
let file = write_temp(
r#"openapi: 3.1.0
components:
schemas:
Cat:
type: object
required: [id, lives]
properties:
id:
type: string
format: uuid
lives:
type: integer
Dog:
type: object
required: [id, breed]
properties:
id:
type: string
format: uuid
breed:
type: string
Animal:
oneOf:
- $ref: '#/components/schemas/Cat'
- $ref: '#/components/schemas/Dog'
CardCheckout:
type: object
required: [method, cardLast4]
properties:
method:
type: string
const: card
cardLast4:
type: string
BankCheckout:
type: object
required: [method, accountId]
properties:
method:
type: string
const: bank
accountId:
type: string
Checkout:
oneOf:
- $ref: '#/components/schemas/CardCheckout'
- $ref: '#/components/schemas/BankCheckout'
discriminator:
propertyName: method
mapping:
card: '#/components/schemas/CardCheckout'
bank: '#/components/schemas/BankCheckout'
paths:
/checkout:
post:
operationId: createCheckout
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Checkout'
responses:
'200':
description: ok
content:
application/json:
schema:
$ref: '#/components/schemas/Animal'
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("union schemas should load");
let animal = plan
.contract
.schemas
.iter()
.find(|schema| schema.name == "Animal")
.expect("Animal schema should exist");
match &animal.shape {
ContractSchemaShape::OneOf(targets) => {
assert_eq!(targets, &vec!["Cat".to_string(), "Dog".to_string()]);
}
shape => panic!("expected oneOf schema, got {shape:?}"),
}
let checkout = plan
.contract
.schemas
.iter()
.find(|schema| schema.name == "Checkout")
.expect("Checkout schema should exist");
match &checkout.shape {
ContractSchemaShape::Discriminator { field, branches } => {
assert_eq!(field, "method");
assert_eq!(branches.len(), 2);
assert!(branches.iter().any(|branch| branch.tag == "card" && branch.target == "CardCheckout"));
assert!(branches.iter().any(|branch| branch.tag == "bank" && branch.target == "BankCheckout"));
}
shape => panic!("expected discriminator schema, got {shape:?}"),
}
let operation = &plan.contract.operations[0];
let request_body = operation.request_body.as_ref().expect("request body should exist");
match request_body.schema.as_ref().expect("request body schema should exist") {
ContractResponseSchema::Type(value_type) => {
assert_eq!(value_type.target, "Checkout");
}
schema => panic!("expected named request target, got {schema:?}"),
}
let response_body = operation.response_body.as_ref().expect("response body should exist");
match response_body.schema.as_ref().expect("response schema should exist") {
ContractResponseSchema::Type(value_type) => {
assert_eq!(value_type.target, "Animal");
}
schema => panic!("expected named response target, got {schema:?}"),
}
}
#[test]
fn loads_inline_discriminator_request_body_as_synthetic_schema() {
let file = write_temp(
r#"openapi: 3.1.0
paths:
/checkout:
post:
operationId: createCheckout
requestBody:
required: true
content:
application/json:
schema:
discriminator:
propertyName: method
oneOf:
- type: object
required: [method, cardLast4]
properties:
method:
type: string
const: card
cardLast4:
type: string
- type: object
required: [method, accountId]
properties:
method:
type: string
const: bank
accountId:
type: string
responses:
'204':
description: ok
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("inline discriminator should load");
let operation = &plan.contract.operations[0];
let request_body = operation.request_body.as_ref().expect("request body should exist");
let ContractResponseSchema::Type(value_type) = request_body
.schema
.as_ref()
.expect("request body schema should exist")
else {
panic!("expected synthetic named request target");
};
let discriminator_schema = plan
.contract
.schemas
.iter()
.find(|schema| schema.name == value_type.target)
.expect("synthetic discriminator schema should exist");
match &discriminator_schema.shape {
ContractSchemaShape::Discriminator { field, branches } => {
assert_eq!(field, "method");
assert_eq!(branches.len(), 2);
assert!(branches.iter().any(|branch| branch.tag == "card"));
assert!(branches.iter().any(|branch| branch.tag == "bank"));
}
shape => panic!("expected synthetic discriminator schema, got {shape:?}"),
}
}
#[test]
fn loads_plain_component_schema_aliases() {
let file = write_temp(
r#"openapi: 3.1.0
components:
schemas:
PetBase:
type: object
required: [id]
properties:
id:
type: string
format: uuid
name:
type: string
Pet:
$ref: '#/components/schemas/PetBase'
paths:
/pets:
get:
operationId: getPet
responses:
'200':
description: ok
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
"#,
".yaml",
);
let plan = load_import_plan(file.path()).expect("plain component aliases should load");
let pet_schema = plan
.contract
.schemas
.iter()
.find(|schema| schema.name == "Pet")
.expect("Pet schema should exist");
match &pet_schema.shape {
ContractSchemaShape::Alias(value_type) => {
assert_eq!(value_type.target, "PetBase");
assert_eq!(value_type.array_depth, 0);
assert!(!value_type.nullable);
}
shape => panic!("expected aliased schema, got {shape:?}"),
}
let response_body = plan.contract.operations[0]
.response_body
.as_ref()
.expect("response body should exist");
match response_body.schema.as_ref().expect("response schema should exist") {
ContractResponseSchema::Type(value_type) => {
assert_eq!(value_type.target, "Pet");
}
schema => panic!("expected named response target, got {schema:?}"),
}
}
}