#!/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())