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
22 changes: 22 additions & 0 deletions .changeset/openapi-static-artifact-contract-only.md
Original file line number Diff line number Diff line change
@@ -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 在写盘前自检覆盖,**时效性**没有)。
304 changes: 68 additions & 236 deletions packages/spec/scripts/build-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
description?: string;
}>;
requestBody?: {
required: boolean;
content: Record<string, { schema: Record<string, unknown> }>;
};
responses: Record<string, {
description: string;
content?: Record<string, { schema: Record<string, unknown> }>;
}>;
};
}

function generateCrudPaths(basePath: string): Record<string, OpenApiPath> {
const paths: Record<string, OpenApiPath> = {};

// 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<string, OpenApiPath> {
const paths: Record<string, OpenApiPath> = {};

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<string, OpenApiPath> {
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<string, Record<string, unknown>> {
const schemas: Record<string, Record<string, unknown>> = {};
const degraded: string[] = [];
Expand Down Expand Up @@ -282,8 +99,6 @@ function generateComponentSchemas(): Record<string, Record<string, unknown>> {

// ─── Build OpenAPI Spec ──────────────────────────────────────────────

const basePath = '/api';

const openapi: Record<string, unknown> = {
openapi: '3.1.0',
info: {
Expand All @@ -299,19 +114,16 @@ const openapi: Record<string, unknown> = {
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: {
Expand All @@ -335,10 +147,27 @@ 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`
// 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
Expand All @@ -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)`);
13 changes: 12 additions & 1 deletion packages/spec/scripts/check-generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
];

Expand Down
Loading
Loading