小白python入门 – 50. OpenAPI与接口契约
1. 本课定位:是什么、为何重要
前几课书签 API 已能跑路由、模型、鉴权与后台任务。一到联调,前端仍常问:分页是 items 还是 data?404 长什么样?改字段要不要通知?只丢仓库链接会反复试错。
本节把「能生成文档」升级为「能协作的契约」:OpenAPI、/docs、tags、Field 描述、分页与错误形状、/api/v1 版本、导出 openapi.json。学完应能把 Bookmark API 写成人与工具都能消费的约定。
FastAPI 自动生成 OpenAPI(/docs、/redoc、/openapi.json)。「能生成」≠「能联调」。
| OpenAPI | HTTP API 的机器可读规范 |
| 接口契约 | 双方同意的路径、字段、错误、鉴权 |
| Swagger UI | 浏览器交互文档 /docs |
| 版本化 | 破坏性变更如何并存与迁移 |
| 契约导出 | openapi.json 交给工具/SDK |
为何重要: API 是团队边界产品;契约不清,联调成本指数上升。
| 注解「顺便」出文档 | 主动写到可协作 |
| 会写路由 | 约定分页/错误/版本 |
| /docs 能点 | openapi.json 能导出 |
| 字段能过校验 | 字段有说明与示例 |
| 口头说有列表 | 菜单无配料 | 无契约 |
| 有路径无描述 | 菜单只有菜名 | 半成品 |
| 统一分页错误 | 统一点餐单 | 可复用 |
| /api/v1 | 大改出第 2 版 | 可迁移 |
| 导出 json | 标准菜单给平台 | 工具导入 |
2. 本质:文档即契约的一部分
很多人以为 OpenAPI 是写完再补的说明。在 FastAPI 里,路径、类型、response_model、状态码、tags、Field 都会进规范——改代码常即改契约。本质是把 HTTP 边界说清,让人、浏览器、工具共用同一真相。
上节说了协作痛。本节分开「实现细节」与「对外承诺」:内部名可改,对外 JSON 与错误体应稳定。先有契约感,再写 tags 与分页模型。
本质: 文档是契约载体;生成来自代码,描述与约定靠主动补齐。
注解/模型/tags → OpenAPI → /docs 给人 | openapi.json 给工具 | 测试锁形状
| 路径方法 | GET /api/v1/bookmarks | 前端、测试 |
| 请求模型 | Query/Body 约束 | 校验+文档 |
| 响应模型 | PageOut/BookmarkOut | 类型与示例 |
| 错误形状 | {"detail":"…"} | 统一错误处理 |
| 元信息 | title/version/description | 对接说明 |
| 返回一个 list | 固定 items/total/page/size |
| 找不到就抛 | 404 + detail 约定 |
| 以后再写文档 | 改接口先想破坏性 |
| 无 description | Field 带说明与 examples |
3. 约束与常见坑
契约最怕两张皮,或每接口一套分页字段;破坏性变更不升版本会让前端无声全红;examples 写真实密钥则文档一导出即泄密。
约束与坑表钉红线后再写 Field。综合实践按统一约定实现 PageOut 与 404。
约束: ①破坏性变更升版本或兼容期 ②分页/时间/错误全项目统一 ③鉴权在文档可见 ④示例用假数据 ⑤以运行中 openapi.json 为准 ⑥业务路径用 /api/v1。
| 文档实现两张皮 | 按文档联调失败 | 以生成规范为准并补描述 |
| 分页字段各异 | 组件难复用 | 统一四字段 |
| 无版本前缀 | 破坏无处可逃 | /api/v1 |
| 示例含密钥 | 泄密 | 假数据 |
| 只写 200 | 错误分支瞎猜 | responses+detail |
| 暴露内部字段 | 重构即破坏 | 稳定 response_model |
| 新增可选字段/接口 | 删改字段名或类型 |
| 放宽校验 | 收紧致旧客户端失败 |
| 补充错误说明 | 改鉴权无兼容 |
4. 约定表:分页、错误、鉴权、版本
契约宜少而硬。书签 API 入门:分页四字段、错误 detail、ISO8601、Bearer、URL 版本。可直接贴进对接说明。
| 分页 | items+total+page+size |
| 时间 | ISO8601,优先 UTC |
| 错误 | {"detail":"…"}(422 为列表) |
| 鉴权 | Authorization: Bearer <token> |
| 版本 | /api/v1 前缀 |
| 过滤 | Query q 等可选 |
{"items":[{"id":1,"title":"FastAPI 文档","url":"https://fastapi.tiangolo.com"}],"total":1,"page":1,"size":10}
| 未登录 | 401 | {"detail":"Not authenticated"} |
| 校验失败 | 422 | {"detail":[…]} |
| 不存在 | 404 | {"detail":"bookmark not found"} |
| 冲突 | 409 | {"detail":"…"} |
| tags / openapi_tags | 分组 |
| summary / docstring | 人话标题 |
| Field(description/examples) | 字段说明 |
| responses={404:…} | 错误说明 |
| version/description | 元信息 |
| 导出 openapi.json | 工具导入 |
5. tags 与应用元信息
接口一多 /docs 变成长列表。tags 分组;openapi_tags 写组说明;title/version/description 做对接抬头。不改业务,却显著提升可读性。
| title | 如 Bookmark API |
| version | 如 1.0.0 |
| description | Markdown 对接说明 |
| openapi_tags | 组 name+description |
| 路由 tags | 接口归组 |
| summary | 列表短标题 |
小步:
from fastapi import FastAPI
app = FastAPI(
title="Bookmark API", version="1.0.0",
description="书签服务入门契约",
openapi_tags=[
{"name": "meta", "description": "健康检查"},
{"name": "bookmarks", "description": "书签资源"},
],
)
@app.get("/health", tags=["meta"], summary="健康检查")
def health():
return {"status": "ok"}
预期: /docs 有 meta 组与「健康检查」标题。
| 全堆默认组 | meta/bookmarks 分组 |
| 无版本 | version 可见 |
| 仅路径串 | summary 人话 |
6. Field 描述、示例与 response_model
类型只保证 int/str,不保证「页码从 1」。Field/Query 的 description、examples 进 OpenAPI;response_model 固定成功形状。
| title: str | 类型 |
| Field(description=…) | 描述 |
| Field(examples=[…]) | 假数据示例 |
| Query(1, ge=1, description=…) | 默认+约束+描述 |
| response_model=PageOut | 成功 schema |
小步:
from pydantic import BaseModel, Field
from fastapi import Query
class BookmarkOut(BaseModel):
id: int = Field(description="书签 ID", examples=[1])
title: str = Field(description="标题", examples=["FastAPI 文档"])
url: str = Field(description="链接", examples=["https://fastapi.tiangolo.com"])
class PageOut(BaseModel):
items: list[BookmarkOut]
total: int = Field(description="总条数")
page: int = Field(description="页码从 1")
size: int = Field(description="每页条数")
def params(
page: int = Query(1, ge=1, description="页码从 1"),
size: int = Query(10, ge=1, le=100, description="每页条数"),
q: str | None = Query(None, description="标题关键字"),
):
return page, size, q
预期: /docs 参数有中文说明;Schemas 有 PageOut 四字段。
| 猜 data/items | 固定 items |
| 页码口头约定 | description 写死 |
| 多返回内部字段 | response_model 裁剪 |
7. 错误形状与 responses
成功体写清后,错误仍是黑洞。422 默认来自校验;业务用 HTTPException 统一 detail;responses={404:…} 让文档显示失败说明。
| 缺 Token | 401 | 字符串 |
| 非法 Body/Query | 422 | 列表 |
| HTTPException | 自定 | 通常字符串 |
| 未捕获 | 500 | 生产隐藏细节 |
小步:
from fastapi import FastAPI, HTTPException, Path
app = FastAPI()
@app.get("/api/v1/bookmarks/{bookmark_id}", responses={404: {"description": "不存在"}})
def get_bookmark(bookmark_id: int = Path(..., description="书签 ID")):
if bookmark_id != 1:
raise HTTPException(status_code=404, detail="bookmark not found")
return {"id": 1, "title": "demo", "url": "https://example.com"}
预期: 999→404+detail;/docs 见 404 说明。
| {"error":…} 各异 | 统一 detail |
| 文档仅 200 | 声明 404 |
8. 版本化 /api/v1 与导出 openapi.json
字段破坏性变更时,无版本路径会让旧客户端无声坏掉。入门推荐 URL 前缀:/api/v1 稳定,破坏性开 /api/v2。机器侧导出 /openapi.json 给 Apifox/Postman/SDK。
| URL /api/v1 | 直观 | 略长 | 推荐 |
| Header 版本 | 路径干净 | 难调试 | 了解 |
| 永久兼容 | 无迁移 | 包袱重 | 难坚持 |
| 新增可选字段 | v1 内加并文档注明 |
| 删除必填/改类型 | 新版本或兼容期 |
| 改鉴权 | 公告+双轨 |
/api/v1/bookmarks /api/v2/bookmarks /health /openapi.json
| /docs /redoc | 人 |
| /openapi.json | 工具 |
| description/README | 前端对接 |
curl -s http://127.0.0.1:8000/openapi.json -o openapi.json
检查:含 paths、PageOut、tags;examples 无真实密钥。
9. 综合实践:可协作的 Bookmark API 契约
合成最小可运行服务:健康检查、分页列表、详情 404、统一 PageOut、路径在 /api/v1、description 写对接约定。Windows 若 cat <<'EOF' 不可用,请用 Cygwin / Git Bash / WSL,或手动建 main.py。
mkdir -p ~/python-lab/src/day50 && cd ~/python-lab/src/day50
pip install fastapi "uvicorn[standard]"
cat > main.py << 'EOF'
from fastapi import FastAPI, HTTPException, Path, Query
from pydantic import BaseModel, Field
description = """
## Bookmark API 对接说明(Day50)
1. 本演示无真实登录。
2. 分页:`items` / `total` / `page` / `size`。
3. 错误:`{"detail": "…"}`(422 为列表)。
4. 业务路径:`/api/v1`。
5. 导出:`/openapi.json`。
"""
app = FastAPI(
title="Bookmark API", description=description, version="1.0.0",
openapi_tags=[
{"name": "meta", "description": "健康检查与元信息"},
{"name": "bookmarks", "description": "书签资源"},
],
)
class BookmarkOut(BaseModel):
id: int = Field(description="书签 ID", examples=[1])
title: str = Field(description="标题", examples=["FastAPI 文档"])
url: str = Field(description="链接", examples=["https://fastapi.tiangolo.com"])
tags: list[str] = Field(default_factory=list, description="标签", examples=[["python"]])
class PageOut(BaseModel):
items: list[BookmarkOut]
total: int
page: int
size: int
_DB = [
BookmarkOut(id=1, title="FastAPI 文档", url="https://fastapi.tiangolo.com", tags=["python", "web"]),
BookmarkOut(id=2, title="Pydantic", url="https://docs.pydantic.dev", tags=["python"]),
BookmarkOut(id=3, title="OpenAPI 规范", url="https://swagger.io/specification/", tags=["api"]),
]
@app.get("/health", tags=["meta"], summary="健康检查")
def health():
return {"status": "ok", "version": "1.0.0"}
@app.get("/api/v1/bookmarks", tags=["bookmarks"], summary="分页列出书签", response_model=PageOut)
def list_bookmarks(
page: int = Query(1, ge=1, description="页码从 1"),
size: int = Query(10, ge=1, le=100, description="每页条数"),
q: str | None = Query(None, description="标题关键字"),
):
rows = _DB
if q:
rows = [x for x in rows if q.lower() in x.title.lower()]
start = (page – 1) * size
return PageOut(items=rows[start:start + size], total=len(rows), page=page, size=size)
@app.get(
"/api/v1/bookmarks/{bookmark_id}", tags=["bookmarks"], summary="书签详情",
response_model=BookmarkOut, responses={404: {"description": "不存在"}},
)
def get_bookmark(bookmark_id: int = Path(…, ge=1, description="书签 ID")):
for item in _DB:
if item.id == bookmark_id:
return item
raise HTTPException(status_code=404, detail="bookmark not found")
EOF
uvicorn main:app –reload –port 8000
自测:
curl -s http://127.0.0.1:8000/openapi.json -o openapi.json
curl -s "http://127.0.0.1:8000/api/v1/bookmarks?page=1&size=2"
curl -s "http://127.0.0.1:8000/api/v1/bookmarks?q=OpenAPI"
curl -s http://127.0.0.1:8000/api/v1/bookmarks/1
curl -s http://127.0.0.1:8000/api/v1/bookmarks/999
curl -s http://127.0.0.1:8000/health
| size=2 | items 长 2,total=3 |
| q=OpenAPI | 命中相关书签 |
| id=1 | 完整 BookmarkOut |
| id=999 | 404 + detail |
| /docs | 分组与字段说明 |
| openapi.json | 含 paths 与 PageOut |
清单: title/version、description 约定、tags、Query 描述、404、Schema 齐全。
10. 能力总表与自我检查
跑通后对照:约定是否统一、文档是否增强、json 能否导出。查「可协作」而非仅「200」。
| 给人 | /docs /redoc |
| 给工具 | 导出 openapi.json |
| 给前端 | 对接说明+稳定字段 |
| 给演进 | /api/v1 版本 |
| 给测试 | 下节断言锁契约 |
- 说清 OpenAPI 与契约
- 分页四字段统一
- 错误以 detail 为主
- tags + Field 已写
- 路径在 /api/v1
- 会导出 openapi.json
- 知破坏性要版本
- 示例无密钥
总结
从「生成能点」到「约定可协作」,要把分页、错误、版本与字段说明当产品。OpenAPI 是机器契约;/docs 给人;openapi.json 给工具。Bookmark API 统一形状并挂 /api/v1,联调成本会下降。下节用测试锁住形状。
- OpenAPI 描述 API;契约是双方同意的形状与行为。
- 代码生成规范;tags/Field/response_model 决定可读性。
- 分页统一 items/total/page/size;错误优先 detail。
- 破坏性变更用版本或兼容期。
- 导出 openapi.json;示例用假数据;以运行中规范为准。
小练笔
契约已从约定表落到可运行服务。先独立作答;建议对照 /docs 与 openapi.json。
题 1
/openapi.json 给谁用?举两类消费者。
题 2
为何分页字段要全项目统一?
题 3
破坏性删除响应字段时如何版本化?
题 4
判断:有 /docs 就等于前端无需其它说明。(对/错)
题 5
写出分页成功 JSON 骨架(items/total/page/size)。
题 6
Field(description/examples) 比只写 title: str 多了什么?
题 7
为何业务接口建议 /api/v1?
题 8
详情找不到书签时的状态码与 body?
题 9
哪项是兼容变更?A 删 url B 新增可选 tags C id 改 string
题 10(可选)
导出 openapi.json,确认含 /api/v1/bookmarks 与 PageOut。
小练笔参考答案
先做后看。
题 1
工具导入(Postman/Apifox)、生成客户端/SDK、契约校验等。
题 2
前端组件与 SDK 可复用,降联调成本。
题 3
升主版本或 /api/v2,给迁移/兼容期并更新文档。
题 4
错
题 5
{"items":[],"total":0,"page":1,"size":10}
题 6
人类可读描述与示例,文档更清晰。
题 7
破坏性可并存新版本;网关与路由好切分。
题 8
404,{"detail":"bookmark not found"}
题 9
B
题 10
文件中能搜到路径与 PageOut。
网硕互联帮助中心





评论前必须登录!
注册