#!/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 \"https://api.hoteltrisolaris.in\" \\", " -H \"Authorization: Bearer \" \\", " -H \"Content-Type: application/json\" \\", " -d ''", "```", "", "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()