#!/usr/bin/env python3 """ Write docs/MESHBAY_HTTP_API.md from the routes of the hub and of the node's control API. python docs/generate_http_api.py Generated rather than written: a list of a hundred routes kept by hand is wrong by the next one added. `test_http_api_doc.py` fails when the file and the code disagree, and when a route has no docstring to describe it. """ import inspect import re import sys from pathlib import Path from fastapi.routing import APIRoute, APIWebSocketRoute OUT = Path(__file__).resolve().parent / "MESHBAY_HTTP_API.md" # The hub's routers, by module, in the order the hub includes them. SECTIONS = { "hub": "Instance", "users": "Accounts", "nodes": "Nodes", "groups": "Groups", "invite_links": "Invitation links", "revocation": "Revocation and the node socket", "moderation": "Moderation", "federation": "Federation (MHP)", "health": "Health", "signaling": "Signaling", "admin": "Administration", "notifications": "Notifications", } # Strongest first: a route is labelled by the first dependency it carries. AUTH = [ ("require_admin", "admin"), ("require_moderator", "moderator"), ("require_node_scope", "node"), ("require_user_scope", "user"), ("get_current_user", "user or node"), ("_decode_token", "token"), ("_federation_open", "peer hub"), ] AUTH_LEGEND = """\ | Auth | What the request carries | |---|---| | none | No session. Any credential is in the request itself, as the route says | | user | `Authorization: Bearer`, a person's session. A node's token is refused | | user or node | A person's session, or a node daemon's token from `/v1/nodes/auth` | | node | A node daemon's token only | | moderator | A person's session, for an account with the moderator or admin role | | admin | A person's session, for an account with the admin role | | peer hub | Another hub's MHP token. Every route answers 503 unless federation is enabled | """ HEADER = """\ # MeshBay HTTP API > **Generated** by `docs/generate_http_api.py` from the routes themselves. Do not > edit this file: change the route's docstring and run the script again. A test > fails when the two disagree. MeshBay has two HTTP APIs: the **hub's**, for accounts, groups and signaling, and the **node's control API**, which only its own machine reaches. Files, the index and chat do not use either: they travel between a client and a node over MNP (`MESHBAY_NODE_PROTOCOL.md`). Why each API is shaped as it is, who may call what and what the hub must never see are in `MESHBAY_DESIGN.md`; this file lists what exists. `examples/` has small Python programs that use both. """ HUB_INTRO = """\ ## Hub Under the hub's address, `https://meshbay.org` on the reference deployment. The hub also serves the web application at `/` and `/app/`, which are not listed. """ NODE_INTRO = """\ ## Node control API `http://127.0.0.1:`, port 18000 unless `ui_port` in `node.toml` says otherwise, and never on another address. Every request carries the token the daemon writes to `/ui-token` (`~/.local/share/meshbay/ui-token` on Linux), as the `X-MeshBay-Token` header or the `t` query parameter. The daemon draws a new one at each start and deletes the file when it stops. | Method | Path | What | |---|---|---| """ def flatten(routes): for r in routes: if hasattr(r, "original_router"): yield from flatten(r.original_router.routes) elif isinstance(r, (APIRoute, APIWebSocketRoute)): yield r def summary(route) -> str: """The docstring's first sentence.""" doc = inspect.getdoc(route.endpoint) or "" first = " ".join(doc.split("\n\n")[0].split()) return re.split(r"(?<=[.!?])\s+(?=[A-Z`])", first)[0].replace("|", "\\|") def method(route) -> str: if isinstance(route, APIWebSocketRoute): return "WS" return ", ".join(sorted(route.methods - {"HEAD"})) def auth(route) -> str: names = set() def walk(dependant): for d in dependant.dependencies: if d.call is not None: names.add(getattr(d.call, "__name__", "")) walk(d) walk(route.dependant) return next((label for name, label in AUTH if name in names), "none") def hub_routes() -> list: from meshbay_hub.app import create_app return [r for r in flatten(create_app().routes) if r.endpoint.__module__.rsplit(".", 1)[-1] != "webapp"] def node_routes() -> list: from meshbay_node.ui.app import create_ui_app return list(flatten(create_ui_app({}).routes)) def render() -> str: out = [HEADER, HUB_INTRO, AUTH_LEGEND] by_module: dict[str, list] = {} for r in hub_routes(): by_module.setdefault(r.endpoint.__module__.rsplit(".", 1)[-1], []).append(r) for module, routes in by_module.items(): out.append(f"\n### {SECTIONS.get(module, module.replace('_', ' ').capitalize())}\n\n") out.append("| Method | Path | Auth | What |\n|---|---|---|---|\n") for r in routes: out.append(f"| {method(r)} | `{r.path}` | {auth(r)} | {summary(r)} |\n") out.append("\n" + NODE_INTRO) for r in node_routes(): out.append(f"| {method(r)} | `{r.path}` | {summary(r)} |\n") return "".join(out) if __name__ == "__main__": OUT.write_text(render(), encoding="utf-8") print(f"wrote {OUT}", file=sys.stderr)