349 lines
12 KiB
Python
349 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate API reference markdown from Spring controller annotations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import glob
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import List
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
CONTROLLER_ROOT = ROOT / "src/main/kotlin/com/android/trisolarisserver/controller"
|
|
OUTPUT = ROOT / "docs/API_REFERENCE.md"
|
|
|
|
HTTP_BY_ANNOTATION = {
|
|
"GetMapping": "GET",
|
|
"PostMapping": "POST",
|
|
"PutMapping": "PUT",
|
|
"DeleteMapping": "DELETE",
|
|
"PatchMapping": "PATCH",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class Endpoint:
|
|
method: str
|
|
path: str
|
|
path_params: List[str]
|
|
query_params: List[str]
|
|
body_type: str
|
|
response_type: str
|
|
status: str
|
|
behavior: str
|
|
handler_file: str
|
|
handler_name: str
|
|
handler_line: int
|
|
|
|
|
|
def method_from_annotations(annotations: List[str]) -> List[str]:
|
|
for ann in annotations:
|
|
m = re.match(r"@(\w+)", ann.strip())
|
|
if not m:
|
|
continue
|
|
name = m.group(1)
|
|
if name in HTTP_BY_ANNOTATION:
|
|
return [HTTP_BY_ANNOTATION[name]]
|
|
if name == "RequestMapping":
|
|
methods = re.findall(r"RequestMethod\.(GET|POST|PUT|DELETE|PATCH)", ann)
|
|
return methods or ["ANY"]
|
|
return []
|
|
|
|
|
|
def mapping_path(annotations: List[str]) -> str:
|
|
for ann in annotations:
|
|
if "Mapping" in ann:
|
|
m = re.search(r'"([^"]*)"', ann)
|
|
if m:
|
|
return m.group(1)
|
|
return ""
|
|
|
|
|
|
def class_base_path(lines: List[str], class_line_index: int) -> str:
|
|
start = max(0, class_line_index - 50)
|
|
for i in range(start, class_line_index):
|
|
line = lines[i].strip()
|
|
if line.startswith("@RequestMapping"):
|
|
m = re.search(r'"([^"]+)"', line)
|
|
if m:
|
|
return m.group(1)
|
|
return ""
|
|
|
|
|
|
def split_params(param_blob: str) -> List[str]:
|
|
parts: List[str] = []
|
|
buf = []
|
|
angle = 0
|
|
paren = 0
|
|
bracket = 0
|
|
brace = 0
|
|
for ch in param_blob:
|
|
if ch == "<":
|
|
angle += 1
|
|
elif ch == ">":
|
|
angle = max(0, angle - 1)
|
|
elif ch == "(":
|
|
paren += 1
|
|
elif ch == ")":
|
|
paren = max(0, paren - 1)
|
|
elif ch == "[":
|
|
bracket += 1
|
|
elif ch == "]":
|
|
bracket = max(0, bracket - 1)
|
|
elif ch == "{":
|
|
brace += 1
|
|
elif ch == "}":
|
|
brace = max(0, brace - 1)
|
|
if ch == "," and angle == 0 and paren == 0 and bracket == 0 and brace == 0:
|
|
part = "".join(buf).strip()
|
|
if part:
|
|
parts.append(part)
|
|
buf = []
|
|
continue
|
|
buf.append(ch)
|
|
tail = "".join(buf).strip()
|
|
if tail:
|
|
parts.append(tail)
|
|
return parts
|
|
|
|
|
|
def extract_types_from_params(param_blob: str) -> tuple[list[str], list[str], str]:
|
|
path_params: List[str] = []
|
|
query_params: List[str] = []
|
|
body_type = "-"
|
|
for raw in split_params(param_blob):
|
|
segment = " ".join(raw.split())
|
|
name_match = re.search(r"(\w+)\s*:", segment)
|
|
param_name = name_match.group(1) if name_match else "param"
|
|
type_match = re.search(r":\s*([^=]+)", segment)
|
|
param_type = type_match.group(1).strip() if type_match else "Unknown"
|
|
|
|
if "@PathVariable" in segment:
|
|
path_params.append(f"{param_name}:{param_type}")
|
|
elif "@RequestParam" in segment:
|
|
required = "optional" if "required = false" in segment else "required"
|
|
query_params.append(f"{param_name}:{param_type} ({required})")
|
|
elif "@RequestBody" in segment:
|
|
body_type = param_type
|
|
return path_params, query_params, body_type
|
|
|
|
|
|
def default_status(method: str, response_type: str, explicit: str | None) -> str:
|
|
if explicit:
|
|
return explicit
|
|
return "200"
|
|
|
|
|
|
def explicit_status_from_annotations(annotations: List[str]) -> str | None:
|
|
for ann in annotations:
|
|
if ann.strip().startswith("@ResponseStatus"):
|
|
if "CREATED" in ann:
|
|
return "201"
|
|
if "NO_CONTENT" in ann:
|
|
return "204"
|
|
m = re.search(r"HttpStatus\.([A-Z_]+)", ann)
|
|
if m:
|
|
return m.group(1)
|
|
return None
|
|
|
|
|
|
def behavior_from_name(name: str) -> str:
|
|
words = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name).lower()
|
|
if name.startswith("list"):
|
|
return f"List resources ({words})."
|
|
if name.startswith("create"):
|
|
return f"Create resource ({words})."
|
|
if name.startswith("update"):
|
|
return f"Update resource ({words})."
|
|
if name.startswith("delete"):
|
|
return f"Delete resource ({words})."
|
|
if name.startswith("get"):
|
|
return f"Get resource ({words})."
|
|
if name.startswith("stream"):
|
|
return f"Stream events/data ({words})."
|
|
return f"{words.capitalize()}."
|
|
|
|
|
|
def parse_endpoints(file_path: Path) -> List[Endpoint]:
|
|
text = file_path.read_text(encoding="utf-8")
|
|
if "@RestController" not in text and "@Controller" not in text:
|
|
return []
|
|
lines = text.splitlines()
|
|
class_line = None
|
|
for idx, line in enumerate(lines):
|
|
if re.search(r"\bclass\b", line):
|
|
class_line = idx
|
|
break
|
|
if class_line is None:
|
|
return []
|
|
base = class_base_path(lines, class_line)
|
|
|
|
endpoints: List[Endpoint] = []
|
|
pending_annotations: List[str] = []
|
|
i = 0
|
|
while i < len(lines):
|
|
stripped = lines[i].strip()
|
|
if stripped.startswith("@"):
|
|
pending_annotations.append(stripped)
|
|
i += 1
|
|
continue
|
|
|
|
if "fun " in stripped and pending_annotations:
|
|
method_names = method_from_annotations(pending_annotations)
|
|
if not method_names:
|
|
pending_annotations = []
|
|
i += 1
|
|
continue
|
|
|
|
fun_line = i + 1
|
|
signature = stripped
|
|
name_match = re.search(r"\bfun\s+(\w+)\s*\(", signature)
|
|
if not name_match:
|
|
pending_annotations = []
|
|
i += 1
|
|
continue
|
|
fun_name = name_match.group(1)
|
|
|
|
# Parse function parameters with parenthesis depth, so annotation arguments
|
|
# like @RequestParam(required = false) do not break parsing.
|
|
line_idx = i
|
|
line_pos = lines[i].find("(")
|
|
depth = 1
|
|
param_chars: List[str] = []
|
|
tail_chars: List[str] = []
|
|
while line_idx < len(lines):
|
|
current = lines[line_idx]
|
|
start = line_pos + 1 if line_idx == i else 0
|
|
cursor = start
|
|
while cursor < len(current):
|
|
ch = current[cursor]
|
|
if depth > 0:
|
|
if ch == "(":
|
|
depth += 1
|
|
param_chars.append(ch)
|
|
elif ch == ")":
|
|
depth -= 1
|
|
if depth > 0:
|
|
param_chars.append(ch)
|
|
else:
|
|
param_chars.append(ch)
|
|
else:
|
|
tail_chars.append(ch)
|
|
cursor += 1
|
|
if depth == 0:
|
|
# Capture any extra return type tokens from following lines until body opens.
|
|
if "{" not in "".join(tail_chars) and "=" not in "".join(tail_chars):
|
|
look = line_idx + 1
|
|
while look < len(lines):
|
|
nxt = lines[look].strip()
|
|
if nxt.startswith("{") or nxt.startswith("="):
|
|
break
|
|
if nxt.startswith("@"):
|
|
break
|
|
tail_chars.append(" ")
|
|
tail_chars.append(nxt)
|
|
if "{" in nxt or "=" in nxt:
|
|
break
|
|
look += 1
|
|
break
|
|
line_idx += 1
|
|
|
|
param_blob = "".join(param_chars).strip()
|
|
tail = "".join(tail_chars).strip()
|
|
path_params, query_params, body_type = extract_types_from_params(param_blob)
|
|
|
|
return_type = "Unit"
|
|
rmatch = re.search(r":\s*([^{=]+)", tail)
|
|
if rmatch:
|
|
return_type = rmatch.group(1).strip()
|
|
|
|
rel_path = mapping_path(pending_annotations)
|
|
full_path = "/" + "/".join([p.strip("/") for p in [base, rel_path] if p])
|
|
if full_path == "":
|
|
full_path = "/"
|
|
|
|
explicit_status = explicit_status_from_annotations(pending_annotations)
|
|
behavior = behavior_from_name(fun_name)
|
|
rel_file = os.path.relpath(file_path, ROOT)
|
|
|
|
for method in method_names:
|
|
endpoints.append(
|
|
Endpoint(
|
|
method=method,
|
|
path=full_path or "/",
|
|
path_params=path_params,
|
|
query_params=query_params,
|
|
body_type=body_type,
|
|
response_type=return_type,
|
|
status=default_status(method, return_type, explicit_status),
|
|
behavior=behavior,
|
|
handler_file=rel_file,
|
|
handler_name=fun_name,
|
|
handler_line=fun_line,
|
|
)
|
|
)
|
|
pending_annotations = []
|
|
i = line_idx + 1
|
|
continue
|
|
|
|
if stripped and not stripped.startswith("//"):
|
|
pending_annotations = []
|
|
i += 1
|
|
|
|
return endpoints
|
|
|
|
|
|
def main() -> None:
|
|
endpoints: List[Endpoint] = []
|
|
for file_name in sorted(glob.glob(str(CONTROLLER_ROOT / "**/*.kt"), recursive=True)):
|
|
endpoints.extend(parse_endpoints(Path(file_name)))
|
|
|
|
uniq = {}
|
|
for e in endpoints:
|
|
key = (e.method, e.path, e.handler_file, e.handler_name)
|
|
uniq[key] = e
|
|
ordered = sorted(uniq.values(), key=lambda e: (e.path, e.method, e.handler_file, e.handler_name))
|
|
|
|
lines = [
|
|
"# API Reference",
|
|
"",
|
|
"Generated from controller source. Use this for usage, params, response type, and behavior.",
|
|
"",
|
|
f"- Total endpoints: **{len(ordered)}**",
|
|
"- Auth: Firebase Bearer token unless endpoint is public.",
|
|
"- Regenerate: `python scripts/generate_api_docs.py`",
|
|
"",
|
|
"## Usage Template",
|
|
"",
|
|
"```bash",
|
|
"curl -X <METHOD> \"https://api.hoteltrisolaris.in<PATH>\" \\",
|
|
" -H \"Authorization: Bearer <FIREBASE_ID_TOKEN>\" \\",
|
|
" -H \"Content-Type: application/json\" \\",
|
|
" -d '<REQUEST_BODY_JSON>'",
|
|
"```",
|
|
"",
|
|
"Behavior notes in this file are handler summaries; strict business rules remain in controller code.",
|
|
"",
|
|
"| Method | Path | Path Params | Query Params | Body Type | Response Type | Status | Behavior | Handler |",
|
|
"|---|---|---|---|---|---|---|---|---|",
|
|
]
|
|
|
|
for e in ordered:
|
|
path_params = ", ".join(e.path_params) if e.path_params else "-"
|
|
query_params = ", ".join(e.query_params) if e.query_params else "-"
|
|
handler = f"`{e.handler_file}:{e.handler_line}` (`{e.handler_name}`)"
|
|
lines.append(
|
|
f"| `{e.method}` | `{e.path}` | `{path_params}` | `{query_params}` | `{e.body_type}` | `{e.response_type}` | `{e.status}` | {e.behavior} | {handler} |"
|
|
)
|
|
|
|
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
|
OUTPUT.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
print(f"Wrote {OUTPUT} ({len(ordered)} endpoints)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|