首页
/ 基于 FastAPI 自动生成 TypeScript SDK:用 OpenAPI 客户端代码生成打通前后端类型安全

基于 FastAPI 自动生成 TypeScript SDK:用 OpenAPI 客户端代码生成打通前后端类型安全

2026-09-06 19:16:56作者:尤峻淳Whitney

FastAPI 基于 OpenAPI 规范构建,每个应用都会自动产出一份结构完整的 API 描述文件,因此可以由机器解析这份描述并自动生成多语言的客户端 SDK。本文以仓库内官方进阶文档 docs/es/docs/advanced/generate-clients.md(英文同版位于 docs/en/docs/advanced/generate-clients.md)为主线,带你从零到一实践:从声明模型、产出 OpenAPI schema、用 Hey API 生成 TypeScript SDK,到通过自定义 generate_unique_id_function 与预处理 OpenAPI 规范获得"干净"的客户端方法名。读完你可以把"后端改模型 → 重新生成 → 前端立刻类型对齐"这条自动化链路落地到真实项目。

为什么 FastAPI 天然适合"代码生成"

FastAPI 的核心设计是"声明即文档":pydantic 模型负责请求/响应数据的结构定义,路径操作装饰器把它们登记进路由,而应用最终会把这一切自动序列化为一台 /openapi.json 上的 OpenAPI 规范描述。任何能够理解该标准的工具——文档渲染器、测试脚手架、SDK 生成器——都能消费这份描述。

文档中特别给出一条提醒:FastAPI 自动生成的规范是 OpenAPI 3.1,因此选择任何 SDK 生成工具时都要确认它支持该版本,否则无法正确处理 schema 结构。

从源码结构可以印证这条链路:应用实例化后会把每个 APIRoute 的模型、参数、tag 等信息汇总进 openapi schema(见 fastapi/applications.py),而客户端生成器所消费的正是这些来自模型声明的信息。

开源 SDK 生成器怎么选

文档推荐的选项分两类:

  • OpenAPI Generator:通用型方案,覆盖大量编程语言,可以从同一份 OpenAPI 规范生成多语言 SDK,适合多语言客户端并存或语言栈不固定的团队。
  • Hey API:面向 TypeScript 客户端量身打造的工具,围绕 TS 生态做了深度优化(类型导出、自动补全体验、产物组织方式),是本文演示的主角。

此外,社区维护的 OpenAPI.Tools 站点汇总了更多 SDK 生成器,可按语言、功能做横向筛选。(上述工具站点均为文档引用的外部资料,不在仓库证据范围内,仅作选型背景说明。)

从一个最小的 FastAPI 应用开始

无论后端多复杂,生成 SDK 的前提只有一条:把请求/响应数据结构用模型声明出来。文档演示的最小应用如下(完整源码见 docs_src/generate_clients/tutorial001_py310.py):

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class Item(BaseModel):
    name: str
    price: float


class ResponseMessage(BaseModel):
    message: str


@app.post("/items/", response_model=ResponseMessage)
async def create_item(item: Item):
    return {"message": "item received"}


@app.get("/items/", response_model=list[Item])
async def get_items():
    return [
        {"name": "Plumbus", "price": 3},
        {"name": "Portal Gun", "price": 9001},
    ]

注意两条 path operations 都用模型显式定义了数据契约:

  • 请求 payload 由 Item 描述(create_item 的请求体);
  • 响应 payload 由 ResponseMessage(单对象)与 list[Item](列表)描述。

这些模型就是后续客户端代码中类型定义与自动补全的源头。

/docs 上确认 schema 已生成

启动应用(例如 fastapi devuvicorn main:app)后访问 /docs,Swagger UI 中会列出每个接口的请求/响应 schema——例如 ItemResponseMessage。能看到这些 schema,正是因为接口上声明了模型,FastAPI 把它们登记进了 OpenAPI schema,再由文档界面渲染出来。

FastAPI Swagger UI 中根据 Item、ResponseMessage 模型展示出的请求体与响应体 schema

同一份/docs 使用的 OpenAPI schema 信息,正是生成客户端代码的数据来源——这是"一次声明、多处复用"的关键。

用 Hey API 生成 TypeScript SDK

有了声明好模型的应用,生成 TS 客户端最快的方式是用 npx 直接跑 Hey API 的官方 CLI:

npx @hey-api/openapi-ts -i http://localhost:8000/openapi.json -o src/client

参数含义:

  • -i:OpenAPI 规范的输入源,这里指向本地开发服务器动态产出的 /openapi.json
  • -o:生成产物的输出目录,这里为 ./src/client

命令执行后,一份可直接被前端工程 import 的 TypeScript SDK 就会生成到 ./src/client。关于 @hey-api/openapi-ts 的安装方式与生成产物结构,文档建议查阅其官网指引(外部站点,不展开)。

生成的 SDK 使用体验

文档以编辑器截图逐一展示了这份 SDK 带来的开发体验提升:

  • 方法级自动补全:所有路径操作都成为可被编辑器提示的方法;
  • 请求 payload 自动补全:如 Item 模型中的 nameprice 字段,会在你构造调用参数时被提示——这正是定义在 FastAPI 模型 Item 上的字段被穿透到了前端;
  • 响应对象自动补全:返回数据的字段结构同样可被 IDE 感知;
  • 行内错误提示:当传入的数据与模型类型不符时,TS 编译期(编辑器)即报错。

这类"前后端字段同名同构"的效果,完全得益于后端模型被完整导入了 OpenAPI schema,客户端生成器据此反推出 TS 类型。

更大的应用:用 tags 组织接口与客户端代码

真实项目往往把接口按业务域分组。文档给出一个同时包含 itemsusers 两个分组的示例(完整源码见 docs_src/generate_clients/tutorial002_py310.py):

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class Item(BaseModel):
    name: str
    price: float


class ResponseMessage(BaseModel):
    message: str


class User(BaseModel):
    username: str
    email: str


@app.post("/items/", response_model=ResponseMessage, tags=["items"])
async def create_item(item: Item):
    return {"message": "Item received"}


@app.get("/items/", response_model=list[Item], tags=["items"])
async def get_items():
    return [
        {"name": "Plumbus", "price": 3},
        {"name": "Portal Gun", "price": 9001},
    ]


@app.post("/users/", response_model=ResponseMessage, tags=["users"])
async def create_user(user: User):
    return {"message": "User received"}

关键变化是每条路径操作都显式挂了 tags=["items"]tags=["users"]。tag 会原样进入 OpenAPI 描述,而主流客户端生成器通常依据 tags 对生成的客户端代码做分组

tags 被翻译成客户端 Service 分组

对带 tags 的应用重新运行生成命令后,生成的客户端通常会自动拆出按业务域组织的命名空间/类,文档演示的结果是:

  • ItemsService
  • UsersService

每个 Service 内聚对应分组的路径操作,请求/响应类型也随之归拢,代码结构在后端与前端两侧保持一致,方便按业务模块维护。

为什么默认方法名会长成 createItemItemsPost

分组虽清晰,方法名却不尽人意:

ItemsService.createItemItemsPost({name: "Plumbus", price: 5})

原因是:客户端生成器使用的是 OpenAPI 内部为每条路径操作分配的 operation ID,而 OpenAPI 要求 operation ID 在全局唯一。为保证唯一性,FastAPI 默认用 函数名 + 路径 + HTTP 方法 三要素拼接出 operation ID。

这一点在仓库源码中有直接证据:默认实现位于 fastapi/utils.py

def generate_unique_id(route: "APIRoute") -> str:
    operation_id = f"{route.name}{route.path_format}"
    operation_id = re.sub(r"\W", "_", operation_id)
    assert route.methods
    operation_id = f"{operation_id}_{list(route.methods)[0].lower()}"
    return operation_id

即把 函数名 + 路径 中所有非单词字符替换成下划线,再追加 HTTP 方法名(小写)。例如 create_item + /items/ + POST 会得到类似 create_item_items__post 的原始 operation ID,经客户端生成器驼峰化后就成了 createItemItemsPost——信息完整、保证唯一,但确实冗余。

fastapi/routing.py 中,每条路由在创建时执行 route.unique_id = route.operation_id or current_generate_unique_id(route),即显式指定了 operation_id 则优先采用,否则调用该函数兜底。

自定义 operation ID,得到更好的方法名

既然方法名难看源于默认 operation ID 拼接规则,自然可以改写规则本身。约束条件也同步变化:必须用其他方式保证 operation ID 唯一。文档给出的思路是——保证每条路径操作都有 tag,然后让 operation ID = 第一个 tag + 函数名

自定义 generate_unique_id_function

FastAPI 允许传入自定义的"唯一 ID 生成函数",签名是"接收一个 APIRoute,返回一个 str"。完整示例见 docs_src/generate_clients/tutorial003_py310.py

from fastapi import FastAPI
from fastapi.routing import APIRoute
from pydantic import BaseModel


def custom_generate_unique_id(route: APIRoute):
    return f"{route.tags[0]}-{route.name}"


app = FastAPI(generate_unique_id_function=custom_generate_unique_id)


class Item(BaseModel):
    name: str
    price: float


class ResponseMessage(BaseModel):
    message: str


class User(BaseModel):
    username: str
    email: str


@app.post("/items/", response_model=ResponseMessage, tags=["items"])
async def create_item(item: Item):
    return {"message": "Item received"}


@app.get("/items/", response_model=list[Item], tags=["items"])
async def get_items():
    return [
        {"name": "Plumbus", "price": 3},
        {"name": "Portal Gun", "price": 9001},
    ]


@app.post("/users/", response_model=ResponseMessage, tags=["users"])
async def create_user(user: User):
    return {"message": "User received"}

要点拆解:

  • 函数体里访问了 APIRoutetags[0](文档提示:实践中每个操作通常只有一个 tag)与 name(即路径操作函数名);
  • 通过 FastAPI(generate_unique_id_function=custom_generate_unique_id) 把自定义函数注入应用。

文档同时强调:这个唯一 ID 不只用于 operation ID,还会用于自动生成的请求/响应自定义模型的命名,因此它影响的是整份 schema 的标识体系,而不只是方法名。

补充说明(来自源码与测试):该配置并不只存在于 FastAPI()。仓库测试 tests/test_generate_unique_id_function.py 覆盖了在多级 APIRouterinclude_router(..., generate_unique_id_function=...) 场景下的行为,其中 route.unique_id 的取值遵循就近覆盖的优先级——应用层、路由层、子路由层逐级可配,越具体者越优先。

重新生成:方法名立刻变清爽

再次运行同一生成命令后,operation ID 由 items-create_item 这类结构构成(标签 + 下划线 + 函数名),不再混入 URL 路径与 HTTP 动词,客户端方法名相应变成 createItemgetItems 级别的简洁形态,且依旧挂载在 ItemsService / UsersService 之下。

在生成前预处理 OpenAPI:去掉方法名里的重复前缀

自定义后方法名已经"瘦身",但仍有重复信息:方法既然已经在 ItemsService(取自 tag)里,方法名前缀再带一遍 tag 意义不大。出于 OpenAPI 自身唯一性的考虑,我们希望保留 schema 里带 tag 前缀的 operation ID(保证全局唯一),只在喂给客户端生成器之前"临时改写"

方案:先把 OpenAPI 规范落到本地 openapi.json 文件,再运行脚本剥离 "{tag}-" 前缀。下载规范可用:

curl http://localhost:8000/openapi.json -o openapi.json

(若应用未运行于 8000 端口,请替换为实际地址。)

Python 预处理脚本

文档给出的 Python 版本(docs_src/generate_clients/tutorial004_py310.py):

import json
from pathlib import Path

file_path = Path("./openapi.json")
openapi_content = json.loads(file_path.read_text())

for path_data in openapi_content["paths"].values():
    for operation in path_data.values():
        tag = operation["tags"][0]
        operation_id = operation["operationId"]
        to_remove = f"{tag}-"
        new_operation_id = operation_id[len(to_remove) :]
        operation["operationId"] = new_operation_id

file_path.write_text(json.dumps(openapi_content))

逻辑很直白:遍历 paths 下每个 HTTP 方法的 operation,取 tags[0],若 operation ID 以 "{tag}-" 开头则原地截断去掉前缀,最后写回文件。

Node.js 预处理脚本

若你的工具链在 Node 侧,文档在同一 tab 中提供了等价实现(docs_src/generate_clients/tutorial004.js):

import * as fs from 'fs'

async function modifyOpenAPIFile(filePath) {
  try {
    const data = await fs.promises.readFile(filePath)
    const openapiContent = JSON.parse(data)

    const paths = openapiContent.paths
    for (const pathKey of Object.keys(paths)) {
      const pathData = paths[pathKey]
      for (const method of Object.keys(pathData)) {
        const operation = pathData[method]
        if (operation.tags && operation.tags.length > 0) {
          const tag = operation.tags[0]
          const operationId = operation.operationId
          const toRemove = `${tag}-`
          if (operationId.startsWith(toRemove)) {
            const newOperationId = operationId.substring(toRemove.length)
            operation.operationId = newOperationId
          }
        }
      }
    }

    await fs.promises.writeFile(
      filePath,
      JSON.stringify(openapiContent, null, 2),
    )
    console.log('File successfully modified')
  } catch (err) {
    console.error('Error:', err)
  }
}

const filePath = './openapi.json'
modifyOpenAPIFile(filePath)

相比 Python 版本,它额外做了防御:仅当 operationId 确实以 toRemove 开头时才截取(保留缩进与 console.log 反馈)。两种脚本效果一致——operation ID 会从类似 items-get_items 变为干净的 get_items

用预处理后的规范重新生成

由于入口从动态 URL 变成了本地文件,生成命令的输入参数也要相应切换:

npx @hey-api/openapi-ts -i ./openapi.json -o src/client

预处理 operation ID 后重新生成,TS 客户端中 ItemsService 的方法名变为简洁的 createItem、getItems 并保留自动补全

此时产物同时具备:清爽的方法名(如 createItem/getItems)、完整的类型自动补全与行内报错能力,并且重复的 tag 前缀信息已被去掉。

这套自动化工作流带来的收益

把上述流程沉淀为"每次后端变更后自动执行"的流水线,就能收获文档总结的四个核心收益:

  • 方法级自动补全:新路径操作生成后直接成为可调用的类型安全方法;
  • 请求 payload 自动补全:body、query 参数等均由后端模型推导而来;
  • 响应 payload 自动补全:返回结构变更会被同步到前端类型;
  • 一切皆有行内错误:任何字段名、类型或结构的失配都会在编辑器/构建期暴露。

更关键的是其"同步保障"性质:每当后端更新、前端重新生成,新增路径操作会自动以方法形态出现,已删除的会消失,其余变更全部反映到生成代码。若客户端使用方改错了数据结构,build 阶段就会因类型失配而报错——错误被提前到开发早期发现,而不是等生产环境报错后再逆向排查。这正是"后端即契约、前端零手写类型"的核心价值。

深入阅读指引

登录后查看全文
热门项目推荐
相关项目推荐