1use std::collections::{BTreeMap, BTreeSet};
19
20use serde_json::Value;
21
22use crate::compat::{Change, Direction, SchemaDiff, Severity, sort};
23
24pub 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
56fn 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 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 (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 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 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
209fn 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}