Co-Study4Grid / scripts /check_openapi_contract.py
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
4.02 kB
#!/usr/bin/env python3
# Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
# This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
# If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
# you can obtain one at http://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0
"""Machine-check the API contract (D2, 2026-07).
The FastAPI app is the single source of truth for the request/response
schema. This script renders ``app.openapi()`` to a stable, normalized
JSON document and either:
- ``--write``: writes it to ``expert_backend/openapi.snapshot.json``
(run this whenever an endpoint or a Pydantic model changes on purpose), or
- default (check mode): diffs the freshly-rendered spec against the
committed snapshot and FAILS (exit 1) on any drift — so a
response-/request-shape change becomes a reviewable diff in the PR
instead of silently diverging from the hand-mirrored ``types.ts``.
Wired into CI alongside the other gate scripts. The snapshot is also
the input for generating the frontend types (see
``docs/architecture/api-contract-machine-check.md``).
Normalization: the OpenAPI version string and FastAPI's auto-generated
``title`` / ``version`` are pinned so an unrelated FastAPI upgrade
doesn't churn the snapshot; ``paths`` and ``components.schemas`` are
key-sorted for a deterministic, minimal diff.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parent.parent
_SNAPSHOT = _REPO_ROOT / "expert_backend" / "openapi.snapshot.json"
def _render_spec() -> dict:
# Import lazily so the heavy backend import cost is only paid when
# this script actually runs (not at module import for --help).
sys.path.insert(0, str(_REPO_ROOT))
from expert_backend.main import app
spec = app.openapi()
# Pin the FastAPI-generated metadata so a framework upgrade or a
# default-title change doesn't churn the contract snapshot.
spec["openapi"] = "3.1.0"
spec["info"] = {"title": "Co-Study4Grid API", "version": "contract"}
return spec
def _normalize(spec: dict) -> str:
"""Deterministic JSON: recursively key-sorted, stable separators."""
return json.dumps(spec, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--write",
action="store_true",
help="Overwrite the committed snapshot with the current spec.",
)
args = parser.parse_args()
rendered = _normalize(_render_spec())
if args.write:
_SNAPSHOT.write_text(rendered, encoding="utf-8")
print(f"Wrote OpenAPI snapshot → {_SNAPSHOT.relative_to(_REPO_ROOT)}")
return 0
if not _SNAPSHOT.exists():
print(
"OpenAPI snapshot missing. Generate it with:\n"
" python scripts/check_openapi_contract.py --write",
file=sys.stderr,
)
return 1
committed = _SNAPSHOT.read_text(encoding="utf-8")
if rendered == committed:
print("OpenAPI contract OK — spec matches the committed snapshot.")
return 0
# Show a compact unified diff so the reviewer sees exactly what moved.
import difflib
diff = difflib.unified_diff(
committed.splitlines(keepends=True),
rendered.splitlines(keepends=True),
fromfile="openapi.snapshot.json (committed)",
tofile="app.openapi() (current)",
n=2,
)
sys.stdout.writelines(diff)
print(
"\nOpenAPI contract DRIFT: the live spec differs from the committed "
"snapshot.\nIf this change is intentional, regenerate the snapshot:\n"
" python scripts/check_openapi_contract.py --write\n"
"and review the diff (it mirrors what the frontend types must track).",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
raise SystemExit(main())