diff --git a/.changeset/openapi-static-artifact-contract-only.md b/.changeset/openapi-static-artifact-contract-only.md new file mode 100644 index 0000000000..dfd33dd111 --- /dev/null +++ b/.changeset/openapi-static-artifact-contract-only.md @@ -0,0 +1,22 @@ +--- +"@objectstack/spec": major +--- + +**`@objectstack/spec/openapi.json` 不再描述任何路由 —— 静态产物收缩为它真正拥有的契约半边(#5744,#5588 裁定 C 第二棒)** + +`packages/spec/scripts/build-openapi.ts` 里手写的 built-in 路由段(`generateCrudPaths` / `generateMetadataPaths` / `generateDiscoveryPaths`,7 条 path、10 个 operation)整体摘除。这一段在真实 boot 上逐条探测 **0/10 命中**:路径按字面量 `basePath = '/api'` 拼接,于是全部缺 `/v1`(CRUD 还缺 `/data`);`PUT {object}/{id}` 的动词服务器明确回 405;`/api/meta/types` 全仓无此路由;`/api/.well-known/objectstack` 是 runtime dispatcher 的路由、挂在**根路径**上。而且它在这个座位上原理上就写不对 —— `apiPath` 是部署级配置(`api.apiPath ?? api.basePath + '/' + version`),随包发布的静态 JSON 无法为所有部署拼对前缀。 + +段落的唯一属主是**挂载这些路由的包**(ADR-0076 一路由一属主;本文档的属主由 #5078 的真实 boot 坐实为 `@objectstack/rest`)。第一棒 #5821 已让 REST 服务在 serve 期从 `routeManager.getAll()` 产出该段、并**整体丢弃**静态产物带来的 `paths`,所以本次摘除对服务出的 `GET {apiPath}/openapi.json` 是**零行为变化**:那份文档的路由段早已逐字节来自 rest。 + +发布出去的静态产物现在只剩 `openapi` / `info` / `servers` / `components`(`schemas` + `securitySchemes`)/ `security` 五个顶层键 —— 正是 rest serve 期会从产物里读走的那几项。 + +**破坏性**:直接 `import '@objectstack/spec/openapi.json'` 的消费者会看到 + +- **`paths` 键消失**(不是变成 `{}`)。OpenAPI 3.1 里 `paths` 可省(`paths` / `components` / `webhooks` 三者有其一即为合法文档),而两种写法说的不是一件事:`paths: {}` 断言「这个 API 什么都不服务」——假的;键不存在则对路由不作任何断言 —— 这才是这份产物有资格作出的声明。`doc.paths` 上直接取值的代码需要改成防御式读取,或者改去读服务出的 `GET {apiPath}/openapi.json`(那份是完整文档)。 +- **`tags` 键消失**。`CRUD` / `Metadata` / `Discovery` 三个 tag 只为命名被摘掉的三段而存在,任何文档里都没有 operation 携带它们;服务出的文档的 tag 列表由 rest 与路由段一起产出。 + +要一份**带路由**的文档,唯一正确的来源是运行中的服务:`GET {apiPath}/openapi.json`。 + +**门禁随形状调整,不靠留活口维持覆盖**:#5168 的产物自恰门保留,但如实标注 —— 9 个 `$ref` 全部住在被摘掉的 operation 请求/响应体里,所以 `assertRefsResolve` 在**今天**的产物上是空断言。留着它是因为幸存的那半边仍然能走到它防的形状:`z.toJSONSchema` 会把复用/递归子 schema 放进它**返回值**根部的 `$defs`,并用根相对的 `#/$defs/…` 指过去;这些 schema 各自独立转换后被停在 `components.schemas[Name]` 下,于是该指针指的是整份 OpenAPI 文档的根 —— 那里没有 `$defs`。九个契约 schema 今天都不是递归形状,反向验证用变异复现了这一天(注入 `#/$defs/Recursive` → 生成器非零退出)。另新增两条钉子:产物**不含**路由段(七条幽灵路径与三个 tag 逐条断言不存在),以及产物**仍完整保留** spec 拥有的五个顶层键 —— 后者防的是把这次收缩做过头、连 rest 要读的东西一起删掉。 + +`check:generated` 台账里 `gen:openapi` 那条 `why` 同步改写:原文「no check gate compares it to the routes」在裁定 C 之下已无第二方可对账,现在如实记录真正剩下的缺口 —— 没有任何东西把产物的 `components.schemas` 与 `src/api` 对账,产物过期不会让任何东西变红(自恰性由 #5168 在写盘前自检覆盖,**时效性**没有)。 diff --git a/packages/spec/scripts/build-openapi.ts b/packages/spec/scripts/build-openapi.ts index 72d08308b8..0a50a2fb57 100644 --- a/packages/spec/scripts/build-openapi.ts +++ b/packages/spec/scripts/build-openapi.ts @@ -14,228 +14,45 @@ const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../package.json' const SPEC_VERSION = pkg.version; /** - * Generates an OpenAPI 3.1 specification from the ObjectStack REST API protocol schemas. - * This auto-generates documentation for all CRUD operations and platform endpoints. + * Generates the OpenAPI 3.1 **contract half** of `GET {apiPath}/openapi.json` + * from this package's REST API protocol schemas: `components.schemas`, `info`, + * `securitySchemes`, the document-level `security` requirement, and a fallback + * `servers` entry. That is the whole artifact, and the whole of what + * `packages/spec` owns. + * + * ## It deliberately describes NO routes (#5588 ruling C, #5744) + * + * This generator used to hand-write a built-in route section — + * `generateCrudPaths` / `generateMetadataPaths` / `generateDiscoveryPaths`, + * 7 paths and 10 operations under a literal `basePath = '/api'`. A real boot + * probed it row by row and matched **0 of 10**: every path was missing `/v1` + * (CRUD also missing `/data`), `PUT {object}/{id}` named a verb the server + * answers 405 to, `/api/meta/types` exists nowhere in the repo, and + * `/api/.well-known/objectstack` is the runtime dispatcher's route, served at + * the ROOT rather than under the API base. + * + * It could not be written correctly from this seat, either: `apiPath` is + * per-deployment configuration (`api.apiPath ?? api.basePath + '/' + version`), + * so no statically published JSON can spell the prefix right for every + * deployment. A route section can only be produced by the package that MOUNTS + * the routes — ADR-0076 (one route, one owner), with `packages/rest` confirmed + * as this document's owner by the real boot in #5078. + * + * So the section has one producer, and it is not here: since #5821 the REST + * server builds it at serve time from `routeManager.getAll()` — the same table + * the router matches requests against — and DISCARDS whatever `paths` the + * static artifact carries. #5744 (this change) removes the emission, which is + * why the removal is a zero-behaviour-change cleanup rather than a regression: + * the served document's route section was already rest's, byte for byte. + * + * `paths` is therefore ABSENT from the emitted document rather than present + * and empty. OpenAPI 3.1 makes `paths` optional (a document is valid with any + * one of `paths` / `components` / `webhooks`), and the two spellings say + * different things: `paths: {}` asserts "this API serves nothing", which is + * false, while an absent key asserts nothing about routes, which is exactly + * the claim this artifact is entitled to make. */ -interface OpenApiPath { - [method: string]: { - summary: string; - description?: string; - tags: string[]; - operationId: string; - parameters?: Array<{ - name: string; - in: string; - required: boolean; - schema: Record; - description?: string; - }>; - requestBody?: { - required: boolean; - content: Record }>; - }; - responses: Record }>; - }>; - }; -} - -function generateCrudPaths(basePath: string): Record { - const paths: Record = {}; - - // List records - paths[`${basePath}/{object}`] = { - get: { - summary: 'List records', - description: 'Query records with filtering, sorting, and pagination', - tags: ['CRUD'], - operationId: 'listRecords', - parameters: [ - { name: 'object', in: 'path', required: true, schema: { type: 'string' }, description: 'Object name (snake_case)' }, - { name: 'top', in: 'query', required: false, schema: { type: 'integer', default: 25 }, description: 'Page size' }, - { name: 'skip', in: 'query', required: false, schema: { type: 'integer', default: 0 }, description: 'Offset' }, - { name: 'sort', in: 'query', required: false, schema: { type: 'string' }, description: 'Sort field (prefix with - for desc)' }, - { name: 'fields', in: 'query', required: false, schema: { type: 'string' }, description: 'Comma-separated field list' }, - ], - responses: { - '200': { - description: 'List of records', - content: { 'application/json': { schema: { $ref: '#/components/schemas/ListRecordResponse' } } }, - }, - '400': { description: 'Invalid query', content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } } }, - '401': { description: 'Unauthorized' }, - }, - }, - post: { - summary: 'Create a record', - description: 'Create a new record in the specified object', - tags: ['CRUD'], - operationId: 'createRecord', - parameters: [ - { name: 'object', in: 'path', required: true, schema: { type: 'string' }, description: 'Object name (snake_case)' }, - ], - requestBody: { - required: true, - content: { 'application/json': { schema: { $ref: '#/components/schemas/CreateRequest' } } }, - }, - responses: { - '201': { - description: 'Record created', - content: { 'application/json': { schema: { $ref: '#/components/schemas/SingleRecordResponse' } } }, - }, - '400': { description: 'Validation error', content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } } }, - '401': { description: 'Unauthorized' }, - }, - }, - }; - - // Single record operations - paths[`${basePath}/{object}/{id}`] = { - get: { - summary: 'Get a record', - description: 'Retrieve a single record by ID', - tags: ['CRUD'], - operationId: 'getRecord', - parameters: [ - { name: 'object', in: 'path', required: true, schema: { type: 'string' }, description: 'Object name' }, - { name: 'id', in: 'path', required: true, schema: { type: 'string' }, description: 'Record ID' }, - ], - responses: { - '200': { - description: 'Record found', - content: { 'application/json': { schema: { $ref: '#/components/schemas/SingleRecordResponse' } } }, - }, - '404': { description: 'Record not found' }, - '401': { description: 'Unauthorized' }, - }, - }, - put: { - summary: 'Update a record', - description: 'Update an existing record by ID', - tags: ['CRUD'], - operationId: 'updateRecord', - parameters: [ - { name: 'object', in: 'path', required: true, schema: { type: 'string' }, description: 'Object name' }, - { name: 'id', in: 'path', required: true, schema: { type: 'string' }, description: 'Record ID' }, - ], - requestBody: { - required: true, - content: { 'application/json': { schema: { $ref: '#/components/schemas/UpdateRequest' } } }, - }, - responses: { - '200': { - description: 'Record updated', - content: { 'application/json': { schema: { $ref: '#/components/schemas/SingleRecordResponse' } } }, - }, - '400': { description: 'Validation error' }, - '404': { description: 'Record not found' }, - '401': { description: 'Unauthorized' }, - }, - }, - delete: { - summary: 'Delete a record', - description: 'Delete a record by ID', - tags: ['CRUD'], - operationId: 'deleteRecord', - parameters: [ - { name: 'object', in: 'path', required: true, schema: { type: 'string' }, description: 'Object name' }, - { name: 'id', in: 'path', required: true, schema: { type: 'string' }, description: 'Record ID' }, - ], - responses: { - '200': { - description: 'Record deleted', - content: { 'application/json': { schema: { $ref: '#/components/schemas/DeleteResponse' } } }, - }, - '404': { description: 'Record not found' }, - '401': { description: 'Unauthorized' }, - }, - }, - }; - - return paths; -} - -function generateMetadataPaths(basePath: string): Record { - const paths: Record = {}; - - paths[`${basePath}/meta`] = { - get: { - summary: 'Get platform metadata', - description: 'Returns platform-level metadata including registered types and capabilities', - tags: ['Metadata'], - operationId: 'getMetadata', - responses: { - '200': { description: 'Platform metadata' }, - }, - }, - }; - - paths[`${basePath}/meta/types`] = { - get: { - summary: 'List metadata types', - description: 'Returns all registered metadata type names', - tags: ['Metadata'], - operationId: 'listMetadataTypes', - responses: { - '200': { description: 'List of metadata type names' }, - }, - }, - }; - - paths[`${basePath}/meta/{type}`] = { - get: { - summary: 'List metadata by type', - description: 'Returns all metadata entries for the specified type', - tags: ['Metadata'], - operationId: 'listMetadataByType', - parameters: [ - { name: 'type', in: 'path', required: true, schema: { type: 'string' }, description: 'Metadata type (e.g., object, view, flow)' }, - ], - responses: { - '200': { description: 'List of metadata entries' }, - '404': { description: 'Unknown metadata type' }, - }, - }, - }; - - paths[`${basePath}/meta/{type}/{name}`] = { - get: { - summary: 'Get metadata by type and name', - description: 'Returns a single metadata entry by type and name', - tags: ['Metadata'], - operationId: 'getMetadataByName', - parameters: [ - { name: 'type', in: 'path', required: true, schema: { type: 'string' }, description: 'Metadata type' }, - { name: 'name', in: 'path', required: true, schema: { type: 'string' }, description: 'Metadata name' }, - ], - responses: { - '200': { description: 'Metadata entry' }, - '404': { description: 'Metadata not found' }, - }, - }, - }; - - return paths; -} - -function generateDiscoveryPaths(basePath: string): Record { - return { - [`${basePath}/.well-known/objectstack`]: { - get: { - summary: 'Platform discovery', - description: 'Returns ObjectStack platform discovery information including available services and capabilities', - tags: ['Discovery'], - operationId: 'discover', - responses: { - '200': { description: 'Discovery response with platform info, services, and capabilities' }, - }, - }, - }, - }; -} - function generateComponentSchemas(): Record> { const schemas: Record> = {}; const degraded: string[] = []; @@ -282,8 +99,6 @@ function generateComponentSchemas(): Record> { // ─── Build OpenAPI Spec ────────────────────────────────────────────── -const basePath = '/api'; - const openapi: Record = { openapi: '3.1.0', info: { @@ -299,19 +114,16 @@ const openapi: Record = { url: 'https://www.apache.org/licenses/LICENSE-2.0', }, }, + // Kept: the REST server prepends the live request origin and keeps this as a + // trailing fallback entry, so dropping it would change the SERVED document — + // and this change is meant to be invisible there. servers: [ { url: 'http://localhost:3000', description: 'Local development' }, ], - tags: [ - { name: 'CRUD', description: 'Data record operations' }, - { name: 'Metadata', description: 'Platform metadata and introspection' }, - { name: 'Discovery', description: 'Service discovery and capabilities' }, - ], - paths: { - ...generateCrudPaths(basePath), - ...generateMetadataPaths(basePath), - ...generateDiscoveryPaths(basePath), - }, + // No `tags`. The three that used to sit here (`CRUD` / `Metadata` / + // `Discovery`) existed only to name the removed route sections; no operation + // in any document carries them, and the served document's tag list is + // produced with its route section, from the tags the routes register. components: { schemas: generateComponentSchemas(), securitySchemes: { @@ -335,10 +147,27 @@ const openapi: Record = { // ─── 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` +// emitted at all. `gen:openapi` still 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. +// standing between a silently-broken collector and the published artifact. +// Throwing exits non-zero and fails the build. +// +// Since #5744 removed the hand-written route section, the emitted document +// happens to carry ZERO `$ref`s — every one of the nine lived in a path +// operation's request/response body. Be honest about what that means: on +// today's document this call is VACUOUS, and it is retained for a reason that +// is about tomorrow's, not a reason to feel covered by it now. +// +// The live hazard it guards is `$defs`. `z.toJSONSchema` emits reused and +// recursive subschemas into a `$defs` block at the root of the schema it +// RETURNS, pointing at them with root-relative `#/$defs/…` pointers. Each of +// the nine is converted independently and then parked at +// `components.schemas[Name]`, so the moment any contract schema becomes +// recursive or shares a subschema, the pointer means `#/$defs/…` of the whole +// OpenAPI document — which has no `$defs` — and every consumer that resolves it +// gets nothing. None of the nine is in that shape today; `findDanglingRefs` +// resolves by JSON Pointer rather than by a `#/components/schemas/` prefix +// precisely so that day costs nobody a debugging session. assertRefsResolve(openapi); // Write output @@ -350,5 +179,8 @@ const outPath = path.join(OUT_DIR, 'openapi.json'); fs.writeFileSync(outPath, JSON.stringify(openapi, null, 2)); console.log(`✅ Generated OpenAPI spec: ${outPath}`); console.log(` Version: ${SPEC_VERSION}`); -console.log(` Paths: ${Object.keys(openapi.paths as object).length}`); +// No `Paths:` line — the document has no `paths`, and a `Paths: 0` would read +// as "the collector produced nothing" rather than "this artifact does not +// describe routes". The route section is served by @objectstack/rest (#5588). console.log(` Components: ${Object.keys((openapi.components as any).schemas).length}`); +console.log(` Route sections: none — served by @objectstack/rest (#5588, ADR-0076)`); diff --git a/packages/spec/scripts/check-generated.ts b/packages/spec/scripts/check-generated.ts index a57f75fccb..c59dcbd507 100644 --- a/packages/spec/scripts/check-generated.ts +++ b/packages/spec/scripts/check-generated.ts @@ -159,7 +159,18 @@ const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [ * follow-up, not a formality. */ const UNGATED_GENERATORS: ReadonlyArray<{ gen: string; why: string }> = [ - { gen: 'gen:openapi', why: 'the OpenAPI document is generated but no check gate compares it to the routes' }, + // The `why` used to read "no check gate compares it to the routes". Since + // #5744 that names a reconciliation with no second party: the document + // carries no route section at all — built-in routes are produced at serve + // time by the package that mounts them (#5588 ruling C, #5078, ADR-0076), so + // there is nothing here to compare against a route table. What IS still + // ungated is staleness against `src/api`: nothing fails when a contract + // schema changes and the artifact is not regenerated. Coherence is covered + // (the generator self-checks before writing, #5168) — currency is not. + { + gen: 'gen:openapi', + why: 'the OpenAPI document is generated but nothing compares its components.schemas against src/api — a stale artifact fails nothing (it IS self-checked for coherence at write time, #5168, and since #5744 it describes no routes to reconcile)', + }, { gen: 'gen:sbom', why: 'the SBOM is a release artifact, regenerated at publish time rather than checked in' }, ]; diff --git a/packages/spec/scripts/lib/openapi-self-consistency.ts b/packages/spec/scripts/lib/openapi-self-consistency.ts index b4f6b22de4..d251d4cf5b 100644 --- a/packages/spec/scripts/lib/openapi-self-consistency.ts +++ b/packages/spec/scripts/lib/openapi-self-consistency.ts @@ -37,6 +37,23 @@ * is covered without touching this file. * 2. **No schema is silently degraded.** See `assertNoDegradedSchemas`. * + * ## Rule 1 after #5744 — vacuous today, retained for `$defs` + * + * Every `$ref` the document carried lived in the hand-written route section + * that #5744 removed (its producer is `packages/rest`, at serve time — #5588 + * ruling C, ADR-0076). The artifact now emits **zero** refs, so rule 1 walks an + * empty set and is honestly described as vacuous rather than as coverage. + * + * It is kept because the shape it guards is one the surviving half can still + * reach: `z.toJSONSchema` parks reused and recursive subschemas in a `$defs` + * block at the root of the schema it RETURNS, and points at them with + * root-relative `#/$defs/…`. Each contract schema is converted independently + * and then parked under `components.schemas[Name]`, so such a pointer would + * address the OpenAPI document's root — which has no `$defs` — and resolve to + * nothing. None of the nine is recursive today; the "resolve by pointer, not by + * prefix" choice above is what makes that day cheap instead of a debugging + * session. `openapi-self-consistency.test.ts` reproduces it as a mutation. + * * 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:` diff --git a/packages/spec/scripts/openapi-self-consistency.test.ts b/packages/spec/scripts/openapi-self-consistency.test.ts index ef796b4814..2291464881 100644 --- a/packages/spec/scripts/openapi-self-consistency.test.ts +++ b/packages/spec/scripts/openapi-self-consistency.test.ts @@ -23,6 +23,25 @@ // second group is the reverse verification: a gate that has never been // observed failing is not known to be a gate. // +// ── What #5744 changed here, and what it did NOT ────────────────────────── +// The route section this generator used to hand-write is gone: it hit 0 of its +// 10 operations on a real boot, and its one legitimate producer is the package +// that mounts the routes (#5588 ruling C, ADR-0076, #5078). Two consequences, +// both reflected below rather than papered over: +// +// • All nine `$ref`s the document carried lived in those operations' request +// and response bodies, so `assertRefsResolve` on TODAY's artifact is +// vacuous — it walks a document with zero refs. The honest reaction is not +// to keep a route section alive so the assertion has something to chew on; +// it is to say so (`emits a document with no $ref at all`, below) and keep +// the reverse verification pointed at a shape the document can still reach. +// The mutations therefore inject their dangling ref into `components`, the +// surviving surface — including the `#/$defs/…` shape `z.toJSONSchema` +// really does emit the day a contract schema becomes recursive. +// • "This artifact describes no routes" is now an ownership boundary, not an +// accident, so it is pinned as one (`publishes no route section`, below). +// Re-adding a `paths` block here goes red on that test. +// // ── Why a sandbox for the subprocess group ──────────────────────────────── // The script resolves its output dir from its own `__dirname` (`../json-schema` // -> the package's real, gitignored artifact) and a concurrent @@ -147,7 +166,9 @@ describe('assertNoDegradedSchemas', () => { let sandbox: string; /** Run a (possibly mutated) copy of `build-openapi.ts` in an isolated tree. */ -function runGenerator(mutate?: (src: string) => string): { status: number; output: string } { +function runGenerator( + mutate?: (src: string) => string, +): { status: number; output: string; dir: string; artifact: string } { const dir = fs.mkdtempSync(path.join(sandbox, 'gen-')); fs.cpSync(path.join(PKG_ROOT, 'scripts'), path.join(dir, 'scripts'), { recursive: true }); for (const entry of ['src', 'node_modules', 'package.json']) { @@ -167,9 +188,36 @@ function runGenerator(mutate?: (src: string) => string): { status: number; outpu encoding: 'utf-8', env: { ...process.env, NODE_OPTIONS: '--max-old-space-size=4096' }, }); - return { status: res.status ?? -1, output: `${res.stdout ?? ''}${res.stderr ?? ''}` }; + return { + status: res.status ?? -1, + output: `${res.stdout ?? ''}${res.stderr ?? ''}`, + dir, + artifact: path.join(dir, 'json-schema', 'openapi.json'), + }; +} + +/** Read back the artifact a `runGenerator()` call just wrote. */ +function readArtifact(run: ReturnType): any { + expect(fs.existsSync(run.artifact), `the generator wrote no artifact:\n${run.output}`).toBe(true); + return JSON.parse(fs.readFileSync(run.artifact, 'utf-8')); } +/** + * The seven paths the removed hand-written section published. Every one of them + * was a phantom — see `packages/rest/src/rest-openapi-route.test.ts`, which + * asserts the same list is absent from the SERVED document. Here the claim is + * the other half: the static artifact stopped producing them at the source. + */ +const REMOVED_BUILTIN_PATHS = [ + '/api/{object}', + '/api/{object}/{id}', + '/api/meta', + '/api/meta/types', + '/api/meta/{type}', + '/api/meta/{type}/{name}', + '/api/.well-known/objectstack', +]; + describe('build-openapi.ts end to end', () => { beforeAll(() => { sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'os-openapi-5168-')); @@ -186,24 +234,91 @@ describe('build-openapi.ts end to end', () => { expect(status).toBe(0); }); - it('writes a document in which every $ref resolves', () => { - const { status } = runGenerator(); - expect(status).toBe(0); + it('writes a document whose components are complete and self-contained', () => { + const run = runGenerator(); + expect(run.status).toBe(0); // Re-read the artifact the run just produced and check it independently of // the generator's own gate. - const dirs = fs - .readdirSync(sandbox) - .map((d) => path.join(sandbox, d, 'json-schema', 'openapi.json')) - .filter((p) => fs.existsSync(p)); - const doc = JSON.parse(fs.readFileSync(dirs[dirs.length - 1], 'utf-8')); + const doc = readArtifact(run); expect(Object.keys(doc.components.schemas)).toHaveLength(9); expect(findDanglingRefs(doc)).toEqual([]); }); + it('emits a document with no $ref at all — so the ref gate is vacuous TODAY', () => { + // Stated rather than hidden. All nine `$ref`s lived in the route section + // #5744 removed, so `findDanglingRefs(doc) === []` above is currently true + // because there is nothing to walk, not because a check passed. This test + // exists so that fact is written down where the next reader of the ref gate + // will see it — and so that the day a contract schema starts emitting + // `$defs` pointers, this expectation goes red and points at the reason the + // gate was kept (see `build-openapi.ts`'s comment above `assertRefsResolve`). + const doc = readArtifact(runGenerator()); + const refs: string[] = []; + const walk = (node: any): void => { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) return void node.forEach(walk); + for (const [k, v] of Object.entries(node)) { + if (k === '$ref' && typeof v === 'string') refs.push(v); + else walk(v); + } + }; + walk(doc); + expect(refs).toEqual([]); + }); + + it('publishes no route section — those belong to @objectstack/rest (#5588, #5744)', () => { + // The ownership boundary, pinned. `paths` is ABSENT rather than `{}`: + // OpenAPI 3.1 makes it optional, and an empty object would assert "this API + // serves nothing" — false — where an absent key asserts nothing about + // routes, which is the only claim this artifact is entitled to make. + const doc = readArtifact(runGenerator()); + expect(doc.paths, 'the static artifact must not describe routes').toBeUndefined(); + // The three tags described exactly the removed sections; nothing carries + // them, and the served document produces its own tag list with its routes. + expect(doc.tags).toBeUndefined(); + + // Not just the container: none of the seven phantom paths may survive + // anywhere in the document, under any key. + const serialized = JSON.stringify(doc); + for (const phantom of REMOVED_BUILTIN_PATHS) { + expect(serialized, `'${phantom}' is still described by the static artifact`).not.toContain( + phantom, + ); + } + for (const tag of ['CRUD', 'Metadata', 'Discovery']) { + expect(serialized).not.toContain(`"${tag}"`); + } + }); + + it('keeps exactly what packages/spec owns', () => { + // The other direction of the removal: the surviving half is a whole + // document, not a husk. These five keys are what `rest`'s serve-time + // pipeline reads out of the artifact (`rest-server.ts`), so a later + // "cleanup" that drops one of them changes the SERVED document. + const doc = readArtifact(runGenerator()); + expect(Object.keys(doc).sort()).toEqual(['components', 'info', 'openapi', 'security', 'servers']); + expect(doc.openapi).toBe('3.1.0'); + expect(doc.info.title).toBe('ObjectStack REST API'); + expect(doc.info.version).toBe( + JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf-8')).version, + ); + expect(Object.keys(doc.components.securitySchemes).sort()).toEqual(['apiKey', 'bearerAuth']); + expect(doc.security).toEqual([{ bearerAuth: [] }]); + // The fallback server entry rest appends behind the live request origin. + expect(doc.servers).toEqual([{ url: 'http://localhost:3000', description: 'Local development' }]); + }); + // ── Reverse verification ──────────────────────────────────────────────── - // Predicted direction for BOTH: RED (non-zero exit). These are not + // Predicted direction for ALL FOUR: RED (non-zero exit). These are not // decoration — before #5168 the generator exited 0 on a document with six // dangling refs, so "the gate can fail" is the claim under test. + // + // Since #5744 the ref mutations inject their dangling pointer into + // `components` rather than into a path operation: the literals they used to + // rewrite (`#/components/schemas/ApiError'` inside `generateCrudPaths`) no + // longer exist in the source, so the old mutations would fail + // `runGenerator`'s "mutation must actually change the source" guard and + // report a broken test rather than a working gate. it('goes RED when the lazySchema Proxy is rejected again (the original bug)', () => { const { status, output } = runGenerator((src) => @@ -217,22 +332,50 @@ describe('build-openapi.ts end to end', () => { expect(output).toContain('ApiError'); }); - it('goes RED when a $ref points at a schema that does not exist', () => { + it('goes RED when a component $ref points at a schema that does not exist', () => { + // A component referencing a sibling that was renamed — the shape the ref + // gate now guards, since nothing else in the document carries a `$ref`. const { status, output } = runGenerator((src) => - src.replace(/#\/components\/schemas\/ApiError'/g, "#/components/schemas/ApiErrorTypo'"), + src.replace( + ' return schemas;', + " return { ...schemas, Broken: { $ref: '#/components/schemas/ApiErrorTypo' } };", + ), ); expect(status).not.toBe(0); expect(output).toMatch(/unresolvable \$ref/); expect(output).toContain('#/components/schemas/ApiErrorTypo'); }); + it('goes RED on the `$defs` pointer z.toJSONSchema emits for a recursive schema', () => { + // The LIVE hazard the gate is retained for, reproduced as the converter + // really shapes it: `z.toJSONSchema` parks reused/recursive subschemas in a + // `$defs` block at the root of the schema it RETURNS and points at them + // with root-relative `#/$defs/…`. Parked under `components.schemas[Name]`, + // that pointer addresses the OpenAPI document's root — which has no + // `$defs` — so it resolves to nothing for every consumer. None of the nine + // contract schemas is recursive today; this is what happens on the day one + // becomes so. + const { status, output } = runGenerator((src) => + src.replace( + ' return schemas;', + " return { ...schemas, Recursive: { type: 'object', " + + "properties: { next: { $ref: '#/$defs/Recursive' } } } };", + ), + ); + expect(status).not.toBe(0); + expect(output).toMatch(/unresolvable \$ref/); + expect(output).toContain('#/$defs/Recursive'); + }); + it('refuses to WRITE the artifact when the document is inconsistent', () => { - const dirsBefore = new Set(fs.readdirSync(sandbox)); - runGenerator((src) => - src.replace(/#\/components\/schemas\/ApiError'/g, "#/components/schemas/ApiErrorTypo'"), + const run = runGenerator((src) => + src.replace( + ' return schemas;', + " return { ...schemas, Broken: { $ref: '#/components/schemas/ApiErrorTypo' } };", + ), ); - const newDir = fs.readdirSync(sandbox).find((d) => !dirsBefore.has(d))!; + expect(run.status).not.toBe(0); // The gate runs before the write, so no half-broken document is published. - expect(fs.existsSync(path.join(sandbox, newDir, 'json-schema', 'openapi.json'))).toBe(false); + expect(fs.existsSync(run.artifact)).toBe(false); }); });