Skip to main content

soothfast_spec/
compat.rs

1//! Consumer compatibility: what changed, and whether it breaks anyone.
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?". Every generated dialect asks that same question,
6//! and the payload half of the answer is always a JSON Schema comparison, so
7//! it lives here rather than in any one dialect.
8//!
9//! Requiredness means opposite things in the two directions, which is the
10//! subtlety the whole module turns on:
11//!
12//! - **Request** (consumer → us): growing *stricter* breaks them. A newly
13//!   required field is breaking; dropping a requirement is not.
14//! - **Response** (us → consumer): providing *less* breaks them. A field that
15//!   stops being guaranteed is breaking; a new one is not.
16
17use std::collections::{BTreeMap, BTreeSet};
18
19use serde_json::Value;
20
21/// Whether a change can break an existing consumer.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub enum Severity {
24    /// Safe to release: consumers written against the old spec still work.
25    Additive,
26    /// Breaks consumers written against the old spec.
27    Breaking,
28}
29
30/// One difference between two versions of a spec.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Change {
33    pub severity: Severity,
34    /// Where it happened, e.g. `POST /items requestBody.qty`.
35    pub at: String,
36    pub detail: String,
37}
38
39impl Change {
40    pub fn new(severity: Severity, at: impl Into<String>, detail: impl Into<String>) -> Self {
41        Change {
42            severity,
43            at: at.into(),
44            detail: detail.into(),
45        }
46    }
47}
48
49/// True when nothing in the diff would break an existing consumer.
50pub fn is_compatible(changes: &[Change]) -> bool {
51    !changes.iter().any(|c| c.severity == Severity::Breaking)
52}
53
54/// Order a diff for reporting: breaking first, then by location, so output is
55/// stable between runs.
56pub fn sort(changes: &mut [Change]) {
57    changes.sort_by(|a, b| {
58        b.severity
59            .cmp(&a.severity)
60            .then_with(|| a.at.cmp(&b.at))
61            .then_with(|| a.detail.cmp(&b.detail))
62    });
63}
64
65/// Which way the data flows, which decides what "required" costs.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Direction {
68    /// Consumer-authored data arriving at us.
69    Request,
70    /// Data we hand to a consumer.
71    Response,
72}
73
74/// Compares schemas belonging to two revisions of a document.
75///
76/// Both documents are held because a `$ref` only means anything relative to
77/// the document it came from: the same pointer can name different shapes on
78/// either side of the comparison.
79pub struct SchemaDiff<'a> {
80    old_doc: &'a Value,
81    new_doc: &'a Value,
82}
83
84impl<'a> SchemaDiff<'a> {
85    /// Compare schemas from `old_doc` against schemas from `new_doc`.
86    pub fn new(old_doc: &'a Value, new_doc: &'a Value) -> Self {
87        SchemaDiff { old_doc, new_doc }
88    }
89
90    /// Compare two schemas in a known data-flow direction, appending whatever
91    /// differs to `changes`.
92    pub fn compare(
93        &self,
94        changes: &mut Vec<Change>,
95        at: &str,
96        old: &Value,
97        new: &Value,
98        dir: Direction,
99    ) {
100        self.walk(changes, at, old, new, dir, &mut BTreeSet::new());
101    }
102
103    fn walk(
104        &self,
105        changes: &mut Vec<Change>,
106        at: &str,
107        old: &Value,
108        new: &Value,
109        dir: Direction,
110        seen: &mut BTreeSet<(String, String)>,
111    ) {
112        // Self-referential schemas would otherwise recurse forever.
113        let pair = (
114            old["$ref"].as_str().unwrap_or_default().to_string(),
115            new["$ref"].as_str().unwrap_or_default().to_string(),
116        );
117        if !pair.0.is_empty() || !pair.1.is_empty() {
118            if seen.contains(&pair) {
119                return;
120            }
121            seen.insert(pair);
122        }
123
124        let old = deref(self.old_doc, old);
125        let new = deref(self.new_doc, new);
126
127        let (ot, nt) = (&old["type"], &new["type"]);
128        if ot != nt && !(ot.is_null() && nt.is_null()) {
129            changes.push(Change::new(
130                Severity::Breaking,
131                at,
132                format!("type {} -> {}", render(ot), render(nt)),
133            ));
134            return; // Field-level diffs are meaningless once the type moved.
135        }
136
137        // Arrays: compare element schemas.
138        if ot == "array" {
139            self.walk(
140                changes,
141                &format!("{at}[]"),
142                &old["items"],
143                &new["items"],
144                dir,
145                seen,
146            );
147            return;
148        }
149
150        let empty = serde_json::Map::new();
151        let old_props = old["properties"].as_object().unwrap_or(&empty);
152        let new_props = new["properties"].as_object().unwrap_or(&empty);
153        let old_req = required_set(old);
154        let new_req = required_set(new);
155
156        let names: BTreeSet<&String> = old_props.keys().chain(new_props.keys()).collect();
157        for name in names {
158            let where_ = format!("{at}.{name}");
159            match (old_props.get(name), new_props.get(name)) {
160                (Some(_), None) => {
161                    // Losing a response field breaks readers; losing a request
162                    // field means data callers still send is now ignored.
163                    changes.push(Change::new(Severity::Breaking, where_, "property removed"));
164                }
165                (None, Some(_)) => {
166                    let newly_required = new_req.contains(name.as_str());
167                    let severity = match (dir, newly_required) {
168                        // A new required request field breaks every caller.
169                        (Direction::Request, true) => Severity::Breaking,
170                        _ => Severity::Additive,
171                    };
172                    changes.push(Change::new(severity, where_, "property added"));
173                }
174                (Some(o), Some(n)) => {
175                    match (
176                        old_req.contains(name.as_str()),
177                        new_req.contains(name.as_str()),
178                    ) {
179                        (false, true) if dir == Direction::Request => changes.push(Change::new(
180                            Severity::Breaking,
181                            &where_,
182                            "became required",
183                        )),
184                        (true, false) if dir == Direction::Response => changes.push(Change::new(
185                            Severity::Breaking,
186                            &where_,
187                            "no longer guaranteed",
188                        )),
189                        (false, true) => changes.push(Change::new(
190                            Severity::Additive,
191                            &where_,
192                            "became required",
193                        )),
194                        (true, false) => changes.push(Change::new(
195                            Severity::Additive,
196                            &where_,
197                            "became optional",
198                        )),
199                        _ => {}
200                    }
201                    self.walk(changes, &where_, o, n, dir, seen);
202                }
203                (None, None) => {}
204            }
205        }
206
207        compare_enum(changes, at, old, new);
208    }
209}
210
211/// A removed accepted value breaks callers that still send or expect it.
212fn compare_enum(changes: &mut Vec<Change>, at: &str, old: &Value, new: &Value) {
213    let values = |v: &Value| -> BTreeSet<String> {
214        let mut out = BTreeSet::new();
215        if let Some(c) = v.get("const") {
216            out.insert(render(c));
217        }
218        if let Some(list) = v["enum"].as_array() {
219            out.extend(list.iter().map(render));
220        }
221        out
222    };
223    let (o, n) = (values(old), values(new));
224    if o.is_empty() && n.is_empty() {
225        return;
226    }
227    for gone in o.difference(&n) {
228        changes.push(Change::new(
229            Severity::Breaking,
230            at,
231            format!("value {gone} no longer accepted"),
232        ));
233    }
234    for added in n.difference(&o) {
235        changes.push(Change::new(
236            Severity::Additive,
237            at,
238            format!("value {added} added"),
239        ));
240    }
241}
242
243/// Follow a local `$ref` to the schema it names, once.
244///
245/// Resolution is by JSON Pointer rather than by a fixed `components/schemas`
246/// prefix: dialects disagree on where shared schemas live (`#/$defs/X` inside
247/// one tool's schema, `#/components/schemas/X` at the document root), and the
248/// pointer already says which.
249pub fn deref<'a>(doc: &'a Value, schema: &'a Value) -> &'a Value {
250    let Some(pointer) = schema["$ref"].as_str() else {
251        return schema;
252    };
253    match resolve_pointer(doc, pointer) {
254        Some(target) => target,
255        None => schema,
256    }
257}
258
259/// Walk a `#/a/b/c` JSON Pointer from a document root.
260fn resolve_pointer<'a>(doc: &'a Value, pointer: &str) -> Option<&'a Value> {
261    let path = pointer.strip_prefix('#')?;
262    let mut node = doc;
263    for raw in path.split('/').skip(1) {
264        if raw.is_empty() {
265            continue;
266        }
267        let token = raw.replace("~1", "/").replace("~0", "~");
268        node = node.get(&token)?;
269    }
270    Some(node)
271}
272
273/// The `required` names of an object schema.
274pub fn required_set(schema: &Value) -> BTreeSet<&str> {
275    schema["required"]
276        .as_array()
277        .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
278        .unwrap_or_default()
279}
280
281/// A JSON value as it should read inside a diff message.
282pub fn render(v: &Value) -> String {
283    match v {
284        Value::String(s) => s.clone(),
285        Value::Null => "none".into(),
286        other => other.to_string(),
287    }
288}
289
290/// Compare two maps of named entries, reporting bare presence changes and
291/// handing matched pairs to `both`.
292///
293/// Added and removed entries mean the same thing in every dialect — a tool, a
294/// channel, an operation or a field that appears or disappears — so only the
295/// wording and the severity of a *removal* differ between callers.
296pub fn compare_keys<F>(
297    changes: &mut Vec<Change>,
298    old: &BTreeMap<String, Value>,
299    new: &BTreeMap<String, Value>,
300    noun: &str,
301    mut both: F,
302) where
303    F: FnMut(&mut Vec<Change>, &str, &Value, &Value),
304{
305    let keys: BTreeSet<&String> = old.keys().chain(new.keys()).collect();
306    for key in keys {
307        match (old.get(key), new.get(key)) {
308            (Some(_), None) => changes.push(Change::new(
309                Severity::Breaking,
310                key.clone(),
311                format!("{noun} removed"),
312            )),
313            (None, Some(_)) => changes.push(Change::new(
314                Severity::Additive,
315                key.clone(),
316                format!("{noun} added"),
317            )),
318            (Some(o), Some(n)) => both(changes, key, o, n),
319            (None, None) => {}
320        }
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use serde_json::json;
328
329    fn diff_schemas(old: &Value, new: &Value, dir: Direction) -> Vec<Change> {
330        let empty = json!({});
331        let mut changes = Vec::new();
332        SchemaDiff::new(&empty, &empty).compare(&mut changes, "at", old, new, dir);
333        changes
334    }
335
336    #[test]
337    fn a_new_required_request_field_breaks_callers() {
338        let old = json!({ "type": "object", "properties": { "a": { "type": "string" } } });
339        let new = json!({ "type": "object",
340                          "properties": { "a": { "type": "string" }, "b": { "type": "string" } },
341                          "required": ["b"] });
342        let changes = diff_schemas(&old, &new, Direction::Request);
343        assert_eq!(changes[0].severity, Severity::Breaking);
344        assert_eq!(changes[0].at, "at.b");
345    }
346
347    #[test]
348    fn the_same_new_field_on_a_response_is_additive() {
349        let old = json!({ "type": "object", "properties": { "a": { "type": "string" } } });
350        let new = json!({ "type": "object",
351                          "properties": { "a": { "type": "string" }, "b": { "type": "string" } },
352                          "required": ["b"] });
353        let changes = diff_schemas(&old, &new, Direction::Response);
354        assert_eq!(changes[0].severity, Severity::Additive);
355    }
356
357    #[test]
358    fn a_response_field_that_stops_being_guaranteed_breaks_readers() {
359        let old = json!({ "type": "object", "properties": { "a": { "type": "string" } },
360                          "required": ["a"] });
361        let new = json!({ "type": "object", "properties": { "a": { "type": "string" } } });
362        let changes = diff_schemas(&old, &new, Direction::Response);
363        assert_eq!(changes[0].severity, Severity::Breaking);
364        assert_eq!(changes[0].detail, "no longer guaranteed");
365    }
366
367    #[test]
368    fn refs_resolve_through_a_json_pointer_wherever_defs_live() {
369        let old_doc = json!({ "$defs": { "Item": { "type": "object",
370                                                   "properties": { "id": { "type": "string" } } } } });
371        let new_doc = json!({ "$defs": { "Item": { "type": "object",
372                                                   "properties": { "id": { "type": "integer" } } } } });
373        let r = json!({ "$ref": "#/$defs/Item" });
374        let mut changes = Vec::new();
375        SchemaDiff::new(&old_doc, &new_doc).compare(
376            &mut changes,
377            "Item",
378            &r,
379            &r,
380            Direction::Response,
381        );
382        assert_eq!(changes.len(), 1, "got {changes:?}");
383        assert_eq!(changes[0].detail, "type string -> integer");
384    }
385
386    #[test]
387    fn a_ref_that_resolves_nowhere_is_left_as_written() {
388        let doc = json!({});
389        let schema = json!({ "$ref": "#/components/schemas/Gone" });
390        assert_eq!(deref(&doc, &schema), &schema);
391    }
392
393    #[test]
394    fn a_self_referential_schema_terminates() {
395        let doc = json!({ "$defs": { "Node": { "type": "object",
396                                               "properties": { "next": { "$ref": "#/$defs/Node" } } } } });
397        let r = json!({ "$ref": "#/$defs/Node" });
398        let mut changes = Vec::new();
399        SchemaDiff::new(&doc, &doc).compare(&mut changes, "Node", &r, &r, Direction::Response);
400        assert!(changes.is_empty(), "got {changes:?}");
401    }
402
403    #[test]
404    fn a_removed_enum_value_breaks_consumers() {
405        let old = json!({ "type": "string", "enum": ["a", "b"] });
406        let new = json!({ "type": "string", "enum": ["a"] });
407        let changes = diff_schemas(&old, &new, Direction::Response);
408        assert_eq!(changes[0].severity, Severity::Breaking);
409        assert!(changes[0].detail.contains('b'));
410    }
411
412    #[test]
413    fn breaking_changes_sort_ahead_of_additive_ones() {
414        let mut changes = vec![
415            Change::new(Severity::Additive, "z", "added"),
416            Change::new(Severity::Breaking, "a", "removed"),
417        ];
418        sort(&mut changes);
419        assert_eq!(changes[0].severity, Severity::Breaking);
420        assert!(!is_compatible(&changes));
421    }
422
423    #[test]
424    fn presence_changes_report_with_the_callers_noun() {
425        let old: BTreeMap<String, Value> = [("gone".to_string(), json!({}))].into();
426        let new: BTreeMap<String, Value> = [("fresh".to_string(), json!({}))].into();
427        let mut changes = Vec::new();
428        compare_keys(&mut changes, &old, &new, "tool", |_, _, _, _| {});
429        sort(&mut changes);
430        assert_eq!(changes[0].detail, "tool removed");
431        assert_eq!(changes[0].severity, Severity::Breaking);
432        assert_eq!(changes[1].detail, "tool added");
433    }
434}