From e512556703e21a2256b7535cc05ad97bd59c9a21 Mon Sep 17 00:00:00 2001 From: retoor Date: Mon, 5 Oct 2026 16:00:14 +0200 Subject: [PATCH] Auto-generate docs downloads from the API group registry Shared endpoint enricher builds curl, request, and response samples for every registry endpoint so download.md/html stay complete without a hand-maintained duplicate. Group pages reuse the same curl builder. --- molodetz/docs_export.py | 225 +++++++++++++++++++++++++++++++++---- molodetz/routers/docs.py | 7 +- pyproject.toml | 2 +- tests/api/docs/download.py | 86 ++++++++++++++ 4 files changed, 295 insertions(+), 25 deletions(-) create mode 100644 tests/api/docs/download.py diff --git a/molodetz/docs_export.py b/molodetz/docs_export.py index e16c944..3574e5c 100644 --- a/molodetz/docs_export.py +++ b/molodetz/docs_export.py @@ -1,37 +1,222 @@ # retoor +import json from html import escape -from molodetz.docs_api import ordered_groups +from molodetz.docs_api import ordered_groups, substitute from molodetz.docs_prose import page_source, render_page, visible_pages +BASE_PLACEHOLDER = "https://example.com" +USERNAME_PLACEHOLDER = "retoor" +API_KEY_PLACEHOLDER = "YOUR_API_KEY" + + +def _filled(value): + if value is None: + return None + if isinstance(value, str): + return substitute(value, BASE_PLACEHOLDER, USERNAME_PLACEHOLDER, API_KEY_PLACEHOLDER) + if isinstance(value, dict): + return {key: _filled(item) for key, item in value.items()} + if isinstance(value, list): + return [_filled(item) for item in value] + return value + + +def request_sample(item): + if item.get("probe_body") is not None: + return _filled(item["probe_body"]) + body = {} + query = {} + path = {} + for field in item.get("fields") or []: + example = field.get("example") + if example is None and field.get("enum"): + example = field["enum"][0] + if example is None: + continue + example = _filled(example) + location = field.get("location") or "body" + if location == "query": + query[field["name"]] = example + elif location == "path": + path[field["name"]] = example + else: + body[field["name"]] = example + sample = {} + if path: + sample["path"] = path + if query: + sample["query"] = query + if body: + sample["body"] = body + return sample or None + + +def response_sample(item): + return _filled(item.get("sample")) + + +def curl_example(item, base=BASE_PLACEHOLDER, username=USERNAME_PLACEHOLDER, api_key=API_KEY_PLACEHOLDER): + path = item["path"] + for field in item.get("fields") or []: + if field.get("location") == "path" and field.get("example") is not None: + path = path.replace("{" + field["name"] + "}", str(_filled(field["example"]))) + headers = ["-H 'Accept: application/json'"] + if item.get("auth") != "public": + headers.append("-H 'X-API-KEY: {{api_key}}'") + parts = ["curl", "-X", item["method"], *headers] + body = request_sample(item) + payload = None + if isinstance(body, dict) and "body" in body: + payload = body["body"] + elif isinstance(body, dict) and body and "path" not in body and "query" not in body: + payload = body + elif item.get("probe_body") is not None: + payload = _filled(item["probe_body"]) + if payload is not None and item["method"] not in ("GET", "HEAD"): + parts.append("-H 'Content-Type: application/json'") + parts.append(f"-d '{json.dumps(payload, ensure_ascii=False)}'") + query = body.get("query") if isinstance(body, dict) else None + url = f"{{{{base}}}}{path}" + if query: + from urllib.parse import urlencode + + url = f"{url}?{urlencode(query, doseq=True)}" + parts.append(f"'{url}'") + return substitute(" ".join(parts), base, username, api_key) + + +def enrich_endpoint(item, base=BASE_PLACEHOLDER, username=USERNAME_PLACEHOLDER, api_key=API_KEY_PLACEHOLDER): + return { + **item, + "curl": curl_example(item, base, username, api_key), + "request_sample": request_sample(item), + "response_sample": response_sample(item), + } + + +def visible_api_groups(viewer_is_admin): + return [group for group in ordered_groups() if viewer_is_admin or group["slug"] != "admin"] + + +def _json_block(value): + if value is None: + return "null" + return json.dumps(value, indent=2, ensure_ascii=False, sort_keys=True) + + +def format_endpoint_markdown(item): + enriched = enrich_endpoint(item) + lines = [ + f"### `{enriched['method']} {enriched['path']}`", + "", + f"- **Title:** {enriched['title']}", + f"- **Auth:** {enriched['auth']} ({enriched['min_role']})", + f"- **Negotiation:** {enriched['negotiation']}", + ] + if enriched.get("description"): + lines += ["", enriched["description"]] + if enriched.get("fields"): + lines += ["", "#### Fields", ""] + for field in enriched["fields"]: + required = "required" if field.get("required") else "optional" + enum = f" allowed={field['enum']}" if field.get("enum") else "" + example = f" example={field['example']!r}" if field.get("example") is not None else "" + lines.append( + f"- `{field['name']}` ({field.get('type', 'string')}, {field.get('location', 'body')}, {required}){enum}{example}" + + (f" - {field['description']}" if field.get("description") else "") + ) + lines += ["", "#### Request sample", "", "```bash", enriched["curl"], "```"] + if enriched["request_sample"] is not None: + lines += ["", "```json", _json_block(enriched["request_sample"]), "```"] + lines += ["", "#### Response sample", "", "```json", _json_block(enriched["response_sample"]), "```", ""] + return "\n".join(lines) + + +def format_endpoint_html(item): + enriched = enrich_endpoint(item) + fields_rows = "" + if enriched.get("fields"): + rows = [] + for field in enriched["fields"]: + rows.append( + "" + f"{escape(field['name'])}" + f"{escape(str(field.get('type', 'string')))}" + f"{escape(str(field.get('location', 'body')))}" + f"{'yes' if field.get('required') else 'no'}" + f"{escape(str(field.get('example', '')))}" + "" + ) + fields_rows = ( + "

Fields

" + "" + + "".join(rows) + + "
NameTypeLocationRequiredExample
" + ) + request_json = ( + f"

Request JSON

{escape(_json_block(enriched['request_sample']))}
" + if enriched["request_sample"] is not None + else "" + ) + return ( + f"
" + f"

{escape(enriched['method'])} {escape(enriched['path'])}

" + f"

{escape(enriched['title'])} · auth {escape(enriched['auth'])} " + f"({escape(enriched['min_role'])}) · {escape(enriched['negotiation'])}

" + + (f"

{escape(enriched['description'])}

" if enriched.get("description") else "") + + fields_rows + + f"

Request sample

{escape(enriched['curl'])}
" + + request_json + + f"

Response sample

{escape(_json_block(enriched['response_sample']))}
" + + "
" + ) + + +def format_group_markdown(group): + parts = [f"## API: {group['title']}", "", group.get("description") or "", ""] + for item in group["endpoints"]: + parts.append(format_endpoint_markdown(item)) + return "\n".join(parts).strip() + "\n" + + +def format_group_html(group): + body = "".join(format_endpoint_html(item) for item in group["endpoints"]) + return ( + f"

API: {escape(group['title'])}

" + f"

{escape(group.get('description') or '')}

{body}
" + ) + def export_markdown(viewer_is_admin): - parts = ["# Molodetz documentation", ""] + parts = [ + "# Molodetz documentation", + "", + "Auto-generated from the live docs registry. Give this file to an agent to learn the API.", + "", + ] for page in visible_pages(viewer_is_admin): parts += [page_source(page["slug"]).strip(), ""] - for group in ordered_groups(): - if group["slug"] == "admin" and not viewer_is_admin: - continue - parts += [f"## API: {group['title']}", "", group["description"], ""] - for item in group["endpoints"]: - parts.append(f"- `{item['method']} {item['path']}` ({item['auth']}): {item['title']}") + parts.append("# API reference") + parts.append("") + for group in visible_api_groups(viewer_is_admin): + parts.append(format_group_markdown(group)) parts.append("") - return "\n".join(parts) + return "\n".join(parts).rstrip() + "\n" def export_html(viewer_is_admin): - body = [] + body = [ + "

Molodetz documentation

", + "

Auto-generated from the live docs registry. Give this file to an agent to learn the API.

", + ] for page in visible_pages(viewer_is_admin): body.append(f"
{render_page(page['slug'])}
") - for group in ordered_groups(): - if group["slug"] == "admin" and not viewer_is_admin: - continue - items = "".join( - f"
  • {escape(item['method'])} {escape(item['path'])} ({escape(item['auth'])}): {escape(item['title'])}
  • " - for item in group["endpoints"] - ) - body.append(f"

    API: {escape(group['title'])}

      {items}
    ") + body.append("

    API reference

    ") + for group in visible_api_groups(viewer_is_admin): + body.append(format_group_html(group)) return ( - "Molodetz documentation" - f"

    Molodetz documentation

    {''.join(body)}" + "" + "Molodetz documentation" + f"{''.join(body)}" ) diff --git a/molodetz/routers/docs.py b/molodetz/routers/docs.py index ecaa06a..658d492 100644 --- a/molodetz/routers/docs.py +++ b/molodetz/routers/docs.py @@ -2,8 +2,8 @@ from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse, PlainTextResponse -from molodetz.docs_api import group_by_slug, ordered_groups, substitute -from molodetz.docs_export import export_html, export_markdown +from molodetz.docs_api import group_by_slug, ordered_groups +from molodetz.docs_export import curl_example, export_html, export_markdown from molodetz.docs_live import live_services from molodetz.docs_prose import page_by_slug, render_page, sidebar from molodetz.docs_search import search @@ -58,8 +58,7 @@ async def docs_api_group(request: Request, group_slug: str): api_key = viewer["api_key"] if viewer else "YOUR_API_KEY" endpoints = [] for item in group["endpoints"]: - example = substitute(f"curl -H 'Accept: application/json' -H 'X-API-KEY: {{{{api_key}}}}' {{{{base}}}}{item['path']}", base, username, api_key) - endpoints.append({**item, "curl": example}) + endpoints.append({**item, "curl": curl_example(item, base, username, api_key)}) return templates.TemplateResponse( request, "docs/api.html", diff --git a/pyproject.toml b/pyproject.toml index ba1bf1c..f73a55c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "molodetz" -version = "1.0.16" +version = "1.0.17" description = "Molodetz, a calm community blog roll." readme = "README.md" requires-python = ">=3.12" diff --git a/tests/api/docs/download.py b/tests/api/docs/download.py new file mode 100644 index 0000000..65a3f91 --- /dev/null +++ b/tests/api/docs/download.py @@ -0,0 +1,86 @@ +# retoor +import json + +from molodetz.docs_api import ordered_groups +from molodetz.docs_export import enrich_endpoint, export_markdown + + +def test_download_md_lists_each_api_group_and_known_samples(anon): + body = anon.get("/docs/download.md").text + assert body.startswith("# Molodetz documentation") + for title in ("Content", "Join", "Account"): + assert f"## API: {title}" in body + assert "## API: Admin" not in body + assert '"status": "ok"' in body + assert "probe@example.com" in body + assert "#### Request sample" in body + assert "#### Response sample" in body + assert "curl -X GET" in body + + +def test_download_html_lists_each_api_group_and_known_samples(anon): + body = anon.get("/docs/download.html").text + assert "Molodetz documentation" in body + for title in ("Content", "Join", "Account"): + assert f"API: {title}" in body + assert "API: Admin" not in body + assert "status" in body and "ok" in body + assert ""status": "ok"" in body + assert "probe@example.com" in body + + +def test_download_includes_every_registry_endpoint_and_sample(anon): + body = anon.get("/docs/download.md").text + for group in ordered_groups(): + if group["slug"] == "admin": + continue + assert f"## API: {group['title']}" in body + assert group["description"] in body + for item in group["endpoints"]: + assert f"{item['method']} {item['path']}" in body, item["path"] + assert f"**Auth:** {item['auth']}" in body + enriched = enrich_endpoint(item) + assert enriched["curl"] in body + if enriched["response_sample"] is not None: + blob = json.dumps(enriched["response_sample"], indent=2, ensure_ascii=False, sort_keys=True) + assert blob in body, item["path"] + if enriched["request_sample"] is not None: + blob = json.dumps(enriched["request_sample"], indent=2, ensure_ascii=False, sort_keys=True) + assert blob in body, item["path"] + + +def test_download_auto_includes_new_registry_sample(monkeypatch): + from molodetz.docs_api import content + + original = list(content.GROUP["endpoints"]) + marker = {"auto_include_probe": "download-must-show-this-token-9f3a"} + try: + content.GROUP["endpoints"] = original + [ + { + "method": "GET", + "path": "/__docs_auto_include_probe", + "title": "Auto include probe", + "description": "Temporary registry probe for download coverage.", + "auth": "public", + "min_role": "guest", + "fields": [], + "sample": marker, + "negotiation": "json", + "destructive": False, + "interactive": False, + "probe_path": "/__docs_auto_include_probe", + "probe_body": None, + } + ] + body = export_markdown(False) + assert "GET /__docs_auto_include_probe" in body + assert "download-must-show-this-token-9f3a" in body + assert "Auto include probe" in body + finally: + content.GROUP["endpoints"] = original + + +def test_admin_download_includes_admin_group(admin): + body = admin.get("/docs/download.md").text + assert "## API: Admin" in body + assert "GET /admin" in body