fastapi-breakgate 1.0.1__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.
- fastapi_breakgate-1.0.1.dist-info/METADATA +191 -0
- fastapi_breakgate-1.0.1.dist-info/RECORD +22 -0
- fastapi_breakgate-1.0.1.dist-info/WHEEL +4 -0
- fastapi_breakgate-1.0.1.dist-info/entry_points.txt +6 -0
- fastapi_breakgate-1.0.1.dist-info/licenses/LICENSE +21 -0
- fastapi_contract/__init__.py +26 -0
- fastapi_contract/__main__.py +4 -0
- fastapi_contract/__version__.py +1 -0
- fastapi_contract/check.py +167 -0
- fastapi_contract/cli.py +295 -0
- fastapi_contract/comment.py +101 -0
- fastapi_contract/config.py +161 -0
- fastapi_contract/diff/__init__.py +5 -0
- fastapi_contract/diff/classifier.py +779 -0
- fastapi_contract/diff/oasdiff.py +160 -0
- fastapi_contract/diff/policy.py +85 -0
- fastapi_contract/doctor.py +251 -0
- fastapi_contract/export.py +283 -0
- fastapi_contract/intentional.py +109 -0
- fastapi_contract/models.py +33 -0
- fastapi_contract/py.typed +0 -0
- fastapi_contract/pytest_plugin.py +98 -0
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
"""Pure-Python OpenAPI breaking-change classifier (production ruleset)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastapi_contract.diff.policy import ChangeKind, severity_for
|
|
10
|
+
from fastapi_contract.models import PolicyName
|
|
11
|
+
|
|
12
|
+
HTTP_METHODS = ("get", "post", "put", "patch", "delete", "options", "head", "trace")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ChangeSeverity(str, Enum):
|
|
16
|
+
BREAKING = "breaking"
|
|
17
|
+
RISKY = "risky"
|
|
18
|
+
SAFE = "safe"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Change:
|
|
23
|
+
kind: ChangeKind
|
|
24
|
+
severity: ChangeSeverity
|
|
25
|
+
path: str
|
|
26
|
+
message: str
|
|
27
|
+
method: str | None = None
|
|
28
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict[str, Any]:
|
|
31
|
+
return {
|
|
32
|
+
"kind": self.kind.value,
|
|
33
|
+
"severity": self.severity.value,
|
|
34
|
+
"path": self.path,
|
|
35
|
+
"method": self.method,
|
|
36
|
+
"message": self.message,
|
|
37
|
+
"details": self.details,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class DiffResult:
|
|
43
|
+
changes: list[Change]
|
|
44
|
+
base_paths: int
|
|
45
|
+
candidate_paths: int
|
|
46
|
+
engine: str = "python"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def breaking(self) -> list[Change]:
|
|
50
|
+
return [c for c in self.changes if c.severity == ChangeSeverity.BREAKING]
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def risky(self) -> list[Change]:
|
|
54
|
+
return [c for c in self.changes if c.severity == ChangeSeverity.RISKY]
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def safe(self) -> list[Change]:
|
|
58
|
+
return [c for c in self.changes if c.severity == ChangeSeverity.SAFE]
|
|
59
|
+
|
|
60
|
+
def has_failing(self, fail_on: str) -> bool:
|
|
61
|
+
if fail_on == "any":
|
|
62
|
+
return bool(self.breaking or self.risky or self.safe)
|
|
63
|
+
if fail_on == "risky":
|
|
64
|
+
return bool(self.breaking or self.risky)
|
|
65
|
+
return bool(self.breaking)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _sev(kind: ChangeKind, policy: PolicyName) -> ChangeSeverity:
|
|
69
|
+
return ChangeSeverity(severity_for(kind, policy))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _change(
|
|
73
|
+
kind: ChangeKind,
|
|
74
|
+
policy: PolicyName,
|
|
75
|
+
path: str,
|
|
76
|
+
message: str,
|
|
77
|
+
*,
|
|
78
|
+
method: str | None = None,
|
|
79
|
+
**details: Any,
|
|
80
|
+
) -> Change:
|
|
81
|
+
return Change(
|
|
82
|
+
kind=kind,
|
|
83
|
+
severity=_sev(kind, policy),
|
|
84
|
+
path=path,
|
|
85
|
+
method=method,
|
|
86
|
+
message=message,
|
|
87
|
+
details=details,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _paths(schema: dict[str, Any]) -> dict[str, Any]:
|
|
92
|
+
paths = schema.get("paths") or {}
|
|
93
|
+
return paths if isinstance(paths, dict) else {}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _components_schemas(schema: dict[str, Any]) -> dict[str, Any]:
|
|
97
|
+
components = schema.get("components") or {}
|
|
98
|
+
if not isinstance(components, dict):
|
|
99
|
+
return {}
|
|
100
|
+
schemas = components.get("schemas") or {}
|
|
101
|
+
return schemas if isinstance(schemas, dict) else {}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _resolve_ref(schema: dict[str, Any], node: Any, *, _seen: set[str] | None = None) -> Any:
|
|
105
|
+
if not isinstance(node, dict):
|
|
106
|
+
return node
|
|
107
|
+
ref = node.get("$ref")
|
|
108
|
+
if not isinstance(ref, str) or not ref.startswith("#/"):
|
|
109
|
+
return node
|
|
110
|
+
seen = _seen or set()
|
|
111
|
+
if ref in seen:
|
|
112
|
+
return node
|
|
113
|
+
seen.add(ref)
|
|
114
|
+
cur: Any = schema
|
|
115
|
+
for part in ref[2:].split("/"):
|
|
116
|
+
if not isinstance(cur, dict) or part not in cur:
|
|
117
|
+
return node
|
|
118
|
+
cur = cur[part]
|
|
119
|
+
if isinstance(cur, dict) and "$ref" in cur:
|
|
120
|
+
return _resolve_ref(schema, cur, _seen=seen)
|
|
121
|
+
return cur
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _schema_type(node: dict[str, Any]) -> str | None:
|
|
125
|
+
t = node.get("type")
|
|
126
|
+
if isinstance(t, list):
|
|
127
|
+
return ",".join(str(x) for x in t)
|
|
128
|
+
return str(t) if t is not None else None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _enum_values(node: dict[str, Any]) -> set[Any] | None:
|
|
132
|
+
if "enum" not in node:
|
|
133
|
+
return None
|
|
134
|
+
values = node.get("enum")
|
|
135
|
+
if not isinstance(values, list):
|
|
136
|
+
return set()
|
|
137
|
+
return set(values)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _properties(node: dict[str, Any]) -> dict[str, Any]:
|
|
141
|
+
props = node.get("properties") or {}
|
|
142
|
+
return props if isinstance(props, dict) else {}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _required(node: dict[str, Any]) -> set[str]:
|
|
146
|
+
req = node.get("required") or []
|
|
147
|
+
if not isinstance(req, list):
|
|
148
|
+
return set()
|
|
149
|
+
return {str(x) for x in req}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _get_request_body_schema(
|
|
153
|
+
root: dict[str, Any], operation: dict[str, Any]
|
|
154
|
+
) -> dict[str, Any] | None:
|
|
155
|
+
body = operation.get("requestBody")
|
|
156
|
+
if not isinstance(body, dict):
|
|
157
|
+
return None
|
|
158
|
+
content = body.get("content") or {}
|
|
159
|
+
if not isinstance(content, dict):
|
|
160
|
+
return None
|
|
161
|
+
for media in ("application/json", "application/x-www-form-urlencoded", "multipart/form-data"):
|
|
162
|
+
if media in content and isinstance(content[media], dict):
|
|
163
|
+
schema = content[media].get("schema")
|
|
164
|
+
if isinstance(schema, dict):
|
|
165
|
+
resolved = _resolve_ref(root, schema)
|
|
166
|
+
return resolved if isinstance(resolved, dict) else None
|
|
167
|
+
# first available
|
|
168
|
+
for item in content.values():
|
|
169
|
+
if isinstance(item, dict) and isinstance(item.get("schema"), dict):
|
|
170
|
+
resolved = _resolve_ref(root, item["schema"])
|
|
171
|
+
return resolved if isinstance(resolved, dict) else None
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _response_schemas(
|
|
176
|
+
root: dict[str, Any], operation: dict[str, Any]
|
|
177
|
+
) -> dict[str, dict[str, Any]]:
|
|
178
|
+
result: dict[str, dict[str, Any]] = {}
|
|
179
|
+
responses = operation.get("responses") or {}
|
|
180
|
+
if not isinstance(responses, dict):
|
|
181
|
+
return result
|
|
182
|
+
for status, resp in responses.items():
|
|
183
|
+
if not isinstance(resp, dict):
|
|
184
|
+
continue
|
|
185
|
+
content = resp.get("content") or {}
|
|
186
|
+
if not isinstance(content, dict):
|
|
187
|
+
result[str(status)] = {}
|
|
188
|
+
continue
|
|
189
|
+
media = content.get("application/json") or next(iter(content.values()), None)
|
|
190
|
+
if isinstance(media, dict) and isinstance(media.get("schema"), dict):
|
|
191
|
+
resolved = _resolve_ref(root, media["schema"])
|
|
192
|
+
result[str(status)] = resolved if isinstance(resolved, dict) else {}
|
|
193
|
+
else:
|
|
194
|
+
result[str(status)] = {}
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _compare_object_schema(
|
|
199
|
+
*,
|
|
200
|
+
base_root: dict[str, Any],
|
|
201
|
+
cand_root: dict[str, Any],
|
|
202
|
+
base: dict[str, Any],
|
|
203
|
+
cand: dict[str, Any],
|
|
204
|
+
policy: PolicyName,
|
|
205
|
+
path: str,
|
|
206
|
+
method: str,
|
|
207
|
+
location: str,
|
|
208
|
+
changes: list[Change],
|
|
209
|
+
) -> None:
|
|
210
|
+
base_props = _properties(base)
|
|
211
|
+
cand_props = _properties(cand)
|
|
212
|
+
base_req = _required(base)
|
|
213
|
+
cand_req = _required(cand)
|
|
214
|
+
|
|
215
|
+
for name in sorted(base_props.keys() - cand_props.keys()):
|
|
216
|
+
kind = (
|
|
217
|
+
ChangeKind.RESPONSE_FIELD_REMOVED
|
|
218
|
+
if location == "response"
|
|
219
|
+
else ChangeKind.REQUEST_FIELD_REMOVED
|
|
220
|
+
)
|
|
221
|
+
changes.append(
|
|
222
|
+
_change(
|
|
223
|
+
kind,
|
|
224
|
+
policy,
|
|
225
|
+
path,
|
|
226
|
+
f"Removed {location} field '{name}'",
|
|
227
|
+
method=method,
|
|
228
|
+
field=name,
|
|
229
|
+
location=location,
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
for name in sorted(cand_props.keys() - base_props.keys()):
|
|
234
|
+
if location == "request":
|
|
235
|
+
if name in cand_req:
|
|
236
|
+
changes.append(
|
|
237
|
+
_change(
|
|
238
|
+
ChangeKind.REQUIRED_REQUEST_FIELD_ADDED,
|
|
239
|
+
policy,
|
|
240
|
+
path,
|
|
241
|
+
f"Added required request field '{name}'",
|
|
242
|
+
method=method,
|
|
243
|
+
field=name,
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
changes.append(
|
|
248
|
+
Change(
|
|
249
|
+
kind=ChangeKind.OTHER,
|
|
250
|
+
severity=ChangeSeverity.SAFE,
|
|
251
|
+
path=path,
|
|
252
|
+
method=method,
|
|
253
|
+
message=f"Added optional request field '{name}'",
|
|
254
|
+
details={"field": name, "location": "request"},
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
else:
|
|
258
|
+
if name in cand_req and name not in base_req:
|
|
259
|
+
changes.append(
|
|
260
|
+
_change(
|
|
261
|
+
ChangeKind.RESPONSE_REQUIRED_FIELD_ADDED,
|
|
262
|
+
policy,
|
|
263
|
+
path,
|
|
264
|
+
f"Added required response field '{name}'",
|
|
265
|
+
method=method,
|
|
266
|
+
field=name,
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
else:
|
|
270
|
+
changes.append(
|
|
271
|
+
_change(
|
|
272
|
+
ChangeKind.RESPONSE_FIELD_ADDED,
|
|
273
|
+
policy,
|
|
274
|
+
path,
|
|
275
|
+
f"Added response field '{name}'",
|
|
276
|
+
method=method,
|
|
277
|
+
field=name,
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
for name in sorted(cand_req - base_req):
|
|
282
|
+
if name in base_props and location == "request":
|
|
283
|
+
changes.append(
|
|
284
|
+
_change(
|
|
285
|
+
ChangeKind.REQUEST_FIELD_OPTIONAL_TO_REQUIRED,
|
|
286
|
+
policy,
|
|
287
|
+
path,
|
|
288
|
+
f"Request field '{name}' became required",
|
|
289
|
+
method=method,
|
|
290
|
+
field=name,
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
for name in sorted(base_req - cand_req):
|
|
295
|
+
if name in cand_props and location == "request":
|
|
296
|
+
changes.append(
|
|
297
|
+
_change(
|
|
298
|
+
ChangeKind.REQUEST_FIELD_REQUIRED_TO_OPTIONAL,
|
|
299
|
+
policy,
|
|
300
|
+
path,
|
|
301
|
+
f"Request field '{name}' became optional",
|
|
302
|
+
method=method,
|
|
303
|
+
field=name,
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
for name in sorted(base_props.keys() & cand_props.keys()):
|
|
308
|
+
b = base_props[name]
|
|
309
|
+
c = cand_props[name]
|
|
310
|
+
if not isinstance(b, dict) or not isinstance(c, dict):
|
|
311
|
+
continue
|
|
312
|
+
_compare_schema_nodes(
|
|
313
|
+
base_root=base_root,
|
|
314
|
+
cand_root=cand_root,
|
|
315
|
+
base=b,
|
|
316
|
+
cand=c,
|
|
317
|
+
policy=policy,
|
|
318
|
+
path=path,
|
|
319
|
+
method=method,
|
|
320
|
+
location=f"{location}.{name}",
|
|
321
|
+
changes=changes,
|
|
322
|
+
field_name=name,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _compare_schema_nodes(
|
|
327
|
+
*,
|
|
328
|
+
base_root: dict[str, Any],
|
|
329
|
+
cand_root: dict[str, Any],
|
|
330
|
+
base: dict[str, Any],
|
|
331
|
+
cand: dict[str, Any],
|
|
332
|
+
policy: PolicyName,
|
|
333
|
+
path: str,
|
|
334
|
+
method: str,
|
|
335
|
+
location: str,
|
|
336
|
+
changes: list[Change],
|
|
337
|
+
field_name: str | None = None,
|
|
338
|
+
) -> None:
|
|
339
|
+
b = _resolve_ref(base_root, base) if base_root else base
|
|
340
|
+
c = _resolve_ref(cand_root, cand) if cand_root else cand
|
|
341
|
+
if not isinstance(b, dict) or not isinstance(c, dict):
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
bt = _schema_type(b)
|
|
345
|
+
ct = _schema_type(c)
|
|
346
|
+
if bt and ct and bt != ct:
|
|
347
|
+
# narrowing / widening heuristics
|
|
348
|
+
widen_pairs = {("integer", "number"), ("number", "string")}
|
|
349
|
+
if (bt, ct) in widen_pairs:
|
|
350
|
+
kind = ChangeKind.TYPE_WIDENED
|
|
351
|
+
elif (ct, bt) in widen_pairs:
|
|
352
|
+
kind = ChangeKind.TYPE_NARROWED
|
|
353
|
+
else:
|
|
354
|
+
kind = ChangeKind.TYPE_CHANGED
|
|
355
|
+
label = field_name or location
|
|
356
|
+
changes.append(
|
|
357
|
+
_change(
|
|
358
|
+
kind,
|
|
359
|
+
policy,
|
|
360
|
+
path,
|
|
361
|
+
f"Type of '{label}' changed from {bt} to {ct}",
|
|
362
|
+
method=method,
|
|
363
|
+
field=field_name,
|
|
364
|
+
from_type=bt,
|
|
365
|
+
to_type=ct,
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
be = _enum_values(b)
|
|
370
|
+
ce = _enum_values(c)
|
|
371
|
+
if be is not None and ce is not None:
|
|
372
|
+
removed = be - ce
|
|
373
|
+
added = ce - be
|
|
374
|
+
label = field_name or location
|
|
375
|
+
for value in sorted(removed, key=lambda x: str(x)):
|
|
376
|
+
changes.append(
|
|
377
|
+
_change(
|
|
378
|
+
ChangeKind.ENUM_VALUE_REMOVED,
|
|
379
|
+
policy,
|
|
380
|
+
path,
|
|
381
|
+
f"Enum value {value!r} removed from '{label}'",
|
|
382
|
+
method=method,
|
|
383
|
+
field=field_name,
|
|
384
|
+
value=value,
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
for value in sorted(added, key=lambda x: str(x)):
|
|
388
|
+
changes.append(
|
|
389
|
+
_change(
|
|
390
|
+
ChangeKind.ENUM_VALUE_ADDED,
|
|
391
|
+
policy,
|
|
392
|
+
path,
|
|
393
|
+
f"Enum value {value!r} added to '{label}'",
|
|
394
|
+
method=method,
|
|
395
|
+
field=field_name,
|
|
396
|
+
value=value,
|
|
397
|
+
)
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if _properties(b) or _properties(c):
|
|
401
|
+
loc = "request"
|
|
402
|
+
if "response" in location:
|
|
403
|
+
loc = "response"
|
|
404
|
+
elif "request" in location:
|
|
405
|
+
loc = "request"
|
|
406
|
+
_compare_object_schema(
|
|
407
|
+
base_root=base_root,
|
|
408
|
+
cand_root=cand_root,
|
|
409
|
+
base=b,
|
|
410
|
+
cand=c,
|
|
411
|
+
policy=policy,
|
|
412
|
+
path=path,
|
|
413
|
+
method=method,
|
|
414
|
+
location=loc,
|
|
415
|
+
changes=changes,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _compare_parameters(
|
|
420
|
+
*,
|
|
421
|
+
base_root: dict[str, Any],
|
|
422
|
+
cand_root: dict[str, Any],
|
|
423
|
+
base_op: dict[str, Any],
|
|
424
|
+
cand_op: dict[str, Any],
|
|
425
|
+
policy: PolicyName,
|
|
426
|
+
path: str,
|
|
427
|
+
method: str,
|
|
428
|
+
changes: list[Change],
|
|
429
|
+
) -> None:
|
|
430
|
+
def index(op: dict[str, Any], root: dict[str, Any]) -> dict[tuple[str, str], dict[str, Any]]:
|
|
431
|
+
out: dict[tuple[str, str], dict[str, Any]] = {}
|
|
432
|
+
params = op.get("parameters") or []
|
|
433
|
+
if not isinstance(params, list):
|
|
434
|
+
return out
|
|
435
|
+
for raw in params:
|
|
436
|
+
if not isinstance(raw, dict):
|
|
437
|
+
continue
|
|
438
|
+
node = _resolve_ref(root, raw)
|
|
439
|
+
if not isinstance(node, dict):
|
|
440
|
+
continue
|
|
441
|
+
name = str(node.get("name", ""))
|
|
442
|
+
location = str(node.get("in", "query"))
|
|
443
|
+
if name:
|
|
444
|
+
out[(name, location)] = node
|
|
445
|
+
return out
|
|
446
|
+
|
|
447
|
+
b_params = index(base_op, base_root)
|
|
448
|
+
c_params = index(cand_op, cand_root)
|
|
449
|
+
|
|
450
|
+
for key in sorted(b_params.keys() - c_params.keys()):
|
|
451
|
+
name, location = key
|
|
452
|
+
changes.append(
|
|
453
|
+
_change(
|
|
454
|
+
ChangeKind.PARAMETER_REMOVED,
|
|
455
|
+
policy,
|
|
456
|
+
path,
|
|
457
|
+
f"Removed {location} parameter '{name}'",
|
|
458
|
+
method=method,
|
|
459
|
+
parameter=name,
|
|
460
|
+
location=location,
|
|
461
|
+
)
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
for key in sorted(c_params.keys() - b_params.keys()):
|
|
465
|
+
name, location = key
|
|
466
|
+
node = c_params[key]
|
|
467
|
+
required = bool(node.get("required"))
|
|
468
|
+
kind = (
|
|
469
|
+
ChangeKind.PARAMETER_ADDED_REQUIRED
|
|
470
|
+
if required
|
|
471
|
+
else ChangeKind.PARAMETER_ADDED_OPTIONAL
|
|
472
|
+
)
|
|
473
|
+
changes.append(
|
|
474
|
+
_change(
|
|
475
|
+
kind,
|
|
476
|
+
policy,
|
|
477
|
+
path,
|
|
478
|
+
f"Added {'required ' if required else ''}{location} parameter '{name}'",
|
|
479
|
+
method=method,
|
|
480
|
+
parameter=name,
|
|
481
|
+
location=location,
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
for key in sorted(b_params.keys() & c_params.keys()):
|
|
486
|
+
b = b_params[key]
|
|
487
|
+
c = c_params[key]
|
|
488
|
+
if not bool(b.get("required")) and bool(c.get("required")):
|
|
489
|
+
name, location = key
|
|
490
|
+
changes.append(
|
|
491
|
+
_change(
|
|
492
|
+
ChangeKind.PARAMETER_BECAME_REQUIRED,
|
|
493
|
+
policy,
|
|
494
|
+
path,
|
|
495
|
+
f"{location} parameter '{name}' became required",
|
|
496
|
+
method=method,
|
|
497
|
+
parameter=name,
|
|
498
|
+
)
|
|
499
|
+
)
|
|
500
|
+
b_schema = b.get("schema") if isinstance(b.get("schema"), dict) else {}
|
|
501
|
+
c_schema = c.get("schema") if isinstance(c.get("schema"), dict) else {}
|
|
502
|
+
if b_schema and c_schema:
|
|
503
|
+
_compare_schema_nodes(
|
|
504
|
+
base_root=base_root,
|
|
505
|
+
cand_root=cand_root,
|
|
506
|
+
base=b_schema,
|
|
507
|
+
cand=c_schema,
|
|
508
|
+
policy=policy,
|
|
509
|
+
path=path,
|
|
510
|
+
method=method,
|
|
511
|
+
location=f"parameter.{key[0]}",
|
|
512
|
+
changes=changes,
|
|
513
|
+
field_name=key[0],
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _compare_security(
|
|
518
|
+
base_op: dict[str, Any],
|
|
519
|
+
cand_op: dict[str, Any],
|
|
520
|
+
policy: PolicyName,
|
|
521
|
+
path: str,
|
|
522
|
+
method: str,
|
|
523
|
+
changes: list[Change],
|
|
524
|
+
) -> None:
|
|
525
|
+
base_sec = base_op.get("security")
|
|
526
|
+
cand_sec = cand_op.get("security")
|
|
527
|
+
if not base_sec and cand_sec:
|
|
528
|
+
changes.append(
|
|
529
|
+
_change(
|
|
530
|
+
ChangeKind.SECURITY_ADDED,
|
|
531
|
+
policy,
|
|
532
|
+
path,
|
|
533
|
+
"Operation now requires security",
|
|
534
|
+
method=method,
|
|
535
|
+
)
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _compare_operation(
|
|
540
|
+
*,
|
|
541
|
+
base_root: dict[str, Any],
|
|
542
|
+
cand_root: dict[str, Any],
|
|
543
|
+
base_op: dict[str, Any],
|
|
544
|
+
cand_op: dict[str, Any],
|
|
545
|
+
policy: PolicyName,
|
|
546
|
+
path: str,
|
|
547
|
+
method: str,
|
|
548
|
+
changes: list[Change],
|
|
549
|
+
) -> None:
|
|
550
|
+
if not base_op.get("deprecated") and cand_op.get("deprecated"):
|
|
551
|
+
changes.append(
|
|
552
|
+
_change(
|
|
553
|
+
ChangeKind.OPERATION_DEPRECATED,
|
|
554
|
+
policy,
|
|
555
|
+
path,
|
|
556
|
+
"Operation marked deprecated",
|
|
557
|
+
method=method,
|
|
558
|
+
)
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
_compare_parameters(
|
|
562
|
+
base_root=base_root,
|
|
563
|
+
cand_root=cand_root,
|
|
564
|
+
base_op=base_op,
|
|
565
|
+
cand_op=cand_op,
|
|
566
|
+
policy=policy,
|
|
567
|
+
path=path,
|
|
568
|
+
method=method,
|
|
569
|
+
changes=changes,
|
|
570
|
+
)
|
|
571
|
+
_compare_security(base_op, cand_op, policy, path, method, changes)
|
|
572
|
+
|
|
573
|
+
base_body = _get_request_body_schema(base_root, base_op)
|
|
574
|
+
cand_body = _get_request_body_schema(cand_root, cand_op)
|
|
575
|
+
if base_body is not None and cand_body is not None:
|
|
576
|
+
_compare_schema_nodes(
|
|
577
|
+
base_root=base_root,
|
|
578
|
+
cand_root=cand_root,
|
|
579
|
+
base=base_body,
|
|
580
|
+
cand=cand_body,
|
|
581
|
+
policy=policy,
|
|
582
|
+
path=path,
|
|
583
|
+
method=method,
|
|
584
|
+
location="request",
|
|
585
|
+
changes=changes,
|
|
586
|
+
)
|
|
587
|
+
elif base_body is not None and cand_body is None:
|
|
588
|
+
changes.append(
|
|
589
|
+
_change(
|
|
590
|
+
ChangeKind.MEDIA_TYPE_REMOVED,
|
|
591
|
+
policy,
|
|
592
|
+
path,
|
|
593
|
+
"Request body removed",
|
|
594
|
+
method=method,
|
|
595
|
+
)
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
base_resps = _response_schemas(base_root, base_op)
|
|
599
|
+
cand_resps = _response_schemas(cand_root, cand_op)
|
|
600
|
+
for status in sorted(set(base_resps) - set(cand_resps)):
|
|
601
|
+
# ignore default-only noise somewhat
|
|
602
|
+
changes.append(
|
|
603
|
+
_change(
|
|
604
|
+
ChangeKind.RESPONSE_STATUS_REMOVED,
|
|
605
|
+
policy,
|
|
606
|
+
path,
|
|
607
|
+
f"Response status {status} removed",
|
|
608
|
+
method=method,
|
|
609
|
+
status=status,
|
|
610
|
+
)
|
|
611
|
+
)
|
|
612
|
+
for status in sorted(set(cand_resps) - set(base_resps)):
|
|
613
|
+
changes.append(
|
|
614
|
+
_change(
|
|
615
|
+
ChangeKind.RESPONSE_STATUS_ADDED,
|
|
616
|
+
policy,
|
|
617
|
+
path,
|
|
618
|
+
f"Response status {status} added",
|
|
619
|
+
method=method,
|
|
620
|
+
status=status,
|
|
621
|
+
)
|
|
622
|
+
)
|
|
623
|
+
for status in sorted(set(base_resps) & set(cand_resps)):
|
|
624
|
+
b_schema = base_resps[status]
|
|
625
|
+
c_schema = cand_resps[status]
|
|
626
|
+
if b_schema or c_schema:
|
|
627
|
+
_compare_schema_nodes(
|
|
628
|
+
base_root=base_root,
|
|
629
|
+
cand_root=cand_root,
|
|
630
|
+
base=b_schema or {},
|
|
631
|
+
cand=c_schema or {},
|
|
632
|
+
policy=policy,
|
|
633
|
+
path=path,
|
|
634
|
+
method=method,
|
|
635
|
+
location=f"response.{status}",
|
|
636
|
+
changes=changes,
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def diff_openapi(
|
|
641
|
+
base: dict[str, Any],
|
|
642
|
+
candidate: dict[str, Any],
|
|
643
|
+
*,
|
|
644
|
+
policy: PolicyName = PolicyName.ADDITIVE_FRIENDLY,
|
|
645
|
+
) -> DiffResult:
|
|
646
|
+
"""Compare two OpenAPI documents and classify changes."""
|
|
647
|
+
changes: list[Change] = []
|
|
648
|
+
base_paths = _paths(base)
|
|
649
|
+
cand_paths = _paths(candidate)
|
|
650
|
+
|
|
651
|
+
for path in sorted(set(base_paths) - set(cand_paths)):
|
|
652
|
+
changes.append(
|
|
653
|
+
_change(
|
|
654
|
+
ChangeKind.PATH_REMOVED,
|
|
655
|
+
policy,
|
|
656
|
+
path,
|
|
657
|
+
f"Path removed: {path}",
|
|
658
|
+
)
|
|
659
|
+
)
|
|
660
|
+
for path in sorted(set(cand_paths) - set(base_paths)):
|
|
661
|
+
changes.append(
|
|
662
|
+
_change(
|
|
663
|
+
ChangeKind.PATH_ADDED,
|
|
664
|
+
policy,
|
|
665
|
+
path,
|
|
666
|
+
f"Path added: {path}",
|
|
667
|
+
)
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
for path in sorted(set(base_paths) & set(cand_paths)):
|
|
671
|
+
b_item = base_paths[path] if isinstance(base_paths[path], dict) else {}
|
|
672
|
+
c_item = cand_paths[path] if isinstance(cand_paths[path], dict) else {}
|
|
673
|
+
for method in HTTP_METHODS:
|
|
674
|
+
b_op = b_item.get(method)
|
|
675
|
+
c_op = c_item.get(method)
|
|
676
|
+
b_ok = isinstance(b_op, dict)
|
|
677
|
+
c_ok = isinstance(c_op, dict)
|
|
678
|
+
if b_ok and not c_ok:
|
|
679
|
+
changes.append(
|
|
680
|
+
_change(
|
|
681
|
+
ChangeKind.OPERATION_REMOVED,
|
|
682
|
+
policy,
|
|
683
|
+
path,
|
|
684
|
+
f"Operation removed: {method.upper()} {path}",
|
|
685
|
+
method=method,
|
|
686
|
+
)
|
|
687
|
+
)
|
|
688
|
+
elif c_ok and not b_ok:
|
|
689
|
+
changes.append(
|
|
690
|
+
_change(
|
|
691
|
+
ChangeKind.OPERATION_ADDED,
|
|
692
|
+
policy,
|
|
693
|
+
path,
|
|
694
|
+
f"Operation added: {method.upper()} {path}",
|
|
695
|
+
method=method,
|
|
696
|
+
)
|
|
697
|
+
)
|
|
698
|
+
elif b_ok and c_ok:
|
|
699
|
+
_compare_operation(
|
|
700
|
+
base_root=base,
|
|
701
|
+
cand_root=candidate,
|
|
702
|
+
base_op=b_op,
|
|
703
|
+
cand_op=c_op,
|
|
704
|
+
policy=policy,
|
|
705
|
+
path=path,
|
|
706
|
+
method=method,
|
|
707
|
+
changes=changes,
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
# Component schema removals / enum-level diffs (FastAPI often uses $ref enums)
|
|
711
|
+
base_schemas = _components_schemas(base)
|
|
712
|
+
cand_schemas = _components_schemas(candidate)
|
|
713
|
+
for name in sorted(set(base_schemas) - set(cand_schemas)):
|
|
714
|
+
changes.append(
|
|
715
|
+
_change(
|
|
716
|
+
ChangeKind.SCHEMA_REMOVED,
|
|
717
|
+
policy,
|
|
718
|
+
f"#/components/schemas/{name}",
|
|
719
|
+
f"Component schema removed: {name}",
|
|
720
|
+
)
|
|
721
|
+
)
|
|
722
|
+
for name in sorted(set(base_schemas) & set(cand_schemas)):
|
|
723
|
+
b_node = base_schemas[name]
|
|
724
|
+
c_node = cand_schemas[name]
|
|
725
|
+
if not isinstance(b_node, dict) or not isinstance(c_node, dict):
|
|
726
|
+
continue
|
|
727
|
+
be = _enum_values(b_node)
|
|
728
|
+
ce = _enum_values(c_node)
|
|
729
|
+
if be is not None and ce is not None:
|
|
730
|
+
for value in sorted(be - ce, key=lambda x: str(x)):
|
|
731
|
+
changes.append(
|
|
732
|
+
_change(
|
|
733
|
+
ChangeKind.ENUM_VALUE_REMOVED,
|
|
734
|
+
policy,
|
|
735
|
+
f"#/components/schemas/{name}",
|
|
736
|
+
f"Enum value {value!r} removed from '{name}'",
|
|
737
|
+
value=value,
|
|
738
|
+
)
|
|
739
|
+
)
|
|
740
|
+
for value in sorted(ce - be, key=lambda x: str(x)):
|
|
741
|
+
changes.append(
|
|
742
|
+
_change(
|
|
743
|
+
ChangeKind.ENUM_VALUE_ADDED,
|
|
744
|
+
policy,
|
|
745
|
+
f"#/components/schemas/{name}",
|
|
746
|
+
f"Enum value {value!r} added to '{name}'",
|
|
747
|
+
value=value,
|
|
748
|
+
)
|
|
749
|
+
)
|
|
750
|
+
bt = _schema_type(b_node)
|
|
751
|
+
ct = _schema_type(c_node)
|
|
752
|
+
if bt and ct and bt != ct:
|
|
753
|
+
changes.append(
|
|
754
|
+
_change(
|
|
755
|
+
ChangeKind.TYPE_CHANGED,
|
|
756
|
+
policy,
|
|
757
|
+
f"#/components/schemas/{name}",
|
|
758
|
+
f"Type of '{name}' changed from {bt} to {ct}",
|
|
759
|
+
from_type=bt,
|
|
760
|
+
to_type=ct,
|
|
761
|
+
)
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
# Deduplicate identical messages
|
|
765
|
+
unique: list[Change] = []
|
|
766
|
+
seen: set[tuple[Any, ...]] = set()
|
|
767
|
+
for change in changes:
|
|
768
|
+
key = (change.kind, change.severity, change.path, change.method, change.message)
|
|
769
|
+
if key in seen:
|
|
770
|
+
continue
|
|
771
|
+
seen.add(key)
|
|
772
|
+
unique.append(change)
|
|
773
|
+
|
|
774
|
+
return DiffResult(
|
|
775
|
+
changes=unique,
|
|
776
|
+
base_paths=len(base_paths),
|
|
777
|
+
candidate_paths=len(cand_paths),
|
|
778
|
+
engine="python",
|
|
779
|
+
)
|