Skip to content

发布出去的 OpenAPI 文档 components.schemas 是空的,而 6 个 $ref 全部悬空 —— lazySchema Proxy 撞上 typeof === 'object' 判据 #5168

Description

@os-zhuang

#5093(#5040 E6)实施中发现,越范围,未认领。基线:origin/main @ 81e2744

事实(可逐条复核)

packages/spec/scripts/build-openapi.ts 生成的 json-schema/openapi.json —— 也就是 GET /api/v1/openapi.json 真正发布出去的那份文档的 base spec —— components.schemas 恒为 {},而 paths 里有 6 个指向它的 $ref 全部悬空:

refs used:      ListRecordResponse, ApiError, CreateRequest,
                SingleRecordResponse, UpdateRequest, DeleteResponse
defined schemas: []   ← 空

生成器自己的收尾日志就把这件事印在屏幕上,只是没人把它当断言:

$ pnpm --filter @objectstack/spec gen:openapi
✅ Generated OpenAPI spec: .../json-schema/openapi.json
   Paths: 7
   Components: 0        ← 应为 9

覆盖面不是边角:这 6 个引用覆盖 /api/{object}/api/{object}/{id}全部 CRUD 操作的请求体与响应体,即文档里除了 discovery/meta 之外的所有 schema 内容。

根因(已用对照实验坐实,不是推断)

build-openapi.ts:255 的收集判据:

if (schema && typeof schema === 'object' && '_zod' in schema) {
  schemas[name] = z.toJSONSchema(schema, { target: 'draft-2020-12' });
}

而这 9 个契约 schema(CreateRequestSchema / ApiErrorSchema / …)都经 lazySchema() 包装。lazySchema 的 Proxy target 是 const target = function lazyZod() {},于是 typeof proxy === 'function',不是 'object' —— 判据第一段就短路,9 个全部落空,循环一个都没加,components.schemas 留空;paths 那边的 $ref 是手写字面量,不受影响,照常写出去。

对照实验(OS_EAGER_SCHEMAS=1 正是 lazySchema 自带的「绕过 Proxy」应急开关):

$ npx tsx scripts/build-openapi.ts                    → Components: 0
$ OS_EAGER_SCHEMAS=1 npx tsx scripts/build-openapi.ts → Components: 9

同一份源码、同一条命令,唯一变量是 Proxy 在不在。根因就是这一处 typeof 判据。

运行时探针:

const API = await import('./packages/spec/dist/api/index.mjs');
typeof API.ApiErrorSchema   // 'function'  ← 判据期待 'object'

用户可见性

/api/v1/openapi.json 是对外发布的机器可读契约,两类消费者直接受影响:

  1. GET /api/v1/docs(Scalar viewer,rest-server.ts 注册)加载这份文档 —— 6 个悬空 $ref 上的请求/响应示例与 schema 面板无内容可渲染;
  2. 任何从该文档做客户端代码生成的集成方(openapi-generator / orval / …)会在解析期就报 unresolvable reference,或生成出 any 化的 CRUD 客户端。

严重度不预判(#4949:立单时的严重度判断两个方向都不可靠),交分诊定级。

为什么没被任何门禁挡住

check:generated 自己的收尾行点名了这件事:

Generated but ungated (2): gen:openapi, gen:sbom — nothing verifies these are current.

gen:openapi 是全仓两个完全无门禁的生成器之一 —— 既没有「产物是否最新」的校验,也没有「产物是否自洽」的校验。#5078 结尾把前者记为旁注;本单是后者的一个实锤:产物不自洽了三个层次(空 components、悬空 ref、日志里明晃晃的 Components: 0),没有任何一处红。

建议处置(供分诊,未预设)

  1. 修判据:'_zod' in schema 这一段对 Proxy 是有效的(lazySchema 专门做了 _zod facade,注释里写明是为 toJSONSchema 遍历准备的),问题只在前面的 typeof === 'object'。放宽为 (typeof schema === 'object' || typeof schema === 'function') 即可,不需要动 lazySchema;
  2. 补门禁(这才是防复发的部分):生成后断言 paths 里出现的每个 #/components/schemas/X 都能在 components.schemas 里解析到,不能则 exit 非零。这类「产物自洽」断言比「产物最新」更便宜也更值钱,且能顺带覆盖将来新增的 $ref;
  3. 是否把 gen:openapi 一并纳入 check:generated 的最新性门,属更大的一步,建议单独定夺(与 GET /openapi.json 有两个属主:rest-server 真serve,http-dispatchergenerateOpenApi 分支全仓无实现(ADR-0076 D1 影子重复) #5078 旁注同源)。

lazySchematypeof === 'function' 形状会不会在别处也撞上同类判据,本单没有普查,不作声称。

关联


Generated by Claude Code

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions