Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/openapi-components-lazyschema-proxy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@objectstack/spec": patch
"@objectstack/rest": patch
---

**发布出去的 OpenAPI 文档 `components.schemas` 不再是空的,6 个 `$ref` 不再悬空(#5168)**

`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 操作的请求体与响应体。

判据放宽为同时接受 `'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`。

两类消费者直接受益:`GET /api/v1/docs` 的 Scalar viewer 现在有 schema 可渲染;从该文档做客户端代码生成的集成方(openapi-generator / orval / …)不再在解析期撞上 unresolvable reference。

**同时补上防复发的门禁。** 这个缺陷三个层次同时可见(空 components、悬空 ref、控制台明晃晃的 `Components: 0`)却没有任何一处红 —— `gen:openapi` 是全仓两个完全无门禁的生成器之一。生成器现在在**写盘之前**自检两条,任一不满足即以非零码退出,自恰不了的文档根本不会被写出来:

1. **每个本地 `$ref` 都必须解析得到。** 按 JSON Pointer 解析而不是按 `#/components/schemas/` 前缀匹配,将来新增的 `#/$defs/…` 引用自动被覆盖;报错逐条点名悬空的 `$ref` 及其在文档中的位置,并把「已定义的 schema 列表」一并打出来 —— 哪一侧是空的是读者最先需要的信息。
2. **没有 schema 被静默降级。** 九个契约 schema 是一张字面清单,某个名字没产出东西永远是缺陷而不是「这个可选」。原先的循环写成 `if (像 zod) { 收 }` 且没有 `else`,正是这个「静默跳过」的形状让九次跳过发布成了空文档;现在**声明即强制**,漏掉的名字会被点名。`z.toJSONSchema()` 抛错时原先会塞一个 `{type:'object'}` 占位描述冒充契约,这条同样改为响亮失败 —— 当前九个全部干净转换,零占位。

门禁接在生成器内部而不是单独的 `check:` 脚本,因为 `packages/spec/json-schema/` 是 gitignore 的、每次 `pnpm build` 重新生成,独立检查脚本无论如何都要先跑一次生成器才有东西可查。「产物自恰」这类断言比「产物最新」更便宜,且不需要任何基线快照。

`packages/rest` 侧无行为改动:声明式端点的 enrichment 仍然只写 `type: object` 而不编造 `$ref` —— 九个契约 schema 是通用 CRUD 信封,不是某个具体对象的 body 形状 —— 但三处以现在时陈述「`components.schemas` 是空的」的注释已按事实更新。
15 changes: 12 additions & 3 deletions packages/rest/src/openapi-endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,14 @@ import {
// Helpers
// ---------------------------------------------------------------------------

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

it('never invents a response schema — only descriptions', () => {
// The shipped document has ZERO component schemas (#5168), so any `$ref`
// this module emitted would dangle. Descriptions are the honest maximum.
// The shipped document's component schemas are the generic CRUD envelopes,
// never a per-object response shape (before #5168 there were none at all),
// so any `$ref` this module emitted would name something that does not
// describe THIS endpoint. Descriptions are the honest maximum.
const op = buildEndpointOperation(
endpoint({ ...OBJECT_FIND, method: 'POST', objectParams: { object: 'showcase_task', operation: 'create' } }),
undefined,
Expand Down
12 changes: 7 additions & 5 deletions packages/rest/src/openapi-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,11 +238,13 @@ export function buildEndpointOperation(
if (facts.readsBody && BODY_METHODS.has(endpoint.method)) {
// Free-form object, deliberately: the executor forwards the body (through
// `inputMapping`, when declared) to the same pipeline the built-in route
// uses, and this document has no per-object schemas to point at — its
// `components.schemas` is in fact EMPTY today (#5168), so a `$ref` emitted
// here would dangle exactly as the six built-in ones already do. An empty
// `type: object` says "a JSON object, shape not described here", which is
// true; naming fields we have not derived would not be.
// uses, and this document has no PER-OBJECT schemas to point at. Since
// #5168 `components.schemas` is no longer empty — it carries the nine
// contract schemas, and the six built-in `$ref`s resolve — but those are
// the generic CRUD envelopes (`CreateRequest`, `ApiError`, …), not the
// shape of `showcase_task`'s body. An empty `type: object` says "a JSON
// object, shape not described here", which is true; naming fields we have
// not derived would not be.
operation.requestBody = {
required: true,
content: { 'application/json': { schema: { type: 'object' } } },
Expand Down
39 changes: 32 additions & 7 deletions packages/spec/scripts/build-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { z } from 'zod';
// Dynamic imports from spec source
import * as API from '../src/api';
import * as Data from '../src/data';
import { assertRefsResolve, assertNoDegradedSchemas } from './lib/openapi-self-consistency';

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

function generateComponentSchemas(): Record<string, Record<string, unknown>> {
const schemas: Record<string, Record<string, unknown>> = {};

const degraded: string[] = [];

// Map of contract schema names to their Zod schemas
const contractSchemas: Record<string, z.ZodType> = {
CreateRequest: (API as any).CreateRequestSchema,
Expand All @@ -252,15 +254,29 @@ function generateComponentSchemas(): Record<string, Record<string, unknown>> {
};

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

try {
schemas[name] = z.toJSONSchema(schema as z.ZodType, { target: 'draft-2020-12' });
} catch {
degraded.push(name);
}
}

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

return schemas;
}

Expand Down Expand Up @@ -316,6 +332,15 @@ const openapi: Record<string, unknown> = {
],
};

// ─── Self-consistency gate (#5168) ───────────────────────────────────
//
// Runs BEFORE the write, so a document whose `$ref`s do not resolve is never
// emitted at all. `gen:openapi` has no staleness gate (`check:generated`
// reports it as one of the two ungated generators), so this is the only thing
// standing between a silently-broken collector and the published
// `GET /api/v1/openapi.json`. Throwing exits non-zero and fails the build.
assertRefsResolve(openapi);

// Write output
if (!fs.existsSync(OUT_DIR)) {
fs.mkdirSync(OUT_DIR, { recursive: true });
Expand Down
184 changes: 184 additions & 0 deletions packages/spec/scripts/lib/openapi-self-consistency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Self-consistency assertions for the generated OpenAPI document (#5168).
*
* ## The gap this closes
*
* `gen:openapi` is one of the two completely ungated generators in the repo
* (`check:generated`'s own closing line names it: "Generated but ungated (2):
* gen:openapi, gen:sbom"). Ungated in BOTH senses — nothing verifies the
* artifact is current, and nothing verified it was even internally coherent.
*
* #5168 is what the second gap costs. Every one of the nine contract schemas
* is wrapped in `lazySchema()`, whose Proxy target is `function lazyZod() {}`,
* so `typeof schema === 'function'`. The collector's guard led with
* `typeof schema === 'object'`, short-circuited on all nine, and emitted
* `components.schemas: {}` — while the hand-written `$ref` literals in `paths`
* were written out regardless. The published document therefore carried six
* `$ref`s pointing at nothing, covering the request and response bodies of
* every CRUD operation, and the failure was visible three ways at once (empty
* components, dangling refs, a `Components: 0` line printed to the console)
* without a single thing going red.
*
* A "the artifact is coherent" assertion is cheaper than a "the artifact is
* current" one and catches strictly this class: it needs no baseline, no
* committed snapshot (`packages/spec/json-schema/` is gitignored and rebuilt
* on every `pnpm build`), and it covers `$ref`s added in the future for free.
*
* ## The two rules
*
* 1. **Every local `$ref` resolves.** Any `$ref` beginning with `#/` is a JSON
* Pointer into this same document; if it does not resolve, the document is
* broken for every consumer that parses it (Scalar's viewer at
* `GET /api/v1/docs`, and any client generator pointed at
* `GET /api/v1/openapi.json`). Resolution is by pointer rather than by a
* `#/components/schemas/` prefix match so that a future `#/$defs/…` ref
* is covered without touching this file.
* 2. **No schema is silently degraded.** See `assertNoDegradedSchemas`.
*
* Both are consulted BEFORE the document is written: a self-inconsistent
* artifact is never emitted at all, rather than emitted and then complained
* about. The gate is wired into the generator itself (not a separate `check:`
* script) because the artifact is regenerated on every build — a standalone
* checker would have to run the generator first to have anything to check.
*/

/** One unresolvable `$ref`, with the document location that carried it. */
export interface DanglingRef {
/** The `$ref` value verbatim, e.g. `#/components/schemas/ApiError`. */
ref: string;
/** Where it appeared, as a readable path: `paths./api/{object}.get.…`. */
at: string;
}

/**
* Resolve a JSON Pointer (RFC 6901) against `root`.
*
* Returns `undefined` when any segment is missing. `~1` decodes to `/` and
* `~0` to `~`, in that order — reversing the order corrupts a literal `~1`.
*/
function resolvePointer(root: unknown, pointer: string): unknown {
// '#' alone addresses the whole document.
if (pointer === '#' || pointer === '#/') return root;

const segments = pointer
.slice(2) // drop the leading '#/'
.split('/')
.map((s) => decodeURIComponent(s).replace(/~1/g, '/').replace(/~0/g, '~'));

let node: unknown = root;
for (const segment of segments) {
if (node === null || typeof node !== 'object') return undefined;
const container = node as Record<string, unknown>;
if (!Object.prototype.hasOwnProperty.call(container, segment)) return undefined;
node = container[segment];
}
return node;
}

/**
* Collect every local (`#/…`) `$ref` in `doc` that does not resolve.
*
* External refs (`https://…`, `./other.json#/…`) are out of scope — this
* document has never contained one, and resolving them would mean fetching.
* They are simply not reported either way.
*/
export function findDanglingRefs(doc: unknown): DanglingRef[] {
const dangling: DanglingRef[] = [];
const seen = new Set<unknown>();

const walk = (node: unknown, at: string): void => {
if (node === null || typeof node !== 'object') return;
// Generated documents are trees, but guard against a cycle regardless:
// an unguarded walk would hang the build instead of failing it.
if (seen.has(node)) return;
seen.add(node);

if (Array.isArray(node)) {
node.forEach((item, i) => walk(item, `${at}[${i}]`));
return;
}

for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
const here = at ? `${at}.${key}` : key;
if (key === '$ref' && typeof value === 'string') {
if (value.startsWith('#') && resolvePointer(doc, value) === undefined) {
dangling.push({ ref: value, at });
}
continue;
}
walk(value, here);
}
};

walk(doc, '');
return dangling;
}

/**
* Throw when any `$ref` in `doc` dangles. Message names every offender and the
* schemas that WERE defined, because "which of the two sides is empty" is the
* first thing a reader needs (in #5168 the defined side was `[]` entirely).
*/
export function assertRefsResolve(doc: unknown): void {
const dangling = findDanglingRefs(doc);
if (dangling.length === 0) return;

const defined = Object.keys(
((doc as Record<string, any>)?.components?.schemas ?? {}) as Record<string, unknown>,
);

const lines = dangling.map((d) => ` - ${d.ref} (referenced at ${d.at || '<root>'})`);
throw new Error(
`OpenAPI document is not self-consistent: ${dangling.length} unresolvable $ref(s).\n` +
`${lines.join('\n')}\n` +
` defined components.schemas: [${defined.join(', ') || '<none>'}]\n` +
`\n` +
` A $ref that resolves to nothing breaks every consumer of the published\n` +
` document (the Scalar viewer at GET /api/v1/docs renders an empty schema\n` +
` panel; client generators fail at parse time). If components.schemas is\n` +
` empty, the collector in build-openapi.ts skipped its inputs — note that\n` +
` lazySchema() returns a Proxy whose typeof is 'function', not 'object'\n` +
` (#5168).`,
);
}

/**
* Throw when any contract schema was dropped or degraded during collection.
*
* `build-openapi.ts` names its nine contract schemas in a literal table, so a
* name that fails to convert is never a "this one is optional" — it is an
* export that moved, was renamed, or stopped being a Zod schema. The original
* loop expressed that as `if (looks-like-zod) { emit }` with no `else`, which
* is precisely how nine silent skips published an empty `components.schemas`.
* Declared here therefore means enforced: every declared name must produce a
* real converted schema, or the build fails naming the ones that did not.
*/
export function assertNoDegradedSchemas(
declared: readonly string[],
emitted: Readonly<Record<string, unknown>>,
degraded: readonly string[],
): void {
const missing = declared.filter((name) => !(name in emitted));
if (missing.length === 0 && degraded.length === 0) return;

const parts: string[] = ['OpenAPI component schema collection is incomplete.'];
if (missing.length > 0) {
parts.push(
` not emitted at all (${missing.length}): ${missing.join(', ')}\n` +
` The export is missing, renamed, or is not a Zod schema. Note that a\n` +
` lazySchema() Proxy has typeof 'function' — a guard demanding\n` +
` typeof 'object' rejects every one of them (#5168).`,
);
}
if (degraded.length > 0) {
parts.push(
` converted to a placeholder (${degraded.length}): ${degraded.join(', ')}\n` +
` z.toJSONSchema() threw for these. Publishing a bare {type:'object'}\n` +
` in their place would ship a contract that describes nothing while\n` +
` looking complete — fix the schema instead.`,
);
}
throw new Error(parts.join('\n'));
}
Loading
Loading