《Python编程实战》7.2 OpenAPI 契约与客户端代码生成

上一节设计好的 Pydantic 模型,本身就是一份机器可读的契约。本节让 FastAPI 导出真实的 /openapi.json,逐段解读 schema 里字段约束如何映射,再讲客户端代码生成的三条工具路径与命令,并手写一个基于 httpx + ASGITransport 的最小客户端跑通端到端调用,最后把 schema 断言写进 CI。

本节目标:理解 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-clientPython生成类型化的 httpx 客户端,最贴合 Python 项目
datamodel-code-generatorPython擅长从 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 模型设计 · 下一节:版本演进与向后兼容 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时