Coverage for src/data_api/checks/openapi.py: 67%
49 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-13 19:28 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-13 19:28 +0000
1"""Structured compatibility baseline, excluding documentation-only metadata."""
3import argparse
4import json
5from pathlib import Path
6from typing import Any
8HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options", "trace"}
9DOCUMENTATION_KEYS = {"title", "description", "summary", "examples", "example", "externalDocs"}
10BASELINE = Path("tests") / "fixtures" / "openapi_contract.json"
13def contract_projection(schema: dict[str, Any]) -> dict[str, Any]:
14 def strip_docs(value: Any) -> Any:
15 if isinstance(value, dict):
16 return {
17 key: (
18 {name: strip_docs(field) for name, field in sorted(item.items())}
19 if key in {"properties", "$defs"}
20 else strip_docs(item)
21 )
22 for key, item in sorted(value.items())
23 if key not in DOCUMENTATION_KEYS
24 }
25 if isinstance(value, list):
26 return [strip_docs(item) for item in value]
27 return value
29 operations = {}
30 for path, path_item in sorted(schema["paths"].items()):
31 for method, operation in sorted(path_item.items()):
32 if method not in HTTP_METHODS:
33 continue
34 parameters = {
35 f"{parameter['in']}:{parameter['name']}": {
36 "required": parameter.get("required", False),
37 "schema": strip_docs(parameter["schema"]),
38 }
39 for parameter in operation.get("parameters", [])
40 }
41 operations[f"{method.upper()} {path}"] = {
42 "operationId": operation["operationId"],
43 "deprecated": operation.get("deprecated", False),
44 "parameters": parameters,
45 "requestBody": strip_docs(operation.get("requestBody")),
46 "responses": strip_docs(operation["responses"]),
47 }
48 return {
49 "operations": operations,
50 "schemas": strip_docs(schema.get("components", {}).get("schemas", {})),
51 }
54def contract_differences(expected: Any, actual: Any, path: str = "contract") -> list[str]:
55 if isinstance(expected, dict) and isinstance(actual, dict):
56 changes = [f"{path}.{key}: removed" for key in sorted(expected.keys() - actual.keys())]
57 changes += [f"{path}.{key}: added" for key in sorted(actual.keys() - expected.keys())]
58 for key in sorted(expected.keys() & actual.keys()):
59 changes.extend(contract_differences(expected[key], actual[key], f"{path}.{key}"))
60 return changes
61 if expected != actual:
62 return [f"{path}: changed from {expected!r} to {actual!r}"]
63 return []
66def main() -> int:
67 from data_api.api.api import app
69 parser = argparse.ArgumentParser(description=__doc__)
70 parser.add_argument(
71 "--write", action="store_true", help="Regenerate only for reviewed API changes"
72 )
73 args = parser.parse_args()
74 actual = contract_projection(app.openapi())
75 if args.write:
76 BASELINE.write_text(
77 json.dumps(actual, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
78 encoding="utf-8",
79 )
80 print(f"Wrote {BASELINE}")
81 return 0
82 expected = json.loads(BASELINE.read_text(encoding="utf-8"))
83 changes = contract_differences(expected, actual)
84 for change in changes:
85 print(change)
86 return int(bool(changes))
89if __name__ == "__main__":
90 raise SystemExit(main())