Add detailed API reference with params and response metadata
All checks were successful
build-and-deploy / build-deploy (push) Successful in 17s

This commit is contained in:
androidlover5842
2026-02-04 12:06:00 +05:30
parent aa319401d2
commit fdb6792018
3 changed files with 499 additions and 2 deletions

View 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()