Files
TJWaterServerBinary/scripts/check_openapi.py
T

144 lines
4.8 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sys
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
KEBAB_SEGMENT = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
SNAKE_PARAMETER = re.compile(r"^[a-z][a-z0-9_]*$")
def canonical_json(document: dict[str, Any]) -> bytes:
return (
json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8")
+ b"\n"
)
def current_contract_bytes() -> bytes:
os.environ.setdefault("ENVIRONMENT", "development")
from app.main import app
document = app.openapi()
document["info"]["version"] = "1.0.0"
return canonical_json(document)
def _iter_operations(document: dict[str, Any]):
for path, path_item in document.get("paths", {}).items():
for method, operation in path_item.items():
if method in HTTP_METHODS and isinstance(operation, dict):
yield path, method, operation
def validate(document: dict[str, Any]) -> list[str]:
errors: list[str] = []
operation_ids: set[str] = set()
for path, method, operation in _iter_operations(document):
if path != path.rstrip("/"):
errors.append(f"{method.upper()} {path}: trailing slash")
if "//" in path:
errors.append(f"{method.upper()} {path}: double slash")
for segment in path.split("/"):
if not segment or (segment.startswith("{") and segment.endswith("}")):
continue
if not KEBAB_SEGMENT.fullmatch(segment):
errors.append(f"{method.upper()} {path}: non-kebab segment {segment!r}")
operation_id = operation.get("operationId")
if not operation_id:
errors.append(f"{method.upper()} {path}: missing operationId")
elif operation_id in operation_ids:
errors.append(f"{method.upper()} {path}: duplicate operationId {operation_id}")
else:
operation_ids.add(operation_id)
if not operation.get("tags"):
errors.append(f"{method.upper()} {path}: missing tags")
if not operation.get("summary"):
errors.append(f"{method.upper()} {path}: missing summary")
for parameter in operation.get("parameters", []):
if (
parameter.get("in") in {"query", "path"}
and not SNAKE_PARAMETER.fullmatch(str(parameter.get("name", "")))
):
errors.append(
f"{method.upper()} {path}: non-snake parameter "
f"{parameter.get('name')!r}"
)
success_responses = [
(status, response)
for status, response in operation.get("responses", {}).items()
if str(status).startswith("2")
]
if not success_responses:
errors.append(f"{method.upper()} {path}: missing success response")
for status, response in success_responses:
if str(status) == "204":
continue
if "content" not in response:
errors.append(f"{method.upper()} {path}: success response has no content schema")
for media in response.get("content", {}).values():
if media.get("schema") == {}:
errors.append(f"{method.upper()} {path}: empty success schema")
return errors
def main() -> int:
parser = argparse.ArgumentParser(description="Validate TJWater REST OpenAPI invariants")
parser.add_argument(
"contract",
nargs="?",
type=Path,
default=Path("contracts/server-v1.openapi.json"),
)
parser.add_argument(
"--manifest",
type=Path,
default=Path("contracts/manifest.json"),
)
args = parser.parse_args()
raw = args.contract.read_bytes()
document = json.loads(raw)
errors = validate(document)
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
expected_hash = manifest["contracts"]["server"]["sha256"]
actual_hash = hashlib.sha256(raw).hexdigest()
if expected_hash != actual_hash:
errors.append(
f"contract hash mismatch: manifest={expected_hash}, actual={actual_hash}"
)
current = current_contract_bytes()
if raw != current:
errors.append(
"contract is stale: run "
"`python scripts/export_openapi.py` and commit the regenerated files"
)
if errors:
print("\n".join(f"- {error}" for error in errors))
return 1
print(
f"validated {len(document['paths'])} paths; "
f"sha256={actual_hash}; version={document['info']['version']}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())