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.
This commit is contained in:
+205
-20
@@ -1,37 +1,222 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
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(
|
||||
"<tr>"
|
||||
f"<td><code>{escape(field['name'])}</code></td>"
|
||||
f"<td>{escape(str(field.get('type', 'string')))}</td>"
|
||||
f"<td>{escape(str(field.get('location', 'body')))}</td>"
|
||||
f"<td>{'yes' if field.get('required') else 'no'}</td>"
|
||||
f"<td>{escape(str(field.get('example', '')))}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
fields_rows = (
|
||||
"<h4>Fields</h4><table><thead><tr><th>Name</th><th>Type</th><th>Location</th>"
|
||||
"<th>Required</th><th>Example</th></tr></thead><tbody>"
|
||||
+ "".join(rows)
|
||||
+ "</tbody></table>"
|
||||
)
|
||||
request_json = (
|
||||
f"<h4>Request JSON</h4><pre>{escape(_json_block(enriched['request_sample']))}</pre>"
|
||||
if enriched["request_sample"] is not None
|
||||
else ""
|
||||
)
|
||||
return (
|
||||
f"<section>"
|
||||
f"<h3><code>{escape(enriched['method'])} {escape(enriched['path'])}</code></h3>"
|
||||
f"<p><strong>{escape(enriched['title'])}</strong> · auth {escape(enriched['auth'])} "
|
||||
f"({escape(enriched['min_role'])}) · {escape(enriched['negotiation'])}</p>"
|
||||
+ (f"<p>{escape(enriched['description'])}</p>" if enriched.get("description") else "")
|
||||
+ fields_rows
|
||||
+ f"<h4>Request sample</h4><pre>{escape(enriched['curl'])}</pre>"
|
||||
+ request_json
|
||||
+ f"<h4>Response sample</h4><pre>{escape(_json_block(enriched['response_sample']))}</pre>"
|
||||
+ "</section>"
|
||||
)
|
||||
|
||||
|
||||
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"<section><h2>API: {escape(group['title'])}</h2>"
|
||||
f"<p>{escape(group.get('description') or '')}</p>{body}</section>"
|
||||
)
|
||||
|
||||
|
||||
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 = [
|
||||
"<h1>Molodetz documentation</h1>",
|
||||
"<p>Auto-generated from the live docs registry. Give this file to an agent to learn the API.</p>",
|
||||
]
|
||||
for page in visible_pages(viewer_is_admin):
|
||||
body.append(f"<section>{render_page(page['slug'])}</section>")
|
||||
for group in ordered_groups():
|
||||
if group["slug"] == "admin" and not viewer_is_admin:
|
||||
continue
|
||||
items = "".join(
|
||||
f"<li><code>{escape(item['method'])} {escape(item['path'])}</code> ({escape(item['auth'])}): {escape(item['title'])}</li>"
|
||||
for item in group["endpoints"]
|
||||
)
|
||||
body.append(f"<section><h2>API: {escape(group['title'])}</h2><ul>{items}</ul></section>")
|
||||
body.append("<h1>API reference</h1>")
|
||||
for group in visible_api_groups(viewer_is_admin):
|
||||
body.append(format_group_html(group))
|
||||
return (
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>Molodetz documentation</title></head>"
|
||||
f"<body><h1>Molodetz documentation</h1>{''.join(body)}</body></html>"
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">"
|
||||
"<title>Molodetz documentation</title></head>"
|
||||
f"<body>{''.join(body)}</body></html>"
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
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
|
||||
Reference in New Issue
Block a user