144 lines
4.8 KiB
Python
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())
|