from __future__ import annotations import argparse import ast import json import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] OUTPUT = ROOT / "config" / "api.config.json" if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) def _owner(path: str) -> str: ordered = ( ("/api/health", "health"), ("/api/auth", "auth"), ("/api/admin", "admin"), ("/api/account", "account"), ("/api/sentiment", "sentiment"), ("/api/dashboard", "market"), ("/api/realtime-aggregate", "market"), ("/api/rotation", "rotation"), ("/api/auction", "auction"), ("/api/themes", "themes"), ("/api/popularity", "popularity"), ("/api/dragon-tiger", "dragon_tiger"), ("/api/search", "search"), ("/api/chart", "charts"), ("/api/stock", "market"), ("/api/screener", "screener"), ("/api/mentors", "mentor"), ("/api/heaven", "heaven"), ("/api/alerts", "alerts"), ("/api/trades", "review"), ("/api/assistant", "review"), ("/api/watchlist", "review"), ("/api/notes", "review"), ("/api/reasons", "admin"), ("/api/seat-aliases", "admin"), ("/api/backfill", "admin"), ) for prefix, owner in ordered: if path.startswith(prefix): return owner raise ValueError(f"API owner is not registered: {path}") def _role(method: str, path: str) -> str: if path == "/api/health" or path in {"/api/auth/register", "/api/auth/login"}: return "public" from api_access import required_role sample = re.sub(r"\\d\{6\}", "000001", path) sample = re.sub(r"\\d\+", "1", sample) sample = sample.replace("(.+)", "sample").replace("(", "").replace(")", "") return required_role(method, sample) def _mapped_paths(text: str) -> dict[str, set[str]]: paths = {method: set() for method in ("GET", "POST", "DELETE")} tree = ast.parse(text) for node in tree.body: if not isinstance(node, ast.Assign) or len(node.targets) != 1: continue target = node.targets[0] if not isinstance(target, ast.Name): continue match = re.fullmatch( r"(?:PUBLIC_|AUTHENTICATED_)?(GET|POST|DELETE)_HANDLERS", target.id ) if not match: continue mapping = ast.literal_eval(node.value) if not isinstance(mapping, dict) or not all( isinstance(path, str) and path.startswith("/api/") for path in mapping ): raise ValueError(f"Invalid route handler map: {target.id}") paths[match.group(1)].update(mapping) return paths def _route_sources() -> list[tuple[Path, str]]: paths = [ROOT / "backend" / "http" / "dispatch.py"] paths.extend(sorted((ROOT / "backend" / "features").glob("*/routes.py"))) return [(path, path.read_text(encoding="utf-8")) for path in paths] def _method_blocks(path: Path, text: str) -> list[tuple[str, str]]: tree = ast.parse(text, filename=str(path)) lines = text.splitlines() blocks: list[tuple[str, str]] = [] for node in ast.walk(tree): if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): continue method = "" if node.name.startswith("do_") and node.name[3:] in {"GET", "POST", "DELETE"}: method = node.name[3:] else: suffix = node.name.rsplit("_", 1)[-1].upper() if node.name.startswith("_handle_") and suffix in {"GET", "POST", "DELETE"}: method = suffix if method: blocks.append((method, "\n".join(lines[node.lineno - 1 : node.end_lineno]))) return blocks def build() -> dict: sources = _route_sources() mapped_paths = {method: set() for method in ("GET", "POST", "DELETE")} for _, text in sources: discovered = _mapped_paths(text) for method in mapped_paths: mapped_paths[method].update(discovered[method]) discovered_routes = {method: {"exact": set(), "patterns": set()} for method in mapped_paths} for path, text in sources: for method, block in _method_blocks(path, text): discovered_routes[method]["exact"].update( re.findall(r'parsed\.path\s*==\s*"(/api/[^"]+)"', block) ) discovered_routes[method]["patterns"].update( re.findall( r're\.(?:fullmatch|match)\(\s*r?["\']([^"\']*?/api/[^"\']+)["\']\s*,\s*parsed\.path', block, ) ) routes = [] for method in ("GET", "POST", "DELETE"): exact_paths = discovered_routes[method]["exact"] | mapped_paths[method] patterns = discovered_routes[method]["patterns"] for path in sorted(exact_paths): routes.append( {"method": method, "path": path, "match": "exact", "feature": _owner(path), "access": _role(method, path)} ) for path in sorted(patterns): normalized = path.replace("^", "").replace("$", "") routes.append( {"method": method, "path": normalized, "match": "regex", "feature": _owner(normalized), "access": _role(method, normalized)} ) routes.sort(key=lambda item: (item["path"], item["method"], item["match"])) return { "schema_version": 1, "generated_from": "server.py", "routes": routes, } def main() -> int: parser = argparse.ArgumentParser(description="Build the transitional API ownership registry") parser.add_argument("--check", action="store_true") args = parser.parse_args() rendered = json.dumps(build(), ensure_ascii=False, indent=2) + "\n" if args.check: if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != rendered: raise SystemExit("API registry is stale; run tools/build_api_registry.py") print("API registry is current.") return 0 OUTPUT.parent.mkdir(parents=True, exist_ok=True) OUTPUT.write_text(rendered, encoding="utf-8") print(OUTPUT.relative_to(ROOT).as_posix()) return 0 if __name__ == "__main__": raise SystemExit(main())