#![allow(missing_docs, clippy::doc_markdown)]
use std::collections::BTreeSet;
use anyhow::{anyhow, bail, Context, Result};
use serde_yaml::{Mapping, Value};
pub const ERROR_ALIASES: &[&str] = &[];
const NULL_VARIANT_SCHEMA_KEYS: &[&str] = &["oneOf", "anyOf"];
pub const KEEP_REQUIRED: &[(&str, &str)] = &[
("AgentEventListResponse", "meta"),
("PaginatedMeta", "has_more"),
("AgentEvent", "data"),
("AgentEvent", "event"),
("AgentEvent", "event_id"),
("AgentEvent", "occurred_at"),
("BidEscrowParamsResponse", "amount"),
("BidEscrowParamsResponse", "arbitrator_address"),
("BidEscrowParamsResponse", "chain_id"),
("BidEscrowParamsResponse", "decimals"),
("BidEscrowParamsResponse", "platform_fee_amount"),
("BidEscrowParamsResponse", "platform_wallet"),
("BidEscrowParamsResponse", "poster_approval_deadline"),
("BidEscrowParamsResponse", "poster_approval_min_lifetime"),
("BidEscrowParamsResponse", "task_escrow_contract"),
("BidEscrowParamsResponse", "task_id"),
("BidEscrowParamsResponse", "token_address"),
("BidEscrowParamsResponse", "worker_address"),
("getStakeQuote.200", "required_amount"),
("TaskDraftPrepareResponse", "draft_id"),
("TaskDraftPrepareResponse", "payload_to_sign"),
("TaskDraftPrepareResponse", "token_address"),
("TaskDraftSubmitResponse", "id"),
("TaskDraftSubmitResponse", "status"),
("AgentReadiness", "settlement_domain"),
("AgentReadiness.settlement_domain", "chain_id"),
("AgentReadiness.settlement_domain", "verifying_contract"),
("TaskDetail.allOf.1.settlement_domain", "chain_id"),
("TaskDetail.allOf.1.settlement_domain", "verifying_contract"),
("AgentReadiness", "checks"),
("AgentReadiness.checks", "funded"),
("AgentReadiness.checks", "wallet"),
("AgentReadinessCheck", "status"),
("WebhookConfig", "url"),
("Artifact", "id"),
];
pub fn normalize_spec(yaml: &str) -> Result<String> {
let (out, report) = normalize_with_aliases(yaml, ERROR_ALIASES)?;
tracing_nothing(&report); Ok(out)
}
pub fn normalize_spec_with_report(yaml: &str) -> Result<(String, Report)> {
normalize_with_aliases(yaml, ERROR_ALIASES)
}
fn normalize_with_aliases(yaml: &str, aliases: &[&str]) -> Result<(String, Report)> {
let mut doc: Value = serde_yaml::from_str(yaml).context("parse spec YAML")?;
let report = normalize_in_place(&mut doc, aliases)?;
let out = serde_yaml::to_string(&doc).context("re-serialize normalized spec")?;
Ok((out, report))
}
#[derive(Debug, Default, Clone)]
pub struct Report {
pub folded_aliases: Vec<String>,
pub refs_rewritten: usize,
pub stripped_operations: Vec<String>,
pub error_responses_stripped: usize,
pub null_variants_stripped: usize,
pub request_media_collapsed: usize,
pub response_enums_stripped: usize,
pub response_required_relaxed: usize,
}
fn normalize_in_place(doc: &mut Value, aliases: &[&str]) -> Result<Report> {
let mut report = Report::default();
let schemas_path = ["components", "schemas"];
let canonical_shape = {
let error = get_mapping_path(doc, &schemas_path)
.and_then(|m| m.get(Value::from("Error")))
.ok_or_else(|| anyhow!("canonical schema `Error` not found in components.schemas"))?;
structural_shape(error)
};
let present_aliases: Vec<String> = {
let schemas = get_mapping_path(doc, &schemas_path)
.ok_or_else(|| anyhow!("components.schemas not a mapping"))?;
aliases
.iter()
.copied()
.filter(|a| schemas.contains_key(Value::from(*a)))
.map(str::to_owned)
.collect()
};
for alias in &present_aliases {
let schema = get_mapping_path(doc, &schemas_path)
.and_then(|m| m.get(Value::from(alias.as_str())))
.expect("alias presence confirmed above");
let alias_shape = structural_shape(schema);
if alias_shape != canonical_shape {
bail!(
"alias `{alias}` has drifted from canonical `Error` shape — \
either restore structural equality or remove `{alias}` from ERROR_ALIASES.\n\
canonical: {:#?}\nalias: {:#?}",
canonical_shape,
alias_shape
);
}
}
for alias in &present_aliases {
let from = format!("#/components/schemas/{alias}");
let to = "#/components/schemas/Error";
report.refs_rewritten += rewrite_refs(doc, &from, to);
}
if let Some(schemas) = get_mapping_path_mut(doc, &schemas_path) {
for alias in &present_aliases {
schemas.remove(Value::from(alias.as_str()));
}
}
report.folded_aliases = present_aliases;
report.stripped_operations = strip_multipart_only_operations(doc);
report.request_media_collapsed = collapse_multi_media_request_bodies(doc);
report.error_responses_stripped = strip_non_success_responses(doc);
report.null_variants_stripped = strip_null_type_variants(doc);
strip_closed_objects(doc);
report.response_enums_stripped = strip_response_enums(doc);
report.response_required_relaxed = relax_response_required(doc);
Ok(report)
}
fn strip_response_enums(doc: &mut Value) -> usize {
let enums: Vec<_> = response_schema_paths(doc)
.into_iter()
.filter(|path| {
value_at_path(doc, path).is_some_and(|schema| {
schema.get("type").and_then(Value::as_str) == Some("string")
&& schema.get("enum").is_some()
})
})
.collect();
let mut stripped = 0;
for path in enums {
if value_at_path_mut(doc, &path)
.and_then(Value::as_mapping_mut)
.and_then(|map| map.remove("enum"))
.is_some()
{
stripped += 1;
}
}
stripped
}
fn relax_response_required(doc: &mut Value) -> usize {
let mut relaxed = 0;
for path in response_schema_paths(doc) {
let key = schema_key(doc, &path);
let Some(map) = value_at_path_mut(doc, &path).and_then(Value::as_mapping_mut) else {
continue;
};
let Some(Value::Sequence(required)) = map.get_mut("required") else {
continue;
};
let before = required.len();
required.retain(|property| {
property
.as_str()
.is_some_and(|property| KEEP_REQUIRED.contains(&(key.as_str(), property)))
});
relaxed += before - required.len();
if required.is_empty() {
map.remove("required");
}
}
relaxed
}
fn schema_key(doc: &Value, path: &[String]) -> String {
let (head, rest) = match path {
[c, s, name, rest @ ..] if c == "components" && s == "schemas" => (name.clone(), rest),
[c, r, name, _content, _media, _schema, rest @ ..]
if c == "components" && r == "responses" =>
{
(format!("responses.{name}"), rest)
}
[p, route, verb, _responses, code, _content, _media, _schema, rest @ ..]
if p == "paths" =>
{
let op = value_at_path(doc, &path[..3])
.and_then(|op| op.get("operationId"))
.and_then(Value::as_str)
.map_or_else(|| format!("{verb} {route}"), str::to_owned);
(format!("{op}.{code}"), rest)
}
_ => (path.join("."), &[][..]),
};
std::iter::once(head)
.chain(rest.iter().filter(|seg| *seg != "properties").cloned())
.collect::<Vec<_>>()
.join(".")
}
fn response_schema_paths(doc: &Value) -> BTreeSet<Vec<String>> {
let mut visited = BTreeSet::new();
let mut schemas = BTreeSet::new();
if let Some(paths) = doc.get("paths").and_then(Value::as_mapping) {
for (path, item) in paths {
let Some(path) = path.as_str() else { continue };
for verb in [
"get", "put", "post", "delete", "patch", "options", "head", "trace",
] {
let Some(responses) = item
.get(verb)
.and_then(|op| op.get("responses"))
.and_then(Value::as_mapping)
else {
continue;
};
for code in responses
.keys()
.filter_map(Value::as_str)
.filter(|code| is_success_or_default(code))
{
let root = ["paths", path, verb, "responses", code]
.map(str::to_owned)
.to_vec();
collect_response_schemas(doc, &root, &mut visited, &mut schemas);
}
}
}
}
schemas
}
fn value_at_path<'a>(doc: &'a Value, path: &[String]) -> Option<&'a Value> {
path.iter().try_fold(doc, |node, key| match node {
Value::Sequence(seq) => seq.get(key.parse::<usize>().ok()?),
_ => node.get(key.as_str()),
})
}
fn value_at_path_mut<'a>(doc: &'a mut Value, path: &[String]) -> Option<&'a mut Value> {
path.iter().try_fold(doc, |node, key| match node {
Value::Sequence(seq) => seq.get_mut(key.parse::<usize>().ok()?),
_ => node.get_mut(key.as_str()),
})
}
fn local_ref_path(node: &Value) -> Option<Vec<String>> {
Some(
node.get("$ref")?
.as_str()?
.strip_prefix("#/")?
.split('/')
.map(|key| key.replace("~1", "/").replace("~0", "~"))
.collect(),
)
}
fn collect_response_schemas(
doc: &Value,
path: &[String],
visited: &mut BTreeSet<Vec<String>>,
schemas: &mut BTreeSet<Vec<String>>,
) {
if !visited.insert(path.to_vec()) {
return;
}
let Some(response) = value_at_path(doc, path) else {
return;
};
if let Some(target) = local_ref_path(response) {
collect_response_schemas(doc, &target, visited, schemas);
}
if let Some(content) = response.get("content").and_then(Value::as_mapping) {
for media in content.keys().filter_map(Value::as_str) {
let mut schema_path = path.to_vec();
schema_path.extend(["content".to_owned(), media.to_owned(), "schema".to_owned()]);
collect_schemas(doc, &schema_path, visited, schemas);
}
}
}
fn collect_schemas(
doc: &Value,
path: &[String],
visited: &mut BTreeSet<Vec<String>>,
schemas: &mut BTreeSet<Vec<String>>,
) {
if !visited.insert(path.to_vec()) {
return;
}
let Some(schema) = value_at_path(doc, path) else {
return;
};
schemas.insert(path.to_vec());
if let Some(target) = local_ref_path(schema) {
collect_schemas(doc, &target, visited, schemas);
}
for key in [
"allOf",
"oneOf",
"anyOf",
"properties",
"additionalProperties",
"items",
] {
let Some(child) = schema.get(key) else {
continue;
};
let mut child_path = path.to_vec();
child_path.push(key.to_owned());
match child {
Value::Sequence(seq) => {
for index in 0..seq.len() {
let mut item_path = child_path.clone();
item_path.push(index.to_string());
collect_schemas(doc, &item_path, visited, schemas);
}
}
Value::Mapping(map) if key == "properties" => {
for property in map.keys().filter_map(Value::as_str) {
let mut property_path = child_path.clone();
property_path.push(property.to_owned());
collect_schemas(doc, &property_path, visited, schemas);
}
}
_ => collect_schemas(doc, &child_path, visited, schemas),
}
}
}
fn strip_closed_objects(v: &mut Value) {
match v {
Value::Mapping(map) => {
if map.get("additionalProperties") == Some(&Value::Bool(false)) {
map.remove("additionalProperties");
}
for (_, child) in map.iter_mut() {
strip_closed_objects(child);
}
}
Value::Sequence(seq) => seq.iter_mut().for_each(strip_closed_objects),
_ => {}
}
}
fn strip_null_type_variants(doc: &mut Value) -> usize {
fn is_null_type_schema(v: &Value) -> bool {
v.as_mapping()
.and_then(|m| m.get("type"))
.and_then(Value::as_str)
== Some("null")
}
fn walk(v: &mut Value, count: &mut usize) {
match v {
Value::Mapping(map) => {
for key in NULL_VARIANT_SCHEMA_KEYS {
let Some(seq) = map.get_mut(*key).and_then(Value::as_sequence_mut) else {
continue;
};
let before = seq.len();
seq.retain(|item| !is_null_type_schema(item));
*count += before - seq.len();
if before > 0 && seq.is_empty() {
map.remove(*key);
}
}
for (_, child) in map.iter_mut() {
walk(child, count);
}
}
Value::Sequence(seq) => {
for child in seq.iter_mut() {
walk(child, count);
}
}
_ => {}
}
}
let mut count = 0;
walk(doc, &mut count);
count
}
fn strip_multipart_only_operations(doc: &mut Value) -> Vec<String> {
let mut stripped = Vec::new();
let Some(paths) = doc
.as_mapping_mut()
.and_then(|m| m.get_mut(Value::from("paths")))
.and_then(Value::as_mapping_mut)
else {
return stripped;
};
const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
for (_path_key, path_item) in paths.iter_mut() {
let Some(ops) = path_item.as_mapping_mut() else {
continue;
};
let mut to_remove = Vec::new();
for verb in HTTP_VERBS {
if let Some(op) = ops.get(Value::from(*verb)) {
if operation_is_multipart_only(op) {
let op_id = op
.get(Value::from("operationId"))
.and_then(Value::as_str)
.unwrap_or("<anonymous>")
.to_string();
to_remove.push(*verb);
stripped.push(op_id);
}
}
}
for verb in to_remove {
ops.remove(Value::from(verb));
}
}
stripped
}
fn collapse_multi_media_request_bodies(doc: &mut Value) -> usize {
let mut collapsed = 0usize;
let Some(paths) = doc
.as_mapping_mut()
.and_then(|m| m.get_mut(Value::from("paths")))
.and_then(Value::as_mapping_mut)
else {
return 0;
};
const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
for (_path_key, path_item) in paths.iter_mut() {
let Some(ops) = path_item.as_mapping_mut() else {
continue;
};
for verb in HTTP_VERBS {
let Some(content) = ops
.get_mut(Value::from(*verb))
.and_then(Value::as_mapping_mut)
.and_then(|op| op.get_mut(Value::from("requestBody")))
.and_then(Value::as_mapping_mut)
.and_then(|body| body.get_mut(Value::from("content")))
.and_then(Value::as_mapping_mut)
else {
continue;
};
if content.len() <= 1 {
continue;
}
let keep = if content.contains_key(Value::from("application/json")) {
"application/json".to_string()
} else {
content
.keys()
.next()
.and_then(Value::as_str)
.map(str::to_owned)
.expect("content has >1 entry")
};
content.retain(|k, _| k.as_str() == Some(keep.as_str()));
collapsed += 1;
}
}
collapsed
}
fn strip_non_success_responses(doc: &mut Value) -> usize {
let mut removed = 0usize;
let Some(paths) = doc
.as_mapping_mut()
.and_then(|m| m.get_mut(Value::from("paths")))
.and_then(Value::as_mapping_mut)
else {
return 0;
};
const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
for (_path_key, path_item) in paths.iter_mut() {
let Some(ops) = path_item.as_mapping_mut() else {
continue;
};
for verb in HTTP_VERBS {
let Some(op) = ops.get_mut(Value::from(*verb)) else {
continue;
};
let Some(responses) = op
.as_mapping_mut()
.and_then(|m| m.get_mut(Value::from("responses")))
.and_then(Value::as_mapping_mut)
else {
continue;
};
let to_remove: Vec<Value> = responses
.iter()
.filter_map(|(k, _)| {
let key = k.as_str()?;
if is_success_or_default(key) {
None
} else {
Some(k.clone())
}
})
.collect();
for k in to_remove {
if responses.remove(&k).is_some() {
removed += 1;
}
}
}
}
removed
}
fn is_success_or_default(code: &str) -> bool {
if code == "default" {
return true;
}
match code.chars().next() {
Some('2') => true,
_ => false,
}
}
fn operation_is_multipart_only(op: &Value) -> bool {
let Some(body) = op
.as_mapping()
.and_then(|m| m.get(Value::from("requestBody")))
else {
return false;
};
let Some(content) = body
.as_mapping()
.and_then(|m| m.get(Value::from("content")))
.and_then(Value::as_mapping)
else {
return false;
};
let keys: Vec<&str> = content.keys().filter_map(Value::as_str).collect();
keys.contains(&"multipart/form-data")
}
fn structural_shape(v: &Value) -> Value {
match v {
Value::Mapping(m) => {
let mut out = Mapping::new();
for (k, val) in m {
let keep = match k.as_str() {
Some("description") | Some("example") | Some("summary") | Some("default")
| Some("deprecated") | Some("title") => false,
_ => true,
};
if keep {
out.insert(k.clone(), structural_shape(val));
}
}
Value::Mapping(out)
}
Value::Sequence(s) => Value::Sequence(s.iter().map(structural_shape).collect()),
other => other.clone(),
}
}
fn rewrite_refs(doc: &mut Value, from: &str, to: &str) -> usize {
let mut count = 0;
rewrite_refs_inner(doc, from, to, &mut count);
count
}
fn rewrite_refs_inner(v: &mut Value, from: &str, to: &str, count: &mut usize) {
match v {
Value::Mapping(m) => {
if let Some(target) = m.get_mut(Value::from("$ref")) {
if target.as_str() == Some(from) {
*target = Value::from(to);
*count += 1;
}
}
for (_, val) in m.iter_mut() {
rewrite_refs_inner(val, from, to, count);
}
}
Value::Sequence(s) => {
for val in s.iter_mut() {
rewrite_refs_inner(val, from, to, count);
}
}
_ => {}
}
}
fn get_mapping_path<'a>(v: &'a Value, path: &[&str]) -> Option<&'a Mapping> {
let mut cur = v;
for key in path {
cur = cur.as_mapping()?.get(Value::from(*key))?;
}
cur.as_mapping()
}
fn get_mapping_path_mut<'a>(v: &'a mut Value, path: &[&str]) -> Option<&'a mut Mapping> {
let mut cur = v;
for key in path {
cur = cur.as_mapping_mut()?.get_mut(Value::from(*key))?;
}
cur.as_mapping_mut()
}
fn tracing_nothing(_r: &Report) {}
#[cfg(test)]
mod tests {
use super::*;
const BASE_SPEC: &str = r#"
openapi: 3.0.0
info:
title: test
version: 0.0.0
paths:
/foo:
get:
responses:
'200':
description: ok
'404':
description: not found
content:
application/json:
schema:
$ref: '#/components/schemas/WalletBalanceNotFoundError'
'503':
description: unavailable
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Error:
type: object
required:
- error
- message
properties:
error:
type: string
description: Error code
message:
type: string
description: Human message
WalletBalanceNotFoundError:
type: object
required:
- error
- message
properties:
error:
type: string
description: Error code
example: "wallet_not_applicable"
message:
type: string
description: Human message
example: "not a tempo agent"
Untouched:
type: object
"#;
#[test]
fn normalize_folds_alias_refs_and_drops_schema() {
let (out, report) =
normalize_with_aliases(BASE_SPEC, &["WalletBalanceNotFoundError"]).unwrap();
assert_eq!(
report.folded_aliases,
vec!["WalletBalanceNotFoundError".to_string()]
);
assert_eq!(report.refs_rewritten, 1);
assert!(
out.contains("Error:\n type: object"),
"canonical Error schema missing:\n{out}"
);
assert!(
!out.contains("WalletBalanceNotFoundError"),
"alias name fully removed, got:\n{out}"
);
assert!(out.contains("Untouched:"));
}
#[test]
fn idempotent_when_no_aliases_present() {
let (out1, _) = normalize_spec_with_report(BASE_SPEC).unwrap();
let (out2, report2) = normalize_spec_with_report(&out1).unwrap();
assert_eq!(out1, out2);
assert!(report2.folded_aliases.is_empty());
assert_eq!(report2.refs_rewritten, 0);
}
#[test]
fn drift_check_rejects_mismatched_alias() {
let drifted = BASE_SPEC.replace(
"WalletBalanceNotFoundError:\n type: object\n required:\n - error\n - message",
"WalletBalanceNotFoundError:\n type: object\n required:\n - error\n - message\n - drift_field",
);
let err = normalize_with_aliases(&drifted, &["WalletBalanceNotFoundError"]).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("drifted from canonical"), "got: {msg}");
}
#[test]
fn missing_error_schema_is_error() {
let bad = BASE_SPEC.replace("Error:", "NotError:");
let err = normalize_spec(bad.as_str()).unwrap_err();
assert!(format!("{err:#}").contains("canonical schema `Error` not found"));
}
const MULTIPART_SPEC: &str = r#"
openapi: 3.0.0
info: { title: test, version: 0.0.0 }
paths:
/upload:
post:
operationId: uploadThing
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: { type: string, format: binary }
responses:
'201': { description: ok }
get:
operationId: listThings
responses:
'200': { description: ok }
/json_only:
post:
operationId: postJsonThing
requestBody:
required: true
content:
application/json:
schema: { type: object }
responses:
'201': { description: ok }
components:
schemas:
Error:
type: object
required: [error, message]
properties:
error: { type: string }
message: { type: string }
"#;
#[test]
fn non_success_responses_are_stripped() {
let (out, report) = normalize_spec_with_report(BASE_SPEC).unwrap();
assert_eq!(report.error_responses_stripped, 2);
assert!(out.contains("'200':"));
assert!(!out.contains("'404':"), "404 not stripped:\n{out}");
assert!(!out.contains("'503':"), "503 not stripped:\n{out}");
}
#[test]
fn multi_media_request_body_collapses_to_json() {
const SPEC: &str = r#"
openapi: 3.0.0
info: { title: test, version: 0.0.0 }
paths:
/oauth2/token:
post:
operationId: oauthToken
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/Tok' }
application/x-www-form-urlencoded:
schema: { $ref: '#/components/schemas/Tok' }
responses:
'200': { description: ok }
/forms:
post:
operationId: formOnly
requestBody:
content:
application/x-www-form-urlencoded:
schema: { type: object }
text/plain:
schema: { type: string }
responses:
'200': { description: ok }
components:
schemas:
Error:
type: object
required: [error, message]
properties:
error: { type: string }
message: { type: string }
Tok:
type: object
"#;
let (out, report) = normalize_spec_with_report(SPEC).unwrap();
assert_eq!(report.request_media_collapsed, 2);
assert_eq!(
out.matches("application/json:").count(),
1,
"expected oauth body kept as json:\n{out}"
);
assert_eq!(
out.matches("application/x-www-form-urlencoded:").count(),
1,
"expected form-only body kept as form-urlencoded:\n{out}"
);
assert!(
!out.contains("text/plain"),
"lower-priority text/plain variant not collapsed:\n{out}"
);
}
#[test]
fn multipart_only_operation_is_stripped() {
let (out, report) = normalize_spec_with_report(MULTIPART_SPEC).unwrap();
assert_eq!(report.stripped_operations, vec!["uploadThing".to_string()]);
assert!(!out.contains("uploadThing"), "uploadThing not stripped");
assert!(out.contains("listThings"), "sibling GET removed by mistake");
assert!(
out.contains("postJsonThing"),
"JSON-only POST removed by mistake"
);
assert!(out.contains("/upload:"));
}
const NULL_VARIANT_SPEC: &str = r#"
openapi: 3.0.0
info: { title: test, version: 0.0.0 }
paths:
/thing:
get:
responses:
'200':
description: ok
content:
application/json:
schema:
oneOf:
- { type: object, properties: { name: { type: string } } }
- { type: "null" }
components:
schemas:
Error:
type: object
required: [error, message]
properties:
error: { type: string }
message: { type: string }
Nullable:
anyOf:
- { type: integer }
- { type: "null", description: "explicit absence" }
AllNull:
oneOf:
- { type: "null" }
- { type: "null" }
Untouched:
oneOf:
- { type: integer }
- { type: string }
"#;
#[test]
fn null_type_variants_are_stripped_and_counted() {
let (out, report) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
assert_eq!(report.null_variants_stripped, 4);
assert!(out.contains("Nullable:"));
assert!(out.contains("Untouched:"));
assert!(
!out.contains("explicit absence"),
"null variant with sibling description not stripped:\n{out}"
);
assert!(
out.contains("Untouched:") && out.matches("type: string").count() >= 2,
"Untouched.oneOf altered:\n{out}"
);
}
#[test]
fn empty_oneof_after_strip_is_removed_outright() {
let (out, _report) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
let idx = out.find("AllNull:").expect("AllNull schema present");
let tail = &out[idx..];
let block_end = tail[8..].find("\n ").map_or(tail.len(), |n| n + 8);
let block = &tail[..block_end];
assert!(
!block.contains("oneOf"),
"empty oneOf not removed from AllNull:\n{block}"
);
}
#[test]
fn null_strip_is_idempotent() {
let (out1, _) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
let (out2, report2) = normalize_spec_with_report(&out1).unwrap();
assert_eq!(out1, out2);
assert_eq!(report2.null_variants_stripped, 0);
}
#[test]
fn response_enums_are_opened_but_request_enums_stay_typed() {
const SPEC: &str = r#"
openapi: 3.0.0
info: { title: test, version: 0.0.0 }
paths:
/thing:
post:
parameters:
- { name: filter, in: query, schema: { type: string, enum: [known] } }
requestBody:
content:
application/json:
schema: { $ref: '#/components/schemas/Request' }
responses:
'200':
description: ok
content:
application/json:
schema: { $ref: '#/components/schemas/Response' }
default:
description: fallback
content:
application/json:
schema: { type: string, enum: [fallback] }
'400':
description: bad request
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
components:
schemas:
Error: { type: string, enum: [bad] }
Request:
type: object
properties:
kind: { type: string, enum: [known] }
shared: { $ref: '#/components/schemas/Shared' }
Shared: { type: string, enum: [known] }
Response:
allOf:
- type: object
properties:
status: { type: string, enum: [active] }
nullable_status: { type: string, nullable: true, enum: [active, null] }
shared: { $ref: '#/components/schemas/Shared' }
recursive: { $ref: '#/components/schemas/Response' }
choices:
oneOf:
- { $ref: '#/components/schemas/Choice' }
- { type: "null" }
alternatives:
anyOf:
- { type: string, enum: [known] }
list:
type: array
items: { $ref: '#/components/schemas/Choice' }
map:
type: object
additionalProperties: { $ref: '#/components/schemas/Choice' }
number: { type: integer, enum: [1, 2] }
Choice: { type: string, enum: [known] }
"#;
let (out, _) = normalize_spec_with_report(SPEC).unwrap();
let doc: Value = serde_yaml::from_str(&out).unwrap();
let schemas = &doc["components"]["schemas"];
let properties = &schemas["Response"]["allOf"][0]["properties"];
assert!(properties["status"].get("enum").is_none());
assert!(properties["nullable_status"].get("enum").is_none());
assert_eq!(properties["nullable_status"]["nullable"], Value::Bool(true));
assert!(schemas["Request"]["properties"]["kind"]
.get("enum")
.is_some());
assert!(doc["paths"]["/thing"]["post"]["parameters"][0]["schema"]
.get("enum")
.is_some());
assert!(schemas["Shared"].get("enum").is_none());
assert!(schemas["Choice"].get("enum").is_none());
assert!(properties["alternatives"]["anyOf"][0].get("enum").is_none());
assert!(properties["number"].get("enum").is_some());
assert!(schemas["Error"].get("enum").is_some());
assert!(
doc["paths"]["/thing"]["post"]["responses"]["default"]["content"]["application/json"]
["schema"]
.get("enum")
.is_none()
);
}
#[test]
fn reusable_responses_follow_refs_without_opening_example_enums() {
const SPEC: &str = r#"
openapi: 3.0.0
info: { title: test, version: 0.0.0 }
paths:
/thing:
get:
responses:
'2XX': { $ref: '#/components/responses/Thing' }
components:
responses:
Thing:
description: ok
content:
application/json:
schema: { $ref: '#/components/schemas/Thing~1Status~0' }
example: { type: string, enum: [literal_payload] }
schemas:
Error: { type: object }
Thing/Status~: { type: string, enum: [active] }
"#;
let (out, report) = normalize_spec_with_report(SPEC).unwrap();
let doc: Value = serde_yaml::from_str(&out).unwrap();
assert!(doc["components"]["schemas"]["Thing/Status~"]
.get("enum")
.is_none());
assert!(
doc["components"]["responses"]["Thing"]["content"]["application/json"]["example"]
.get("enum")
.is_some()
);
assert_eq!(report.response_enums_stripped, 1);
}
#[test]
fn live_spec_response_enum_inventory_is_normalized() {
fn enum_count(node: &Value) -> usize {
match node {
Value::Mapping(map) => {
usize::from(
map.get("type").and_then(Value::as_str) == Some("string")
&& map.contains_key("enum"),
) + map.values().map(enum_count).sum::<usize>()
}
Value::Sequence(seq) => seq.iter().map(enum_count).sum(),
_ => 0,
}
}
fn refs(node: &Value, schemas: &Mapping, found: &mut BTreeSet<String>) {
match node {
Value::Mapping(map) => {
if let Some(name) = map
.get("$ref")
.and_then(Value::as_str)
.and_then(|target| target.strip_prefix("#/components/schemas/"))
{
if found.insert(name.to_owned()) {
refs(&schemas[name], schemas, found);
}
}
for child in map.values() {
refs(child, schemas, found);
}
}
Value::Sequence(seq) => {
for child in seq {
refs(child, schemas, found);
}
}
_ => {}
}
}
let src = include_str!("../../../spec/openapi.yaml");
let doc: Value = serde_yaml::from_str(src).unwrap();
let schemas = doc["components"]["schemas"].as_mapping().unwrap();
let mut response_refs = BTreeSet::new();
let mut request_refs = BTreeSet::new();
let mut inline_response_ops = 0;
let mut inline_request_ops = 0;
let mut inline_response_enums = 0;
for item in doc["paths"].as_mapping().unwrap().values() {
refs(&item["parameters"], schemas, &mut request_refs);
for verb in [
"get", "put", "post", "delete", "patch", "options", "head", "trace",
] {
let Some(op) = item.get(verb) else { continue };
refs(&op["requestBody"], schemas, &mut request_refs);
refs(&op["parameters"], schemas, &mut request_refs);
inline_request_ops +=
usize::from(enum_count(&op["requestBody"]) + enum_count(&op["parameters"]) > 0);
let mut op_enums = 0;
for (code, response) in op["responses"].as_mapping().unwrap() {
let code = code.as_str().unwrap();
if code == "default" || code.starts_with('2') {
refs(&response["content"], schemas, &mut response_refs);
op_enums += enum_count(&response["content"]);
}
}
inline_response_ops += usize::from(op_enums > 0);
inline_response_enums += op_enums;
}
}
let enum_components: BTreeSet<String> = schemas
.iter()
.filter(|(_, schema)| enum_count(schema) > 0)
.map(|(name, _)| name.as_str().unwrap().to_owned())
.collect();
let response_components: BTreeSet<_> = enum_components
.intersection(&response_refs)
.cloned()
.collect();
let request_components: BTreeSet<_> = enum_components
.intersection(&request_refs)
.cloned()
.collect();
assert_eq!(
response_components.difference(&request_components).count(),
50
);
assert_eq!(
request_components.difference(&response_components).count(),
8
);
assert_eq!(
response_components
.intersection(&request_components)
.map(String::as_str)
.collect::<Vec<_>>(),
["CompletionCriterionInput"]
);
assert_eq!(
enum_components
.difference(
&response_components
.union(&request_components)
.cloned()
.collect()
)
.map(String::as_str)
.collect::<Vec<_>>(),
["RateLimitError"]
);
assert_eq!(inline_response_ops, 3);
assert_eq!(inline_request_ops, 10);
let expected = inline_response_enums
+ response_components
.iter()
.map(|name| enum_count(&schemas[name.as_str()]))
.sum::<usize>();
let (out, report) = normalize_spec_with_report(src).unwrap();
assert_eq!(report.response_enums_stripped, expected);
let normalized: Value = serde_yaml::from_str(&out).unwrap();
for name in &response_components {
assert_eq!(
enum_count(&normalized["components"]["schemas"][name.as_str()]),
0,
"response component {name}"
);
}
for name in request_components.difference(&response_components) {
assert_eq!(
enum_count(&normalized["components"]["schemas"][name.as_str()]),
enum_count(&schemas[name.as_str()]),
"request-only component {name}"
);
}
let (again, report) = normalize_spec_with_report(&out).unwrap();
assert_eq!(again, out);
assert_eq!(report.response_enums_stripped, 0);
}
#[test]
fn response_required_is_relaxed_except_kept_and_request_fields() {
const SPEC: &str = r#"
openapi: 3.0.0
info: { title: test, version: 0.0.0 }
paths:
/hooks:
put:
requestBody:
content:
application/json:
schema: { $ref: '#/components/schemas/Request' }
responses:
'200':
description: ok
content:
application/json:
schema: { $ref: '#/components/schemas/WebhookConfig' }
/stake/quote:
get:
operationId: getStakeQuote
responses:
'200':
description: ok
content:
application/json:
schema:
type: object
required: [required_amount, currency]
properties:
required_amount: { type: integer }
currency: { type: string }
components:
schemas:
Error: { type: object }
Request:
type: object
required: [url]
properties:
url: { type: string }
WebhookConfig:
type: object
required: [url, secret, nested]
properties:
url: { type: string }
secret: { type: string }
nested:
type: object
required: [inner]
properties:
inner: { type: string }
"#;
let (out, report) = normalize_spec_with_report(SPEC).unwrap();
let doc: Value = serde_yaml::from_str(&out).unwrap();
let schemas = &doc["components"]["schemas"];
let required = |node: &Value| -> Vec<String> {
node.get("required")
.and_then(Value::as_sequence)
.map(|seq| {
seq.iter()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
};
assert_eq!(required(&schemas["WebhookConfig"]), ["url"]);
assert!(schemas["WebhookConfig"]["properties"]["nested"]
.get("required")
.is_none());
assert_eq!(required(&schemas["Request"]), ["url"]);
let quote = &doc["paths"]["/stake/quote"]["get"]["responses"]["200"]["content"]
["application/json"]["schema"];
assert_eq!(required(quote), ["required_amount"]);
assert_eq!(report.response_required_relaxed, 4);
let (again, report) = normalize_spec_with_report(&out).unwrap();
assert_eq!(again, out);
assert_eq!(report.response_required_relaxed, 0);
}
#[test]
fn every_kept_required_entry_exists_in_the_vendored_spec() {
let src = include_str!("../../../spec/openapi.yaml");
let doc: Value = serde_yaml::from_str(src).unwrap();
let mut present = BTreeSet::new();
for path in response_schema_paths(&doc) {
let key = schema_key(&doc, &path);
if let Some(required) = value_at_path(&doc, &path)
.and_then(|schema| schema.get("required"))
.and_then(Value::as_sequence)
{
for property in required.iter().filter_map(Value::as_str) {
present.insert((key.clone(), property.to_owned()));
}
}
}
let missing: Vec<_> = KEEP_REQUIRED
.iter()
.filter(|(key, property)| {
!present.contains(&((*key).to_owned(), (*property).to_owned()))
})
.collect();
assert!(
missing.is_empty(),
"stale KEEP_REQUIRED entries: {missing:?}"
);
}
const CLOSED_OBJECT_SPEC: &str = r#"
openapi: 3.0.0
info:
title: test
version: 0.0.0
paths:
/thing:
get:
responses:
'200':
description: ok
content:
application/json:
schema:
type: object
additionalProperties: false
properties:
id:
type: string
components:
schemas:
Error:
type: object
additionalProperties: false
properties:
error:
type: string
Open:
type: object
additionalProperties: true
"#;
#[test]
fn closed_objects_are_opened_everywhere() {
let (out, _) = normalize_spec_with_report(CLOSED_OBJECT_SPEC).unwrap();
assert!(
!out.contains("additionalProperties: false"),
"closed object survived:\n{out}"
);
assert!(
out.contains("additionalProperties: true"),
"non-false value must be left alone:\n{out}"
);
}
}