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