Skip to content

Commit 7d27da0

Browse files
qq9340100claude
andauthored
fix(spec): openapi 静态产物摘除 built-in 路由段,只保留契约半边 (#5744) (#5967)
#5588 裁定 C 第二棒。`build-openapi.ts` 手写的 `generateCrudPaths` / `generateMetadataPaths` / `generateDiscoveryPaths`(7 path / 10 operation,真实 boot 0/10 命中)整体摘除;`paths` 与三个只为命名它们而存在的 tag 一并消失。第 一棒 #5821 已让 rest 在 serve 期自产该段并丢弃静态旧段,故本次为零行为变化。 #5168 自恰门按新形状调整:如实标注 `assertRefsResolve` 今天空断言,保留它防的是 `z.toJSONSchema` 的 `#/$defs/…` 指针;反向验证的变异改注入 components 侧。新增 「产物不含路由段」「产物仍保留 spec 拥有的五个顶层键」两条钉子。`check:generated` 台账 `gen:openapi` 的 `why` 改写为真正剩下的缺口(时效性无门禁)。 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2381c19 commit 7d27da0

5 files changed

Lines changed: 280 additions & 255 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
**`@objectstack/spec/openapi.json` 不再描述任何路由 —— 静态产物收缩为它真正拥有的契约半边(#5744,#5588 裁定 C 第二棒)**
6+
7+
`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 无法为所有部署拼对前缀。
8+
9+
段落的唯一属主是**挂载这些路由的包**(ADR-0076 一路由一属主;本文档的属主由 #5078 的真实 boot 坐实为 `@objectstack/rest`)。第一棒 #5821 已让 REST 服务在 serve 期从 `routeManager.getAll()` 产出该段、并**整体丢弃**静态产物带来的 `paths`,所以本次摘除对服务出的 `GET {apiPath}/openapi.json`**零行为变化**:那份文档的路由段早已逐字节来自 rest。
10+
11+
发布出去的静态产物现在只剩 `openapi` / `info` / `servers` / `components`(`schemas` + `securitySchemes`)/ `security` 五个顶层键 —— 正是 rest serve 期会从产物里读走的那几项。
12+
13+
**破坏性**:直接 `import '@objectstack/spec/openapi.json'` 的消费者会看到
14+
15+
- **`paths` 键消失**(不是变成 `{}`)。OpenAPI 3.1 里 `paths` 可省(`paths` / `components` / `webhooks` 三者有其一即为合法文档),而两种写法说的不是一件事:`paths: {}` 断言「这个 API 什么都不服务」——假的;键不存在则对路由不作任何断言 —— 这才是这份产物有资格作出的声明。`doc.paths` 上直接取值的代码需要改成防御式读取,或者改去读服务出的 `GET {apiPath}/openapi.json`(那份是完整文档)。
16+
- **`tags` 键消失**`CRUD` / `Metadata` / `Discovery` 三个 tag 只为命名被摘掉的三段而存在,任何文档里都没有 operation 携带它们;服务出的文档的 tag 列表由 rest 与路由段一起产出。
17+
18+
要一份**带路由**的文档,唯一正确的来源是运行中的服务:`GET {apiPath}/openapi.json`
19+
20+
**门禁随形状调整,不靠留活口维持覆盖**:#5168 的产物自恰门保留,但如实标注 —— 9 个 `$ref` 全部住在被摘掉的 operation 请求/响应体里,所以 `assertRefsResolve`**今天**的产物上是空断言。留着它是因为幸存的那半边仍然能走到它防的形状:`z.toJSONSchema` 会把复用/递归子 schema 放进它**返回值**根部的 `$defs`,并用根相对的 `#/$defs/…` 指过去;这些 schema 各自独立转换后被停在 `components.schemas[Name]` 下,于是该指针指的是整份 OpenAPI 文档的根 —— 那里没有 `$defs`。九个契约 schema 今天都不是递归形状,反向验证用变异复现了这一天(注入 `#/$defs/Recursive` → 生成器非零退出)。另新增两条钉子:产物**不含**路由段(七条幽灵路径与三个 tag 逐条断言不存在),以及产物**仍完整保留** spec 拥有的五个顶层键 —— 后者防的是把这次收缩做过头、连 rest 要读的东西一起删掉。
21+
22+
`check:generated` 台账里 `gen:openapi` 那条 `why` 同步改写:原文「no check gate compares it to the routes」在裁定 C 之下已无第二方可对账,现在如实记录真正剩下的缺口 —— 没有任何东西把产物的 `components.schemas``src/api` 对账,产物过期不会让任何东西变红(自恰性由 #5168 在写盘前自检覆盖,**时效性**没有)。

packages/spec/scripts/build-openapi.ts

Lines changed: 68 additions & 236 deletions
Original file line numberDiff line numberDiff line change
@@ -14,228 +14,45 @@ const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../package.json'
1414
const SPEC_VERSION = pkg.version;
1515

1616
/**
17-
* Generates an OpenAPI 3.1 specification from the ObjectStack REST API protocol schemas.
18-
* This auto-generates documentation for all CRUD operations and platform endpoints.
17+
* Generates the OpenAPI 3.1 **contract half** of `GET {apiPath}/openapi.json`
18+
* from this package's REST API protocol schemas: `components.schemas`, `info`,
19+
* `securitySchemes`, the document-level `security` requirement, and a fallback
20+
* `servers` entry. That is the whole artifact, and the whole of what
21+
* `packages/spec` owns.
22+
*
23+
* ## It deliberately describes NO routes (#5588 ruling C, #5744)
24+
*
25+
* This generator used to hand-write a built-in route section —
26+
* `generateCrudPaths` / `generateMetadataPaths` / `generateDiscoveryPaths`,
27+
* 7 paths and 10 operations under a literal `basePath = '/api'`. A real boot
28+
* probed it row by row and matched **0 of 10**: every path was missing `/v1`
29+
* (CRUD also missing `/data`), `PUT {object}/{id}` named a verb the server
30+
* answers 405 to, `/api/meta/types` exists nowhere in the repo, and
31+
* `/api/.well-known/objectstack` is the runtime dispatcher's route, served at
32+
* the ROOT rather than under the API base.
33+
*
34+
* It could not be written correctly from this seat, either: `apiPath` is
35+
* per-deployment configuration (`api.apiPath ?? api.basePath + '/' + version`),
36+
* so no statically published JSON can spell the prefix right for every
37+
* deployment. A route section can only be produced by the package that MOUNTS
38+
* the routes — ADR-0076 (one route, one owner), with `packages/rest` confirmed
39+
* as this document's owner by the real boot in #5078.
40+
*
41+
* So the section has one producer, and it is not here: since #5821 the REST
42+
* server builds it at serve time from `routeManager.getAll()` — the same table
43+
* the router matches requests against — and DISCARDS whatever `paths` the
44+
* static artifact carries. #5744 (this change) removes the emission, which is
45+
* why the removal is a zero-behaviour-change cleanup rather than a regression:
46+
* the served document's route section was already rest's, byte for byte.
47+
*
48+
* `paths` is therefore ABSENT from the emitted document rather than present
49+
* and empty. OpenAPI 3.1 makes `paths` optional (a document is valid with any
50+
* one of `paths` / `components` / `webhooks`), and the two spellings say
51+
* different things: `paths: {}` asserts "this API serves nothing", which is
52+
* false, while an absent key asserts nothing about routes, which is exactly
53+
* the claim this artifact is entitled to make.
1954
*/
2055

21-
interface OpenApiPath {
22-
[method: string]: {
23-
summary: string;
24-
description?: string;
25-
tags: string[];
26-
operationId: string;
27-
parameters?: Array<{
28-
name: string;
29-
in: string;
30-
required: boolean;
31-
schema: Record<string, unknown>;
32-
description?: string;
33-
}>;
34-
requestBody?: {
35-
required: boolean;
36-
content: Record<string, { schema: Record<string, unknown> }>;
37-
};
38-
responses: Record<string, {
39-
description: string;
40-
content?: Record<string, { schema: Record<string, unknown> }>;
41-
}>;
42-
};
43-
}
44-
45-
function generateCrudPaths(basePath: string): Record<string, OpenApiPath> {
46-
const paths: Record<string, OpenApiPath> = {};
47-
48-
// List records
49-
paths[`${basePath}/{object}`] = {
50-
get: {
51-
summary: 'List records',
52-
description: 'Query records with filtering, sorting, and pagination',
53-
tags: ['CRUD'],
54-
operationId: 'listRecords',
55-
parameters: [
56-
{ name: 'object', in: 'path', required: true, schema: { type: 'string' }, description: 'Object name (snake_case)' },
57-
{ name: 'top', in: 'query', required: false, schema: { type: 'integer', default: 25 }, description: 'Page size' },
58-
{ name: 'skip', in: 'query', required: false, schema: { type: 'integer', default: 0 }, description: 'Offset' },
59-
{ name: 'sort', in: 'query', required: false, schema: { type: 'string' }, description: 'Sort field (prefix with - for desc)' },
60-
{ name: 'fields', in: 'query', required: false, schema: { type: 'string' }, description: 'Comma-separated field list' },
61-
],
62-
responses: {
63-
'200': {
64-
description: 'List of records',
65-
content: { 'application/json': { schema: { $ref: '#/components/schemas/ListRecordResponse' } } },
66-
},
67-
'400': { description: 'Invalid query', content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } } },
68-
'401': { description: 'Unauthorized' },
69-
},
70-
},
71-
post: {
72-
summary: 'Create a record',
73-
description: 'Create a new record in the specified object',
74-
tags: ['CRUD'],
75-
operationId: 'createRecord',
76-
parameters: [
77-
{ name: 'object', in: 'path', required: true, schema: { type: 'string' }, description: 'Object name (snake_case)' },
78-
],
79-
requestBody: {
80-
required: true,
81-
content: { 'application/json': { schema: { $ref: '#/components/schemas/CreateRequest' } } },
82-
},
83-
responses: {
84-
'201': {
85-
description: 'Record created',
86-
content: { 'application/json': { schema: { $ref: '#/components/schemas/SingleRecordResponse' } } },
87-
},
88-
'400': { description: 'Validation error', content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } } },
89-
'401': { description: 'Unauthorized' },
90-
},
91-
},
92-
};
93-
94-
// Single record operations
95-
paths[`${basePath}/{object}/{id}`] = {
96-
get: {
97-
summary: 'Get a record',
98-
description: 'Retrieve a single record by ID',
99-
tags: ['CRUD'],
100-
operationId: 'getRecord',
101-
parameters: [
102-
{ name: 'object', in: 'path', required: true, schema: { type: 'string' }, description: 'Object name' },
103-
{ name: 'id', in: 'path', required: true, schema: { type: 'string' }, description: 'Record ID' },
104-
],
105-
responses: {
106-
'200': {
107-
description: 'Record found',
108-
content: { 'application/json': { schema: { $ref: '#/components/schemas/SingleRecordResponse' } } },
109-
},
110-
'404': { description: 'Record not found' },
111-
'401': { description: 'Unauthorized' },
112-
},
113-
},
114-
put: {
115-
summary: 'Update a record',
116-
description: 'Update an existing record by ID',
117-
tags: ['CRUD'],
118-
operationId: 'updateRecord',
119-
parameters: [
120-
{ name: 'object', in: 'path', required: true, schema: { type: 'string' }, description: 'Object name' },
121-
{ name: 'id', in: 'path', required: true, schema: { type: 'string' }, description: 'Record ID' },
122-
],
123-
requestBody: {
124-
required: true,
125-
content: { 'application/json': { schema: { $ref: '#/components/schemas/UpdateRequest' } } },
126-
},
127-
responses: {
128-
'200': {
129-
description: 'Record updated',
130-
content: { 'application/json': { schema: { $ref: '#/components/schemas/SingleRecordResponse' } } },
131-
},
132-
'400': { description: 'Validation error' },
133-
'404': { description: 'Record not found' },
134-
'401': { description: 'Unauthorized' },
135-
},
136-
},
137-
delete: {
138-
summary: 'Delete a record',
139-
description: 'Delete a record by ID',
140-
tags: ['CRUD'],
141-
operationId: 'deleteRecord',
142-
parameters: [
143-
{ name: 'object', in: 'path', required: true, schema: { type: 'string' }, description: 'Object name' },
144-
{ name: 'id', in: 'path', required: true, schema: { type: 'string' }, description: 'Record ID' },
145-
],
146-
responses: {
147-
'200': {
148-
description: 'Record deleted',
149-
content: { 'application/json': { schema: { $ref: '#/components/schemas/DeleteResponse' } } },
150-
},
151-
'404': { description: 'Record not found' },
152-
'401': { description: 'Unauthorized' },
153-
},
154-
},
155-
};
156-
157-
return paths;
158-
}
159-
160-
function generateMetadataPaths(basePath: string): Record<string, OpenApiPath> {
161-
const paths: Record<string, OpenApiPath> = {};
162-
163-
paths[`${basePath}/meta`] = {
164-
get: {
165-
summary: 'Get platform metadata',
166-
description: 'Returns platform-level metadata including registered types and capabilities',
167-
tags: ['Metadata'],
168-
operationId: 'getMetadata',
169-
responses: {
170-
'200': { description: 'Platform metadata' },
171-
},
172-
},
173-
};
174-
175-
paths[`${basePath}/meta/types`] = {
176-
get: {
177-
summary: 'List metadata types',
178-
description: 'Returns all registered metadata type names',
179-
tags: ['Metadata'],
180-
operationId: 'listMetadataTypes',
181-
responses: {
182-
'200': { description: 'List of metadata type names' },
183-
},
184-
},
185-
};
186-
187-
paths[`${basePath}/meta/{type}`] = {
188-
get: {
189-
summary: 'List metadata by type',
190-
description: 'Returns all metadata entries for the specified type',
191-
tags: ['Metadata'],
192-
operationId: 'listMetadataByType',
193-
parameters: [
194-
{ name: 'type', in: 'path', required: true, schema: { type: 'string' }, description: 'Metadata type (e.g., object, view, flow)' },
195-
],
196-
responses: {
197-
'200': { description: 'List of metadata entries' },
198-
'404': { description: 'Unknown metadata type' },
199-
},
200-
},
201-
};
202-
203-
paths[`${basePath}/meta/{type}/{name}`] = {
204-
get: {
205-
summary: 'Get metadata by type and name',
206-
description: 'Returns a single metadata entry by type and name',
207-
tags: ['Metadata'],
208-
operationId: 'getMetadataByName',
209-
parameters: [
210-
{ name: 'type', in: 'path', required: true, schema: { type: 'string' }, description: 'Metadata type' },
211-
{ name: 'name', in: 'path', required: true, schema: { type: 'string' }, description: 'Metadata name' },
212-
],
213-
responses: {
214-
'200': { description: 'Metadata entry' },
215-
'404': { description: 'Metadata not found' },
216-
},
217-
},
218-
};
219-
220-
return paths;
221-
}
222-
223-
function generateDiscoveryPaths(basePath: string): Record<string, OpenApiPath> {
224-
return {
225-
[`${basePath}/.well-known/objectstack`]: {
226-
get: {
227-
summary: 'Platform discovery',
228-
description: 'Returns ObjectStack platform discovery information including available services and capabilities',
229-
tags: ['Discovery'],
230-
operationId: 'discover',
231-
responses: {
232-
'200': { description: 'Discovery response with platform info, services, and capabilities' },
233-
},
234-
},
235-
},
236-
};
237-
}
238-
23956
function generateComponentSchemas(): Record<string, Record<string, unknown>> {
24057
const schemas: Record<string, Record<string, unknown>> = {};
24158
const degraded: string[] = [];
@@ -282,8 +99,6 @@ function generateComponentSchemas(): Record<string, Record<string, unknown>> {
28299

283100
// ─── Build OpenAPI Spec ──────────────────────────────────────────────
284101

285-
const basePath = '/api';
286-
287102
const openapi: Record<string, unknown> = {
288103
openapi: '3.1.0',
289104
info: {
@@ -299,19 +114,16 @@ const openapi: Record<string, unknown> = {
299114
url: 'https://www.apache.org/licenses/LICENSE-2.0',
300115
},
301116
},
117+
// Kept: the REST server prepends the live request origin and keeps this as a
118+
// trailing fallback entry, so dropping it would change the SERVED document —
119+
// and this change is meant to be invisible there.
302120
servers: [
303121
{ url: 'http://localhost:3000', description: 'Local development' },
304122
],
305-
tags: [
306-
{ name: 'CRUD', description: 'Data record operations' },
307-
{ name: 'Metadata', description: 'Platform metadata and introspection' },
308-
{ name: 'Discovery', description: 'Service discovery and capabilities' },
309-
],
310-
paths: {
311-
...generateCrudPaths(basePath),
312-
...generateMetadataPaths(basePath),
313-
...generateDiscoveryPaths(basePath),
314-
},
123+
// No `tags`. The three that used to sit here (`CRUD` / `Metadata` /
124+
// `Discovery`) existed only to name the removed route sections; no operation
125+
// in any document carries them, and the served document's tag list is
126+
// produced with its route section, from the tags the routes register.
315127
components: {
316128
schemas: generateComponentSchemas(),
317129
securitySchemes: {
@@ -335,10 +147,27 @@ const openapi: Record<string, unknown> = {
335147
// ─── Self-consistency gate (#5168) ───────────────────────────────────
336148
//
337149
// 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`
150+
// emitted at all. `gen:openapi` still has no staleness gate (`check:generated`
339151
// 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.
152+
// standing between a silently-broken collector and the published artifact.
153+
// Throwing exits non-zero and fails the build.
154+
//
155+
// Since #5744 removed the hand-written route section, the emitted document
156+
// happens to carry ZERO `$ref`s — every one of the nine lived in a path
157+
// operation's request/response body. Be honest about what that means: on
158+
// today's document this call is VACUOUS, and it is retained for a reason that
159+
// is about tomorrow's, not a reason to feel covered by it now.
160+
//
161+
// The live hazard it guards is `$defs`. `z.toJSONSchema` emits reused and
162+
// recursive subschemas into a `$defs` block at the root of the schema it
163+
// RETURNS, pointing at them with root-relative `#/$defs/…` pointers. Each of
164+
// the nine is converted independently and then parked at
165+
// `components.schemas[Name]`, so the moment any contract schema becomes
166+
// recursive or shares a subschema, the pointer means `#/$defs/…` of the whole
167+
// OpenAPI document — which has no `$defs` — and every consumer that resolves it
168+
// gets nothing. None of the nine is in that shape today; `findDanglingRefs`
169+
// resolves by JSON Pointer rather than by a `#/components/schemas/` prefix
170+
// precisely so that day costs nobody a debugging session.
342171
assertRefsResolve(openapi);
343172

344173
// Write output
@@ -350,5 +179,8 @@ const outPath = path.join(OUT_DIR, 'openapi.json');
350179
fs.writeFileSync(outPath, JSON.stringify(openapi, null, 2));
351180
console.log(`✅ Generated OpenAPI spec: ${outPath}`);
352181
console.log(` Version: ${SPEC_VERSION}`);
353-
console.log(` Paths: ${Object.keys(openapi.paths as object).length}`);
182+
// No `Paths:` line — the document has no `paths`, and a `Paths: 0` would read
183+
// as "the collector produced nothing" rather than "this artifact does not
184+
// describe routes". The route section is served by @objectstack/rest (#5588).
354185
console.log(` Components: ${Object.keys((openapi.components as any).schemas).length}`);
186+
console.log(` Route sections: none — served by @objectstack/rest (#5588, ADR-0076)`);

packages/spec/scripts/check-generated.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,18 @@ const EXTERNAL_INPUT_REQUIRED: ReadonlyArray<{
196196
* follow-up, not a formality.
197197
*/
198198
const UNGATED_GENERATORS: ReadonlyArray<{ gen: string; why: string }> = [
199-
{ gen: 'gen:openapi', why: 'the OpenAPI document is generated but no check gate compares it to the routes' },
199+
// The `why` used to read "no check gate compares it to the routes". Since
200+
// #5744 that names a reconciliation with no second party: the document
201+
// carries no route section at all — built-in routes are produced at serve
202+
// time by the package that mounts them (#5588 ruling C, #5078, ADR-0076), so
203+
// there is nothing here to compare against a route table. What IS still
204+
// ungated is staleness against `src/api`: nothing fails when a contract
205+
// schema changes and the artifact is not regenerated. Coherence is covered
206+
// (the generator self-checks before writing, #5168) — currency is not.
207+
{
208+
gen: 'gen:openapi',
209+
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)',
210+
},
200211
{ gen: 'gen:sbom', why: 'the SBOM is a release artifact, regenerated at publish time rather than checked in' },
201212
];
202213

0 commit comments

Comments
 (0)