//! The utoipa document equals `contracts/openapi.yaml` (SPEC §14.3, §11.2 M2a). //! //! Both documents are parsed into JSON values and brought to one canonical form before comparing. The comparison //! fails on any added, missing or differently typed field, path, operation, parameter, request body, response, //! response header, security requirement or enum value. The normalisations, all of which are representational (they //! do not change what a client may send or will receive): //! //! 1. Key order: JSON objects are compared as maps. //! 2. `$ref` naming and placement: every local `$ref` is replaced by the (normalised) value it points to, so it does //! not matter whether something is a named component or inline, or what the component is called. Sibling keys //! next to a `$ref` override the target's (OpenAPI 3.1 Reference Object). Discriminator `mapping` values are //! references too and are resolved the same way. Components that no operation reaches are compared by name. //! 3. Documentation text: `description` and `example`/`examples` keywords are dropped everywhere (property *names* //! called `description` are kept). Operation `summary` and `operationId` are compared. //! 4. Nullable spellings: `type: [T, "null"]` is rewritten to `oneOf: [{type: T, …}, {type: "null"}]`; nested //! `oneOf`s are flattened and their members compared as a set (order of alternatives carries no meaning). //! 5. `const: v` is rewritten to `enum: [v]` with the JSON type of `v`; `enum` values and `required` lists are //! compared as sets. //! 6. Defaults written out or left implicit: `required: false` and `deprecated: false` are dropped; a path parameter //! is always required. //! 7. Parameters are compared as a set keyed by `(in, name)` (their order carries no meaning). //! 8. Security: each operation carries its effective requirement (its own `security`, or the document's); the //! document-level key itself is then dropped. use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; use serde_json::{Map, Value, json}; const NAME_MAPS: &[&str] = &[ "properties", "patternProperties", "dependentRequired", "headers", "content", "responses", "mapping", "securitySchemes", "$defs", ]; const DOC_KEYWORDS: &[&str] = &["description", "example", "examples"]; const METHODS: &[&str] = &[ "get", "put", "post", "delete", "patch", "options", "head", "trace", ]; const COMPONENT_KINDS: &[&str] = &[ "schemas", "responses", "parameters", "headers", "requestBodies", ]; fn contract() -> Value { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../contracts/openapi.yaml"); let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); serde_norway::from_str(&text).expect("contracts/openapi.yaml is YAML") } fn generated() -> Value { serde_json::to_value(kvitt_server::openapi::document()).expect("document serialises") } /// Resolves `#/a/b/c` in `root`. fn pointer<'a>(root: &'a Value, reference: &str) -> &'a Value { let pointer = reference .strip_prefix('#') .unwrap_or_else(|| panic!("only local refs are supported: {reference}")); root.pointer(pointer) .unwrap_or_else(|| panic!("dangling $ref {reference}")) } struct Resolver<'a> { root: &'a Value, reached: BTreeSet, } impl Resolver<'_> { /// Replaces every `$ref` (and discriminator mapping value) by its target, recursively. fn inline(&mut self, value: &Value, stack: &mut Vec) -> Value { match value { Value::Object(map) => { if let Some(Value::String(reference)) = map.get("$ref") { assert!(!stack.contains(reference), "recursive $ref {reference}"); self.reached.insert(reference.clone()); stack.push(reference.clone()); let target = self.inline(pointer(self.root, reference), stack); stack.pop(); let mut merged = match target { Value::Object(t) => t, other => return other, }; for (k, v) in map { if k != "$ref" { merged.insert(k.clone(), self.inline(v, stack)); } } return Value::Object(merged); } let mut out = Map::new(); for (k, v) in map { if k == "discriminator" { let mut d = v.clone(); if let Some(Value::Object(mapping)) = v.get("mapping") { let resolved: Map = mapping .iter() .map(|(name, target)| { let reference = target.as_str().expect("mapping value is a ref string"); ( name.clone(), self.inline(&json!({ "$ref": reference }), stack), ) }) .collect(); d["mapping"] = Value::Object(resolved); } out.insert(k.clone(), d); } else { out.insert(k.clone(), self.inline(v, stack)); } } Value::Object(out) } Value::Array(items) => { Value::Array(items.iter().map(|v| self.inline(v, stack)).collect()) } other => other.clone(), } } } fn json_type(v: &Value) -> &'static str { match v { Value::Null => "null", Value::Bool(_) => "boolean", Value::Number(n) if n.is_i64() || n.is_u64() => "integer", Value::Number(_) => "number", Value::String(_) => "string", Value::Array(_) => "array", Value::Object(_) => "object", } } fn sorted_set(items: Vec) -> Vec { let mut by_text: BTreeMap = BTreeMap::new(); for item in items { by_text.insert(item.to_string(), item); } by_text.into_values().collect() } /// The canonical form (normalisations 3 to 7). `names` is true when the object's keys are names, not keywords. fn canon(value: &Value, names: bool) -> Value { match value { Value::Object(map) if names => Value::Object( map.iter() .map(|(k, v)| (k.clone(), canon(v, false))) .collect(), ), Value::Object(map) => { let mut out = Map::new(); for (k, v) in map { if DOC_KEYWORDS.contains(&k.as_str()) { continue; } if (k == "required" || k == "deprecated") && *v == Value::Bool(false) { continue; } let child = if k == "parameters" { canon_parameters(v) } else { canon(v, NAME_MAPS.contains(&k.as_str())) }; out.insert(k.clone(), child); } if let Some(constant) = out.remove("const") { out.entry("type") .or_insert_with(|| json!(json_type(&constant))); out.insert("enum".into(), json!([constant])); } if let Some(Value::Array(types)) = out.get("type").cloned() { if types.len() > 1 { out.remove("type"); let mut base = out.clone(); let mut alternatives = Vec::new(); for t in types { if t == "null" { alternatives.push(json!({ "type": "null" })); } else { base.insert("type".into(), t); alternatives.push(Value::Object(base.clone())); } } out = Map::new(); out.insert("oneOf".into(), json!(alternatives)); } else if let Some(t) = types.into_iter().next() { out.insert("type".into(), t); } } for key in ["oneOf", "anyOf"] { if let Some(Value::Array(alternatives)) = out.remove(key) { let mut flat = Vec::new(); for alternative in alternatives { let alternative = canon(&alternative, false); match alternative.get(key) { Some(Value::Array(inner)) if alternative.as_object().is_some_and(|o| o.len() == 1) => { flat.extend(inner.iter().cloned()); } _ => flat.push(alternative), } } out.insert(key.into(), Value::Array(sorted_set(flat))); } } for key in ["enum", "required"] { if let Some(Value::Array(items)) = out.remove(key) { out.insert(key.into(), Value::Array(sorted_set(items))); } } Value::Object(out) } Value::Array(items) => Value::Array(items.iter().map(|v| canon(v, false)).collect()), other => other.clone(), } } /// Normalisation 7 (and the path-parameter half of 6): parameters as a map keyed by `in name`. fn canon_parameters(value: &Value) -> Value { let Value::Array(items) = value else { return canon(value, true); }; let mut out = Map::new(); for item in items { let mut p = canon(item, false); let location = p["in"].as_str().unwrap_or_default().to_owned(); let name = p["name"].as_str().unwrap_or_default().to_owned(); if location == "path" { p["required"] = json!(true); } let key = format!("{location} {name}"); assert!(!out.contains_key(&key), "duplicate parameter {key}"); out.insert(key, p); } Value::Object(out) } /// The whole normalised document. fn normalise(doc: &Value) -> Value { let mut resolver = Resolver { root: doc, reached: BTreeSet::new(), }; let root_security = doc.get("security").cloned().unwrap_or(json!([])); let mut operations = Map::new(); for (path, item) in doc["paths"].as_object().expect("paths") { let item = resolver.inline(item, &mut Vec::new()); let mut methods = Map::new(); for (method, operation) in item.as_object().expect("path item") { assert!( METHODS.contains(&method.as_str()), "{path}: path-level key {method} is not supported by this comparison" ); let mut operation = operation.clone(); let security = operation .get("security") .cloned() .unwrap_or_else(|| root_security.clone()); operation["security"] = security; operation.as_object_mut().map(|o| o.remove("tags")); methods.insert(method.clone(), canon(&operation, false)); } operations.insert(path.clone(), Value::Object(methods)); } let mut unreachable = Map::new(); if let Some(components) = doc.get("components") { for kind in COMPONENT_KINDS { for (name, value) in components .get(*kind) .and_then(Value::as_object) .into_iter() .flatten() { let reference = format!("#/components/{kind}/{name}"); if !resolver.reached.contains(&reference) { let mut inner = Resolver { root: doc, reached: BTreeSet::new(), }; unreachable.insert( reference, canon(&inner.inline(value, &mut Vec::new()), false), ); } } } } json!({ "openapi": doc["openapi"], "info": { "title": doc["info"]["title"], "version": doc["info"]["version"] }, "servers": canon(&doc["servers"], false), "securitySchemes": canon(&doc["components"]["securitySchemes"], true), "operations": Value::Object(operations), "unreachable_components": Value::Object(unreachable), "info_extra_keys": doc["info"].as_object().map(|o| { o.keys().filter(|k| !["title", "version", "description"].contains(&k.as_str())).cloned().collect::>() }), }) } /// Paths (JSON-pointer style) where `a` and `b` differ. fn short(v: &Value) -> String { let text = v.to_string(); if text.chars().count() > 300 { format!("{}…", text.chars().take(300).collect::()) } else { text } } fn diff(a: &Value, b: &Value, at: String, out: &mut Vec) { match (a, b) { (Value::Object(x), Value::Object(y)) => { let keys: BTreeSet<&String> = x.keys().chain(y.keys()).collect(); for k in keys { let here = format!("{at}/{}", k.replace('~', "~0").replace('/', "~1")); match (x.get(k), y.get(k)) { (Some(v), Some(w)) => diff(v, w, here, out), (Some(v), None) => out.push(format!("{here}: only in contract: {}", short(v))), (None, Some(w)) => out.push(format!("{here}: only in generated: {}", short(w))), (None, None) => {} } } } (Value::Array(x), Value::Array(y)) if x.len() == y.len() => { for (i, (v, w)) in x.iter().zip(y).enumerate() { diff(v, w, format!("{at}/{i}"), out); } } _ if a == b => {} _ => out.push(format!( "{at}: contract {} != generated {}", short(a), short(b) )), } } fn differences(contract: &Value, generated: &Value) -> Vec { let mut out = Vec::new(); diff( &normalise(contract), &normalise(generated), String::new(), &mut out, ); out } #[test] fn generated_document_equals_contract() { let contract = contract(); let generated = generated(); let diffs = differences(&contract, &generated); assert!( diffs.is_empty(), "{} differences between contracts/openapi.yaml and the utoipa document:\n{}", diffs.len(), diffs .iter() .take(80) .cloned() .collect::>() .join("\n") ); // The contract has 33 operations (SPEC §7, E-1 to E-33); make sure none were lost on both sides. let count = normalise(&generated)["operations"] .as_object() .map(|paths| { paths .values() .filter_map(Value::as_object) .map(serde_json::Map::len) .sum::() }) .unwrap_or(0); assert_eq!(count, 33); } // The comparison itself must be strict: each mutation of the contract below must be detected. fn mutated(f: impl FnOnce(&mut Value)) -> Vec { let contract = contract(); let mut other = contract.clone(); f(&mut other); differences(&contract, &other) } #[test] fn detects_an_added_property() { assert!( !mutated(|d| { d["components"]["schemas"]["User"]["properties"]["extra"] = json!({ "type": "string" }); }) .is_empty() ); } #[test] fn detects_a_missing_required_field() { assert!( !mutated(|d| { d["components"]["schemas"]["Account"]["required"] .as_array_mut() .unwrap() .retain(|v| v != "deleted_at"); }) .is_empty() ); } #[test] fn detects_a_changed_type() { assert!( !mutated(|d| { d["components"]["schemas"]["Share"]["properties"]["share_bp"] = json!({ "type": "string" }); }) .is_empty() ); } #[test] fn detects_a_removed_enum_value() { assert!( !mutated(|d| { d["components"]["schemas"]["ErrorCode"]["enum"] .as_array_mut() .unwrap() .pop(); }) .is_empty() ); } #[test] fn detects_a_removed_parameter() { assert!( !mutated(|d| { d["paths"]["/sync"]["get"]["parameters"] .as_array_mut() .unwrap() .remove(0); }) .is_empty() ); } #[test] fn detects_a_changed_security_requirement() { assert!( !mutated(|d| { d["paths"]["/me"]["get"]["security"] = json!([]); }) .is_empty() ); } #[test] fn detects_a_removed_operation_and_response() { assert!( !mutated(|d| { d["paths"]["/me"].as_object_mut().unwrap().remove("patch"); }) .is_empty() ); assert!( !mutated(|d| { d["paths"]["/me"]["get"]["responses"] .as_object_mut() .unwrap() .remove("426"); }) .is_empty() ); } #[test] fn ignores_key_order_ref_naming_and_descriptions() { assert!( mutated(|d| { // Rename a schema component and point every reference at the new name. let text = serde_json::to_string(d).unwrap().replace( "#/components/schemas/Share\"", "#/components/schemas/DefaultShare\"", ); *d = serde_json::from_str(&text).unwrap(); let share = d["components"]["schemas"] .as_object_mut() .unwrap() .remove("Share") .unwrap(); d["components"]["schemas"]["DefaultShare"] = share; d["components"]["schemas"]["User"]["description"] = json!("changed wording"); }) .is_empty() ); }