feat(api): standardize REST contracts and auth
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user