# -*- coding: utf-8 -*- """从 FastAPI 自己的 OpenAPI schema 生成 docs/api.md。 **为什么是生成的而不是手写的**:手写的接口文档,从改代码的那天起就开始腐烂,而且烂得 无声无息 —— 读者还会以为它是对的。这份每次跑一遍就是最新的。 ./.venv/Scripts/python.exe docs/gen_api_doc.py # Windows python docs/gen_api_doc.py # 服务器/容器里 注意这个脚本**只读** app 对象,不连数据库、不起服务。 """ import pathlib import re import sys # 让脚本能从仓库根目录跑起来(它自己不在根目录)。 sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) from api.main import app # noqa: E402 OUTPUT = pathlib.Path(__file__).resolve().parent / "api.md" # **不能从 schema 里读出鉴权** —— `require_auth` 是个普通依赖,不是 security scheme, # 所以它不会出现在 OpenAPI 的 security 字段里。这份名单是照 api/routers/ 里实际写的 # `dependencies=[Depends(require_auth)]` 核出来的: # # * auth 那一组整体**不加** require_auth(登录本身就不能要求先登录),但组里 # /me 和 /password 各自另有保护 —— 所以不能按「组」一刀切。 # * 其它五个 router 都在 include_router 上统一挂了 require_auth。 PUBLIC_PATHS = { "/api/auth/login", # 登录本身 "/api/auth/logout", # 清了 cookie 就完事,没有会话也能调 } TAG_TITLES = { "auth": "认证", "crawler": "采集", "creator": "运营(博主账号)", "data": "数据", "monitor": "监控", "settings": "设置", } TAG_ORDER = ["auth", "crawler", "creator", "data", "monitor", "settings"] JSON_METHODS = {"post", "put", "patch"} BACKSLASH = chr(92) PIPE = "|" def first_paragraph(text: str) -> str: """文档字符串的第一段 —— 整篇塞进去会把接口清单淹掉。""" text = (text or "").strip() if not text: return "" return text.split("\n\n")[0].strip() def describe_schema(schema: dict, spec: dict) -> str: """把一个 schema 说成人话:引用就展开名字,数组说成「数组」。""" if not schema: return "" if "$ref" in schema: return schema["$ref"].rsplit("/", 1)[-1] kind = schema.get("type", "") if kind == "array": inner = describe_schema(schema.get("items", {}), spec) return f"{inner}[]" if kind == "object": props = schema.get("properties") or {} return "{" + ", ".join(props.keys()) + "}" if props else "object" if schema.get("anyOf") or schema.get("allOf"): variants = schema.get("anyOf") or schema.get("allOf") return " | ".join(filter(None, (describe_schema(v, spec) for v in variants))) return kind or "?" def param_cell(params: list, spec: dict) -> str: if not params: return "" parts = [] for param in params: name = param["name"] where = param["in"] kind = describe_schema(param.get("schema", {}), spec) required = "必填" if param.get("required") else "可选" default = param.get("schema", {}).get("default") suffix = f"={default!r}" if default is not None else "" # 查询参数是前端最常搞错的地方,标出来。 parts.append(f"`{name}`({where} {kind} {required}{suffix})") return "
".join(parts).replace("|", "\\|") def body_cell(op: dict, spec: dict) -> str: body = op.get("requestBody") if not body: return "" content = body.get("content", {}) schema = content.get("application/json", {}).get("schema", {}) # anyOf 会拼出 `integer | null` 这种,竖线不转义就会把这行切成两格。 return describe_schema(schema, spec).replace("|", "\\|") def render() -> str: spec = app.openapi() paths = spec["paths"] by_tag: dict[str, list] = {} for path, operations in paths.items(): for method, op in operations.items(): if method not in ("get", "post", "put", "patch", "delete"): continue tag = (op.get("tags") or ["其他"])[0] by_tag.setdefault(tag, []).append((path, method, op)) lines: list[str] = [] lines.append("# API 接口清单") lines.append("") lines.append("从 FastAPI 自己的 OpenAPI schema 生成的,**不是手写的** ——") lines.append("手写的接口文档从改代码那天起就开始腐烂,而且烂得无声无息。") lines.append("") lines.append("重新生成:") lines.append("") lines.append("```bash") lines.append("./.venv/Scripts/python.exe docs/gen_api_doc.py") lines.append("```") lines.append("") lines.append("## 通用约定") lines.append("") lines.append("| | |") lines.append("|---|---|") lines.append("| 前缀 | 所有接口都在 `/api` 下(WebSocket 是 `/api/ws/...`)|") lines.append( "| 鉴权 | **Cookie 优先,Bearer 次之**:`token_from_request()` 先读会话 Cookie," "读不到再读 `Authorization: Bearer `。浏览器用 Cookie,脚本用 Bearer |" ) lines.append( "| 会话怎么来 | `POST /api/auth/login`(密码),响应种下会话 Cookie;" "有效期由服务端 `resolve_session()` 判定 |" ) lines.append("| 错误形状 | FastAPI 默认的 `{\"detail\": \"...\"}`;401 是登录失效 |") lines.append( "| **没有 `/docs`** | 特意关掉的:`/docs`、`/redoc`、`/openapi.json` 默认" "**不需要登录**,等于把整份接口地图连同「Try it out」一起送出去。要看交互式的," "得挂在鉴权后面 |" ) lines.append( "| WebSocket | `GET /api/ws/logs`(升级为 ws)—— 实时日志流。**不在这里的清单里**:" "OpenAPI 描述不了 WebSocket。它自己用 `require_ws_auth` 校验会话 Cookie |" ) lines.append("") lines.append("## 目录") lines.append("") for tag in TAG_ORDER: if tag in by_tag: title = TAG_TITLES.get(tag, tag) lines.append(f"- [{title}](#{title})({len(by_tag[tag])} 个)") lines.append("") for tag in TAG_ORDER: if tag not in by_tag: continue title = TAG_TITLES.get(tag, tag) lines.append(f"## {title}") lines.append("") if tag == "auth": lines.append( "这一组**没有**统一挂 `require_auth`(登录不能要求先登录),所以逐个标。" ) lines.append("") lines.append("| 方法 | 路径 | 登录 | 说明 | 参数 | 请求体 |") lines.append("|---|---|:--:|---|---|---|") for path, method, op in sorted(by_tag[tag], key=lambda item: (item[0], item[1])): summary = first_paragraph(op.get("description") or op.get("summary") or "") # 表格单元格里的换行/竖线会把表格撑破。 summary = summary.replace("\n", " ").replace("|", "\\|") params = [p for p in op.get("parameters", []) if p["in"] != "header"] lines.append( "| `{}` | `{}` | {} | {} | {} | {} |".format( method.upper(), path, "—" if path in PUBLIC_PATHS else "✓", summary, param_cell(params, spec), body_cell(op, spec), ) ) lines.append("") # ---- 附录:请求体长什么样 ------------------------------------------------ # # 上面那些接口的「请求体」列只写了 schema 的名字,不写字段的话等于没说。 used = set() for operations in paths.values(): for method, op in operations.items(): if method in JSON_METHODS: body = op.get("requestBody") if body: name = describe_schema( body.get("content", {}).get("application/json", {}).get("schema", {}), spec, ) if name: used.add(name) schemas = spec.get("components", {}).get("schemas", {}) if used: lines.append("## 请求体结构") lines.append("") for name in sorted(used): schema = schemas.get(name) if not schema: continue lines.append(f"### {name}") lines.append("") lines.append("| 字段 | 类型 | 必填 | 默认 | 说明 |") lines.append("|---|---|:--:|---|---|") required = set(schema.get("required") or []) for field, prop in (schema.get("properties") or {}).items(): kind = describe_schema(prop, spec).replace("|", "\\|") default = prop.get("default") desc = (prop.get("description") or "").replace("\n", " ").replace("|", "\\|") lines.append( "| `{}` | {} | {} | {} | {} |".format( field, kind, "✓" if field in required else "", "" if default is None else repr(default), desc, ) ) lines.append("") lines.append( "> **响应没有结构可列。** 这些接口返回的是普通 dict,没有声明 " "`response_model`,所以 OpenAPI 里没有响应的 shape。要看响应就调一次," "或者读对应的 `service.py`。" ) lines.append("") return "\n".join(lines) + "\n" def check_tables(text: str) -> None: """生成完顺手验一遍表格。 一个没转义的竖线就能把整张表切歪 —— 而 markdown 不会报错,它只是安静地多出一格。 参数类型里的 `integer | null` 到处都是,所以这不是假想的风险。 """ escaped = BACKSLASH + PIPE sentinel = "\x00" expected = {2, 5, 6} # 约定表 2、请求体表 5、接口表 6 bad = [] for number, line in enumerate(text.splitlines(), 1): if not line.lstrip().startswith(PIPE): continue cells = line.strip().replace(escaped, sentinel).split(PIPE)[1:-1] if len(cells) not in expected: bad.append((number, len(cells), line[:70])) if bad: for number, count, snippet in bad[:5]: print(f" 第 {number} 行有 {count} 格(预期 {sorted(expected)}): {snippet}") raise SystemExit("生成的表格结构不对,先修转义") if __name__ == "__main__": sys.stdout.reconfigure(encoding="utf-8", errors="replace") text = render() check_tables(text) OUTPUT.write_text(text, encoding="utf-8") print(f"写出 {OUTPUT}({len(text.splitlines())} 行)")