mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
211 lines
7.0 KiB
Python
211 lines
7.0 KiB
Python
"""Orphaned endpoint detection — cross-references backend routes with usage.
|
|
|
|
Extracts all registered API routes from the backend (decorator and add_api_route
|
|
patterns) and checks whether each route's static path segments appear in the
|
|
frontend source or in other backend files (e.g. MCP servers that call endpoints
|
|
internally). Routes with no matching reference anywhere are flagged.
|
|
|
|
Limitations (v1):
|
|
- Routes that end with a path parameter (e.g. /{id}) and have no trailing
|
|
static segment are skipped — they're too ambiguous to match.
|
|
- WebSocket routes in main.py are not checked.
|
|
- Backend-only endpoints (health checks, OAuth callbacks) should be excluded
|
|
via the exceptions list or endpoint-ignore-routes in config.json.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fnmatch
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from . import is_excepted, is_lintignored
|
|
|
|
_DECORATOR_RE = re.compile(
|
|
r"@(\w+)\.router\.\w+\(\s*[\"']([^\"']+)[\"']"
|
|
)
|
|
_ADD_ROUTE_RE = re.compile(
|
|
r"(\w+)\.router\.add_api_route\(\s*[\"']([^\"']+)[\"']"
|
|
)
|
|
_SUBAPP_RE = re.compile(
|
|
r"(\w+)\s*=\s*SubApp\(\s*[\"']([^\"']+)[\"']"
|
|
)
|
|
_FUNC_DEF_RE = re.compile(r"\s*(?:async\s+)?def\s+(\w+)")
|
|
_ADD_ROUTE_FUNC_RE = re.compile(r"add_api_route\([^,]+,\s*(?:\w+\.)*(\w+)")
|
|
|
|
_TEMPLATE_ASSIGN_RE = re.compile(
|
|
r"""(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*`([^`]*)`"""
|
|
)
|
|
_STRING_ASSIGN_RE = re.compile(
|
|
r"""(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(['"])(.*?)\2"""
|
|
)
|
|
_TEMPLATE_REF_RE = re.compile(r"\$\{(\w+)\}")
|
|
|
|
|
|
def _static_tail(route_path: str) -> str:
|
|
"""Return the trailing contiguous static segments of a route path.
|
|
|
|
>>> _static_tail("/sessions/{id}/message")
|
|
'/message'
|
|
>>> _static_tail("/usage-summary")
|
|
'/usage-summary'
|
|
>>> _static_tail("/{id}")
|
|
''
|
|
"""
|
|
parts = route_path.strip("/").split("/")
|
|
tail: list[str] = []
|
|
for part in reversed(parts):
|
|
if part.startswith("{"):
|
|
break
|
|
tail.append(part)
|
|
tail.reverse()
|
|
return "/" + "/".join(tail) if tail else ""
|
|
|
|
|
|
def _resolve_frontend_vars(files: list[tuple[str, str]]) -> dict[str, str]:
|
|
"""Collect const/let/var string assignments across files and resolve refs.
|
|
|
|
Handles patterns like:
|
|
const API_BASE = "/api";
|
|
const WORKSPACE_API = `${API_BASE}/outputs/workspace`;
|
|
"""
|
|
raw: dict[str, str] = {}
|
|
for _, text in files:
|
|
for m in _STRING_ASSIGN_RE.finditer(text):
|
|
raw.setdefault(m.group(1), m.group(3))
|
|
for m in _TEMPLATE_ASSIGN_RE.finditer(text):
|
|
raw.setdefault(m.group(1), m.group(2))
|
|
resolved = dict(raw)
|
|
for _ in range(5):
|
|
changed = False
|
|
for name, val in list(resolved.items()):
|
|
new_val = _TEMPLATE_REF_RE.sub(
|
|
lambda m: resolved.get(m.group(1), m.group(0)), val
|
|
)
|
|
if new_val != val:
|
|
resolved[name] = new_val
|
|
changed = True
|
|
if not changed:
|
|
break
|
|
return resolved
|
|
|
|
|
|
def _expand_template_refs(text: str, resolved: dict[str, str]) -> str:
|
|
"""Replace ``${VAR}`` references in *text* with resolved values."""
|
|
return _TEMPLATE_REF_RE.sub(
|
|
lambda m: resolved.get(m.group(1), m.group(0)), text
|
|
)
|
|
|
|
|
|
def _find_func_name(lines: list[str], decorator_idx: int) -> str:
|
|
for j in range(decorator_idx + 1, min(decorator_idx + 5, len(lines))):
|
|
m = _FUNC_DEF_RE.match(lines[j])
|
|
if m:
|
|
return m.group(1)
|
|
return ""
|
|
|
|
|
|
def run_endpoint_check(
|
|
root: Path,
|
|
exceptions: dict[str, list[str]],
|
|
ignore_routes: list[str] | None = None,
|
|
ignores: dict[Path, set[str]] | None = None,
|
|
) -> list[str]:
|
|
"""Find backend API endpoints with no matching frontend or backend reference."""
|
|
backend_dir = root / "backend"
|
|
frontend_dir = root / "frontend" / "src"
|
|
if not backend_dir.exists() or not frontend_dir.exists():
|
|
return []
|
|
|
|
_ignore_routes = ignore_routes or []
|
|
var_to_name: dict[str, str] = {}
|
|
for py in backend_dir.rglob("*.py"):
|
|
if ".venv" in py.parts:
|
|
continue
|
|
for m in _SUBAPP_RE.finditer(py.read_text(errors="ignore")):
|
|
var_to_name[m.group(1)] = m.group(2)
|
|
|
|
routes: list[tuple[str, str, str, int, str]] = []
|
|
|
|
for py in backend_dir.rglob("*.py"):
|
|
if ".venv" in py.parts:
|
|
continue
|
|
text = py.read_text(errors="ignore")
|
|
lines = text.splitlines()
|
|
rel = str(py.relative_to(root))
|
|
|
|
for i, line in enumerate(lines):
|
|
m = _DECORATOR_RE.search(line)
|
|
if m:
|
|
var, path = m.group(1), m.group(2)
|
|
name = var_to_name.get(var)
|
|
if name:
|
|
func = _find_func_name(lines, i)
|
|
routes.append((name, path, rel, i + 1, func))
|
|
|
|
m2 = _ADD_ROUTE_RE.search(line)
|
|
if m2:
|
|
var, path = m2.group(1), m2.group(2)
|
|
name = var_to_name.get(var)
|
|
if name:
|
|
fm = _ADD_ROUTE_FUNC_RE.search(line)
|
|
func = fm.group(1) if fm else ""
|
|
routes.append((name, path, rel, i + 1, func))
|
|
|
|
frontend_files: list[tuple[str, str]] = []
|
|
for ext in ("*.ts", "*.tsx"):
|
|
for f in frontend_dir.rglob(ext):
|
|
frontend_files.append((str(f.relative_to(root)), f.read_text(errors="ignore")))
|
|
|
|
backend_files: list[tuple[str, str]] = []
|
|
for py in backend_dir.rglob("*.py"):
|
|
if ".venv" in py.parts:
|
|
continue
|
|
backend_files.append((str(py.relative_to(root)), py.read_text(errors="ignore")))
|
|
|
|
resolved_vars = _resolve_frontend_vars(frontend_files)
|
|
|
|
errors: list[str] = []
|
|
for subapp_name, route_path, filepath, lineno, func_name in routes:
|
|
if is_excepted(filepath, "endpoints", exceptions):
|
|
continue
|
|
if ignores and is_lintignored(root / filepath, root, "endpoints", ignores):
|
|
continue
|
|
|
|
full_path = f"{subapp_name}{route_path}"
|
|
|
|
if any(fnmatch.fnmatch(full_path, p) for p in _ignore_routes):
|
|
continue
|
|
tail = _static_tail(route_path)
|
|
|
|
if not tail:
|
|
continue
|
|
|
|
found = False
|
|
for _fe_path, fe_text in frontend_files:
|
|
expanded = _expand_template_refs(fe_text, resolved_vars)
|
|
if full_path in expanded:
|
|
found = True
|
|
break
|
|
if subapp_name in expanded and tail in expanded:
|
|
found = True
|
|
break
|
|
|
|
if not found:
|
|
for be_path, be_text in backend_files:
|
|
if be_path == filepath:
|
|
continue
|
|
if full_path in be_text:
|
|
found = True
|
|
break
|
|
|
|
if not found:
|
|
label = func_name or route_path
|
|
errors.append(
|
|
f"{filepath}:{lineno}:1: warning: "
|
|
f"[endpoints] orphaned endpoint '{label}' "
|
|
f"(/api/{full_path}) — no frontend or backend reference found"
|
|
)
|
|
|
|
return sorted(errors)
|