Files
donutbrowser/sdk/tools/extract-api-paths.py
T
2026-09-09 10:09:14 +04:00

146 lines
4.8 KiB
Python

#!/usr/bin/env python3
"""Regenerate sdk/api-paths.json from the Rust REST server.
The served /openapi.json comes from the hand-maintained `ApiDoc` derive in
`src-tauri/src/api_server.rs`, not from the axum router, so this script reads
the same two things the document is built from:
* every `#[utoipa::path(...)]` annotation (its verb and path), and
* the `paths(...)` list inside `#[openapi(...)]`.
An annotation that is not in `paths(...)` never reaches the served document, so
the two lists are compared here and a difference fails the run. The result is a
snapshot both SDK test suites read to prove they cover the whole API.
Usage (from anywhere):
python3 sdk/tools/extract-api-paths.py
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
SOURCE = REPO_ROOT / "src-tauri" / "src" / "api_server.rs"
SNAPSHOT = REPO_ROOT / "sdk" / "api-paths.json"
VERBS = ("get", "post", "put", "delete", "patch", "head", "options")
def read_annotations(lines: list[str]) -> list[dict[str, str]]:
"""Every `#[utoipa::path(...)]` block, paired with the fn it decorates."""
operations: list[dict[str, str]] = []
index = 0
while index < len(lines):
if lines[index].strip() != "#[utoipa::path(":
index += 1
continue
depth = 0
end = index
while end < len(lines):
depth += lines[end].count("(") - lines[end].count(")")
if depth == 0 and end > index:
break
end += 1
block = lines[index : end + 1]
method = next(
(line.strip().rstrip(",") for line in block if line.strip().rstrip(",") in VERBS),
None,
)
path_match = next(
(re.search(r'path\s*=\s*"([^"]+)"', line) for line in block if "path = " in line),
None,
)
name_match = None
for line in lines[end + 1 : end + 4]:
name_match = re.search(r"\bfn\s+(\w+)\s*\(", line)
if name_match:
break
if method is None or path_match is None or name_match is None:
raise SystemExit(
f"{SOURCE}:{index + 1}: could not read a verb, a path and a fn name "
"out of this #[utoipa::path] block"
)
operations.append(
{
"operation_id": name_match.group(1),
"method": method.upper(),
"path": path_match.group(1),
}
)
index = end + 1
return operations
def read_apidoc_paths(text: str) -> list[str]:
"""The operation ids listed in `#[openapi(paths(...))]`."""
start = text.index("#[openapi(")
listed = text.index("paths(", start) + len("paths(")
depth = 1
end = listed
while depth:
if text[end] == "(":
depth += 1
elif text[end] == ")":
depth -= 1
if depth == 0:
break
end += 1
body = re.sub(r"//[^\n]*", "", text[listed:end])
return [item.strip() for item in body.split(",") if item.strip()]
def main() -> int:
text = SOURCE.read_text(encoding="utf-8")
annotated = read_annotations(text.split("\n"))
listed = read_apidoc_paths(text)
annotated_ids = {operation["operation_id"] for operation in annotated}
listed_ids = set(listed)
unpublished = sorted(annotated_ids - listed_ids)
unknown = sorted(listed_ids - annotated_ids)
if unpublished or unknown:
for name in unpublished:
print(
f"error: {name} carries a #[utoipa::path] but is missing from "
"ApiDoc paths(...), so it is absent from the served spec",
file=sys.stderr,
)
for name in unknown:
print(
f"error: ApiDoc paths(...) lists {name}, which has no "
"#[utoipa::path] annotation in this file",
file=sys.stderr,
)
return 1
operations = sorted(annotated, key=lambda op: (op["path"], op["method"]))
snapshot = {
"source": "src-tauri/src/api_server.rs",
"regenerate_with": "python3 sdk/tools/extract-api-paths.py",
"description": (
"Every operation the desktop app publishes in its /openapi.json. The "
"SDK test suites assert this list and their own coverage tables match "
"exactly, so an endpoint added to the app fails the SDK tests until it "
"is either wrapped or deliberately listed as omitted."
),
"operation_count": len(operations),
"operations": operations,
}
SNAPSHOT.write_text(json.dumps(snapshot, indent=2) + "\n", encoding="utf-8")
print(f"wrote {SNAPSHOT.relative_to(REPO_ROOT)} with {len(operations)} operations")
return 0
if __name__ == "__main__":
raise SystemExit(main())