58 个接口,按 auth/crawler/creator/data/monitor/settings 分组,逐条列出方法、路径、 是否需要登录、说明、参数(含类型/必填/默认)、请求体 schema;附录展开请求体的字段。 **生成的,不是手写的** —— 手写的接口文档从改代码那天起就开始腐烂,而且烂得无声无息。 `docs/gen_api_doc.py` 只读 app 对象,不连库不起服务。 两处值得说的: * 鉴权**不能从 schema 里读** —— require_auth 是普通依赖不是 security scheme,不会进 OpenAPI 的 security 字段。所以那份「哪些路径免登录」的名单是照着 include_router 核的,而且 auth 那一组不能一刀切(/me 和 /password 各自另有保护)。 * 生成完**顺手验一遍表格**:参数类型里的 `integer | null` 到处是,一个没转义的竖线 就会把整张表切歪,而 markdown 不报错、只是安静地多一格。验过这个检查确实会拦。 接口的响应没有结构可列 —— 它们返回普通 dict,没声明 response_model。
276 lines
11 KiB
Python
276 lines
11 KiB
Python
# -*- 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 "<br>".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 <token>`。浏览器用 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())} 行)")
|