教程

如何使用 OpenAPI 自动生成 JSON API 文档、TypeScript 类型和 API Client?从 OpenAPI Schema 到代码生成完整教程

手写文档、手写类型、再手写 fetch,三处各漂一版,比写接口本身更贵。把 OpenAPI Schema 当合同,文档、类型和 Client 都从同一份 JSON 长出来。

前后端最贵的往往不是实现 GET /invoices,而是旁边那三份会各自过期的东西:给人看的 API 文档、给编译器看的 TypeScript 类型、给运行时看的 fetch 封装。2026 年仍然有团队在 Wiki 里贴示例 JSON,在 types.ts 里手写同一份字段,再在 api.ts 里拼 URL。OpenAPI Schema 的价值,是把这三件事收成一份可机器读的合同。你维护 openapi.json(或 YAML),文档站、类型文件和 Client 都从它生成;字段改一次,三处一起变。站内 MCP 与 JSON Schema 讲的是工具入参;结构化输出 讲的是模型最终答覆。本文只讲 HTTP JSON API:从 OpenAPI Schema 走到文档、类型和 Client。规范原文见 OpenAPI 3.1。生成器以 openapi-typescript 为主路径。

一份 Schema,三份产物:文档、类型、Client

先把目标说死。OpenAPI 文档不是另一本 Markdown,而是同一份 spec 的渲染:Scalar、Swagger UI、Redoc 都只是阅读器。TypeScript 类型不是另一份手写接口,而是 paths 与 components.schemas 的投影。API Client 不是「再封装一层 axios」,而是按 operationId 或路径生成的、带着请求体和响应类型的函数。三份产物如果有两份是手写的,Schema 就只是墙上的海报。

选型可以后做,合同必须先有。后端用 FastAPI、Nest、Spring、Go 的运行时注解导出 spec,或前端先写 openapi.json 再倒逼实现,都可以。禁止的是:先写 Client,再根据调用方「感觉」补文档。那样没有单一真相,CI 也无法判断谁对。Agent 若要把 REST 当工具,也应读这份 OpenAPI,而不是再抄一份 MCP inputSchema——两份合同会立刻 drift。分层见上文 MCP 文。

OpenAPI 3.1 把 components.schemas 更紧地对齐 JSON Schema 2020-12:可空用 type: ["string", "null"],不再依赖 3.0 的 nullable: true。这对「文档里写可空、类型里却是必填」这类事故更友好。你在 JSONVue 里看到的 Schema 形状,和生成器吃进去的,应当是同一份对象,而不是导出时又转一遍方言。

产物 从哪来 不要手写什么
JSON API 文档同一份 OpenAPI JSON,用 Scalar / Swagger UI 渲染Wiki 里的示例 JSON、过期截图
TypeScript 类型openapi-typescript 生成 paths / componentsinterface Invoice 与 spec 平行维护
API Client / hooksopenapi-fetch、Orval 或 hey-api 生成调用函数手拼 URL、手写 as Invoice

先写对 OpenAPI JSON:paths、components、operationId

生成器能做的上界,就是 spec 的质量。最少要有:openapi 版本、info、paths、以及复用模型放进 components.schemas。每个操作给稳定的 operationId(如 listInvoices、createInvoice),Client 用它当函数名;路径一改,id 别跟着漂。请求和成功响应都声明 application/json,并 $ref 到组件,不要在每个 path 里复制一份字段表。

错误响应也要进合同。400 / 401 / 404 / 422 各给 JSON Schema,Client 才能把失败收成可辨的联合类型,而不是一个 unknown。查询参数、路径参数写成 parameters,不要塞进 body 又在文档里口头约定。安全方案(Bearer、OIDC)写在 securitySchemes,生成的 Client 才知道往哪放 token——鉴权协议本身见 OAuth 2.1,REST 面同样适用「token 在头,不在 JSON 信封」。

下面是一份最小可用的发票 API。它不是完整产品 spec,但够让文档、类型和 Client 三条流水线同时转起来。Invoice 只出现一次;list 与 create 都引用它。先在本地用 JSON 格式化确认能 parse,再用 Schema 工具看 $ref 有没有断。

{
  "openapi": "3.1.1",
  "info": {
    "title": "Invoices API",
    "version": "2026.09.18"
  },
  "paths": {
    "/invoices": {
      "get": {
        "operationId": "listInvoices",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["draft", "paid", "void"]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Invoice list",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["items"],
                  "properties": {
                    "items": {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/Invoice" }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createInvoice",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/InvoiceDraft" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Invoice" }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "InvoiceDraft": {
        "type": "object",
        "required": ["customerId", "amount"],
        "properties": {
          "customerId": { "type": "string" },
          "amount": { "type": "integer", "minimum": 1 },
          "note": { "type": ["string", "null"] }
        }
      },
      "Invoice": {
        "allOf": [
          { "$ref": "#/components/schemas/InvoiceDraft" },
          {
            "type": "object",
            "required": ["id", "status"],
            "properties": {
              "id": { "type": "string" },
              "status": {
                "type": "string",
                "enum": ["draft", "paid", "void"]
              }
            }
          }
        ]
      }
    }
  }
}

生成给人看的 JSON API 文档

文档站的正确形态是:构建时把 openapi.json 拷到静态目录,旁边挂一个阅读器。Scalar、Swagger UI、Redoc 都读同一文件。不要把「文档」理解成再写一叠 Markdown,再在 CI 里和 spec 对两遍。人改文档、机器改 spec,第二天就分叉。

阅读器能做的,只限于 spec 里写过的:没有 example 就没有可复制的示例;没有错误响应就没有失败说明;description 为空,页面上就只剩路径。生成文档之前,先把合同本身补全。预览时用浏览器打开阅读器即可,spec 文件本身用 JSONVue 看结构,比在巨大 YAML 里找断掉的 $ref 更快。

把文档 URL 写进 info.contact 或内部门户,但不要再维护第二份「给产品看的字段表」。产品要看的枚举和必填,应当已经在 Schema 的 enum / required 里。两份字段表是 drift 的经典入口。

生成 TypeScript 类型:openapi-typescript

类型生成走 openapi-typescript 就够作为默认:零运行时、吃 OpenAPI 3.0 与 3.1,输出 paths 与 components。一条命令:npx openapi-typescript openapi.json -o src/api/schema.d.ts。远程 spec 也可以,但生产 CI 应钉住仓库里的文件,避免构建去打会变的 URL。

import type { paths, components } from "./schema";

type Invoice = components["schemas"]["Invoice"];
type ListOk =
  paths["/invoices"]["get"]["responses"][200]["content"]["application/json"];

export type { Invoice, ListOk };

用类型时按路径索引,而不是再声明 interface。paths["/invoices"]["get"]["responses"][200]["content"]["application/json"] 就是列表成功体。需要复用模型时取 components["schemas"]["Invoice"]。这样路径一改,编译器会在调用方报错,而不是让你去搜三个 Invoice 定义。

类型不是运行时校验。生成的 .d.ts 在浏览器里不存在。网络上仍可能来一份缺字段的 JSON;编译通过只说明你按合同写了 Client。需要运行时门卫,再用 Orval/Kubb 生成 Zod,或对关键响应做一次 Schema 校验。不要把「TypeScript 绿了」写成唯一质检。

生成 API Client:openapi-fetch、Orval、hey-api

只要类型时,配 openapi-fetch:createClient<paths>({ baseUrl }) 之后 client.GET("/invoices", { params: { query } })。请求体、查询、路径参数都被类型卡住;你不会把 createInvoice 的 body 传到 list。这是 2026 前端的薄默认:少文件、可控 fetch、没有一仓库生成代码。

import createClient from "openapi-fetch";
import type { paths } from "./schema";

const client = createClient({
  baseUrl: "https://api.example.com",
  headers: { Authorization: "Bearer …" }
});

const { data, error } = await client.GET("/invoices", {
  params: { query: { status: "paid" } }
});

const created = await client.POST("/invoices", {
  body: { customerId: "cus_1", amount: 1999, note: null }
});

需要 React Query hooks、Zod、MSW mock 时,再上 Orval。它从同一份 spec 长出调用函数和钩子,适合已经以 Query 为数据层的应用。hey-api(@hey-api/openapi-ts)走 SDK 函数 + 拦截器,适合要 operationId 当方法名、又不必生成上千个文件的团队。OpenAPI Generator 的 TypeScript-axios 模板仍能用,但输出更重,新项目不必当首选。

无论哪条流水线,生成文件不要手改。要改行为:改 spec、改生成器配置、或在手写层包一层(超时、重试、租户头)。手改生成物等于放弃合同。Client 里的 token 放 Authorization 头,不要塞进 JSON body。

工具 你得到什么 什么时候选
openapi-typescript + openapi-fetch类型 + 薄 fetch Client,接近零运行时默认起点;要自己管缓存时
Orval类型 + 函数 + Query hooks + 可选 Zod/MSW已经用 TanStack Query,要 mock 跟 spec 走
hey-api / KubbSDK 方法名,或插件式多产物要拦截器平台,或一个 operation 一个文件

CI 防漂移,用 JSONVue 核契约

合同一变,生成物必须重跑。CI 里:校验 spec(Spectral 或红帽 OpenAPI linter)→ 跑生成器 → git diff --exit-code 看生成目录。有 diff 就失败,逼提交者带上新类型。不要只在笔记本上跑一次 codegen 然后忘掉。后端改了字段、前端类型还是上周的,是这类事故的标准形态。

npx --yes openapi-typescript openapi.json -o src/api/schema.d.ts
npx --yes @redocly/cli lint openapi.json
git diff --exit-code -- src/api/schema.d.ts

联调时你会经手至少三份 JSON:OpenAPI spec 本身、某次 200 的 body、某次 422 的错误体。再加一份你准备喂给生成器的「精简夹具」。它们职责不同,不要揉进一个文件做「通用校验」。失败样本各留一条:断掉的 $ref、缺 operationId、响应 content 未声明 application/json、两版 spec 只改了枚举。

浏览器里即可完成:JSON 格式化看 spec 能否 parse;JSON Schema 校验核 components.schemas 与真实 body;JSON Diff对比两版 OpenAPI 或「模型 arguments vs 下游 body」;数据不离开本机。延伸阅读:MCP 与 JSON Schema、结构化输出、以及用同一份字段表生成工具外壳。

相关文章:MCP 与 JSON Schema、AI 结构化输出、AI 生成 JSON 错误、MCP 是什么。

常见问题 FAQ

spec 用 YAML 还是 JSON?

生成器两种都吃。仓库里 YAML 对人类 diff 更友好;往浏览器和 JSONVue 送时用 JSON。CI 里固定一种源文件,另一种只做构建产物,避免两份源互相改。

能不能只生成类型、不生成 Client?

能,而且常常应该。openapi-typescript 只出 .d.ts。现有 axios 封装可以逐步改成吃这些类型。等路径和 body 都由类型兜住了,再换 openapi-fetch 或 Orval,比第一天生成三千个文件更可控。

新项目用 OpenAPI 3.0 还是 3.1?

新合同用 3.1,好跟 JSON Schema 2020-12 对齐,可空和 const 少一层方言。存量 3.0 不必强升;先确认你的生成器(openapi-typescript 7.x、Orval 8)声明支持 3.1,再迁。Swagger 2.0 先升级到 3.x,再谈 Client。

OpenAPI 和 MCP inputSchema 能共用一份吗?

业务字段可以共用一份 canonical JSON Schema,再分别生成 OpenAPI requestBody 与 MCP inputSchema。不要把整份 OpenAPI 文件丢给 MCP Client 当工具清单——传输、鉴权、方法名都不是同一层。一份字段表、两副外壳,比复制粘贴更不容易 drift。

总结与下一步

OpenAPI 代码生成可以写成一句:一份机器可读的 Schema,同时长出人读的 JSON API 文档、编译期 TypeScript 类型、以及带类型的 Client。手写其中任何一份,合同就不再是合同。

落地顺序:先让 openapi.json 能 parse、有 operationId、错误响应齐全;再挂文档阅读器;然后生成类型;最后才选 Client。用 JSONVue 把 spec、成功 body、失败 body 留在本地。工具入参读 MCP 与 JSON Schema;模型答覆读结构化输出。