#![allow(missing_docs, clippy::doc_markdown)]
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 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,
}
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);
Ok(report)
}
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);
}
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}"
);
}
}