Add detailed API reference with params and response metadata
All checks were successful
build-and-deploy / build-deploy (push) Successful in 17s
All checks were successful
build-and-deploy / build-deploy (push) Successful in 17s
This commit is contained in:
348
scripts/generate_api_docs.py
Normal file
348
scripts/generate_api_docs.py
Normal file
@@ -0,0 +1,348 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user