M2a: server workspace, kvitt-domain, skeleton routes, OpenAPI match, Dockerfile
Cargo workspace (kvitt-domain, kvitt-server, kvitt-import), Rust 1.99.1 pinned. kvitt-domain passes every Rust vector and adds next_occurrence_date (D-015). kvitt-server: config, SQLite pool (WAL, §8.9 pragmas), migration 0001 identical to the contract schema, error type for all 25 codes, auth extractor and protocol layer, all 33 routes (E-1/E-2 implemented, the rest 501), utoipa document equal to contracts/openapi.yaml, seams for M3/M4/M9, serve/migrate/healthcheck. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,511 @@
|
||||
//! 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(|m| m.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()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user