Skip to content

Commit da5ca32

Browse files
committed
test(spec): 自洽门禁的红/绿双向验证 + rest 侧过时注释按事实更新
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D
1 parent 99cf2e6 commit da5ca32

4 files changed

Lines changed: 278 additions & 8 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' } } },
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Pins the self-consistency gate on the generated OpenAPI document (#5168).
4+
//
5+
// The defect: every contract schema is wrapped in `lazySchema()`, whose Proxy
6+
// target is `function lazyZod() {}`. The collector in `build-openapi.ts` led
7+
// its guard with `typeof schema === 'object'`, so all nine short-circuited and
8+
// `components.schemas` shipped as `{}` — while the hand-written `$ref`
9+
// literals in `paths` were emitted regardless, leaving six dangling refs
10+
// across every CRUD request/response body in the published
11+
// `GET /api/v1/openapi.json`.
12+
//
13+
// Nothing went red. `gen:openapi` is one of the two generators with no gate at
14+
// all, so the breakage was visible three ways (empty components, dangling
15+
// refs, a literal `Components: 0` on the console) and asserted by nothing.
16+
//
17+
// These tests therefore cover BOTH halves, and the second half is the one that
18+
// prevents recurrence:
19+
//
20+
// 1. the pure assertions, against synthetic documents;
21+
// 2. the REAL `build-openapi.ts`, run as a subprocess — green on the shipped
22+
// source, and red again under each of the two ways this can break. That
23+
// second group is the reverse verification: a gate that has never been
24+
// observed failing is not known to be a gate.
25+
//
26+
// ── Why a sandbox for the subprocess group ────────────────────────────────
27+
// The script resolves its output dir from its own `__dirname` (`../json-schema`
28+
// -> the package's real, gitignored artifact) and a concurrent
29+
// `pnpm --filter @objectstack/spec build` under `turbo run test` writes that
30+
// same file. Running the mutated copies in place would be both destructive and
31+
// flaky, so each variant is written into a temp tree that COPIES `scripts/` and
32+
// symlinks the read-only inputs (`src/`, `node_modules/`, `package.json`) —
33+
// the same discipline `build-schemas-check-mode.test.ts` uses, and for the same
34+
// reason: no test-only seam is added to the gate, because a seam is a place
35+
// where the gate can differ from what CI runs.
36+
37+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
38+
import { spawnSync } from 'node:child_process';
39+
import fs from 'node:fs';
40+
import os from 'node:os';
41+
import path from 'node:path';
42+
import {
43+
findDanglingRefs,
44+
assertRefsResolve,
45+
assertNoDegradedSchemas,
46+
} from './lib/openapi-self-consistency';
47+
48+
const PKG_ROOT = path.resolve(__dirname, '..');
49+
50+
describe('findDanglingRefs', () => {
51+
it('reports nothing for a document whose refs all resolve', () => {
52+
const doc = {
53+
paths: {
54+
'/api/{object}': {
55+
get: { responses: { '200': { schema: { $ref: '#/components/schemas/ApiError' } } } },
56+
},
57+
},
58+
components: { schemas: { ApiError: { type: 'object' } } },
59+
};
60+
expect(findDanglingRefs(doc)).toEqual([]);
61+
});
62+
63+
it('reports the #5168 shape: refs present, components.schemas empty', () => {
64+
const doc = {
65+
paths: {
66+
'/api/{object}': {
67+
get: { responses: { '200': { schema: { $ref: '#/components/schemas/ListRecordResponse' } } } },
68+
},
69+
},
70+
components: { schemas: {} },
71+
};
72+
const dangling = findDanglingRefs(doc);
73+
expect(dangling).toHaveLength(1);
74+
expect(dangling[0].ref).toBe('#/components/schemas/ListRecordResponse');
75+
// The location is what makes the failure actionable.
76+
expect(dangling[0].at).toContain('/api/{object}');
77+
});
78+
79+
it('finds refs nested inside arrays', () => {
80+
const doc = {
81+
paths: { '/x': { get: { anyOf: [{ $ref: '#/components/schemas/Gone' }] } } },
82+
components: { schemas: {} },
83+
};
84+
expect(findDanglingRefs(doc).map((d) => d.ref)).toEqual(['#/components/schemas/Gone']);
85+
});
86+
87+
it('resolves by JSON pointer, so a future non-components ref is covered too', () => {
88+
const ok = { $defs: { Node: { type: 'string' } }, a: { $ref: '#/$defs/Node' } };
89+
expect(findDanglingRefs(ok)).toEqual([]);
90+
const bad = { $defs: {}, a: { $ref: '#/$defs/Node' } };
91+
expect(findDanglingRefs(bad).map((d) => d.ref)).toEqual(['#/$defs/Node']);
92+
});
93+
94+
it('ignores external refs rather than guessing about them', () => {
95+
const doc = { a: { $ref: 'https://example.com/schema.json#/Thing' }, components: { schemas: {} } };
96+
expect(findDanglingRefs(doc)).toEqual([]);
97+
});
98+
99+
it('unescapes JSON-pointer ~1 and ~0 segments', () => {
100+
const doc = { paths: { '/api/x': { ok: true } }, a: { $ref: '#/paths/~1api~1x' } };
101+
expect(findDanglingRefs(doc)).toEqual([]);
102+
});
103+
104+
it('terminates on a cyclic document instead of hanging the build', () => {
105+
const doc: Record<string, unknown> = { components: { schemas: {} } };
106+
doc.self = doc;
107+
expect(() => findDanglingRefs(doc)).not.toThrow();
108+
});
109+
});
110+
111+
describe('assertRefsResolve', () => {
112+
it('passes a coherent document', () => {
113+
const doc = { a: { $ref: '#/components/schemas/X' }, components: { schemas: { X: {} } } };
114+
expect(() => assertRefsResolve(doc)).not.toThrow();
115+
});
116+
117+
it('throws naming the offender and the (empty) defined set', () => {
118+
const doc = { a: { $ref: '#/components/schemas/X' }, components: { schemas: {} } };
119+
expect(() => assertRefsResolve(doc)).toThrow(/unresolvable \$ref/);
120+
expect(() => assertRefsResolve(doc)).toThrow(/#\/components\/schemas\/X/);
121+
expect(() => assertRefsResolve(doc)).toThrow(/<none>/);
122+
});
123+
});
124+
125+
describe('assertNoDegradedSchemas', () => {
126+
it('passes when every declared name was emitted', () => {
127+
expect(() => assertNoDegradedSchemas(['A', 'B'], { A: {}, B: {} }, [])).not.toThrow();
128+
});
129+
130+
it('throws on a silently skipped schema — the #5168 root cause', () => {
131+
expect(() => assertNoDegradedSchemas(['A', 'B'], { A: {} }, [])).toThrow(
132+
/not emitted at all \(1\): B/,
133+
);
134+
});
135+
136+
it('throws on a placeholder-converted schema rather than publishing it', () => {
137+
expect(() => assertNoDegradedSchemas(['A'], { A: {} }, ['A'])).toThrow(
138+
/converted to a placeholder \(1\): A/,
139+
);
140+
});
141+
});
142+
143+
// ─────────────────────────────────────────────────────────────────────────
144+
// The real generator, as a subprocess.
145+
// ─────────────────────────────────────────────────────────────────────────
146+
147+
let sandbox: string;
148+
149+
/** Run a (possibly mutated) copy of `build-openapi.ts` in an isolated tree. */
150+
function runGenerator(mutate?: (src: string) => string): { status: number; output: string } {
151+
const dir = fs.mkdtempSync(path.join(sandbox, 'gen-'));
152+
fs.cpSync(path.join(PKG_ROOT, 'scripts'), path.join(dir, 'scripts'), { recursive: true });
153+
for (const entry of ['src', 'node_modules', 'package.json']) {
154+
fs.symlinkSync(path.join(PKG_ROOT, entry), path.join(dir, entry));
155+
}
156+
157+
const scriptPath = path.join(dir, 'scripts', 'build-openapi.ts');
158+
if (mutate) {
159+
const original = fs.readFileSync(scriptPath, 'utf-8');
160+
const mutated = mutate(original);
161+
expect(mutated, 'mutation must actually change the source').not.toBe(original);
162+
fs.writeFileSync(scriptPath, mutated);
163+
}
164+
165+
const res = spawnSync('npx', ['tsx', scriptPath], {
166+
cwd: dir,
167+
encoding: 'utf-8',
168+
env: { ...process.env, NODE_OPTIONS: '--max-old-space-size=4096' },
169+
});
170+
return { status: res.status ?? -1, output: `${res.stdout ?? ''}${res.stderr ?? ''}` };
171+
}
172+
173+
describe('build-openapi.ts end to end', () => {
174+
beforeAll(() => {
175+
sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'os-openapi-5168-'));
176+
});
177+
afterAll(() => {
178+
fs.rmSync(sandbox, { recursive: true, force: true });
179+
});
180+
181+
it('emits all nine components WITHOUT OS_EAGER_SCHEMAS (#5168 regression pin)', () => {
182+
const { status, output } = runGenerator();
183+
expect(output).toContain('Components: 9');
184+
// The exact symptom string from the issue, which nobody was asserting.
185+
expect(output).not.toContain('Components: 0');
186+
expect(status).toBe(0);
187+
});
188+
189+
it('writes a document in which every $ref resolves', () => {
190+
const { status } = runGenerator();
191+
expect(status).toBe(0);
192+
// Re-read the artifact the run just produced and check it independently of
193+
// the generator's own gate.
194+
const dirs = fs
195+
.readdirSync(sandbox)
196+
.map((d) => path.join(sandbox, d, 'json-schema', 'openapi.json'))
197+
.filter((p) => fs.existsSync(p));
198+
const doc = JSON.parse(fs.readFileSync(dirs[dirs.length - 1], 'utf-8'));
199+
expect(Object.keys(doc.components.schemas)).toHaveLength(9);
200+
expect(findDanglingRefs(doc)).toEqual([]);
201+
});
202+
203+
// ── Reverse verification ────────────────────────────────────────────────
204+
// Predicted direction for BOTH: RED (non-zero exit). These are not
205+
// decoration — before #5168 the generator exited 0 on a document with six
206+
// dangling refs, so "the gate can fail" is the claim under test.
207+
208+
it('goes RED when the lazySchema Proxy is rejected again (the original bug)', () => {
209+
const { status, output } = runGenerator((src) =>
210+
src.replace(
211+
"!!schema && (typeof schema === 'object' || typeof schema === 'function') && '_zod' in schema",
212+
"!!schema && typeof schema === 'object' && '_zod' in schema",
213+
),
214+
);
215+
expect(status).not.toBe(0);
216+
expect(output).toMatch(/not emitted at all \(9\)/);
217+
expect(output).toContain('ApiError');
218+
});
219+
220+
it('goes RED when a $ref points at a schema that does not exist', () => {
221+
const { status, output } = runGenerator((src) =>
222+
src.replace(/#\/components\/schemas\/ApiError'/g, "#/components/schemas/ApiErrorTypo'"),
223+
);
224+
expect(status).not.toBe(0);
225+
expect(output).toMatch(/unresolvable \$ref/);
226+
expect(output).toContain('#/components/schemas/ApiErrorTypo');
227+
});
228+
229+
it('refuses to WRITE the artifact when the document is inconsistent', () => {
230+
const dirsBefore = new Set(fs.readdirSync(sandbox));
231+
runGenerator((src) =>
232+
src.replace(/#\/components\/schemas\/ApiError'/g, "#/components/schemas/ApiErrorTypo'"),
233+
);
234+
const newDir = fs.readdirSync(sandbox).find((d) => !dirsBefore.has(d))!;
235+
// The gate runs before the write, so no half-broken document is published.
236+
expect(fs.existsSync(path.join(sandbox, newDir, 'json-schema', 'openapi.json'))).toBe(false);
237+
});
238+
});

0 commit comments

Comments
 (0)