aboutsummaryrefslogtreecommitdiffstats
path: root/docs/generate_http_api.py
diff options
context:
space:
mode:
Diffstat (limited to 'docs/generate_http_api.py')
-rw-r--r--docs/generate_http_api.py164
1 files changed, 164 insertions, 0 deletions
diff --git a/docs/generate_http_api.py b/docs/generate_http_api.py
new file mode 100644
index 0000000..d064519
--- /dev/null
+++ b/docs/generate_http_api.py
@@ -0,0 +1,164 @@
+#!/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:<ui_port>`, port 18000 unless `ui_port` in `node.toml` says
+otherwise, and never on another address. Every request carries the token the
+daemon writes to `<data_dir>/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)