Files
xiaozhi-esp32-server/main/manager-api-fastapi/scripts/extract_java_routes.py
T

169 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Extract the Spring MVC contract without starting the Java application."""
from __future__ import annotations
import argparse
import fnmatch
import json
import re
from dataclasses import asdict, dataclass
from pathlib import Path
MAPPING_RE = re.compile(r"@(Get|Post|Put|Delete|Patch)Mapping(?:\((.*)\))?")
CLASS_MAPPING_RE = re.compile(r"@RequestMapping\(\s*(?:value\s*=\s*)?[\"']([^\"']*)[\"']")
PATH_RE = re.compile(r"[\"']([^\"']*)[\"']")
METHOD_RE = re.compile(r"\bpublic\s+(?:<[^>]+>\s+)?[^=(;]+?\s+(\w+)\s*\(")
PERMISSION_RE = re.compile(r'@RequiresPermissions\(\s*"([^"]+)"')
PUBLIC_PATTERNS = (
"/ota/**",
"/otaMag/download/**",
"/webjars/**",
"/druid/**",
"/v3/api-docs/**",
"/doc.html",
"/favicon.ico",
"/user/captcha",
"/user/smsVerification",
"/user/login",
"/user/pub-config",
"/user/register",
"/user/retrieve-password",
"/agent/chat-history/download/**",
"/agent/play/**",
"/voiceClone/play/**",
)
SERVER_PATTERNS = (
"/config/**",
"/device/address-book/call",
"/agent/chat-history/report",
"/agent/chat-summary/**",
"/agent/chat-title/**",
)
@dataclass(frozen=True, slots=True)
class Route:
method: str
path: str
controller: str
handler: str
auth: str
permission: str | None
source: str
line: int
java_signature: str
def _spring_match(path: str, pattern: str) -> bool:
return fnmatch.fnmatchcase(path, pattern.replace("**", "*"))
def classify_auth(path: str) -> str:
if any(_spring_match(path, pattern) for pattern in PUBLIC_PATTERNS):
return "anonymous"
if any(_spring_match(path, pattern) for pattern in SERVER_PATTERNS):
return "server-secret"
return "database-token"
def join_paths(base: str, child: str) -> str:
if not base:
base = "/"
if not base.startswith("/"):
base = "/" + base
if not child:
return base
if not child.startswith("/"):
child = "/" + child
if base == "/":
return child
return base.rstrip("/") + child
def extract_controller(path: Path, root: Path) -> list[Route]:
source = path.read_text(encoding="utf-8")
class_position = source.find(" class ")
class_header = source[:class_position] if class_position >= 0 else source
class_match = list(CLASS_MAPPING_RE.finditer(class_header))
base = class_match[-1].group(1) if class_match else ""
controller_match = re.search(r"public\s+class\s+(\w+)", source)
controller = controller_match.group(1) if controller_match else path.stem
lines = source.splitlines()
routes: list[Route] = []
for index, line in enumerate(lines):
mapping = MAPPING_RE.search(line)
if not mapping:
continue
method = mapping.group(1).upper()
arguments = mapping.group(2) or ""
path_match = PATH_RE.search(arguments)
child = path_match.group(1) if path_match else ""
decorator_block: list[str] = [line]
signature_lines: list[str] = []
handler = "unknown"
for cursor in range(index + 1, min(index + 40, len(lines))):
candidate = lines[cursor]
if not signature_lines and candidate.lstrip().startswith("@"):
decorator_block.append(candidate)
continue
signature_lines.append(candidate.strip())
signature = " ".join(signature_lines)
handler_match = METHOD_RE.search(signature)
if handler_match:
handler = handler_match.group(1)
break
if "{" in candidate and "public " not in signature:
break
permission_match = PERMISSION_RE.search("\n".join(decorator_block))
route_path = join_paths(base, child)
routes.append(
Route(
method=method,
path=route_path,
controller=controller,
handler=handler,
auth=classify_auth(route_path),
permission=permission_match.group(1) if permission_match else None,
source=str(path.relative_to(root)),
line=index + 1,
java_signature=" ".join(signature_lines),
)
)
return routes
def extract_routes(java_root: Path, repository_root: Path) -> list[Route]:
routes: list[Route] = []
for controller in sorted(java_root.rglob("*Controller.java")):
routes.extend(extract_controller(controller, repository_root))
return sorted(routes, key=lambda route: (route.path, route.method, route.controller, route.handler))
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--repository-root", type=Path, default=Path(__file__).resolve().parents[3])
parser.add_argument("--output", type=Path)
args = parser.parse_args()
repository_root = args.repository_root.resolve()
java_root = repository_root / "main" / "manager-api" / "src" / "main" / "java"
routes = extract_routes(java_root, repository_root)
payload = {
"source": "main/manager-api",
"contextPath": "/xiaozhi",
"count": len(routes),
"routes": [asdict(route) for route in routes],
}
serialized = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(serialized, encoding="utf-8")
else:
print(serialized, end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())