背景/问题
很多团队的接口文档长期处在"看得懂但跑不通"的状态:Markdown 里贴了几段 curl,却没人确认它们在当前版本服务上是否还能用;等到上线前联调,才发现参数名变了、路径改了、错误码不一致。
OpenAPI 能解决"接口定义结构化"的问题,但常见做法是把 OpenAPI 直接丢给 Swagger UI/Redoc 展示------这类页面对阅读友好,却不天然保证"示例可执行"。更现实的是:你需要一个能在 CI 里跑的检查,把文档示例当作回归用例。
这也是我关注到 GitHub Trending 上的 knowsuchagency/mcp2cli 的原因:它的定位是把任意 MCP server 或 OpenAPI spec 在运行时变成 CLI,且无需 codegen(仓库摘要如此)。对于"文档示例要可运行"这类工作流,CLI 往往比网页文档更容易自动化验证。
本文给一个可复现的落地方式:用 OpenAPI 作为单一事实来源,生成 Markdown 文档 + curl 示例;再用 mcp2cli(可选)把同一份 OpenAPI 在运行时变成 CLI,用来做示例回归。
方案概览(2-3种方案)
方案 A:OpenAPI + 自写脚本生成 Markdown(本文主线)
- 优点:可控、可定制(字段、示例、错误码段落、模板风格),适合落到仓库里做版本管理与 CI。
- 缺点:需要维护脚本(但通常很薄)。
方案 B:Swagger UI / Redoc 直接展示 OpenAPI
- 优点:开箱即用、交互性强。
- 缺点:更偏"阅读",示例是否可运行需要额外机制保证;对"文档即回归用例"的支持有限。
方案 C:mcp2cli(运行时)把 OpenAPI 变 CLI,用 CLI 反向校验文档示例
- 优点:不做代码生成(零 codegen),更接近"同一份 OpenAPI 在不同形态下复用";CLI 天然适合脚本化与 CI。
- 注意:本文不会编造 mcp2cli 的具体参数细节(以仓库 README 为准),只把它放在流程中作为"验证层"。
补充:如果你还想让接口说明更"人话"、补齐注意事项/错误码解释,且不想自建模型/处理网络门槛,可以在真智AI(https://truescience.cn)里用同一套提示词模板对生成的 Markdown 做可配置的润色(模型/参数/会话/模板可调),并且通常无需额外网络手段;但这一步建议保持"OpenAPI 为准、AI 为辅",避免事实漂移。
教程步骤(可复现)
0)环境说明
- OS:macOS / Linux / Windows(建议 WSL2)
- Python:3.11
- 依赖:FastAPI、Uvicorn(用于示例服务)、requests(用于拉取 OpenAPI,可选)
目录结构建议:
text
docgen-demo/
app.py
gen_docs.py
requirements.txt
openapi.json # 运行后生成
docs/
api.md # 运行后生成
1)准备一个带 OpenAPI 的示例服务(FastAPI)
创建 requirements.txt:
txt
fastapi==0.115.0
uvicorn[standard]==0.30.6
创建 app.py(包含请求体示例与常见错误码):
python
from typing import Optional
from fastapi import FastAPI, Path, HTTPException
from pydantic import BaseModel, Field
app = FastAPI(title="DocGen Demo", version="1.0.0")
class UserCreate(BaseModel):
name: str = Field(..., example="alice")
age: int = Field(..., ge=0, le=150, example=18)
class UserOut(BaseModel):
id: int
name: str
age: int
_FAKE_DB = {
1: {"id": 1, "name": "alice", "age": 18},
2: {"id": 2, "name": "bob", "age": 20},
}
@app.get(
"/users/{user_id}",
response_model=UserOut,
operation_id="getUser",
responses={
404: {"description": "User not found"},
400: {"description": "Invalid user_id"},
},
)
def get_user(user_id: int = Path(..., ge=1, example=1)):
if user_id not in _FAKE_DB:
raise HTTPException(status_code=404, detail="User not found")
return _FAKE_DB[user_id]
@app.post(
"/users",
response_model=UserOut,
operation_id="createUser",
responses={
400: {"description": "Invalid payload"},
},
)
def create_user(payload: UserCreate):
new_id = max(_FAKE_DB.keys()) + 1
user = {"id": new_id, "name": payload.name, "age": payload.age}
_FAKE_DB[new_id] = user
return user
安装并启动:
bash
python -m venv .venv
# macOS/Linux
source .venv/bin/activate
# Windows PowerShell
# .\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
uvicorn app:app --host 127.0.0.1 --port 8000
验证服务:
bash
curl -s http://127.0.0.1:8000/users/1 | python -m json.tool
2)导出 OpenAPI schema(作为"单一事实来源")
bash
curl -s http://127.0.0.1:8000/openapi.json -o openapi.json
你也可以在浏览器打开:
http://127.0.0.1:8000/docs(Swagger UI)http://127.0.0.1:8000/redoc(Redoc)
截图位说明:此处可截
GET /users/{user_id}和POST /users在 Swagger UI 的参数/示例展示。
3)生成 Markdown 文档(包含 curl 示例与错误码段落)
创建 gen_docs.py(无第三方依赖,直接跑):
python
import argparse
import json
from pathlib import Path
from typing import Any, Dict, List, Tuple
def md_escape(s: str) -> str:
return s.replace("|", r"\|")
def pick_example(schema: Dict[str, Any]) -> Any:
# 优先 OpenAPI example / examples;退化为极简占位
if "example" in schema:
return schema["example"]
if "examples" in schema and isinstance(schema["examples"], dict):
# 任取一个
k = next(iter(schema["examples"].keys()))
ex = schema["examples"][k]
return ex.get("value", ex)
t = schema.get("type")
if t == "string":
return "string"
if t == "integer":
return 0
if t == "number":
return 0
if t == "boolean":
return False
if t == "array":
return []
if t == "object":
props = schema.get("properties", {})
return {k: pick_example(v) for k, v in props.items()}
return None
def build_curl_example(base_url: str, method: str, path: str, params: List[Dict[str, Any]], request_body: Dict[str, Any], examples_enabled: bool) -> str:
url = base_url.rstrip("/") + path
# path params 替换
for p in params:
if p.get("in") == "path":
name = p.get("name")
schema = p.get("schema", {})
ex = pick_example(schema) if examples_enabled else schema.get("type", "value")
url = url.replace("{" + name + "}", str(ex if ex is not None else 1))
headers = []
data_part = ""
if request_body:
content = request_body.get("content", {})
app_json = content.get("application/json")
if app_json:
headers.append("-H 'Content-Type: application/json'")
schema = app_json.get("schema", {})
payload = pick_example(schema) if examples_enabled else {}
data_part = f"-d '{json.dumps(payload, ensure_ascii=False)}'"
pieces = ["curl", "-sS", "-X", method.upper(), f"'{url}'"]
pieces += headers
if data_part:
pieces.append(data_part)
return " \\\n ".join(pieces)
def iter_operations(openapi: Dict[str, Any]) -> List[Tuple[str, str, Dict[str, Any]]]:
ops = []
for path, methods in openapi.get("paths", {}).items():
for method, op in methods.items():
if method.lower() not in {"get", "post", "put", "patch", "delete", "head", "options"}:
continue
ops.append((path, method.lower(), op))
return ops
def gen_markdown(openapi: Dict[str, Any], base_url: str, examples_enabled: bool) -> str:
title = openapi.get("info", {}).get("title", "API")
version = openapi.get("info", {}).get("version", "")
lines: List[str] = []
lines.append(f"# {title} 接口文档")
if version:
lines.append(f"\n- 版本:`{version}`")
lines.append(f"- Base URL:`{base_url}`\n")
lines.append("## 目录")
for path, method, op in iter_operations(openapi):
op_id = op.get("operationId", f"{method}_{path}")
lines.append(f"- [{op_id}](#{op_id.lower()})")
lines.append("")
for path, method, op in iter_operations(openapi):
op_id = op.get("operationId", f"{method}_{path}")
summary = op.get("summary") or ""
desc = op.get("description") or ""
params = op.get("parameters", []) or []
request_body = op.get("requestBody", {}) or {}
responses = op.get("responses", {}) or {}
lines.append(f"## {op_id}")
lines.append(f"\n- 方法:`{method.upper()}`")
lines.append(f"- 路径:`{path}`")
if summary:
lines.append(f"- 摘要:{md_escape(summary)}")
if desc:
lines.append(f"\n{desc}\n")
# 参数表
if params:
lines.append("\n### 参数")
lines.append("\n| 名称 | 位置 | 必填 | 类型 | 示例 | 说明 |")
lines.append("|---|---|---:|---|---|---|")
for p in params:
name = p.get("name", "")
loc = p.get("in", "")
required = "是" if p.get("required") else "否"
schema = p.get("schema", {})
typ = schema.get("type", "")
ex = pick_example(schema) if examples_enabled else schema.get("type", "")
lines.append(f"| {md_escape(str(name))} | {md_escape(str(loc))} | {required} | {md_escape(str(typ))} | {md_escape(str(ex))} | {md_escape(str(p.get('description','') or ''))} |")
else:
lines.append("\n### 参数\n\n无。")
# 请求体
if request_body:
lines.append("\n### 请求体")
content = request_body.get("content", {})
if "application/json" in content:
schema = content["application/json"].get("schema", {})
example = pick_example(schema) if examples_enabled else {}
lines.append("\n```json")
lines.append(json.dumps(example, ensure_ascii=False, indent=2))
lines.append("```")
else:
lines.append("\n(本文示例仅展开 application/json)")
# 示例 curl
lines.append("\n### 示例(curl)")
curl_cmd = build_curl_example(base_url, method, path, params, request_body, examples_enabled)
lines.append("\n```bash")
lines.append(curl_cmd)
lines.append("```")
# 响应/错误码
lines.append("\n### 响应与错误码")
lines.append("\n| HTTP Code | 说明 |")
lines.append("|---:|---|")
for code, r in responses.items():
desc = r.get("description", "") if isinstance(r, dict) else ""
lines.append(f"| {md_escape(str(code))} | {md_escape(str(desc))} |")
lines.append("\n---\n")
return "\n".join(lines)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--in", dest="in_file", required=True, help="OpenAPI JSON 文件路径")
ap.add_argument("--out", dest="out_file", required=True, help="输出文档路径")
ap.add_argument("--base-url", required=True, help="示例请求的 Base URL")
ap.add_argument("--doc-format", default="markdown", choices=["markdown"], help="文档格式(当前仅 markdown)")
ap.add_argument("--examples", default="enabled", choices=["enabled", "disabled"], help="是否生成示例(enabled/disabled)")
args = ap.parse_args()
in_path = Path(args.in_file)
out_path = Path(args.out_file)
openapi = json.loads(in_path.read_text(encoding="utf-8"))
examples_enabled = args.examples == "enabled"
md = gen_markdown(openapi, base_url=args.base_url, examples_enabled=examples_enabled)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(md, encoding="utf-8")
print(f"[ok] wrote: {out_path}")
if __name__ == "__main__":
main()
运行生成(对应题设关键参数):
bash
python gen_docs.py \
--in openapi.json \
--out docs/api.md \
--base-url http://127.0.0.1:8000 \
--doc-format markdown \
--examples enabled
查看输出:
bash
sed -n '1,120p' docs/api.md
截图位说明:此处可截
docs/api.md里"示例(curl)"段落,以及"响应与错误码"表格。
4)用文档里的 curl 示例做一次"可运行性验证"
直接复制 docs/api.md 里的 curl 跑一下,例如(以生成内容为准):
bash
curl -sS -X GET 'http://127.0.0.1:8000/users/1' | python -m json.tool
再测一个错误码:
bash
curl -sS -X GET 'http://127.0.0.1:8000/users/999' | python -m json.tool
5)(可选)引入 mcp2cli 做"运行时 CLI 校验层"
mcp2cli 的仓库定位是"运行时把 OpenAPI 变 CLI、零 codegen"。更贴近工程实践的用法是:CI 里用 CLI 跑一组关键接口,把失败当作"文档/接口定义漂移"的信号。
由于本文不假设它的具体安装方式/参数名(以仓库 README 为准),建议按以下步骤落地:
-
按
knowsuchagency/mcp2cli的 README 完成安装,并确认可执行文件存在:bashmcp2cli --help -
用它加载本地
openapi.json并指向http://127.0.0.1:8000作为 base URL(具体 flag 以帮助信息为准)。 -
让 CLI 跑
getUser/createUser对应的操作(通常会映射到operationId),把命令写进scripts/smoke.sh,在 CI 中执行。
为什么在这个场景下更省事:和"生成一堆客户端代码再写调用脚本"相比,运行时 CLI减少了生成物管理、语言绑定与更新成本;和"只靠 curl"相比,它更容易统一参数校验与子命令组织(尤其当接口数量增长时)。
示例(具体案例跑通)
输入
- 运行中的 FastAPI 服务导出的
openapi.json - 文档生成参数:
doc_format=markdownexamples=enabledbase_url=http://127.0.0.1:8000
生成文档
bash
python gen_docs.py \
--in openapi.json \
--out docs/api.md \
--base-url http://127.0.0.1:8000 \
--doc-format markdown \
--examples enabled
输出片段(节选,实际以生成文件为准)
docs/api.md中会包含类似结构:## getUser- 参数表(path 参数
user_id) curl -X GET 'http://127.0.0.1:8000/users/1'- 错误码表(例如
404 User not found)
关键点
- 示例来源 :优先取 OpenAPI schema 的
example/examples;没有就生成最小占位。 - 错误码来源 :从
responses直接落表,避免"文档手写与实现不一致"。
常见问题与排错(至少5条)
1)生成的 curl 路径里 {user_id} 没被替换
- 原因:OpenAPI 参数不在
in: path,或参数名与路径占位不一致。 - 排查:检查
openapi.json中该操作的parameters是否包含{"in":"path","name":"user_id"}。
2)示例请求体生成出来是空对象 {}
- 原因:schema 没有
example,且type/properties信息不足(例如$ref未展开)。 - 处理:在 FastAPI/Pydantic 侧加
Field(..., example=...),或在 OpenAPI 层补example;进阶可在脚本里做$ref解析(见"进阶优化")。
3)错误码表缺少某些状态码
- 原因:OpenAPI
responses未声明(实现里抛了异常但没在 schema 标注)。 - 处理:在路由装饰器里补
responses={...}(FastAPI 支持),让文档与实现对齐。
4)Base URL 配错导致示例全 404
- 现象:curl 的 host/port 不对,或多了反向代理前缀。
- 处理:把
--base-url设为真实可访问的入口(本地是http://127.0.0.1:8000);如果线上有/api/v1前缀,base URL 应带上前缀。
5)OpenAPI 是 YAML,不是 JSON
- 本文脚本读取 JSON。
- 处理:先转换:
- 若你能拿到服务端
/openapi.json,优先用它; - 或在生成侧把 YAML 转 JSON(可引入
pyyaml),再喂给脚本。
- 若你能拿到服务端
6)mcp2cli 找不到某个操作/子命令名称对不上
- 常见原因:OpenAPI 缺
operationId,或工具按规则派生名称导致不直观。 - 处理:给关键接口显式设置
operationId(本文 FastAPI 示例已设置getUser/createUser),并以mcp2cli --help列出的命令为准。
7)CI 环境跑示例时网络不可达/证书问题
- 处理:在 CI 里优先起本地服务并绑定
127.0.0.1;若必须打到测试环境,统一配置代理/证书,并将 base URL 作为可注入变量。
进阶优化(2-4点)
1)解析 $ref,让示例更接近真实结构
当前脚本对 $ref 未展开。接口复杂后,建议把 components/schemas 建索引并做递归解析,保证示例 JSON 可用。
2)把"示例可运行"纳入 CI
- 在 CI 中启动服务(或 mock server)
- 运行
gen_docs.py - 抽取生成的 curl 命令执行一遍(或用 mcp2cli 的 CLI 子命令执行)
这样可以把"文档漂移"尽早暴露。
3)引入模板系统(Jinja2)统一文档风格
当你需要按团队规范输出"鉴权说明/分页约定/幂等性/字段字典"等固定模块时,用 Jinja2 会比手拼字符串更稳。
4)让 AI 做"非结构化补全",但保留审计边界
例如你希望每个接口补充"使用注意事项/边界条件/常见错误原因"。这类内容 OpenAPI 往往不全,且手写费时。
在这种场景下,用真智AI(https://truescience.cn)把"生成后的 Markdown + OpenAPI 片段"作为输入,配合可复用模板做补全会更省事:一是通常无需额外网络手段即可使用较新的模型;二是价格与配置(模型/参数/会话/模板)更可控;三是产出仍可回到仓库做评审与版本管理。中性对比:自建模型需要额外运维与算力;直接调用各家 API 则要处理鉴权、限流、对话状态与模板管理的工程化问题。
小结
如果你的痛点是"接口文档经常过期、示例不可运行、错误码与实现不一致",可以用本文这套思路把 OpenAPI 当作单一事实来源:脚本生成 Markdown(doc_format=markdown, examples=enabled),再用 CLI(包括 mcp2cli 这类运行时方案)把示例纳入回归。若你还需要补充更贴近业务的说明文本、又不想自建模型环境,可以在真智AI(https://truescience.cn)里用模板化方式辅助整理,但建议始终以 OpenAPI 与实际回归结果为准。