"""docs/tools/extract_api.py — 서버 주소 추출(PLAN 478-1 · FastAPI app.openapi()). 서버를 켜지 않고 `main.app` 을 불러 주소 목록을 얻고, TS 추출의 서버 주소 글자 (`raw/ts_api_calls.json`)와 경로 패턴으로 짝지어 씀: api.json [{method, path, handler, callers[]}] — 범위 파이썬이 받거나 범위 TS 가 부르는 주소 api_unmatched.json {ts_calls: 짝 없는 TS 글자, routes_without_caller: 범위 주소 중 부르는 TS 없음} """ from __future__ import annotations import inspect import json import logging import os import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] DATA = ROOT / "docs/_data" def handler_id(endpoint) -> str | None: fn = inspect.unwrap(endpoint) try: file = Path(inspect.getsourcefile(fn)).resolve().relative_to(ROOT).as_posix() except (TypeError, ValueError): return None return f"py:{file}#{fn.__qualname__.replace('..', '.')}" def load_routes() -> list[dict]: os.chdir(ROOT) sys.path.insert(0, str(ROOT)) logging.disable(logging.CRITICAL) from fastapi.routing import APIRoute import main endpoints = { (m, r.path): r.endpoint for r in main.app.routes if isinstance(r, APIRoute) for m in r.methods } out = [] for path, ops in main.app.openapi()["paths"].items(): for method in ops: m = method.upper() ep = endpoints.get((m, path)) out.append({"method": m, "path": path, "handler": handler_id(ep) if ep else None}) return out def segments(path: str) -> list[str]: return [s for s in re.sub(r"^/api(?=/)", "", path).split("/") if s] def score(ts_path: str, route_path: str) -> int | None: """맞으면 어긋남 수(작을수록 또렷) · 안 맞으면 None. TS `{}` = 아무 글자 · 서버 `{x}` = 칸.""" a, b = segments(ts_path), segments(route_path) if len(a) != len(b): return None miss = 0 for t, r in zip(a, b): param = r.startswith("{") and r.endswith("}") if "{}" in t: pat = "^" + ".*".join(re.escape(x) for x in t.split("{}")) + "$" if param: continue if not re.match(pat, r): return None miss += t == "{}" # 통째 `{}` 가 글자 칸을 먹으면 어긋남 하나 elif param: miss += 1 # 서버 칸이 TS 글자를 먹음 elif t != r: return None return miss def match(call: dict, routes: list[dict]) -> list[dict]: method = call["method"] # 글자 · "*"(변수 · 펼침 = 아무 메서드) · None(모름 = GET 먼저) if method == "*": pool = routes elif method: pool = [r for r in routes if r["method"] == method] else: pool = [r for r in routes if r["method"] == "GET"] scored = [(s, r) for r in pool if (s := score(call["path"], r["path"])) is not None] if not scored and not method: scored = [(s, r) for r in routes if (s := score(call["path"], r["path"])) is not None] if not scored: return [] best = min(s for s, _ in scored) return [r for s, r in scored if s == best] def main() -> None: routes = load_routes() ts_calls = json.loads((DATA / "raw/ts_api_calls.json").read_text(encoding="utf-8")) py_ids = { f["id"] for f in json.loads((DATA / "raw/functions_py.json").read_text(encoding="utf-8")) } callers: dict[tuple[str, str], set[str]] = {} unmatched = [] for call in ts_calls: hits = match(call, routes) if not hits: unmatched.append(call) for r in hits: callers.setdefault((r["method"], r["path"]), set()).add(call["fn"]) api = [ {**r, "callers": sorted(callers.get((r["method"], r["path"]), ()))} for r in routes if r["handler"] in py_ids or (r["method"], r["path"]) in callers ] api.sort(key=lambda r: (r["path"], r["method"])) lonely = [f"{r['method']} {r['path']}" for r in api if not r["callers"]] (DATA / "api.json").write_text( json.dumps(api, ensure_ascii=False, indent=1) + "\n", encoding="utf-8" ) (DATA / "api_unmatched.json").write_text( json.dumps( {"ts_calls": unmatched, "routes_without_caller": lonely}, ensure_ascii=False, indent=1 ) + "\n", encoding="utf-8", ) print( f"api: 서버 주소 전체 {len(routes)} · 범위 {len(api)} · 짝 없는 TS 글자 {len(unmatched)} · " f"부르는 TS 없는 주소 {len(lonely)}" ) if __name__ == "__main__": main()