aboutsummaryrefslogtreecommitdiffstats
path: root/docs/generate_http_api.py
blob: d0645191bad23c776b7f0935a70b7b0f44065950 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
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)