c942d73c66
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
512 lines
18 KiB
Rust
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()
|
|
);
|
|
}
|