#!/usr/bin/env python3 """Generate a compact OpenAPI spec from Drogon HttpController route macros.""" from __future__ import annotations import argparse import json import re from pathlib import Path ROUTE_RE = re.compile( r"ADD_METHOD_TO\(\s*([A-Za-z_]\w*)::([A-Za-z_]\w*)\s*,\s*" r'"([^"]+)"\s*,\s*drogon::([A-Za-z]+)\s*\)', re.MULTILINE, ) METHODS_WITH_JSON_BODY = {"post", "put", "patch"} PUBLIC_OPERATIONS = { "health", "ready", "openApi", "docs", "registerCitizen", "requestOtp", "verifyOtp", "loginWithPassword", } def controller_tag(controller: str) -> str: return controller.removesuffix("Controller") def openapi_path(path: str) -> tuple[str, list[dict]]: parameters: list[dict] = [] def replace(match: re.Match[str]) -> str: name = "id" if match.group(1) == "1" else f"param{match.group(1)}" parameters.append( { "name": name, "in": "path", "required": True, "schema": {"type": "integer", "format": "int64"}, } ) return "{" + name + "}" return re.sub(r"\{(\d+)\}", replace, path), parameters def request_body(path: str, method: str) -> dict | None: if method != "post" and method != "patch": return None if path.endswith("/media") or path.endswith("/photos"): return { "required": True, "content": { "multipart/form-data": { "schema": { "type": "object", "properties": { "files": { "type": "array", "items": {"type": "string", "format": "binary"}, } }, } } }, } if method in METHODS_WITH_JSON_BODY: return { "required": True, "content": { "application/json": {"schema": {"type": "object"}} }, } return None def operation(controller: str, handler: str, raw_path: str, method: str) -> dict: path, params = openapi_path(raw_path) op = { "tags": [controller_tag(controller)], "operationId": f"{controller}.{handler}", "parameters": params, "responses": { "200": { "description": "JSON response", "content": {"application/json": {"schema": {"type": "object"}}}, } }, } body = request_body(path, method) if body: op["requestBody"] = body if handler not in PUBLIC_OPERATIONS: op["security"] = [{"bearerAuth": []}] if method == "post": op["responses"]["201"] = op["responses"]["200"] return path, op def generate(controllers_dir: Path, title: str, version: str) -> dict: spec = { "openapi": "3.0.3", "info": { "title": title, "version": version, "description": "Generated from Drogon controller route macros.", }, "servers": [{"url": "http://localhost:5555"}], "components": { "securitySchemes": { "bearerAuth": {"type": "http", "scheme": "bearer"} } }, "paths": {}, } for header in sorted(controllers_dir.glob("*.h")): text = header.read_text(encoding="utf-8") for controller, handler, raw_path, drogon_method in ROUTE_RE.findall(text): method = drogon_method.lower() path, op = operation(controller, handler, raw_path, method) spec["paths"].setdefault(path, {})[method] = op return spec def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--controllers", type=Path, required=True) parser.add_argument("--out", type=Path, required=True) parser.add_argument("--title", default="Visione API") parser.add_argument("--version", default="0.1.0") args = parser.parse_args() spec = generate(args.controllers, args.title, args.version) args.out.parent.mkdir(parents=True, exist_ok=True) args.out.write_text(json.dumps(spec, indent=2, sort_keys=True) + "\n", encoding="utf-8") if __name__ == "__main__": main()