本节目标:理解 FastAPI 如何从 Pydantic 模型导出 OpenAPI schema,会用文档参数塑造契约,掌握客户端代码生成的路径,并能手写可运行的调用客户端。
适用版本:Python 3.12+(实测 3.14.6);fastapi 0.143.0、pydantic 2.13.5、httpx 0.28.1
7.2 OpenAPI 契约与客户端代码生成
7.1 我们把请求/响应模型设计成了「可被机器读取的契约」。这一节把这句话兑现:FastAPI 会自动把模型导出成 OpenAPI 文档,这份文档既是给前端看的接口说明,也是代码生成器的输入。契约先行(contract-first)的核心价值就在于此——后端改一次模型,前端和客户端代码能跟着机器生成,而不是靠口口相传。
7.2.1 OpenAPI 是什么,为什么值得当成契约
OpenAPI 是一个用 JSON/YAML 描述 HTTP 接口的规范,记录每个路径、方法、参数、请求体、响应体的结构。它的价值不在于「生成一份好看的文档」,而在于:
- 单一事实来源:接口长什么样,由这份文件定义,前端、测试、SDK 都从它派生。
- 可机器消费:能生成客户端 SDK、服务端桩、Mock 服务、契约测试用例。
- 可做兼容性检查:两版 schema 一比,破坏性变更无所遁形(这正是 7.3 节的主题)。
FastAPI 天然产出它:只要用了 Pydantic 模型,schema 就自动有了。
7.2.2 真跑:让 FastAPI 导出 /openapi.json
下面是一个最小的商品服务,重点看模型与文档参数的配合:
import json
from typing import Annotated, Literal
from fastapi import FastAPI, Path, Query
from pydantic import BaseModel, ConfigDict, Field
app = FastAPI(title="Inventory API", version="1.2.0")
class ItemIn(BaseModel):
model_config = ConfigDict(extra="forbid")
sku: str = Field(pattern=r"^SKU-\d{4}$", examples=["SKU-0001"])
name: str = Field(min_length=1, max_length=64)
price: float = Field(gt=0, examples=[299.0])
status: Literal["on_sale", "preorder"] = "on_sale"
class ItemOut(BaseModel):
sku: str
name: str
price: float
status: str
currency: str = "CNY"
@app.post("/items", response_model=ItemOut, status_code=201, tags=["items"],
summary="创建商品", response_description="创建成功的商品")
def create_item(payload: ItemIn) -> ItemOut:
return ItemOut(**payload.model_dump(), currency="CNY")
@app.get("/items/{sku}", response_model=ItemOut, tags=["items"],
deprecated=True, summary="[已废弃] 按 SKU 取商品")
def get_item(sku: Annotated[str, Path(pattern=r"^SKU-\d{4}$")]) -> ItemOut:
...
调用 app.openapi() 就能拿到 schema 字典(也可通过运行时的 /openapi.json 访问)。实测它的骨架:
openapi 版本: 3.1.0
info: {'title': 'Inventory API', 'version': '1.2.0'}
paths: ['/items', '/items/{sku}']
注意 FastAPI 0.143.0 产出的是 OpenAPI 3.1.0。3.1 与 JSON Schema 完全对齐,exclusiveMinimum、anyOf 这些写法和 3.0 不同,选代码生成器时要确认它支持 3.1。
7.2.3 逐段解读 schema
先看 POST /items 的请求体:
{
"$ref": "#/components/schemas/ItemIn"
}
请求体不内联,而是 $ref 指向 components.schemas.ItemIn。这样做的好处是模型复用:多个接口引用同一个 ItemIn,改一处全生效。展开 ItemIn 的约束:
{
"properties": {
"sku": {"type": "string", "pattern": "^SKU-\\d{4}$", "title": "Sku", "examples": ["SKU-0001"]},
"name": {"type": "string", "maxLength": 64, "minLength": 1, "title": "Name"},
"price": {"type": "number", "exclusiveMinimum": 0.0, "title": "Price", "examples": [299.0]},
"status":{"type": "string", "enum": ["on_sale", "preorder"], "title": "Status", "default": "on_sale"}
},
"additionalProperties": false,
"type": "object",
"required": ["sku", "name", "price"],
"title": "ItemIn"
}
对照 7.1 学的东西,一目了然:
| Pydantic 写法 | 导出的 JSON Schema |
|---|---|
Field(pattern=...) | "pattern": "^SKU-\\d{4}$" |
Field(gt=0) | "exclusiveMinimum": 0.0 |
Field(min_length=1, max_length=64) | "minLength": 1, "maxLength": 64 |
Literal["on_sale", "preorder"] | "enum": [...] |
extra="forbid" | "additionalProperties": false |
| 无默认值的字段 | 进入 "required" 数组 |
Field(examples=[...]) | "examples": [...](仅文档,不校验) |
响应码也是契约的一部分:POST /items 的 responses 是 ['201', '422']——201 来自 status_code=201,422 是 FastAPI 对校验失败自动补的。这两个状态码客户端都能预期到。
再看 GET /items 的 query 参数,实测输出:
limit required=False schema={'type': 'integer', 'maximum': 100, 'minimum': 1, 'description': '每页条数', 'default': 20, 'title': 'Limit'}
status required=False schema={'anyOf': [{'enum': ['on_sale', 'preorder'], 'type': 'string'}, {'type': 'null'}], 'title': 'Status'}
Annotated[int, Query(ge=1, le=100, description=...)] 把范围与说明原样搬进了 schema;可选的 Literal | None 变成了 anyOf: [enum, null]——这就是 3.1 的写法。
最后看废弃标记:
GET /items/{sku} deprecated = True
路由上写 deprecated=True,schema 里就有了 "deprecated": true。工具(Swagger UI、代码生成器)会据此给接口打上「不推荐」的标记,但它只是提示,不会阻止调用。真正下线要走 7.3 的版本演进流程。
7.2.4 从 schema 到客户端:代码生成
有了 openapi.json,就能生成客户端。生态里主流三个工具:
| 工具 | 语言 | 特点 |
|---|---|---|
openapi-python-client | Python | 生成类型化的 httpx 客户端,最贴合 Python 项目 |
datamodel-code-generator | Python | 擅长从 schema 生成 Pydantic 模型 |
openapi-generator | 多语言 | Java 系,覆盖几十种语言,适合生成多语言 SDK |
⚠️ 本机未预装这三个工具,下面的命令未实测,仅示意。 它们都需要联网安装:
# 生成类型化 httpx 客户端(openapi-python-client)
uvx openapi-python-client generate --path openapi.json
# 只生成 Pydantic 模型
uvx datamodel-codegen --input openapi.json --input-file-type openapi --output models.py
# 生成多语言客户端(需要 Node / Java)
npx @openapitools/openapi-generator-cli generate -i openapi.json -g python -o ./client
生成结果里,函数名来自 schema 的 operationId。FastAPI 自动生成的 operationId 实测如下:
/items post -> create_item_items_post
/items get -> get_item_items_get
/items/{sku} get -> get_item_items__sku__get
这些名字由「函数名 + 路径 + 方法」拼出来,可读性一般。建议显式指定 operation_id(在路由装饰器里加 operation_id="create_item"),生成的客户端方法名会干净很多。
7.2.5 手写一个最小客户端(真跑)
工具没装也能验证契约的可用性——照 schema 手写一个 httpx 客户端,用 ASGITransport 在进程内直连应用,无需真的起服务:
import asyncio
import httpx
from app import app
class InventoryClient:
"""按 openapi.json 的字段手写的最小客户端。"""
def __init__(self, base_url: str, client: httpx.AsyncClient) -> None:
self._base = base_url
self._c = client
async def create_item(self, sku: str, name: str, price: float) -> dict:
r = await self._c.post(f"{self._base}/items",
json={"sku": sku, "name": name, "price": price})
r.raise_for_status()
return r.json()
async def main() -> None:
transport = httpx.ASGITransport(app=app) # 进程内直连
async with httpx.AsyncClient(transport=transport, base_url="http://inventory.local") as c:
cli = InventoryClient("http://inventory.local", c)
print(await cli.create_item("SKU-0007", "显示器", 1299.0))
asyncio.run(main())
实测输出:
create: {'sku': 'SKU-0007', 'name': '显示器', 'price': 1299.0, 'status': 'on_sale', 'currency': 'CNY'}
list: {'total': 1, 'items': [{'sku': 'SKU-0007', 'name': '显示器', 'price': 1299.0, 'status': 'on_sale', 'currency': 'CNY'}]}
返回结构与 ItemOut 完全一致(status 和 currency 都被服务端补上了)。这就是契约的意义:客户端按 schema 写,服务端按模型实现,两边对齐有据可查。
7.2.6 契约测试:把 schema 钉进 CI
光有 schema 不够,得在 CI 里断言它没被意外改坏。用 FastAPI 的 TestClient 拉 schema,做几条断言:
from fastapi.testclient import TestClient
from app import app
client = TestClient(app)
def test_openapi_is_served() -> None:
r = client.get("/openapi.json")
assert r.status_code == 200
assert r.json()["openapi"].startswith("3.1")
def test_create_item_requires_price() -> None:
schema = client.get("/openapi.json").json()
item_in = schema["components"]["schemas"]["ItemIn"]
assert "price" in item_in["required"]
assert item_in["additionalProperties"] is False
注:本机实测时
TestClient会打印一条StarletteDeprecationWarning(提示改用 httpx2),不影响断言结果。这是 starlette 1.7.0 的提示,非代码缺陷。
更进一步,可以把上一版 schema 存进仓库,每次构建时对比新旧差异,自动标记破坏性变更。这正是 7.3 节要展开的做法。
7.2.7 契约工程清单
- 每个接口都设
response_model,别让 FastAPI 猜返回类型——否则 schema 里会出现空对象。 - 用
summary/response_description/examples丰富文档,它们只影响可读性,不影响校验。 - 显式指定
operation_id,生成的客户端方法名才干净。 - 在 CI 里断言关键 schema 片段(必填字段、枚举、
additionalProperties)。 - 把
openapi.json当产物存档,作为版本对比的基线。
延伸阅读:Python 库与 API 设计 从「库作者」视角讲兼容性与文档工程,与本节的「接口契约」视角互补。
小结
- FastAPI 把 Pydantic 模型自动导出为 OpenAPI 3.1.0 schema,
/openapi.json就是接口的机器可读契约。 Field约束、Literal、extra="forbid"、status_code、deprecated都会原样映射进 schema,$ref+components.schemas实现模型复用。- 客户端代码生成有三条主线:
openapi-python-client(类型化 httpx 客户端)、datamodel-code-generator(Pydantic 模型)、openapi-generator(多语言);本机未预装,命令未实测。 - 工具缺席也能验证契约:照 schema 手写
httpx客户端,用ASGITransport进程内直连即可端到端跑通。 - 把 schema 断言写进 CI,并归档每一版
openapi.json,为兼容性检查留基线。
契约有了、客户端能生成了,接下来最现实的问题就是:接口要改,怎么改才不把老客户端弄挂?下一节进入版本演进与向后兼容。
阅读导航:上一节:Pydantic V2 模型设计 · 下一节:版本演进与向后兼容 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。