Skip to content

Commit aeb9b27

Browse files
os-zhuangclaude
andauthored
fix(spec): OpenAPI components.schemas 不再是空的 —— lazySchema Proxy 撞上 typeof === 'object',并补上产物自洽门禁 (#5459)
* fix(spec): lazySchema Proxy 撞上 typeof === 'object',OpenAPI components.schemas 为空 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D * test(spec): 自洽门禁的红/绿双向验证 + rest 侧过时注释按事实更新 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cd2efe6 commit aeb9b27

6 files changed

Lines changed: 494 additions & 15 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/rest": patch
4+
---
5+
6+
**发布出去的 OpenAPI 文档 `components.schemas` 不再是空的,6 个 `$ref` 不再悬空(#5168)**
7+
8+
`GET /api/v1/openapi.json` 的 base spec 由 `packages/spec/scripts/build-openapi.ts` 生成,它把九个契约 schema(`CreateRequest` / `ApiError` / `ListRecordResponse` / …)转成 JSON Schema 填进 `components.schemas`。收集判据写的是 `typeof schema === 'object' && '_zod' in schema`,而这九个 schema 全部经 `lazySchema()` 包装 —— 其 Proxy target 是 `function lazyZod() {}`,于是 `typeof``'function'` 而不是 `'object'`,判据第一段就短路,九个一个都没进去。`paths` 里那 6 个 `$ref` 是手写字面量,不受影响照常写出,结果是**一份 `components.schemas``{}`、6 个 `$ref` 全部悬空的文档被发布出去**,覆盖 `/api/{object}``/api/{object}/{id}` 上全部 CRUD 操作的请求体与响应体。
9+
10+
判据放宽为同时接受 `'object'``'function'``'_zod' in schema` 那一段对 Proxy 本来就是有效的 —— `lazySchema` 专门维护了 `_zod` facade 供 `toJSONSchema` 遍历 —— 所以 `lazySchema` 本身不需要改动。对照实验坐实了唯一变量就是 Proxy:同一份源码下 `npx tsx scripts/build-openapi.ts` 得到 `Components: 0`,而 `OS_EAGER_SCHEMAS=1`(`lazySchema` 自带的绕过 Proxy 应急开关)得到 `Components: 9`。修复后不带任何环境变量即为 `Components: 9`
11+
12+
两类消费者直接受益:`GET /api/v1/docs` 的 Scalar viewer 现在有 schema 可渲染;从该文档做客户端代码生成的集成方(openapi-generator / orval / …)不再在解析期撞上 unresolvable reference。
13+
14+
**同时补上防复发的门禁。** 这个缺陷三个层次同时可见(空 components、悬空 ref、控制台明晃晃的 `Components: 0`)却没有任何一处红 —— `gen:openapi` 是全仓两个完全无门禁的生成器之一。生成器现在在**写盘之前**自检两条,任一不满足即以非零码退出,自恰不了的文档根本不会被写出来:
15+
16+
1. **每个本地 `$ref` 都必须解析得到。** 按 JSON Pointer 解析而不是按 `#/components/schemas/` 前缀匹配,将来新增的 `#/$defs/…` 引用自动被覆盖;报错逐条点名悬空的 `$ref` 及其在文档中的位置,并把「已定义的 schema 列表」一并打出来 —— 哪一侧是空的是读者最先需要的信息。
17+
2. **没有 schema 被静默降级。** 九个契约 schema 是一张字面清单,某个名字没产出东西永远是缺陷而不是「这个可选」。原先的循环写成 `if (像 zod) { 收 }` 且没有 `else`,正是这个「静默跳过」的形状让九次跳过发布成了空文档;现在**声明即强制**,漏掉的名字会被点名。`z.toJSONSchema()` 抛错时原先会塞一个 `{type:'object'}` 占位描述冒充契约,这条同样改为响亮失败 —— 当前九个全部干净转换,零占位。
18+
19+
门禁接在生成器内部而不是单独的 `check:` 脚本,因为 `packages/spec/json-schema/` 是 gitignore 的、每次 `pnpm build` 重新生成,独立检查脚本无论如何都要先跑一次生成器才有东西可查。「产物自恰」这类断言比「产物最新」更便宜,且不需要任何基线快照。
20+
21+
`packages/rest` 侧无行为改动:声明式端点的 enrichment 仍然只写 `type: object` 而不编造 `$ref` —— 九个契约 schema 是通用 CRUD 信封,不是某个具体对象的 body 形状 —— 但三处以现在时陈述「`components.schemas` 是空的」的注释已按事实更新。

packages/rest/src/openapi-endpoints.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,14 @@ import {
2929
// Helpers
3030
// ---------------------------------------------------------------------------
3131

32-
/** A document shaped like the one `@objectstack/spec/openapi.json` ships. */
32+
/**
33+
* A document shaped like the one `@objectstack/spec/openapi.json` ships.
34+
*
35+
* `components.schemas` is left empty here on purpose: this module's enrichment
36+
* never reads it (only `securitySchemes`, at `resolveSecurityRequirement`), so
37+
* an empty map keeps the fixture minimal. The real artifact carries nine
38+
* schemas since #5168.
39+
*/
3340
function baseDoc() {
3441
return {
3542
openapi: '3.1.0',
@@ -162,8 +169,10 @@ describe('path entries', () => {
162169
});
163170

164171
it('never invents a response schema — only descriptions', () => {
165-
// The shipped document has ZERO component schemas (#5168), so any `$ref`
166-
// this module emitted would dangle. Descriptions are the honest maximum.
172+
// The shipped document's component schemas are the generic CRUD envelopes,
173+
// never a per-object response shape (before #5168 there were none at all),
174+
// so any `$ref` this module emitted would name something that does not
175+
// describe THIS endpoint. Descriptions are the honest maximum.
167176
const op = buildEndpointOperation(
168177
endpoint({ ...OBJECT_FIND, method: 'POST', objectParams: { object: 'showcase_task', operation: 'create' } }),
169178
undefined,

packages/rest/src/openapi-endpoints.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -238,11 +238,13 @@ export function buildEndpointOperation(
238238
if (facts.readsBody && BODY_METHODS.has(endpoint.method)) {
239239
// Free-form object, deliberately: the executor forwards the body (through
240240
// `inputMapping`, when declared) to the same pipeline the built-in route
241-
// uses, and this document has no per-object schemas to point at — its
242-
// `components.schemas` is in fact EMPTY today (#5168), so a `$ref` emitted
243-
// here would dangle exactly as the six built-in ones already do. An empty
244-
// `type: object` says "a JSON object, shape not described here", which is
245-
// true; naming fields we have not derived would not be.
241+
// uses, and this document has no PER-OBJECT schemas to point at. Since
242+
// #5168 `components.schemas` is no longer empty — it carries the nine
243+
// contract schemas, and the six built-in `$ref`s resolve — but those are
244+
// the generic CRUD envelopes (`CreateRequest`, `ApiError`, …), not the
245+
// shape of `showcase_task`'s body. An empty `type: object` says "a JSON
246+
// object, shape not described here", which is true; naming fields we have
247+
// not derived would not be.
246248
operation.requestBody = {
247249
required: true,
248250
content: { 'application/json': { schema: { type: 'object' } } },

packages/spec/scripts/build-openapi.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { z } from 'zod';
77
// Dynamic imports from spec source
88
import * as API from '../src/api';
99
import * as Data from '../src/data';
10+
import { assertRefsResolve, assertNoDegradedSchemas } from './lib/openapi-self-consistency';
1011

1112
const OUT_DIR = path.resolve(__dirname, '../json-schema');
1213
const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8'));
@@ -237,7 +238,8 @@ function generateDiscoveryPaths(basePath: string): Record<string, OpenApiPath> {
237238

238239
function generateComponentSchemas(): Record<string, Record<string, unknown>> {
239240
const schemas: Record<string, Record<string, unknown>> = {};
240-
241+
const degraded: string[] = [];
242+
241243
// Map of contract schema names to their Zod schemas
242244
const contractSchemas: Record<string, z.ZodType> = {
243245
CreateRequest: (API as any).CreateRequestSchema,
@@ -252,15 +254,29 @@ function generateComponentSchemas(): Record<string, Record<string, unknown>> {
252254
};
253255

254256
for (const [name, schema] of Object.entries(contractSchemas)) {
255-
if (schema && typeof schema === 'object' && '_zod' in schema) {
256-
try {
257-
schemas[name] = z.toJSONSchema(schema as z.ZodType, { target: 'draft-2020-12' });
258-
} catch {
259-
schemas[name] = { type: 'object', description: `${name} (schema too complex for auto-generation)` };
260-
}
257+
// `typeof` must admit BOTH 'object' and 'function': every contract schema
258+
// here is wrapped in `lazySchema()`, whose Proxy target is
259+
// `function lazyZod() {}`, so `typeof schema === 'function'`. Demanding
260+
// 'object' short-circuited all nine and published an empty
261+
// `components.schemas` behind six dangling `$ref`s (#5168). The `_zod`
262+
// half of the guard is Proxy-safe as written — `lazySchema` maintains a
263+
// `_zod` facade precisely so `toJSONSchema` can traverse it.
264+
const isZodLike =
265+
!!schema && (typeof schema === 'object' || typeof schema === 'function') && '_zod' in schema;
266+
if (!isZodLike) continue; // reported by assertNoDegradedSchemas below
267+
268+
try {
269+
schemas[name] = z.toJSONSchema(schema as z.ZodType, { target: 'draft-2020-12' });
270+
} catch {
271+
degraded.push(name);
261272
}
262273
}
263274

275+
// Declared = enforced: the table above is a literal list of the contract's
276+
// nine schemas, so a name that produced nothing is a defect, never an
277+
// optional input. Failing here is what makes the #5168 shape unrepeatable.
278+
assertNoDegradedSchemas(Object.keys(contractSchemas), schemas, degraded);
279+
264280
return schemas;
265281
}
266282

@@ -316,6 +332,15 @@ const openapi: Record<string, unknown> = {
316332
],
317333
};
318334

335+
// ─── Self-consistency gate (#5168) ───────────────────────────────────
336+
//
337+
// Runs BEFORE the write, so a document whose `$ref`s do not resolve is never
338+
// emitted at all. `gen:openapi` has no staleness gate (`check:generated`
339+
// reports it as one of the two ungated generators), so this is the only thing
340+
// standing between a silently-broken collector and the published
341+
// `GET /api/v1/openapi.json`. Throwing exits non-zero and fails the build.
342+
assertRefsResolve(openapi);
343+
319344
// Write output
320345
if (!fs.existsSync(OUT_DIR)) {
321346
fs.mkdirSync(OUT_DIR, { recursive: true });
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Self-consistency assertions for the generated OpenAPI document (#5168).
5+
*
6+
* ## The gap this closes
7+
*
8+
* `gen:openapi` is one of the two completely ungated generators in the repo
9+
* (`check:generated`'s own closing line names it: "Generated but ungated (2):
10+
* gen:openapi, gen:sbom"). Ungated in BOTH senses — nothing verifies the
11+
* artifact is current, and nothing verified it was even internally coherent.
12+
*
13+
* #5168 is what the second gap costs. Every one of the nine contract schemas
14+
* is wrapped in `lazySchema()`, whose Proxy target is `function lazyZod() {}`,
15+
* so `typeof schema === 'function'`. The collector's guard led with
16+
* `typeof schema === 'object'`, short-circuited on all nine, and emitted
17+
* `components.schemas: {}` — while the hand-written `$ref` literals in `paths`
18+
* were written out regardless. The published document therefore carried six
19+
* `$ref`s pointing at nothing, covering the request and response bodies of
20+
* every CRUD operation, and the failure was visible three ways at once (empty
21+
* components, dangling refs, a `Components: 0` line printed to the console)
22+
* without a single thing going red.
23+
*
24+
* A "the artifact is coherent" assertion is cheaper than a "the artifact is
25+
* current" one and catches strictly this class: it needs no baseline, no
26+
* committed snapshot (`packages/spec/json-schema/` is gitignored and rebuilt
27+
* on every `pnpm build`), and it covers `$ref`s added in the future for free.
28+
*
29+
* ## The two rules
30+
*
31+
* 1. **Every local `$ref` resolves.** Any `$ref` beginning with `#/` is a JSON
32+
* Pointer into this same document; if it does not resolve, the document is
33+
* broken for every consumer that parses it (Scalar's viewer at
34+
* `GET /api/v1/docs`, and any client generator pointed at
35+
* `GET /api/v1/openapi.json`). Resolution is by pointer rather than by a
36+
* `#/components/schemas/` prefix match so that a future `#/$defs/…` ref
37+
* is covered without touching this file.
38+
* 2. **No schema is silently degraded.** See `assertNoDegradedSchemas`.
39+
*
40+
* Both are consulted BEFORE the document is written: a self-inconsistent
41+
* artifact is never emitted at all, rather than emitted and then complained
42+
* about. The gate is wired into the generator itself (not a separate `check:`
43+
* script) because the artifact is regenerated on every build — a standalone
44+
* checker would have to run the generator first to have anything to check.
45+
*/
46+
47+
/** One unresolvable `$ref`, with the document location that carried it. */
48+
export interface DanglingRef {
49+
/** The `$ref` value verbatim, e.g. `#/components/schemas/ApiError`. */
50+
ref: string;
51+
/** Where it appeared, as a readable path: `paths./api/{object}.get.…`. */
52+
at: string;
53+
}
54+
55+
/**
56+
* Resolve a JSON Pointer (RFC 6901) against `root`.
57+
*
58+
* Returns `undefined` when any segment is missing. `~1` decodes to `/` and
59+
* `~0` to `~`, in that order — reversing the order corrupts a literal `~1`.
60+
*/
61+
function resolvePointer(root: unknown, pointer: string): unknown {
62+
// '#' alone addresses the whole document.
63+
if (pointer === '#' || pointer === '#/') return root;
64+
65+
const segments = pointer
66+
.slice(2) // drop the leading '#/'
67+
.split('/')
68+
.map((s) => decodeURIComponent(s).replace(/~1/g, '/').replace(/~0/g, '~'));
69+
70+
let node: unknown = root;
71+
for (const segment of segments) {
72+
if (node === null || typeof node !== 'object') return undefined;
73+
const container = node as Record<string, unknown>;
74+
if (!Object.prototype.hasOwnProperty.call(container, segment)) return undefined;
75+
node = container[segment];
76+
}
77+
return node;
78+
}
79+
80+
/**
81+
* Collect every local (`#/…`) `$ref` in `doc` that does not resolve.
82+
*
83+
* External refs (`https://…`, `./other.json#/…`) are out of scope — this
84+
* document has never contained one, and resolving them would mean fetching.
85+
* They are simply not reported either way.
86+
*/
87+
export function findDanglingRefs(doc: unknown): DanglingRef[] {
88+
const dangling: DanglingRef[] = [];
89+
const seen = new Set<unknown>();
90+
91+
const walk = (node: unknown, at: string): void => {
92+
if (node === null || typeof node !== 'object') return;
93+
// Generated documents are trees, but guard against a cycle regardless:
94+
// an unguarded walk would hang the build instead of failing it.
95+
if (seen.has(node)) return;
96+
seen.add(node);
97+
98+
if (Array.isArray(node)) {
99+
node.forEach((item, i) => walk(item, `${at}[${i}]`));
100+
return;
101+
}
102+
103+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
104+
const here = at ? `${at}.${key}` : key;
105+
if (key === '$ref' && typeof value === 'string') {
106+
if (value.startsWith('#') && resolvePointer(doc, value) === undefined) {
107+
dangling.push({ ref: value, at });
108+
}
109+
continue;
110+
}
111+
walk(value, here);
112+
}
113+
};
114+
115+
walk(doc, '');
116+
return dangling;
117+
}
118+
119+
/**
120+
* Throw when any `$ref` in `doc` dangles. Message names every offender and the
121+
* schemas that WERE defined, because "which of the two sides is empty" is the
122+
* first thing a reader needs (in #5168 the defined side was `[]` entirely).
123+
*/
124+
export function assertRefsResolve(doc: unknown): void {
125+
const dangling = findDanglingRefs(doc);
126+
if (dangling.length === 0) return;
127+
128+
const defined = Object.keys(
129+
((doc as Record<string, any>)?.components?.schemas ?? {}) as Record<string, unknown>,
130+
);
131+
132+
const lines = dangling.map((d) => ` - ${d.ref} (referenced at ${d.at || '<root>'})`);
133+
throw new Error(
134+
`OpenAPI document is not self-consistent: ${dangling.length} unresolvable $ref(s).\n` +
135+
`${lines.join('\n')}\n` +
136+
` defined components.schemas: [${defined.join(', ') || '<none>'}]\n` +
137+
`\n` +
138+
` A $ref that resolves to nothing breaks every consumer of the published\n` +
139+
` document (the Scalar viewer at GET /api/v1/docs renders an empty schema\n` +
140+
` panel; client generators fail at parse time). If components.schemas is\n` +
141+
` empty, the collector in build-openapi.ts skipped its inputs — note that\n` +
142+
` lazySchema() returns a Proxy whose typeof is 'function', not 'object'\n` +
143+
` (#5168).`,
144+
);
145+
}
146+
147+
/**
148+
* Throw when any contract schema was dropped or degraded during collection.
149+
*
150+
* `build-openapi.ts` names its nine contract schemas in a literal table, so a
151+
* name that fails to convert is never a "this one is optional" — it is an
152+
* export that moved, was renamed, or stopped being a Zod schema. The original
153+
* loop expressed that as `if (looks-like-zod) { emit }` with no `else`, which
154+
* is precisely how nine silent skips published an empty `components.schemas`.
155+
* Declared here therefore means enforced: every declared name must produce a
156+
* real converted schema, or the build fails naming the ones that did not.
157+
*/
158+
export function assertNoDegradedSchemas(
159+
declared: readonly string[],
160+
emitted: Readonly<Record<string, unknown>>,
161+
degraded: readonly string[],
162+
): void {
163+
const missing = declared.filter((name) => !(name in emitted));
164+
if (missing.length === 0 && degraded.length === 0) return;
165+
166+
const parts: string[] = ['OpenAPI component schema collection is incomplete.'];
167+
if (missing.length > 0) {
168+
parts.push(
169+
` not emitted at all (${missing.length}): ${missing.join(', ')}\n` +
170+
` The export is missing, renamed, or is not a Zod schema. Note that a\n` +
171+
` lazySchema() Proxy has typeof 'function' — a guard demanding\n` +
172+
` typeof 'object' rejects every one of them (#5168).`,
173+
);
174+
}
175+
if (degraded.length > 0) {
176+
parts.push(
177+
` converted to a placeholder (${degraded.length}): ${degraded.join(', ')}\n` +
178+
` z.toJSONSchema() threw for these. Publishing a bare {type:'object'}\n` +
179+
` in their place would ship a contract that describes nothing while\n` +
180+
` looking complete — fix the schema instead.`,
181+
);
182+
}
183+
throw new Error(parts.join('\n'));
184+
}

0 commit comments

Comments
 (0)