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-dispatcher 的 generateOpenApi 分支全仓无实现(ADR-0076 D1 影子重复) #5078 旁注同源)。

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

关联


Generated by Claude Code

Activity

  1. self-assigned this
    on Aug 5, 2026
  2. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    ContributorAuthor

    认领:spec 车道第 1 轮
    会话:session_018fxLGQdatPbBUvCgiVxg6D
    分支:claude/issue-5168-openapi-components
    Worktree:objectstack-issue-5168
    域:domain:spec
    文件面:packages/spec/scripts/build-openapi.ts、packages/spec/json-schema/openapi.json(生成)、packages/spec/package.json(自洽门禁接线)。

    范围:建议 1(修 typeof 判据)+ 建议 2($ref 自洽断言,失败 exit 非零);建议 3(纳入 check:generated 最新性门)不在本单,如实测发现必要另立单。


    Generated by Claude Code

  3. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    ContributorAuthor

    验收(spec 车道 PM,session_018fxLGQdatPbBUvCgiVxg6D):ACCEPT → PR #5459(draft,CI 进行中;绿后转 ready 入合并队列)。

    落地内容:

    1. 判据修复:收集判据同时接受 'object' 与 'function',lazySchema 零改动;修复后不带环境变量即 Components: 9,6 个 $ref 全部可解析。
    2. 产物自洽门禁(防复发的那半):生成器写盘前自检 —— (a) 每个本地 $ref 按 JSON Pointer 必须解析到(未来 #/$defs/… 自动覆盖);(b) 九个契约 schema 声明即强制,静默跳过与 {type:'object'} 占位降级都改为响亮失败。两臂「先证红」已固化为自动化测试(真实生成器跑在沙箱变体里)。
    3. 接线点修正(公开确认 dev 对派发指令的证伪):我在认领时要求「提交重新生成的 openapi.json」—— 该产物在 .gitignore:61,不入库、每次 build 重生成并随包发布,这条指令是错的,dev 以事实驳回并附真实 build 日志替代,正确。门禁因此接在生成器内部而非独立 check 脚本,理由成立(独立脚本无论如何要先跑生成器)。
    4. 普查负结果在案:typeof === 'object' 判 lazySchema 的形状全仓无第二处;instanceof z.ZodType 实测 Proxy 安全。
    5. 越范围发现 gen:openapi 的 base spec 用 7 条手写 path 描述路由面,与 rest 真实路由无任何对账 —— 漂移了不会红(#5168 的剩余那一半) #5456 已核实并补 domain:spec 路由(「对账 vs 最新性」的澄清有价值,进下轮发现分诊)。

    changeset 对 @objectstack/spec / @objectstack/rest 各打 patch,妥当 —— 修复改变的是随包发布的文档内容,用户可见。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions