Files
kvitt/server/crates/kvitt-server/tests/openapi_contract.rs
T
jacobh2 c942d73c66 Server overhaul: clear clippy::pedantic wins
Taken from a -W clippy::pedantic scan, no allow added: map_or/map_or_else, method references instead of closures,
clone_from, write! instead of push_str(format!), borrowed Option<&str>, push_of without a needless Option, the
envelope's entity moved instead of cloned, the scheduler's plan() no longer a needless async fn (its doc now says
what it does on failure), sort_unstable, digit separators and inline format args in tests. Not taken: missing
# Errors/# Panics docs, must_use, doc backticks, assert!(x.is_empty()) in tests, too_many_lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-18 22:43:54 +02:00

512 lines
18 KiB
Rust

//! 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<String>,
}
impl Resolver<'_> {
/// Replaces every `$ref` (and discriminator mapping value) by its target, recursively.
fn inline(&mut self, value: &Value, stack: &mut Vec<String>) -> 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<String, Value> = 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<Value>) -> Vec<Value> {
let mut by_text: BTreeMap<String, Value> = 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::<Vec<_>>()
}),
})
}
/// 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::<String>())
} else {
text
}
}
fn diff(a: &Value, b: &Value, at: String, out: &mut Vec<String>) {
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<String> {
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::<Vec<_>>()
.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::<usize>()
})
.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<String> {
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()
);
}