diff --git a/gen_docs.py b/gen_docs.py new file mode 100644 index 0000000..d4d9331 --- /dev/null +++ b/gen_docs.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python +"""Generate API.md from the *openapi.json files in sources/.""" +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +SOURCES = ROOT / "sources" +GATEWAY_URL = "https://notifier.gansejunge.com/internal/" + +HTTP_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"} + + +def resolve(schema, spec): + """Follow $ref pointers like #/components/schemas/Foo.""" + while "$ref" in schema: + node = spec + for part in schema["$ref"].lstrip("#/").split("/"): + node = node[part] + schema = node + return schema + + +def example(schema, spec, depth=0): + """Build an example value from a JSON schema.""" + if depth > 6: + return None + s = resolve(schema, spec) + if "example" in s: + return s["example"] + if "default" in s: + return s["default"] + if "enum" in s: + return s["enum"][0] + for key in ("anyOf", "oneOf"): + if key in s: + options = [o for o in s[key] if o.get("type") != "null"] + return example(options[0], spec, depth + 1) if options else None + if "allOf" in s: + return example(s["allOf"][0], spec, depth + 1) + t = s.get("type") + if t == "object" or "properties" in s: + return {k: example(v, spec, depth + 1) for k, v in s.get("properties", {}).items()} + if t == "array": + return [example(s.get("items", {}), spec, depth + 1)] + return {"string": "string", "integer": 0, "number": 0.0, "boolean": True}.get(t) + + +def type_label(schema, spec): + s = resolve(schema, spec) + for key in ("anyOf", "oneOf"): + if key in s: + return " | ".join(type_label(o, spec) for o in s[key]) + if s.get("type") == "array": + return f"array[{type_label(s.get('items', {}), spec)}]" + return s.get("type", "object" if "properties" in s else "any") + + +def build_url(base, path, params): + url = base + path + for p in params: + if p["in"] == "path": + url = url.replace("{%s}" % p["name"], f"<{p['name']}>") + required_query = [p["name"] for p in params if p["in"] == "query" and p.get("required")] + if required_query: + url += "?" + "&".join(f"{q}=<{q}>" for q in required_query) + return url + + +def render_operation(base, path, method, op, path_params, spec): + params = path_params + op.get("parameters", []) + out = [f"### `{method.upper()} {path}`\n"] + + if op.get("summary"): + out.append(f"**{op['summary']}**\n") + if op.get("description"): + out.append(op["description"] + "\n") + + if params: + out.append("| Name | In | Type | Required |") + out.append("|---|---|---|---|") + for p in params: + p = resolve(p, spec) + out.append( + f"| `{p['name']}` | {p['in']} | " + f"{type_label(p.get('schema', {}), spec)} | {'yes' if p.get('required') else 'no'} |" + ) + out.append("") + + curl = [f"curl -X {method.upper()} '{build_url(base, path, params)}'"] + curl.append(" -H 'X-API-Key: '") + + body = op.get("requestBody", {}).get("content", {}) + if "application/json" in body: + ex = example(body["application/json"].get("schema", {}), spec) + out.append("**Request body:**\n") + out.append("```json\n" + json.dumps(ex, indent=2) + "\n```\n") + curl.append(" -H 'Content-Type: application/json'") + curl.append(" -d '" + json.dumps(ex) + "'") + elif body: + out.append(f"**Request body:** `{', '.join(body)}`\n") + + out.append("**Example:**\n") + out.append("```bash\n" + " \\\n".join(curl) + "\n```\n") + + responses = op.get("responses", {}) + if responses: + out.append("**Responses:**\n") + for code, resp in responses.items(): + resp = resolve(resp, spec) + line = f"- `{code}`: {resp.get('description', '')}" + json_content = resp.get("content", {}).get("application/json") + if json_content and "schema" in json_content: + ex = example(json_content["schema"], spec) + if ex is not None: + line += "\n ```json\n " + json.dumps(ex, indent=2).replace("\n", "\n ") + "\n ```" + out.append(line) + out.append("") + + return "\n".join(out) + + +def render_service(spec, source_name): + title = spec.get("info", {}).get("title") + if not title: + raise ValueError(f"{source_name} has no info.title") + + # The title is the path segment on the gateway + base = GATEWAY_URL.rstrip("/") + "/" + title.strip("/") + + out = [f"# {title}\n", f"Base URL: `{base}`\n"] + for path, item in sorted(spec.get("paths", {}).items()): + path_params = item.get("parameters", []) + for method, op in item.items(): + if method in HTTP_METHODS: + out.append(render_operation(base, path, method, op, path_params, spec)) + return title, "\n".join(out) + + +def main(): + files = sorted(SOURCES.rglob("*openapi.json")) + if not files: + print(f"No *openapi.json files found in {SOURCES}") + return 1 + + for f in files: + try: + title, markdown = render_service(json.loads(f.read_text(encoding="utf-8")), f.name) + safe_title = title.strip("/").replace("/", "-") + out_file = ROOT / f"API-{safe_title}.md" + out_file.write_text(markdown, encoding="utf-8") + print(f"OK {f.relative_to(ROOT)} -> {out_file.name}") + except Exception as e: + print(f"FAIL {f.relative_to(ROOT)}: {e}") + return 0 + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file