OpenAPI Lint、Mock 与 Codegen:把接口描述变成发布门禁
移动端已经按接口文档接入“创建订单”,联调环境却连续出现三种互相矛盾的结果:页面从 Mock 拿到 200,真实服务返回 201;生成客户端把 status 当成任意字符串,业务代码却假定它只有三个枚举值;后端删掉旧字段后重新生成代码仍能编译,旧版本 App 到生产才开始反序列化失败。
问题不在于仓库里缺少 openapi.yaml,而在于这份文件没有成为同一条交付链的输入。Lint 只证明描述能被规则解释,Mock 只证明消费者能与一个按描述响应的替身交互,Codegen 只证明模板可以把中间模型写成源码。它们必须共享同一份已解析规范,并分别留下失败证据,才能阻止“文档、模拟、生成物和实现各自正确”的事故。
先把 3.0、3.1 与 3.2 的语义分开
openapi: 3.0.3 和 openapi: 3.1.0 不是只差一个版本字符串。3.0 的 Schema Object 采用 OpenAPI 自己维护的 JSON Schema 子集;3.1 改为对齐 JSON Schema Draft 2020-12 规范版本,并允许用 jsonSchemaDialect 指定默认方言。3.1 中可直接写 type: [string, "null"]、const、unevaluatedProperties 等 JSON Schema 关键字;3.0 常见的 nullable: true 则不是可机械保留的同义写法。
两个版本表达可空字符串时,合法形态分别是:
# OpenAPI 3.0.x
type: string
nullable: true
# OpenAPI 3.1.x
type:
- string
- "null"OpenAPI 3.2.0 已经是正式规范,不是 3.1 的补丁号。它增加了根级 $self、把 Tag 等对象的能力继续扩展,并补充流式响应、顺序媒体与更完整的 HTTP 建模;同一天发布的 3.1.2 则仍属于 3.1 修订线。规范发布只说明描述语言已经稳定,不能推出工具链已经支持:Redocly CLI 2.x 已加入 3.2 规则与建模能力,Spectral 官方内置 OAS 规则集仍明确列到 3.1,Prism 公开兼容口径写的是 OpenAPI 3.x,而 OpenAPI Generator 7.23.0 的官方兼容矩阵仍只列到 3.1,且把 3.1 标成 beta。使用 3.2 时必须逐工具跑 golden specs,不能把 Redocly lint 通过当成 Mock、diff 和 codegen 都已支持。
具体 generator 的特性矩阵还可能不支持 oneOf、联合类型、回调、多 server 或某种鉴权。typescript-fetch 被标为 stable,说的是该 generator 的发行状态,不是“所有 3.1 关键字均无损映射”。3.2 新字段若被旧工具忽略,比直接报错更危险:生成命令可能成功退出,产物却丢失流式语义、媒体顺序或引用基准。因此 3.2 应先进入隔离试点,发布 bundle 仍维持消费者已验收的 3.1 或 3.0 线,直到所有下游都能对正反样本给出预期证据。
升级规范版本时不要全仓替换 openapi: 3.0.3。先选出联合类型、可空、additionalProperties、discriminator、allOf/oneOf、format 和远程引用等代表性模型,分别执行 lint、bundle、Mock 正反请求、客户端编译、服务端编译和序列化测试。任一环节仍按旧语义处理,就保持 3.0 基线或拆出兼容层;版本字符串领先于工具链只会制造更隐蔽的假通过。
把工具版本锁进仓库
下面使用一组明确基线:Redocly CLI 2.39.0、Prism CLI 5.16.0、OpenAPI Generator 7.23.0、Spectral CLI 6.16.1 和 oasdiff 1.23.0。Redocly CLI 2.x 是 ESM-only,Node.js 需要满足 >=22.12.0,或者使用 20.19.x 这条受支持发行线;Prism 5.16.0 的 npm 元数据要求 Node.js >=24.18.0。团队若仍在 Node 22,不应因为 Prism 偶尔能启动就忽略 EBADENGINE,可先把 Prism 放进独立 Node 24 job 或使用经过验证并锁定 digest 的容器镜像。
OpenAPI Generator 的 JAR 至少需要 Java 11。Java 17 可以运行 7.23.0,但生成出的 Spring 项目使用哪个 JDK、Spring Boot 和 Jakarta 命名空间,取决于 generator 配置和生成物的构建文件,不能由“生成器能启动”反推“服务端能编译”。
在 Node 项目根目录安装并提交 lockfile:
npm install --save-dev --save-exact @redocly/cli@2.39.0 @stoplight/prism-cli@5.16.0 @stoplight/spectral-cli@6.16.1
npx redocly --version
npx prism --version不要在 CI 中使用 npx @redocly/cli@latest 或全局安装。npm ci 读取提交的 package-lock.json,能把 CLI 与传递依赖固定在评审过的集合;全局工具和 latest 会让同一 commit 在不同 runner 上得到不同结果。Prism 仍带有原生或历史依赖时,升级 PR 还要检查 Node engine、弃用告警和目标 CPU 架构。
Generator 选择 JAR 或容器都可以,但必须固定版本并验证来源。JAR 下载到团队工具缓存后校验批准的 SHA-256,再从固定路径调用:
curl --fail --location --output tools/openapi-generator-cli-7.23.0.jar \
https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.23.0/openapi-generator-cli-7.23.0.jar
echo "<APPROVED_SHA256> tools/openapi-generator-cli-7.23.0.jar" | sha256sum --check
java -jar tools/openapi-generator-cli-7.23.0.jar version<APPROVED_SHA256> 应由依赖准入流程从制品库元数据取得并固化,不能在下载后对同一个未知文件临时计算再“自证正确”。容器同理使用 openapitools/openapi-generator-cli@sha256:<APPROVED_DIGEST>,而不是漂移的 tag。oasdiff 可用 go install github.com/oasdiff/oasdiff@v1.23.0 安装到受控工具镜像;生产 CI 更适合预构建并签名该镜像,避免每次运行都重新拉取 Go 模块。
让多文件规范只有一个入口
按业务边界拆文件能减少冲突,但根文档必须唯一。一个可维护的目录可以是:
openapi/
openapi.yaml
paths/
orders.yaml
components/
schemas/
order.yaml
responses/
problem.yaml
redocly.yaml
codegen/
client.yaml
server.yaml
dist/
generated/根文档持有 OpenAPI Object、服务级信息、全局 security 与顶层 paths;Path Item、Schema、Response 再通过相对 $ref 拆出。引用相对“当前文档的检索 URI”解析,不是相对执行命令时的随意工作目录。3.1 还允许 Schema 中的 $id 改变后续相对引用的 base URI,因此只取一个 YAML 片段、脱离完整文档解析,可能得到与根入口完全不同的目标。
# openapi/openapi.yaml
openapi: 3.1.0
info:
title: Order API
version: 1.0.0
servers:
- url: https://api.example.invalid
paths:
/orders/{orderId}:
$ref: ./paths/orders.yaml
components:
schemas:
Order:
$ref: ./components/schemas/order.yaml
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
security:
- bearerAuth: []# openapi/components/schemas/order.yaml
type: object
additionalProperties: false
required: [id, status]
properties:
id:
type: string
format: uuid
status:
type: string
enum: [pending, paid, cancelled]
buyerEmail:
type: [string, "null"]
format: email
example: buyer@example.invalidinfo.version 是这份 OpenAPI Description 的版本,不是 openapi 字段里的规范版本,也不自动等于服务部署版本或 SDK 包版本。团队可以让它与 API 产品版本保持一致,但必须把这项映射写进发布策略;不能把 Git commit、部署批次和生成包版本轮流塞进同一个字段。API 的兼容承诺、生成包 SemVer、服务制品与源码 commit 应由发布流水线分别记录,并能追溯到同一份 bundle 摘要。
拆分后的源文件适合多人编辑,dist/openapi.yaml 则是给 Prism、oasdiff、Generator、网关导入和外部消费者的不可变输入。不要让每个下游工具各自解析远程 $ref;先 lint,再 bundle 一次,并保存 bundle 的 SHA-256、源 commit 与工具版本。这样发生差异时,能确认是规范变化、引用解析变化还是下游模板变化。
用 Redocly 把风格问题变成确定失败
redocly.yaml 同时定义 API 入口、规则和私有远程引用的取值方式:
apis:
orders@v1:
root: ./openapi/openapi.yaml
extends:
- recommended-strict
rules:
no-unresolved-refs: error
no-unused-components: error
operation-operationId: error
operation-summary: error
security-defined: error
resolve:
doNotResolveExamples: true
http:
headers:
- matches: https://schemas.example.invalid/orders/**
name: Authorization
envVariable: SCHEMA_REF_AUTHORIZATIONapis.orders@v1.root 给多文件图一个稳定入口;不带参数运行 redocly lint 时会检查配置中的所有 API。extends 是团队基线,rules 再覆盖严重级别。把核心规则设为 error 才能令进程非零退出;warn 适合迁移观察,不应永久承担发布兼容承诺。doNotResolveExamples: true 只阻止解析单数 example 中的 $ref,不会关闭其他引用解析。
私有远程引用的 header 从环境变量取值,避免 token 进入 YAML。Redocly 对每个 URL 只应用一个匹配 header,而且首个匹配优先;需要同时发送多个认证 header 的仓库不能假定它会自动叠加,应改用能接受单一 bearer 的受控代理或在 bundle 前同步到本地。环境变量的值若是 Bearer ...,完整前缀也要由 secret 注入,日志中只报告变量是否存在,不打印内容。
执行正向链路:
npm ci
npx redocly lint orders@v1 --lint-config=error
npx redocly bundle orders@v1 \
--output dist/openapi.yaml \
--lint-config=error \
--component-renaming-conflicts-severity error
sha256sum dist/openapi.yaml预期是 lint 以零退出,bundle 生成单文件制品;组件重名但内容不同时直接失败。不要加 --force,它会在错误存在时仍写出 bundle,容易让后续 Generator 消费半合法输入。若 JSON 输出配合 --dereferenced,循环引用可能无法表示;保留内部 $ref 的普通 bundle 通常更适合代码生成。
反向实验可以把 orders.yaml 中的 operationId 删除,再把 $ref 故意写成 ../schemas/missing.yaml:
npx redocly lint orders@v1 --lint-config=error
echo "exit=$?"
test ! -f dist/openapi.yaml || rm dist/openapi.yaml应看到 operation-operationId 与 unresolved reference 一类定位信息,退出码非零。若只出现 warning 而 CI 继续,检查规则严重级别;若本机成功而 CI 报无法取远程引用,比较 secret、代理、CA、DNS 与出口策略,不要先关闭 TLS 校验。Lint 报错位置是引用图中的源节点,真正根因也可能是远端返回登录页、404 或错误媒体类型;保存 HTTP 状态与目标主机即可,响应体可能含敏感信息,不要原样上传制品。
Spectral 适合已经维护 .spectral.yaml、需要 JSONPath 自定义规则或跨多种 JSON/YAML 文档复用规则的团队。@stoplight/spectral-cli@6.16.1 可锁入 devDependencies,规则集从 spectral:oas 继承;该内置规则集的公开支持口径到 OAS 3.1,3.2 文档不能未经反证就沿用。Redocly 在 OpenAPI 多文件、bundle 和同一配置复用上更顺手。两者可以迁移期并行观察,但同一语义不要长期定义两套不同严重级别,否则开发者会开始挑选“能通过的那个 linter”。
让 Prism 同时证明成功和拒绝
Prism 从 bundle 建立路由,对 method、path、query、header、body、security 和媒体类型做协商与校验,再从显式 example 或 schema 生成响应。默认是静态策略:有 example 优先返回 example;没有时按 default、examples、nullable、format 和 schema 生成稳定值。-d 开启动态生成,适合扩大样本,但动态数据不是业务状态,也不保证跨请求一致。
启动时只监听回环地址,并把已经 lint、bundle 的制品作为输入。后台进程的 PID 要在启动当下保存,trap 才能覆盖正常结束与中途失败:
mkdir -p dist
npx prism mock -h 127.0.0.1 -p 4010 dist/openapi.yaml \
> dist/prism.log 2>&1 &
PRISM_PID=$!
trap 'kill "$PRISM_PID" 2>/dev/null || true' EXIT
until curl --silent --output /dev/null http://127.0.0.1:4010/; do
kill -0 "$PRISM_PID" 2>/dev/null || { cat dist/prism.log; exit 1; }
sleep 1
done在 response 下准备一个不含真实用户数据的命名示例,例如 approved。合法请求可显式选择它:
curl --fail --silent --show-error \
-H 'Authorization: Bearer mock-token-not-a-secret' \
-H 'Prefer: example=approved' \
http://127.0.0.1:4010/orders/00000000-0000-4000-8000-000000000001预期状态与规范一致,body 使用 approved 示例。Prefer: dynamic=true 可让某次调用临时使用动态 schema 生成。再用 Prefer: example=missing 强制一个不存在的命名示例:门禁应要求请求失败并保存 problem 与日志;若 Prism 5.16.0 返回 500 application/problem+json,这表示 Mock 无法满足示例选择,不是生产 API 定义了 500。升级时仍要回放这个反例,因为 Mock 的协商行为不是 OpenAPI 规范承诺。消费者测试应同时断言状态、媒体类型和关键字段,不能把“返回了 JSON”当成功。
反向请求把 UUID 改坏:
curl --silent --show-error --include \
-H 'Authorization: Bearer mock-token-not-a-secret' \
http://127.0.0.1:4010/orders/not-a-uuid要稳定得到 4xx,规范必须为该 operation 定义可协商的 400、422 或 default 响应;Prism 日志或 sl-violations header 会指出 path 参数不满足 uuid format。若规范没有这些错误响应,Prism 的决策链会返回 500 application/problem+json,这表示 Mock 无法为非法请求协商响应,不是生产接口约定了 500。若反向请求返回 2xx,先确认参数 schema 是否真的带 format: uuid,再检查当前 Prism 版本对该 format 的校验行为。消费者不得把 Prism 自己的 problem 结构误认成生产协议。
Mock 不保存订单,不执行幂等、事务、授权策略或并发控制。Bearer 占位值只证明请求带了符合描述的认证形式,不证明 token 签名、scope 和租户隔离正确。需要状态场景、延迟、故障注入或精确匹配时,用 WireMock 等场景型 stub;需要证明真实服务符合 OpenAPI 时,让测试流量经过 Prism proxy 或专用契约测试,但仍要保留实现测试。Mock 端口在共享 runner 上还要按 job 隔离,结束时无论测试成功失败都终止进程。
生成客户端后必须真的编译和调用
把生成参数写进 codegen/client.yaml,避免一串 --additional-properties 在不同脚本中逐渐分叉:
generatorName: typescript-fetch
inputSpec: dist/openapi.yaml
outputDir: generated/typescript-fetch
additionalProperties:
npmName: "@example/order-api-client"
npmVersion: "1.0.0"
supportsES6: true
enumUnknownDefaultCase: true
modelPropertyNaming: original
nullSafeAdditionalProps: truegeneratorName 决定模板和中间模型映射;inputSpec 必须指向已审查 bundle;outputDir 是可整体删除重建的目录。enumUnknownDefaultCase 给旧客户端一个未知枚举兜底,能减少服务端新增响应枚举导致的解析崩溃,但业务仍必须记录和处理未知值,不能静默当成某个已知状态。modelPropertyNaming: original 保持 wire 名称,若改成 camelCase,要检查序列化映射而不是只看 TypeScript 属性。nullSafeAdditionalProps 会影响索引访问是否包含 undefined,改变调用方类型收窄成本。
生成、锁定构建依赖、编译并验证包入口:
rm -rf generated/typescript-fetch
java -jar tools/openapi-generator-cli-7.23.0.jar generate -c codegen/client.yaml
cd generated/typescript-fetch
npm install --package-lock-only --ignore-scripts
npm ci --ignore-scripts
npm run build
node -e "require('./dist/index.js')"7.23.0 的 typescript-fetch 模板会生成带 build、prepare 的 package.json,但不生成 package-lock.json,也没有 test script;因此直接执行 npm ci 或 npm test 都不是可复制的验收。上面的 npm install --package-lock-only 只用于首次建立依赖锁,锁文件经准入后应成为 CI 输入;CI 不应每次重新解析 typescript: ^4.0 || ^5.0。生成目录出现文件也不是成功:Generator 会先把规范解析成 CodegenModel、CodegenOperation 等中间对象,再由 Mustache 模板写文件;保留字重命名、alias model 折叠、联合类型映射和模板 import 仍可能让生成命令成功而 TypeScript 编译失败。真实验收至少检查命令退出码、warning 白名单、模板生成的 CommonJS 与 ESM 两次编译、包入口可导入,并由仓库自有的消费者测试调用生成 API 访问 Prism。
typescript-fetch 依赖运行环境提供 Fetch API。现代浏览器和满足项目基线的 Node.js 可直接使用;旧 Node、React Native 或特殊 SSR 环境可能需要注入 fetch、Headers、FormData、Blob 等实现。生成包的 package.json、peerDependencies 和目标 tsconfig 才是运行时事实,不能因为源码是 TypeScript 就假定零依赖。
一次生成成功但编译出现 Module has no exported member、模型名被改写或重复路径 warning,应立即阻断。这些是生成成功之后仍可能出现的故障类型,不表示任意 7.23.0 输入都会复现同一错误;证据必须绑定最小 schema、generator、配置、模板摘要和编译器输出。先用 config-help -g typescript-fetch 核对当前版本参数,再最小化触发 schema;不要手改生成文件补 import,因为下一次生成会覆盖它,也会掩盖模板或规范缺陷。确需修模板时,把模板目录版本化,使用 --template-dir,为上游模板升级做差异审查,并记录自定义模板与 Generator 的兼容版本。
服务端骨架不是可直接上线的实现
Spring 服务端配置可以只生成接口和模型,把业务实现留在手写模块:
generatorName: spring
library: spring-boot
inputSpec: dist/openapi.yaml
outputDir: generated/spring-contract
apiPackage: com.example.orders.api
modelPackage: com.example.orders.model
additionalProperties:
interfaceOnly: true
skipDefaultInterface: true
useSpringBoot3: true
useJakartaEe: true
useBeanValidation: true
useTags: true
openApiNullable: falseinterfaceOnly 避免生成看似可运行却只有示例响应的 controller;skipDefaultInterface 防止默认实现掩盖遗漏;useSpringBoot3 与 useJakartaEe 改变 import 和框架依赖,必须与应用基线一致;useBeanValidation 只生成声明式约束,仍需应用启用验证并测试错误映射。openApiNullable 会影响是否引入 JsonNullable 及其序列化模块,关闭它之前要确认“字段缺失”和“显式 null”是否需要区分。
rm -rf generated/spring-contract
java -jar tools/openapi-generator-cli-7.23.0.jar generate -c codegen/server.yaml
cd generated/spring-contract
./mvnw --batch-mode verify生成 POM 或 Gradle 文件中的 Spring、Jackson、Jakarta Validation、Swagger annotations 与 nullable 运行时都是供应链依赖,需要进入 SCA、许可证和升级流程。更稳妥的项目结构是把生成接口/模型作为独立模块,由业务模块实现接口;生成模块可以整目录重建,业务模块不被模板覆盖。若生成完整 server stub,则默认鉴权、错误处理、持久化、幂等和观测都只是空位,任何“能启动”的骨架都不得直接承担生产流量。
客户端与服务端同时生成也不代表双方兼容,因为两者可能用不同模板解释同一个联合类型。保留一组跨语言 golden payload:由服务端序列化,再由客户端反序列化;反向也验证请求。重点覆盖 null/absent、未知枚举、整数宽度、decimal 精度、时区、binary、multipart 和 discriminator。编译测试抓类型错误,运行测试才抓序列化语义。
用 diff 判断消费者是否会被破坏
Lint 关注“候选规范自身是否合规”,不比较旧消费者。删除 endpoint、把可选请求字段改为 required、收窄枚举、改变响应类型或取消成功状态都可能让 lint 继续通过。breaking 门禁必须拿正确基线与候选 bundle 比较。
set -euo pipefail
git show origin/main:dist/openapi.yaml > dist/openapi.base.yaml
oasdiff breaking --fail-on ERR --allow-external-refs=false --format text \
dist/openapi.base.yaml dist/openapi.yaml
oasdiff changelog --allow-external-refs=false --format markdown \
dist/openapi.base.yaml dist/openapi.yaml \
> dist/openapi-changelog.md反向实验是在候选 Order 中新增 required: [deliveryAddress],而基线没有该必填请求字段。oasdiff breaking 会报告 breaking change,但默认只报告而不据此失败;显式设置 --fail-on ERR 后,ERR 级变化才以状态码 1 阻断。若团队也要阻断潜在破坏的 WARN,改用 --fail-on WARN。恢复为可选字段后应通过。若工具认为变化不破坏,也不能直接得出业务兼容:服务端新增响应枚举、format 语义、默认值、鉴权 scope、限流和字段含义变化,未必都能被结构 diff 完整识别。
这些重复参数也可以固化在仓库根目录的 .oasdiff.yaml,让本地与 CI 使用同一口径:
fail-on: ERR
allow-external-refs: falseoasdiff 1.23.0 默认会跟随外部文件和 URL 引用;这里比较的是已经 bundle 的自包含制品,所以主动关闭外部引用。命令行把布尔值设为 false 时必须写成 --allow-external-refs=false,不能写成两个参数;需要例外配置时用 --config 指向受评审文件,并让命令行参数只承担临时诊断,不在 CI 里悄悄覆盖团队策略。
基线必须代表消费者已经依赖的发布版本,而不是 PR 分支刚生成的另一个文件。主干尚未发布时,可比较最近发布 tag 对应的 bundle;多版本 API 则按 major line 分别保存基线。info.version 文本变化不应取代 Git/制品坐标。紧急接受 breaking change 时,用有 owner、原因、消费者迁移证据和失效条件的例外文件;永久 || true 或把全部检查降为 warning 等于移除门禁。
oasdiff 自身支持 3.1,但规范比较仍受解析器、规范化和 check 集合影响。升级 oasdiff 时用固定正反样例回放:无害的 description、新增可选响应字段应通过;删除 operation、增加必填请求字段、收窄枚举应失败。门禁升级如果改变分类,先审查差异再切换,不能让工具升级与业务 breaking change 混在同一个 PR。
CI 只消费一次解析后的事实
把本地命令封装成仓库脚本,开发机与 CI 使用同一入口。一个典型顺序是:安装锁定工具,lint 源图,bundle,比较发布基线,启动 Mock 做消费者 smoke,生成代码,编译与测试,最后上传 bundle、diff 和生成摘要。
name: openapi-contract
on:
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@<APPROVED_COMMIT_SHA>
- uses: actions/setup-node@<APPROVED_COMMIT_SHA>
with:
node-version: "24.14.0"
cache: npm
- run: npm ci --ignore-scripts
- run: npx redocly lint orders@v1 --lint-config=error
- run: npx redocly bundle orders@v1 -o dist/openapi.yaml --lint-config=error --component-renaming-conflicts-severity error
- run: ./ci/check-openapi-breaking.sh dist/openapi.yaml
- run: ./ci/test-prism-smoke.sh dist/openapi.yaml
- run: ./ci/generate-and-verify.sh dist/openapi.yaml
- uses: actions/upload-artifact@<APPROVED_COMMIT_SHA>
with:
name: openapi-evidence
path: |
dist/openapi.yaml
dist/openapi-changelog.md
generated/manifest.sha256Action 用完整 commit SHA 而不是浮动 tag。permissions: contents: read 是这个 job 的起点;私有基线或制品下载若需要 token,拆成最小权限步骤,并避免让来自 fork 的不可信 PR 获得 secret。npm ci --ignore-scripts 降低安装脚本风险,但某些依赖确需构建时要在准入后单独运行,不能靠全局关闭脚本假装供应链问题消失。
Prism smoke 脚本应使用 trap 清理后台进程,等待健康而不是固定 sleep,并对一个合法请求和一个非法请求同时断言。Generator 脚本先清空专用输出目录,再生成、编译、测试,最后对文件清单和摘要做审查。CI 失败上传 lint 输出、breaking report、Prism 日志和编译错误;不要上传带 Authorization、Cookie、真实请求 body 或私有远程 schema 响应的原始日志。
大仓库可按变更图只运行受影响 API,但必须有周期性全量任务发现共享 component 影响。缓存键至少包含 lockfile、Generator JAR 摘要、codegen 配置和 bundle 摘要;只用分支名会错误复用旧生成物。并行生成多个语言时每个 job 写独立目录,聚合阶段只收只读制品,避免两个 generator 覆盖同名文件。
远程引用是下载依赖,也是网络入口
当 lint、bundle、Mock、diff 或 codegen 直接消费仍含 $ref: https://... 的源图时,解析器可能在解析阶段发起网络请求;其中 oasdiff 1.23.0 默认跟随外部引用,需显式关闭。远程引用同时带来四类风险:远端内容在同一 URL 下漂移;认证 token 泄漏到错误主机;恶意引用探测内网形成 SSRF;网络、代理或 CA 故障把契约门禁变成不稳定外部依赖。
最稳的发布链是“受控同步,本地解析”:专用 job 从域名 allowlist 下载远程 schema,验证 TLS、内容类型、大小上限、版本坐标和摘要,保存到仓库依赖目录或内部不可变制品库;随后把引用改写到本地并离线执行 lint、Mock 和 codegen。确需在线解析时,只允许 HTTPS 与批准域名,限制重定向、私网地址、响应大小和超时,runner egress 也做相同约束。
不要把 PAT、API key 写进 $ref URL、query、redocly.yaml 或 shell history。远程仓库若只支持一个 Authorization header,使用只读、短期、限定 schema 路径的机器凭据。失败日志只保留主机、路径模板、状态码和 request ID;完整 URL 可能含凭证,响应体可能是带用户资料的错误页。
Schema 里的 example 会进入 Mock 响应、静态文档、bundle、生成测试、CI artifact 和调试日志。示例邮箱使用 example.invalid,姓名、电话、订单号和地址使用明确合成值;JWT 只写 mock-token-not-a-secret 之类占位符。securitySchemes 描述凭证如何传输,不保存真实凭证。PII 扫描应覆盖 YAML、JSON、生成目录、diff report 和 artifact,而不只是手写源文件。
自定义 Redocly plugin、Generator template、npm CLI、JAR、Go binary、容器与 CI Action 都是可执行供应链。给每一类记录来源、许可证、版本/digest、owner、升级窗口和退出方式;自定义模板尤其要当代码评审,因为它能把任意内容写进所有客户端和服务端。
工具本体与平台服务也不能混为一谈。Redocly CLI 是 MIT 开源工具,Redocly 托管 API 生命周期平台另有商业能力与账号边界;Spectral、Prism 和 OpenAPI Generator 本体采用 Apache-2.0。Generator 本体的许可证不会自动替团队决定生成 SDK 的许可证,模板头、输入规范中的 license、生成依赖和团队自己的发布政策要分别审查。将 SDK 对外发布前,应保存所用模板、第三方依赖清单、NOTICE/许可证文件和制品 SBOM,不能只在准入表里写一句“生成器是 Apache-2.0”。
容量与成本来自引用图和生成矩阵
OpenAPI 工具通常不需要常驻集群,成本却会从 CI 放大。规范越大、远程引用越多、generator 语言越多,解析、bundle、模板渲染、依赖下载、编译和 artifact 存储都会增长。一个 API 同时生成 TypeScript、Java、Go、Python 客户端和 Spring server,真正昂贵的往往不是 Generator CPU,而是五套依赖解析、测试与漏洞扫描。
容量治理看趋势和不变量:同一 bundle 在无变更时不应重复生成;生成任务时长和 artifact 大小不应在 operation 数稳定时持续单调增长;远程引用缓存命中后不应继续产生同规模外部请求;失败重试不能把确定性 lint 或编译错误放大成队列拥堵。阈值由 runner 基线、API 数、发布频率和反馈 SLO 测量决定,不照搬示例数字。
提效顺序是先 bundle 一次并按摘要复用,再按受影响 API 和语言切分,最后才增加 runner。工具二进制与依赖缓存可跨 job 复用,生成源码缓存必须绑定 bundle 与全部配置摘要。生成物若作为独立 SDK 发布,还要计算包仓库存储、漏洞响应、旧 major 支持和消费者迁移成本;“自动生成”不会让维护成本归零。
从失败现象反推断点
本机 lint 成功,CI 报 $ref 无法解析:先比对工作目录、文件名大小写和 bundle 入口,再检查远程 secret、代理、NO_PROXY、企业 CA 与网络出口。Windows 对大小写不敏感时尤其容易把错误路径带进 Linux runner。
Redocly 报配置文件无规则:存在 redocly.yaml 却没有 extends 或 rules 时,不能假定默认 recommended 会继续生效。把基线显式写入配置,并用 --lint-config=error 让拼错的规则名阻断。
Prism 返回 401 而不是参数校验错误:security 校验先拒绝了请求。补合成认证 header 后再观察参数 violation;不要删除全局 security 来迁就测试,否则 Mock 与真实接口的入口已经不同。
Prism 返回 500:常见原因是要求的 status、media type、example 或 schema 不存在,或者强制了一个找不到的 example。检查协商路径和规范响应,而不是在消费者里重试 500。
Generator 成功退出但编译失败:把 warning、生成清单和编译器错误一起看。保留字重命名、alias 未生成、重复文件路径、OAS 3.1 beta 降级和模板 import 冲突都可能发生在写文件阶段。最小化 schema 后向上游报告,临时回退 Generator 或规范关键字,不手改生成物。
生成客户端编译通过但运行解析失败:检查 Content-Type、nullable/absent、日期与 decimal、discriminator、未知枚举和额外字段。把真实失败 payload 脱敏后加入 golden 测试,并确认 Mock example 没有把边界值藏掉。
breaking 检查突然全部通过:先确认 base 与 revision 不是同一个文件,检查 shell 重定向是否失败、工具退出码是否被 || true 吃掉,再看例外配置。打印两份 bundle 摘要和来源 commit,避免比较身份错误。
生成 diff 每次都很大:固定 Generator、模板、JDK/Node、locale 与换行符,关闭包含运行时日期的 snapshot 选项,确保输入 bundle 顺序稳定。无法做到确定性生成时,不要把整目录噪声交给人工评审;先修复可重复性。
清理、回滚和退出都要可证明
本地实验结束先停止 Prism,再删除可重建产物:
trap - EXIT
kill "${PRISM_PID:?missing Prism PID}" 2>/dev/null || true
rm -f dist/openapi.yaml dist/openapi.base.yaml dist/openapi-changelog.md dist/prism.log
rm -rf generated/typescript-fetch generated/spring-contract
unset SCHEMA_REF_AUTHORIZATION${PRISM_PID:?missing Prism PID} 会在 PID 丢失时拒绝继续,避免误杀别的进程。Windows PowerShell 使用保存下来的进程 ID 执行 Stop-Process -Id $PrismPid,并用 Remove-Item Env:SCHEMA_REF_AUTHORIZATION 清除当前会话变量。不要按进程名批量结束所有 Node,也不要删除项目根 node_modules 或整个共享 dist;共享生成包、已发布 bundle 和 breaking 报告属于发布证据,不能按本地缓存方式清理。
规范变更回滚应恢复源文件和 lockfile,重新 lint、bundle、diff、Mock 与 codegen,再发布旧兼容版本;直接把 dist/openapi.yaml 换回旧文件会让源图与发布制品分叉。工具升级回滚则恢复 package lock、Generator JAR/digest、oasdiff binary 和模板版本,用同一组 golden specs 重跑。若新 Generator 已发布 SDK,回滚工具不等于撤回包,还要按包仓库规则发布修复版本并通知消费者。
生成目录有两种治理方式。提交生成物时,PR 必须同时包含规范、配置和确定性生成 diff,CI 从干净目录重生成后验证 git diff --exit-code;不提交生成物时,CI 发布不可变 SDK artifact,并记录 bundle 摘要、Generator 和模板版本。两种方式都禁止手工拥有权不明的“半生成文件”。退出某个 Generator 前,先保留输入 bundle、配置、模板定制、包坐标和消费者清单,再决定迁移到另一生成器或手写薄客户端。
把升级变成可回放的工程事件
每个 API 有规范 owner,每个生成语言有 runtime owner,平台团队维护 lint、bundle、diff 与制品发布入口。API owner 决定兼容语义,runtime owner 对生成代码能否在目标框架编译运行负责;不能把所有 warning 都扔给维护 CI 的人。
工具升级由机器人开独立 PR,先更新锁文件或 digest,再回放 3.0、3.1 与隔离的 3.2 golden specs。检查项包括 lint 规则增减、bundle 摘要变化、Prism 合法/非法请求、breaking 分类、生成文件清单、编译测试、序列化测试、依赖与许可证扫描。3.2 样本至少覆盖 $self 的引用基准、流式响应、顺序媒体和现有 JSON Schema 组合;任何工具静默丢字段都按失败处理。Generator 大量模板变化时先发布 canary SDK,让内部消费者编译,不与业务 schema 变化同批上线。
长期指标围绕证据完整性,而不是“生成了多少代码”:
每个发布 bundle 都能追溯源 commit、工具版本和 SHA-256。每个候选变更都相对正确发布基线执行 breaking 检查,例外有 owner 和失效条件。每个 generator 的生成物都在目标 runtime 编译并运行代表性序列化测试。
Mock 同时保留合法和非法请求,非法请求必须稳定被拒绝。远程引用域名、凭证、摘要与出口可审计,不允许匿名漂移内容进入发布链。生成物、示例、日志和 artifact 不含真实 token、Cookie、内网地址或个人数据。
无规范变化时生成结果保持确定,任务时长、缓存与存储不会无界增长。
Redocly 或 Spectral、Prism、OpenAPI Generator 和 oasdiff 不是四个互相替代的工具。解析与 lint 约束描述质量,Mock 暴露消费者交互,diff 保护已发布兼容承诺,Codegen 把 schema 映射成具体 runtime,而编译和运行测试负责揭穿映射损失。只有这些证据都指向同一个 bundle,OpenAPI 才从“看起来像合同的 YAML”变成真正能阻断错误发布的工程边界。
