Skip to main content

soothfast_spec/openapi/
diff.rs

1//! Compatibility diffing between two generated OpenAPI documents.
2//!
3//! Once a spec is generated it can no longer disagree with the code, so the
4//! question worth gating shifts: not "does this match?" but "did this break
5//! the people calling it?". That is a comparison against the merge-base, and
6//! its answer is aimed at consumers rather than at the author.
7//!
8//! Requiredness means opposite things in the two directions, which is the
9//! subtlety the whole comparison turns on (see [`crate::compat`]):
10//!
11//! - **Request** (client → server): the server growing *stricter* breaks
12//!   callers. A newly required field is breaking; dropping a requirement is
13//!   not.
14//! - **Response** (server → client): the server providing *less* breaks
15//!   callers. A field that stops being guaranteed is breaking; a new one is
16//!   not.
17
18use std::collections::{BTreeMap, BTreeSet};
19
20use serde_json::Value;
21
22use crate::compat::{Change, Direction, SchemaDiff, Severity, sort};
23
24/// Compare two generated documents, oldest first.
25///
26/// Returns every difference found, breaking ones first, then by location so
27/// output is stable between runs.
28pub fn diff(old: &Value, new: &Value) -> Vec<Change> {
29    let mut changes = Vec::new();
30    let old_ops = operations(old);
31    let new_ops = operations(new);
32    let schemas = SchemaDiff::new(old, new);
33
34    let keys: BTreeSet<&String> = old_ops.keys().chain(new_ops.keys()).collect();
35    for key in keys {
36        match (old_ops.get(key), new_ops.get(key)) {
37            (Some(_), None) => changes.push(Change::new(
38                Severity::Breaking,
39                key.clone(),
40                "operation removed",
41            )),
42            (None, Some(_)) => changes.push(Change::new(
43                Severity::Additive,
44                key.clone(),
45                "operation added",
46            )),
47            (Some(o), Some(n)) => compare_operation(&mut changes, key, &schemas, o, n),
48            (None, None) => {}
49        }
50    }
51
52    sort(&mut changes);
53    changes
54}
55
56/// `"POST /items"` → operation object, for every operation in a document.
57fn operations(doc: &Value) -> BTreeMap<String, Value> {
58    let mut out = BTreeMap::new();
59    let Some(paths) = doc["paths"].as_object() else {
60        return out;
61    };
62    for (path, item) in paths {
63        let Some(methods) = item.as_object() else {
64            continue;
65        };
66        for (verb, op) in methods {
67            out.insert(format!("{} {path}", verb.to_uppercase()), op.clone());
68        }
69    }
70    out
71}
72
73fn compare_operation(
74    changes: &mut Vec<Change>,
75    at: &str,
76    schemas: &SchemaDiff,
77    old: &Value,
78    new: &Value,
79) {
80    compare_parameters(changes, at, old, new);
81
82    // Request body.
83    match (body_schema(old), body_schema(new)) {
84        (Some(o), Some(n)) => schemas.compare(
85            changes,
86            &format!("{at} requestBody"),
87            &o,
88            &n,
89            Direction::Request,
90        ),
91        (Some(_), None) => {
92            changes.push(Change::new(Severity::Breaking, at, "request body removed"))
93        }
94        // A body the server did not previously read is now expected.
95        (None, Some(_)) => changes.push(Change::new(
96            Severity::Breaking,
97            at,
98            "request body added — callers that sent none will fail",
99        )),
100        (None, None) => {}
101    }
102
103    // Responses, keyed by status code.
104    let empty = serde_json::Map::new();
105    let old_res = old["responses"].as_object().unwrap_or(&empty);
106    let new_res = new["responses"].as_object().unwrap_or(&empty);
107    let codes: BTreeSet<&String> = old_res.keys().chain(new_res.keys()).collect();
108    for code in codes {
109        match (old_res.get(code), new_res.get(code)) {
110            (Some(_), None) => changes.push(Change::new(
111                Severity::Breaking,
112                format!("{at} {code}"),
113                "response status removed",
114            )),
115            (None, Some(_)) => changes.push(Change::new(
116                Severity::Additive,
117                format!("{at} {code}"),
118                "response status added",
119            )),
120            (Some(o), Some(n)) => {
121                if let (Some(os), Some(ns)) = (content_schema(o), content_schema(n)) {
122                    schemas.compare(
123                        changes,
124                        &format!("{at} {code}"),
125                        &os,
126                        &ns,
127                        Direction::Response,
128                    );
129                }
130            }
131            (None, None) => {}
132        }
133    }
134}
135
136fn compare_parameters(changes: &mut Vec<Change>, at: &str, old: &Value, new: &Value) {
137    let index = |op: &Value| -> BTreeMap<String, Value> {
138        op["parameters"]
139            .as_array()
140            .map(|ps| {
141                ps.iter()
142                    .map(|p| {
143                        let name = p["name"].as_str().unwrap_or("");
144                        let loc = p["in"].as_str().unwrap_or("");
145                        (format!("{loc}:{name}"), p.clone())
146                    })
147                    .collect()
148            })
149            .unwrap_or_default()
150    };
151    let (old_p, new_p) = (index(old), index(new));
152    let keys: BTreeSet<&String> = old_p.keys().chain(new_p.keys()).collect();
153
154    for key in keys {
155        let where_ = format!("{at} parameter {key}");
156        match (old_p.get(key), new_p.get(key)) {
157            (Some(o), None) => {
158                let severity = if o["required"] == Value::Bool(true) {
159                    Severity::Breaking
160                } else {
161                    Severity::Additive
162                };
163                changes.push(Change::new(severity, where_, "parameter removed"));
164            }
165            (None, Some(n)) => {
166                // Only a newly *required* parameter can break a caller.
167                let severity = if n["required"] == Value::Bool(true) {
168                    Severity::Breaking
169                } else {
170                    Severity::Additive
171                };
172                changes.push(Change::new(severity, where_, "parameter added"));
173            }
174            (Some(o), Some(n)) => {
175                match (
176                    o["required"] == Value::Bool(true),
177                    n["required"] == Value::Bool(true),
178                ) {
179                    (false, true) => changes.push(Change::new(
180                        Severity::Breaking,
181                        &where_,
182                        "parameter became required",
183                    )),
184                    (true, false) => changes.push(Change::new(
185                        Severity::Additive,
186                        &where_,
187                        "parameter became optional",
188                    )),
189                    _ => {}
190                }
191                let (ot, nt) = (&o["schema"]["type"], &n["schema"]["type"]);
192                if ot != nt && !ot.is_null() && !nt.is_null() {
193                    changes.push(Change::new(
194                        Severity::Breaking,
195                        &where_,
196                        format!("type {ot} -> {nt}"),
197                    ));
198                }
199            }
200            (None, None) => {}
201        }
202    }
203}
204
205fn body_schema(op: &Value) -> Option<Value> {
206    content_schema(&op["requestBody"])
207}
208
209/// The first (and in generated documents, only) content schema.
210fn content_schema(holder: &Value) -> Option<Value> {
211    holder["content"]
212        .as_object()?
213        .values()
214        .next()
215        .map(|c| c["schema"].clone())
216        .filter(|s| !s.is_null())
217}