specshift 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- specshift/__init__.py +20 -0
- specshift/ai_summary.py +201 -0
- specshift/cli.py +256 -0
- specshift/config.py +79 -0
- specshift/differ.py +721 -0
- specshift/git_utils.py +82 -0
- specshift/models.py +110 -0
- specshift/notifier.py +66 -0
- specshift/reporter.py +167 -0
- specshift/spec_loader.py +122 -0
- specshift-1.0.0.dist-info/METADATA +340 -0
- specshift-1.0.0.dist-info/RECORD +16 -0
- specshift-1.0.0.dist-info/WHEEL +5 -0
- specshift-1.0.0.dist-info/entry_points.txt +2 -0
- specshift-1.0.0.dist-info/licenses/LICENSE +21 -0
- specshift-1.0.0.dist-info/top_level.txt +1 -0
specshift/differ.py
ADDED
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core diff engine that compares two OpenAPI/Swagger specifications and
|
|
3
|
+
classifies each change as breaking, warning, or info.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from specshift.models import Change, ChangeType, DiffResult, Severity
|
|
11
|
+
from specshift.spec_loader import resolve_schema
|
|
12
|
+
|
|
13
|
+
HTTP_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def diff_specs(old_spec: dict, new_spec: dict) -> DiffResult:
|
|
17
|
+
"""Compares two specifications and returns a DiffResult."""
|
|
18
|
+
result = DiffResult(
|
|
19
|
+
old_title=_get_info(old_spec, "title"),
|
|
20
|
+
new_title=_get_info(new_spec, "title"),
|
|
21
|
+
old_version=_get_info(old_spec, "version"),
|
|
22
|
+
new_version=_get_info(new_spec, "version"),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_diff_paths(old_spec, new_spec, result)
|
|
26
|
+
_diff_servers(old_spec, new_spec, result)
|
|
27
|
+
_diff_global_security(old_spec, new_spec, result)
|
|
28
|
+
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_info(spec: dict, key: str) -> str:
|
|
33
|
+
return str(spec.get("info", {}).get(key, "")) if isinstance(spec, dict) else ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _diff_servers(old_spec: dict, new_spec: dict, result: DiffResult) -> None:
|
|
37
|
+
old_servers = {s.get("url") for s in old_spec.get("servers", []) if isinstance(s, dict)}
|
|
38
|
+
new_servers = {s.get("url") for s in new_spec.get("servers", []) if isinstance(s, dict)}
|
|
39
|
+
|
|
40
|
+
removed = old_servers - new_servers
|
|
41
|
+
added = new_servers - old_servers
|
|
42
|
+
|
|
43
|
+
for url in removed:
|
|
44
|
+
result.add(
|
|
45
|
+
Change(
|
|
46
|
+
severity=Severity.WARNING,
|
|
47
|
+
change_type=ChangeType.REMOVED,
|
|
48
|
+
location="servers",
|
|
49
|
+
message=f"Server URL removed: {url}",
|
|
50
|
+
old_value=url,
|
|
51
|
+
category="server",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
for url in added:
|
|
55
|
+
result.add(
|
|
56
|
+
Change(
|
|
57
|
+
severity=Severity.INFO,
|
|
58
|
+
change_type=ChangeType.ADDED,
|
|
59
|
+
location="servers",
|
|
60
|
+
message=f"New server URL added: {url}",
|
|
61
|
+
new_value=url,
|
|
62
|
+
category="server",
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _diff_global_security(old_spec: dict, new_spec: dict, result: DiffResult) -> None:
|
|
68
|
+
old_sec = old_spec.get("security", [])
|
|
69
|
+
new_sec = new_spec.get("security", [])
|
|
70
|
+
|
|
71
|
+
if not old_sec and new_sec:
|
|
72
|
+
result.add(
|
|
73
|
+
Change(
|
|
74
|
+
severity=Severity.BREAKING,
|
|
75
|
+
change_type=ChangeType.ADDED,
|
|
76
|
+
location="security (global)",
|
|
77
|
+
message="Authentication is now required API-wide where it previously was not.",
|
|
78
|
+
new_value=new_sec,
|
|
79
|
+
category="security",
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
elif old_sec and not new_sec:
|
|
83
|
+
result.add(
|
|
84
|
+
Change(
|
|
85
|
+
severity=Severity.WARNING,
|
|
86
|
+
change_type=ChangeType.REMOVED,
|
|
87
|
+
location="security (global)",
|
|
88
|
+
message="The API-wide mandatory authentication requirement has been removed.",
|
|
89
|
+
old_value=old_sec,
|
|
90
|
+
category="security",
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _diff_paths(old_spec: dict, new_spec: dict, result: DiffResult) -> None:
|
|
96
|
+
old_paths: dict = old_spec.get("paths", {}) or {}
|
|
97
|
+
new_paths: dict = new_spec.get("paths", {}) or {}
|
|
98
|
+
|
|
99
|
+
for path in old_paths:
|
|
100
|
+
if path not in new_paths:
|
|
101
|
+
result.add(
|
|
102
|
+
Change(
|
|
103
|
+
severity=Severity.BREAKING,
|
|
104
|
+
change_type=ChangeType.REMOVED,
|
|
105
|
+
location=path,
|
|
106
|
+
message=f"Endpoint completely removed: {path}",
|
|
107
|
+
category="path",
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
for path in new_paths:
|
|
112
|
+
if path not in old_paths:
|
|
113
|
+
result.add(
|
|
114
|
+
Change(
|
|
115
|
+
severity=Severity.INFO,
|
|
116
|
+
change_type=ChangeType.ADDED,
|
|
117
|
+
location=path,
|
|
118
|
+
message=f"New endpoint added: {path}",
|
|
119
|
+
category="path",
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
for path in old_paths:
|
|
124
|
+
if path in new_paths:
|
|
125
|
+
_diff_operations(path, old_paths[path], new_paths[path], old_spec, new_spec, result)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _diff_operations(
|
|
129
|
+
path: str,
|
|
130
|
+
old_path_item: dict,
|
|
131
|
+
new_path_item: dict,
|
|
132
|
+
old_spec: dict,
|
|
133
|
+
new_spec: dict,
|
|
134
|
+
result: DiffResult,
|
|
135
|
+
) -> None:
|
|
136
|
+
old_methods = {m for m in HTTP_METHODS if m in old_path_item}
|
|
137
|
+
new_methods = {m for m in HTTP_METHODS if m in new_path_item}
|
|
138
|
+
|
|
139
|
+
for method in old_methods - new_methods:
|
|
140
|
+
result.add(
|
|
141
|
+
Change(
|
|
142
|
+
severity=Severity.BREAKING,
|
|
143
|
+
change_type=ChangeType.REMOVED,
|
|
144
|
+
location=f"{method.upper()} {path}",
|
|
145
|
+
message=f"HTTP method removed: {method.upper()} {path}",
|
|
146
|
+
category="operation",
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
for method in new_methods - old_methods:
|
|
151
|
+
result.add(
|
|
152
|
+
Change(
|
|
153
|
+
severity=Severity.INFO,
|
|
154
|
+
change_type=ChangeType.ADDED,
|
|
155
|
+
location=f"{method.upper()} {path}",
|
|
156
|
+
message=f"New HTTP method added: {method.upper()} {path}",
|
|
157
|
+
category="operation",
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
for method in old_methods & new_methods:
|
|
162
|
+
loc = f"{method.upper()} {path}"
|
|
163
|
+
old_op = old_path_item[method]
|
|
164
|
+
new_op = new_path_item[method]
|
|
165
|
+
|
|
166
|
+
if bool(old_op.get("deprecated")) is False and bool(new_op.get("deprecated")) is True:
|
|
167
|
+
result.add(
|
|
168
|
+
Change(
|
|
169
|
+
severity=Severity.WARNING,
|
|
170
|
+
change_type=ChangeType.MODIFIED,
|
|
171
|
+
location=loc,
|
|
172
|
+
message="Operation has been marked as deprecated.",
|
|
173
|
+
category="operation",
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
_diff_operation_security(loc, old_op, new_op, result)
|
|
178
|
+
_diff_parameters(loc, old_op, new_op, old_spec, new_spec, result)
|
|
179
|
+
_diff_request_body(loc, old_op, new_op, old_spec, new_spec, result)
|
|
180
|
+
_diff_responses(loc, old_op, new_op, old_spec, new_spec, result)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _diff_operation_security(loc: str, old_op: dict, new_op: dict, result: DiffResult) -> None:
|
|
184
|
+
if "security" not in old_op and "security" not in new_op:
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
old_sec = old_op.get("security")
|
|
188
|
+
new_sec = new_op.get("security")
|
|
189
|
+
|
|
190
|
+
if not old_sec and new_sec:
|
|
191
|
+
result.add(
|
|
192
|
+
Change(
|
|
193
|
+
severity=Severity.BREAKING,
|
|
194
|
+
change_type=ChangeType.ADDED,
|
|
195
|
+
location=loc,
|
|
196
|
+
message="A new mandatory authentication requirement has been added to this operation.",
|
|
197
|
+
category="security",
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
elif old_sec and new_sec == []:
|
|
201
|
+
result.add(
|
|
202
|
+
Change(
|
|
203
|
+
severity=Severity.WARNING,
|
|
204
|
+
change_type=ChangeType.REMOVED,
|
|
205
|
+
location=loc,
|
|
206
|
+
message="The authentication requirement for this operation has been removed.",
|
|
207
|
+
category="security",
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _param_key(param: dict) -> tuple:
|
|
213
|
+
return (param.get("name"), param.get("in"))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _collect_params(op: dict, root_spec: dict) -> dict:
|
|
217
|
+
params: dict = {}
|
|
218
|
+
for raw in op.get("parameters", []) or []:
|
|
219
|
+
resolved = resolve_schema(raw, root_spec) if "$ref" in raw else raw
|
|
220
|
+
if isinstance(resolved, dict) and "name" in resolved:
|
|
221
|
+
params[_param_key(resolved)] = resolved
|
|
222
|
+
return params
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _diff_parameters(
|
|
226
|
+
loc: str, old_op: dict, new_op: dict, old_spec: dict, new_spec: dict, result: DiffResult
|
|
227
|
+
) -> None:
|
|
228
|
+
old_params = _collect_params(old_op, old_spec)
|
|
229
|
+
new_params = _collect_params(new_op, new_spec)
|
|
230
|
+
|
|
231
|
+
for key, param in old_params.items():
|
|
232
|
+
name, location_in = key
|
|
233
|
+
if key not in new_params:
|
|
234
|
+
required = bool(param.get("required")) or location_in == "path"
|
|
235
|
+
severity = Severity.BREAKING if required else Severity.WARNING
|
|
236
|
+
result.add(
|
|
237
|
+
Change(
|
|
238
|
+
severity=severity,
|
|
239
|
+
change_type=ChangeType.REMOVED,
|
|
240
|
+
location=loc,
|
|
241
|
+
message=f"Parameter '{name}' ({location_in}) has been removed.",
|
|
242
|
+
category="parameter",
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
for key, param in new_params.items():
|
|
247
|
+
name, location_in = key
|
|
248
|
+
if key not in old_params:
|
|
249
|
+
required = bool(param.get("required")) or location_in == "path"
|
|
250
|
+
severity = Severity.BREAKING if required else Severity.INFO
|
|
251
|
+
message = (
|
|
252
|
+
f"New required parameter added: '{name}' ({location_in})."
|
|
253
|
+
if required
|
|
254
|
+
else f"New optional parameter added: '{name}' ({location_in})."
|
|
255
|
+
)
|
|
256
|
+
result.add(
|
|
257
|
+
Change(
|
|
258
|
+
severity=severity,
|
|
259
|
+
change_type=ChangeType.ADDED,
|
|
260
|
+
location=loc,
|
|
261
|
+
message=message,
|
|
262
|
+
category="parameter",
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
for key in set(old_params) & set(new_params):
|
|
267
|
+
name, location_in = key
|
|
268
|
+
old_param = old_params[key]
|
|
269
|
+
new_param = new_params[key]
|
|
270
|
+
param_loc = f"{loc} > parameter '{name}'"
|
|
271
|
+
|
|
272
|
+
old_required = bool(old_param.get("required")) or location_in == "path"
|
|
273
|
+
new_required = bool(new_param.get("required")) or location_in == "path"
|
|
274
|
+
|
|
275
|
+
if not old_required and new_required:
|
|
276
|
+
result.add(
|
|
277
|
+
Change(
|
|
278
|
+
severity=Severity.BREAKING,
|
|
279
|
+
change_type=ChangeType.MODIFIED,
|
|
280
|
+
location=param_loc,
|
|
281
|
+
message=f"Parameter '{name}' is now required.",
|
|
282
|
+
category="parameter",
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
elif old_required and not new_required:
|
|
286
|
+
result.add(
|
|
287
|
+
Change(
|
|
288
|
+
severity=Severity.INFO,
|
|
289
|
+
change_type=ChangeType.MODIFIED,
|
|
290
|
+
location=param_loc,
|
|
291
|
+
message=f"Parameter '{name}' is now optional.",
|
|
292
|
+
category="parameter",
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
old_schema = old_param.get("schema", {})
|
|
297
|
+
new_schema = new_param.get("schema", {})
|
|
298
|
+
if old_schema or new_schema:
|
|
299
|
+
_diff_schema(
|
|
300
|
+
resolve_schema(old_schema, old_spec),
|
|
301
|
+
resolve_schema(new_schema, new_spec),
|
|
302
|
+
param_loc,
|
|
303
|
+
old_spec,
|
|
304
|
+
new_spec,
|
|
305
|
+
result,
|
|
306
|
+
context="request",
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _get_json_schema(content: dict, root_spec: dict) -> Optional[dict]:
|
|
311
|
+
if not content:
|
|
312
|
+
return None
|
|
313
|
+
media = content.get("application/json") or next(iter(content.values()), None)
|
|
314
|
+
if not isinstance(media, dict):
|
|
315
|
+
return None
|
|
316
|
+
schema = media.get("schema")
|
|
317
|
+
if not schema:
|
|
318
|
+
return None
|
|
319
|
+
return resolve_schema(schema, root_spec)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _diff_request_body(
|
|
323
|
+
loc: str, old_op: dict, new_op: dict, old_spec: dict, new_spec: dict, result: DiffResult
|
|
324
|
+
) -> None:
|
|
325
|
+
old_body = old_op.get("requestBody")
|
|
326
|
+
new_body = new_op.get("requestBody")
|
|
327
|
+
|
|
328
|
+
if old_body and not new_body:
|
|
329
|
+
result.add(
|
|
330
|
+
Change(
|
|
331
|
+
severity=Severity.BREAKING,
|
|
332
|
+
change_type=ChangeType.REMOVED,
|
|
333
|
+
location=loc,
|
|
334
|
+
message="Request body has been completely removed.",
|
|
335
|
+
category="request_body",
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
if not old_body and new_body:
|
|
341
|
+
required = bool(new_body.get("required"))
|
|
342
|
+
result.add(
|
|
343
|
+
Change(
|
|
344
|
+
severity=Severity.BREAKING if required else Severity.INFO,
|
|
345
|
+
change_type=ChangeType.ADDED,
|
|
346
|
+
location=loc,
|
|
347
|
+
message="A new request body has been added"
|
|
348
|
+
+ (" and made required." if required else " (optional)."),
|
|
349
|
+
category="request_body",
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
if not old_body or not new_body:
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
old_required = bool(old_body.get("required"))
|
|
358
|
+
new_required = bool(new_body.get("required"))
|
|
359
|
+
if not old_required and new_required:
|
|
360
|
+
result.add(
|
|
361
|
+
Change(
|
|
362
|
+
severity=Severity.BREAKING,
|
|
363
|
+
change_type=ChangeType.MODIFIED,
|
|
364
|
+
location=loc,
|
|
365
|
+
message="Request body is now required.",
|
|
366
|
+
category="request_body",
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
old_schema = _get_json_schema(old_body.get("content", {}), old_spec)
|
|
371
|
+
new_schema = _get_json_schema(new_body.get("content", {}), new_spec)
|
|
372
|
+
|
|
373
|
+
if old_schema is not None or new_schema is not None:
|
|
374
|
+
_diff_schema(
|
|
375
|
+
old_schema or {},
|
|
376
|
+
new_schema or {},
|
|
377
|
+
f"{loc} > request body",
|
|
378
|
+
old_spec,
|
|
379
|
+
new_spec,
|
|
380
|
+
result,
|
|
381
|
+
context="request",
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _diff_responses(
|
|
386
|
+
loc: str, old_op: dict, new_op: dict, old_spec: dict, new_spec: dict, result: DiffResult
|
|
387
|
+
) -> None:
|
|
388
|
+
old_responses = old_op.get("responses", {}) or {}
|
|
389
|
+
new_responses = new_op.get("responses", {}) or {}
|
|
390
|
+
|
|
391
|
+
for status in old_responses:
|
|
392
|
+
if status not in new_responses:
|
|
393
|
+
is_success = status.startswith("2")
|
|
394
|
+
result.add(
|
|
395
|
+
Change(
|
|
396
|
+
severity=Severity.BREAKING if is_success else Severity.WARNING,
|
|
397
|
+
change_type=ChangeType.REMOVED,
|
|
398
|
+
location=loc,
|
|
399
|
+
message=f"Response '{status}' has been removed from the definition.",
|
|
400
|
+
category="response",
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
for status in new_responses:
|
|
405
|
+
if status not in old_responses:
|
|
406
|
+
result.add(
|
|
407
|
+
Change(
|
|
408
|
+
severity=Severity.INFO,
|
|
409
|
+
change_type=ChangeType.ADDED,
|
|
410
|
+
location=loc,
|
|
411
|
+
message=f"New response '{status}' has been added.",
|
|
412
|
+
category="response",
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
for status in set(old_responses) & set(new_responses):
|
|
417
|
+
resp_loc = f"{loc} > response {status}"
|
|
418
|
+
old_schema = _get_json_schema(old_responses[status].get("content", {}), old_spec)
|
|
419
|
+
new_schema = _get_json_schema(new_responses[status].get("content", {}), new_spec)
|
|
420
|
+
|
|
421
|
+
if old_schema is not None or new_schema is not None:
|
|
422
|
+
_diff_schema(
|
|
423
|
+
old_schema or {},
|
|
424
|
+
new_schema or {},
|
|
425
|
+
resp_loc,
|
|
426
|
+
old_spec,
|
|
427
|
+
new_spec,
|
|
428
|
+
result,
|
|
429
|
+
context="response",
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _merge_all_of(schema: dict, root_spec: dict, _depth: int = 0) -> dict:
|
|
434
|
+
"""Merges the sub-schemas inside allOf into a single schema (simple merge)."""
|
|
435
|
+
if _depth > 10 or "allOf" not in schema:
|
|
436
|
+
return schema
|
|
437
|
+
|
|
438
|
+
merged_properties: dict = dict(schema.get("properties", {}))
|
|
439
|
+
merged_required: list = list(schema.get("required", []))
|
|
440
|
+
merged_type = schema.get("type")
|
|
441
|
+
|
|
442
|
+
for sub in schema.get("allOf", []):
|
|
443
|
+
resolved_sub = resolve_schema(sub, root_spec)
|
|
444
|
+
resolved_sub = _merge_all_of(resolved_sub, root_spec, _depth + 1)
|
|
445
|
+
merged_properties.update(resolved_sub.get("properties", {}))
|
|
446
|
+
merged_required.extend(resolved_sub.get("required", []))
|
|
447
|
+
merged_type = merged_type or resolved_sub.get("type")
|
|
448
|
+
|
|
449
|
+
result = dict(schema)
|
|
450
|
+
result.pop("allOf", None)
|
|
451
|
+
result["properties"] = merged_properties
|
|
452
|
+
result["required"] = list(dict.fromkeys(merged_required))
|
|
453
|
+
if merged_type:
|
|
454
|
+
result["type"] = merged_type
|
|
455
|
+
return result
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _diff_schema(
|
|
459
|
+
old_schema: dict,
|
|
460
|
+
new_schema: dict,
|
|
461
|
+
loc: str,
|
|
462
|
+
old_spec: dict,
|
|
463
|
+
new_spec: dict,
|
|
464
|
+
result: DiffResult,
|
|
465
|
+
context: str,
|
|
466
|
+
_depth: int = 0,
|
|
467
|
+
) -> None:
|
|
468
|
+
if _depth > 12:
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
old_schema = resolve_schema(old_schema or {}, old_spec)
|
|
472
|
+
new_schema = resolve_schema(new_schema or {}, new_spec)
|
|
473
|
+
old_schema = _merge_all_of(old_schema, old_spec)
|
|
474
|
+
new_schema = _merge_all_of(new_schema, new_spec)
|
|
475
|
+
|
|
476
|
+
if not old_schema and not new_schema:
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
_diff_schema_type(old_schema, new_schema, loc, result)
|
|
480
|
+
_diff_schema_enum(old_schema, new_schema, loc, result)
|
|
481
|
+
_diff_schema_nullable(old_schema, new_schema, loc, context, result)
|
|
482
|
+
|
|
483
|
+
old_type = old_schema.get("type")
|
|
484
|
+
new_type = new_schema.get("type")
|
|
485
|
+
|
|
486
|
+
if old_type == "array" or new_type == "array":
|
|
487
|
+
old_items = resolve_schema(old_schema.get("items", {}), old_spec)
|
|
488
|
+
new_items = resolve_schema(new_schema.get("items", {}), new_spec)
|
|
489
|
+
if old_items or new_items:
|
|
490
|
+
_diff_schema(
|
|
491
|
+
old_items, new_items, f"{loc}[]", old_spec, new_spec, result, context, _depth + 1
|
|
492
|
+
)
|
|
493
|
+
return
|
|
494
|
+
|
|
495
|
+
old_props = old_schema.get("properties", {}) or {}
|
|
496
|
+
new_props = new_schema.get("properties", {}) or {}
|
|
497
|
+
old_required = set(old_schema.get("required", []) or [])
|
|
498
|
+
new_required = set(new_schema.get("required", []) or [])
|
|
499
|
+
|
|
500
|
+
for name in old_props:
|
|
501
|
+
if name not in new_props:
|
|
502
|
+
field_loc = f"{loc} > field '{name}'"
|
|
503
|
+
if context == "response":
|
|
504
|
+
result.add(
|
|
505
|
+
Change(
|
|
506
|
+
severity=Severity.BREAKING,
|
|
507
|
+
change_type=ChangeType.REMOVED,
|
|
508
|
+
location=field_loc,
|
|
509
|
+
message=f"Field '{name}' has been removed from the response, clients relying on it are affected.",
|
|
510
|
+
category="schema",
|
|
511
|
+
)
|
|
512
|
+
)
|
|
513
|
+
else:
|
|
514
|
+
result.add(
|
|
515
|
+
Change(
|
|
516
|
+
severity=Severity.WARNING,
|
|
517
|
+
change_type=ChangeType.REMOVED,
|
|
518
|
+
location=field_loc,
|
|
519
|
+
message=f"Field '{name}' is no longer accepted (removed from the request schema).",
|
|
520
|
+
category="schema",
|
|
521
|
+
)
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
for name in new_props:
|
|
525
|
+
if name not in old_props:
|
|
526
|
+
field_loc = f"{loc} > field '{name}'"
|
|
527
|
+
is_new_required = name in new_required
|
|
528
|
+
if context == "request" and is_new_required:
|
|
529
|
+
result.add(
|
|
530
|
+
Change(
|
|
531
|
+
severity=Severity.BREAKING,
|
|
532
|
+
change_type=ChangeType.ADDED,
|
|
533
|
+
location=field_loc,
|
|
534
|
+
message=f"New required field added: '{name}'. Existing clients must now send it.",
|
|
535
|
+
category="schema",
|
|
536
|
+
)
|
|
537
|
+
)
|
|
538
|
+
else:
|
|
539
|
+
result.add(
|
|
540
|
+
Change(
|
|
541
|
+
severity=Severity.INFO,
|
|
542
|
+
change_type=ChangeType.ADDED,
|
|
543
|
+
location=field_loc,
|
|
544
|
+
message=f"New field added: '{name}'.",
|
|
545
|
+
category="schema",
|
|
546
|
+
)
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
newly_required = (new_required - old_required) & set(old_props) & set(new_props)
|
|
550
|
+
for name in newly_required:
|
|
551
|
+
field_loc = f"{loc} > field '{name}'"
|
|
552
|
+
if context == "request":
|
|
553
|
+
result.add(
|
|
554
|
+
Change(
|
|
555
|
+
severity=Severity.BREAKING,
|
|
556
|
+
change_type=ChangeType.MODIFIED,
|
|
557
|
+
location=field_loc,
|
|
558
|
+
message=f"Field '{name}' is now required.",
|
|
559
|
+
category="schema",
|
|
560
|
+
)
|
|
561
|
+
)
|
|
562
|
+
else:
|
|
563
|
+
result.add(
|
|
564
|
+
Change(
|
|
565
|
+
severity=Severity.INFO,
|
|
566
|
+
change_type=ChangeType.MODIFIED,
|
|
567
|
+
location=field_loc,
|
|
568
|
+
message=f"Field '{name}' is now always guaranteed to be present.",
|
|
569
|
+
category="schema",
|
|
570
|
+
)
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
no_longer_required = (old_required - new_required) & set(old_props) & set(new_props)
|
|
574
|
+
for name in no_longer_required:
|
|
575
|
+
field_loc = f"{loc} > field '{name}'"
|
|
576
|
+
if context == "response":
|
|
577
|
+
result.add(
|
|
578
|
+
Change(
|
|
579
|
+
severity=Severity.BREAKING,
|
|
580
|
+
change_type=ChangeType.MODIFIED,
|
|
581
|
+
location=field_loc,
|
|
582
|
+
message=f"Field '{name}' is no longer guaranteed to be present (became optional).",
|
|
583
|
+
category="schema",
|
|
584
|
+
)
|
|
585
|
+
)
|
|
586
|
+
else:
|
|
587
|
+
result.add(
|
|
588
|
+
Change(
|
|
589
|
+
severity=Severity.INFO,
|
|
590
|
+
change_type=ChangeType.MODIFIED,
|
|
591
|
+
location=field_loc,
|
|
592
|
+
message=f"Field '{name}' is now optional.",
|
|
593
|
+
category="schema",
|
|
594
|
+
)
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
for name in set(old_props) & set(new_props):
|
|
598
|
+
_diff_schema(
|
|
599
|
+
old_props[name],
|
|
600
|
+
new_props[name],
|
|
601
|
+
f"{loc} > field '{name}'",
|
|
602
|
+
old_spec,
|
|
603
|
+
new_spec,
|
|
604
|
+
result,
|
|
605
|
+
context,
|
|
606
|
+
_depth + 1,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _diff_schema_type(old_schema: dict, new_schema: dict, loc: str, result: DiffResult) -> None:
|
|
611
|
+
old_type = old_schema.get("type")
|
|
612
|
+
new_type = new_schema.get("type")
|
|
613
|
+
if old_type and new_type and old_type != new_type:
|
|
614
|
+
result.add(
|
|
615
|
+
Change(
|
|
616
|
+
severity=Severity.BREAKING,
|
|
617
|
+
change_type=ChangeType.MODIFIED,
|
|
618
|
+
location=loc,
|
|
619
|
+
message=f"Data type changed: '{old_type}' -> '{new_type}'.",
|
|
620
|
+
old_value=old_type,
|
|
621
|
+
new_value=new_type,
|
|
622
|
+
category="schema",
|
|
623
|
+
)
|
|
624
|
+
)
|
|
625
|
+
return
|
|
626
|
+
|
|
627
|
+
old_format = old_schema.get("format")
|
|
628
|
+
new_format = new_schema.get("format")
|
|
629
|
+
if old_format and new_format and old_format != new_format:
|
|
630
|
+
result.add(
|
|
631
|
+
Change(
|
|
632
|
+
severity=Severity.WARNING,
|
|
633
|
+
change_type=ChangeType.MODIFIED,
|
|
634
|
+
location=loc,
|
|
635
|
+
message=f"Format changed: '{old_format}' -> '{new_format}'.",
|
|
636
|
+
old_value=old_format,
|
|
637
|
+
new_value=new_format,
|
|
638
|
+
category="schema",
|
|
639
|
+
)
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _diff_schema_enum(old_schema: dict, new_schema: dict, loc: str, result: DiffResult) -> None:
|
|
644
|
+
old_enum = old_schema.get("enum")
|
|
645
|
+
new_enum = new_schema.get("enum")
|
|
646
|
+
if old_enum is None and new_enum is None:
|
|
647
|
+
return
|
|
648
|
+
|
|
649
|
+
old_set = set(old_enum or [])
|
|
650
|
+
new_set = set(new_enum or [])
|
|
651
|
+
|
|
652
|
+
removed_values = old_set - new_set
|
|
653
|
+
added_values = new_set - old_set
|
|
654
|
+
|
|
655
|
+
if old_enum is not None and new_enum is None:
|
|
656
|
+
result.add(
|
|
657
|
+
Change(
|
|
658
|
+
severity=Severity.INFO,
|
|
659
|
+
change_type=ChangeType.MODIFIED,
|
|
660
|
+
location=loc,
|
|
661
|
+
message="Enum constraint has been completely removed, any value is now accepted.",
|
|
662
|
+
category="schema",
|
|
663
|
+
)
|
|
664
|
+
)
|
|
665
|
+
return
|
|
666
|
+
|
|
667
|
+
if removed_values:
|
|
668
|
+
result.add(
|
|
669
|
+
Change(
|
|
670
|
+
severity=Severity.BREAKING,
|
|
671
|
+
change_type=ChangeType.MODIFIED,
|
|
672
|
+
location=loc,
|
|
673
|
+
message=f"Enum values removed: {sorted(str(v) for v in removed_values)}.",
|
|
674
|
+
old_value=list(removed_values),
|
|
675
|
+
category="schema",
|
|
676
|
+
)
|
|
677
|
+
)
|
|
678
|
+
if added_values:
|
|
679
|
+
result.add(
|
|
680
|
+
Change(
|
|
681
|
+
severity=Severity.INFO,
|
|
682
|
+
change_type=ChangeType.MODIFIED,
|
|
683
|
+
location=loc,
|
|
684
|
+
message=f"New enum values added: {sorted(str(v) for v in added_values)}.",
|
|
685
|
+
new_value=list(added_values),
|
|
686
|
+
category="schema",
|
|
687
|
+
)
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _diff_schema_nullable(
|
|
692
|
+
old_schema: dict, new_schema: dict, loc: str, context: str, result: DiffResult
|
|
693
|
+
) -> None:
|
|
694
|
+
old_nullable = bool(old_schema.get("nullable", False))
|
|
695
|
+
new_nullable = bool(new_schema.get("nullable", False))
|
|
696
|
+
|
|
697
|
+
if old_nullable == new_nullable:
|
|
698
|
+
return
|
|
699
|
+
|
|
700
|
+
if old_nullable and not new_nullable:
|
|
701
|
+
severity = Severity.BREAKING if context == "response" else Severity.INFO
|
|
702
|
+
result.add(
|
|
703
|
+
Change(
|
|
704
|
+
severity=severity,
|
|
705
|
+
change_type=ChangeType.MODIFIED,
|
|
706
|
+
location=loc,
|
|
707
|
+
message="Field no longer accepts null.",
|
|
708
|
+
category="schema",
|
|
709
|
+
)
|
|
710
|
+
)
|
|
711
|
+
else:
|
|
712
|
+
severity = Severity.INFO if context == "response" else Severity.WARNING
|
|
713
|
+
result.add(
|
|
714
|
+
Change(
|
|
715
|
+
severity=severity,
|
|
716
|
+
change_type=ChangeType.MODIFIED,
|
|
717
|
+
location=loc,
|
|
718
|
+
message="Field now accepts a null value.",
|
|
719
|
+
category="schema",
|
|
720
|
+
)
|
|
721
|
+
)
|