1use std::collections::{BTreeMap, BTreeSet};
18
19use serde_json::Value;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23pub enum Severity {
24 Additive,
26 Breaking,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Change {
33 pub severity: Severity,
34 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
49pub fn is_compatible(changes: &[Change]) -> bool {
51 !changes.iter().any(|c| c.severity == Severity::Breaking)
52}
53
54pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Direction {
68 Request,
70 Response,
72}
73
74pub struct SchemaDiff<'a> {
80 old_doc: &'a Value,
81 new_doc: &'a Value,
82}
83
84impl<'a> SchemaDiff<'a> {
85 pub fn new(old_doc: &'a Value, new_doc: &'a Value) -> Self {
87 SchemaDiff { old_doc, new_doc }
88 }
89
90 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 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; }
136
137 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 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 (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
211fn 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
243pub 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
259fn 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
273pub 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
281pub 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
290pub 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}