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
16 changes: 16 additions & 0 deletions .changeset/openapi-declared-endpoints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@objectstack/rest": minor
"@objectstack/runtime": patch
---

**声明式端点进 OpenAPI 文档;`/openapi.json` 的影子属主摘除(#5040 E6,并入 #5078)**

`GET {basePath}/openapi.json` 只有一个属主,而且实测坐实是 `packages/rest`(#5078:真实 boot 拿到 355KB 的 OpenAPI 3.1 文档,`servers[0]` 按 Host 注入、`{object}` 展开出 199 条 paths、两条 `x-template` —— 三个指纹全部是 rest-server 的行为)。因此 `apis:` 端点的文档面加入 **rest-server 既有的 enrichment 管线**(与 `{object}` 展开同根、同一次请求、同样 best-effort),而**不是**在某个 metadata service 上实现 `generateOpenApi` —— 那会造出 ADR-0076 第 1 条明令禁止的第二属主。E1 的契约成员因此已剔除。

每条声明贡献一个 path 条目:`path` 原样、`method` 小写作为 Operation 键、`operationId` = `name`,以及词表**真正带有**的两个文档字段 `summary` / `description`(缺省即缺省,不生成替身)。除此之外只写「执行器会怎么对待这条声明」的事实,逐条注明出处:`object_operation` 的 `get`/`update`/`delete` 记录 id 取 `query.id`(词表无路径模板语法)、`create` 答 201 其余 200、`script` / `proxy` 与缺 `objectParams` 的 `object_operation` 答 **501**。不编造任何 request/response schema —— 出厂文档的 `components.schemas` 是空的,凭空写 `$ref` 只会得到悬空引用。

`authRequired` 由 schema parse 物化(缺省即 `true`),为 true 的条目引用**从文档自身读出**的 security 方案(不在 rest 里硬写方案名,否则就是第二处需要保持正确的地方),为 false 的条目写显式 `security: []` —— 这是 review 时一眼能看见的那个形状。不满足 `ApiEndpointSchema` 的存量条目**响亮跳过**并点名(与端点匹配器的装载门同一姿态);同 `method+path` 撞车时按「`name` 字典序在前者胜」裁决,与匹配器**同一条规则**,否则文档会指认一个运行时并不执行的端点;撞上内建路径时内建保留,声明被略过并报错。

同时摘除 `http-dispatcher.ts` 里的 `generateOpenApi` 探测死分支:该方法在本仓与两个兄弟仓**零实现**,且 boot 实测**没有任何路由**把 `/openapi.json` 送进 `dispatch()` —— 双重死。`route-ledger.ts` 里对应的行与 `LEGACY_CHAIN_PREFIXES` 条目一并移除(原注记「falls through when metadata service lacks a generator」把「从来没有」写成了「有时没有」,正是 #5078 立单的失准点;把 prefix 留在一张自述为「if-chain 分支」的清单里,会在同一个 PR 里再造一次同样的谎)。该路由的唯一台账行在 `packages/rest/src/rest-route-ledger.ts`,一直是准的。

**现网行为零变更**:publish / validate 对非空 `apis:` 仍然硬拒(E7 前不撤),所以今天枚举出的是空集,enrichment 原样返回同一个文档对象 —— 服务出去的字节与本次改动前逐字节相同,并有测试钉住。
312 changes: 312 additions & 0 deletions packages/rest/src/openapi-endpoints.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #5040 E6 — declared endpoints in the OpenAPI document.
*
* Two jobs, and the second one is the load-bearing one TODAY:
*
* 1. the positive shapes, driven straight through the pure enrichment with
* parsed declarations (publish still refuses to let any of them exist, so
* there is no boot that could exercise them end to end yet);
* 2. the empty-set invariant — with no declarations the document must come
* back not merely equivalent but IDENTICAL, because that is the entire
* live-behaviour claim this change makes until the E7 flip.
*/

import { describe, it, expect, vi } from 'vitest';
import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api';
import {
buildEndpointOperation,
enrichOpenApiWithEndpoints,
resolveSecurityRequirement,
selectDocumentableEndpoints,
} from './openapi-endpoints';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** A document shaped like the one `@objectstack/spec/openapi.json` ships. */
function baseDoc() {
return {
openapi: '3.1.0',
info: { title: 'ObjectStack API', version: '17.0.0' },
servers: [{ url: 'http://localhost:3000' }],
paths: {
'/api/{object}': { get: { operationId: 'listRecords' }, post: { operationId: 'createRecord' } },
'/api/meta': { get: { operationId: 'getMeta' } },
},
components: { schemas: {}, securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } } },
security: [{ bearerAuth: [] }],
};
}

/** Parse a declaration the way the store's readers do — defaults materialised. */
function endpoint(input: Record<string, unknown>): ApiEndpoint {
return ApiEndpointSchema.parse(input);
}

const OBJECT_FIND = {
name: 'list_tasks',
path: '/api/v1/apps/showcase/tasks',
method: 'GET',
type: 'object_operation',
target: 'showcase_task',
objectParams: { object: 'showcase_task', operation: 'find' },
};

function collectingLogger() {
return { error: vi.fn() };
}

// ---------------------------------------------------------------------------
// The invariant this change stands on
// ---------------------------------------------------------------------------

describe('empty set — the served document does not move (#5093)', () => {
it('returns the SAME document object when no api items are declared', () => {
const doc = baseDoc();
expect(enrichOpenApiWithEndpoints(doc, [])).toBe(doc);
});

it('serialises byte-identically to the un-enriched document', () => {
const before = JSON.stringify(baseDoc());
const after = JSON.stringify(enrichOpenApiWithEndpoints(baseDoc(), []));
expect(after).toBe(before);
});

it('is byte-identical when every declared item is unparseable, too', () => {
// The degenerate middle case: items exist, none survive the schema. The
// document must land exactly where the empty case lands rather than
// sprouting an empty `paths` rewrite.
const logger = collectingLogger();
const doc = baseDoc();
const out = enrichOpenApiWithEndpoints(doc, [{ nope: true }, null], logger);
expect(out).toBe(doc);
expect(JSON.stringify(out)).toBe(JSON.stringify(baseDoc()));
});

it('leaves the base document untouched when endpoints ARE added', () => {
// The enricher must copy, never mutate: the base spec is cached across
// requests, so a mutation would leak one request's endpoints into every
// later response.
const doc = baseDoc();
const snapshot = JSON.stringify(doc);
const out = enrichOpenApiWithEndpoints(doc, [OBJECT_FIND]);
expect(out).not.toBe(doc);
expect(JSON.stringify(doc)).toBe(snapshot);
expect(Object.keys((out as any).paths)).toContain('/api/v1/apps/showcase/tasks');
});
});

// ---------------------------------------------------------------------------
// Entry shape, per endpoint type
// ---------------------------------------------------------------------------

describe('path entries', () => {
it('emits the literal path and the lower-cased method', () => {
const out = enrichOpenApiWithEndpoints(baseDoc(), [
{ ...OBJECT_FIND, method: 'DELETE', objectParams: { object: 'showcase_task', operation: 'delete' } },
]) as any;
const item = out.paths['/api/v1/apps/showcase/tasks'];
expect(Object.keys(item)).toEqual(['delete']);
expect(item.delete.operationId).toBe('list_tasks');
});

it('carries `summary` / `description` when declared and omits them when not', () => {
const documented = buildEndpointOperation(
endpoint({ ...OBJECT_FIND, summary: 'List tasks', description: 'Open tasks for the caller.' }),
undefined,
);
expect(documented.summary).toBe('List tasks');
expect(documented.description).toBe('Open tasks for the caller.');

const bare = buildEndpointOperation(endpoint(OBJECT_FIND), undefined);
expect(bare).not.toHaveProperty('summary');
expect(bare).not.toHaveProperty('description');
});

it('object_operation find: no id parameter, no body, 200', () => {
const op = buildEndpointOperation(endpoint(OBJECT_FIND), undefined);
expect(op).not.toHaveProperty('parameters');
expect(op).not.toHaveProperty('requestBody');
expect(Object.keys(op.responses as object)).toContain('200');
});

it.each(['get', 'update', 'delete'] as const)(
'object_operation %s: documents the required `id` query parameter',
(operation) => {
const op = buildEndpointOperation(
endpoint({ ...OBJECT_FIND, method: 'POST', objectParams: { object: 'showcase_task', operation } }),
undefined,
);
const params = op.parameters as Array<Record<string, unknown>>;
expect(params).toHaveLength(1);
expect(params[0]).toMatchObject({ name: 'id', in: 'query', required: true });
},
);

it('object_operation create: request body plus a 201', () => {
const op = buildEndpointOperation(
endpoint({ ...OBJECT_FIND, method: 'POST', objectParams: { object: 'showcase_task', operation: 'create' } }),
undefined,
);
expect(op.requestBody).toEqual({
required: true,
content: { 'application/json': { schema: { type: 'object' } } },
});
expect(Object.keys(op.responses as object)).toContain('201');
});

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.
const op = buildEndpointOperation(
endpoint({ ...OBJECT_FIND, method: 'POST', objectParams: { object: 'showcase_task', operation: 'create' } }),
undefined,
);
for (const response of Object.values(op.responses as Record<string, any>)) {
expect(Object.keys(response)).toEqual(['description']);
}
});

it('flow: the body is the flow input', () => {
const op = buildEndpointOperation(
endpoint({ name: 'purge', path: '/api/v1/apps/showcase/purge', method: 'POST', type: 'flow', target: 'janitor' }),
undefined,
);
expect(op.requestBody).toBeDefined();
expect(Object.keys(op.responses as object)).toContain('200');
});

it('omits the request body on methods that do not carry one', () => {
const op = buildEndpointOperation(
endpoint({ name: 'purge', path: '/api/v1/apps/showcase/purge', method: 'GET', type: 'flow', target: 'janitor' }),
undefined,
);
expect(op).not.toHaveProperty('requestBody');
});

it.each(['script', 'proxy'] as const)('%s is documented as 501, not as working', (type) => {
// Declared ≠ enforced is the defect this program removes; a document that
// advertised these as live endpoints would re-create it in the one artifact
// consumers generate clients from.
const op = buildEndpointOperation(
endpoint({ name: 'x', path: '/api/v1/apps/showcase/x', method: 'POST', type, target: 'whatever' }),
undefined,
);
const responses = op.responses as Record<string, { description: string }>;
expect(Object.keys(responses)).toContain('501');
expect(responses['501'].description).toMatch(/does not execute/);
expect(responses).not.toHaveProperty('200');
});

it('an object_operation missing objectParams is documented as 501', () => {
const op = buildEndpointOperation(
endpoint({ name: 'x', path: '/api/v1/apps/showcase/x', method: 'GET', type: 'object_operation', target: 't' }),
undefined,
);
expect(Object.keys(op.responses as object)).toContain('501');
});
});

// ---------------------------------------------------------------------------
// authRequired → security
// ---------------------------------------------------------------------------

describe('authRequired → security', () => {
it('reads the requirement off the document rather than hard-coding a scheme', () => {
expect(resolveSecurityRequirement(baseDoc())).toEqual([{ bearerAuth: [] }]);
// No document-level default → fall back to the first declared scheme.
expect(
resolveSecurityRequirement({ components: { securitySchemes: { apiKey: {}, bearerAuth: {} } } } as any),
).toEqual([{ apiKey: [] }]);
// Nothing declared → say nothing.
expect(resolveSecurityRequirement({} as any)).toBeUndefined();
});

it('authRequired defaults to true and points at the document scheme', () => {
const parsed = endpoint(OBJECT_FIND);
expect(parsed.authRequired).toBe(true);
const out = enrichOpenApiWithEndpoints(baseDoc(), [OBJECT_FIND]) as any;
const op = out.paths['/api/v1/apps/showcase/tasks'].get;
expect(op.security).toEqual([{ bearerAuth: [] }]);
expect(op.responses).toHaveProperty('401');
});

it('authRequired: false emits an explicit empty security list', () => {
const out = enrichOpenApiWithEndpoints(baseDoc(), [{ ...OBJECT_FIND, authRequired: false }]) as any;
const op = out.paths['/api/v1/apps/showcase/tasks'].get;
expect(op.security).toEqual([]);
expect(op.responses).not.toHaveProperty('401');
});

it('does not share security-requirement objects with the base document', () => {
const doc = baseDoc();
const out = enrichOpenApiWithEndpoints(doc, [OBJECT_FIND]) as any;
expect(out.paths['/api/v1/apps/showcase/tasks'].get.security[0]).not.toBe(doc.security[0]);
});
});

// ---------------------------------------------------------------------------
// Loud skips
// ---------------------------------------------------------------------------

describe('invalid and conflicting declarations are skipped loudly', () => {
it('drops an item that fails ApiEndpointSchema and names it', () => {
const logger = collectingLogger();
const kept = selectDocumentableEndpoints(
[{ name: 'Bad Name', path: 'no-leading-slash', method: 'GET', type: 'flow', target: 't' }, OBJECT_FIND],
logger,
);
expect(kept.map((e) => e.name)).toEqual(['list_tasks']);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error.mock.calls[0][0]).toContain("'Bad Name'");
expect(logger.error.mock.calls[0][0]).toContain('OMITTED');
});

it('resolves a duplicate route claim the way the endpoint matcher does', () => {
// `buildEndpointIndex` keeps the lexicographically-first `name`. If this
// module picked differently the document would name an endpoint the
// runtime does not run — a lie with a straight face.
const logger = collectingLogger();
const kept = selectDocumentableEndpoints(
[
{ ...OBJECT_FIND, name: 'zeta' },
{ ...OBJECT_FIND, name: 'alpha' },
],
logger,
);
expect(kept.map((e) => e.name)).toEqual(['alpha']);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error.mock.calls[0][0]).toContain('duplicate endpoint claim');
});

it('treats a trailing slash as the same route the matcher treats it as', () => {
const kept = selectDocumentableEndpoints([
{ ...OBJECT_FIND, name: 'alpha', path: '/api/v1/apps/showcase/tasks' },
{ ...OBJECT_FIND, name: 'beta', path: '/api/v1/apps/showcase/tasks/' },
]);
expect(kept).toHaveLength(1);
});

it('never displaces a built-in path+method (ADR-0076)', () => {
const logger = collectingLogger();
const doc = baseDoc();
const out = enrichOpenApiWithEndpoints(doc, [{ ...OBJECT_FIND, path: '/api/meta', method: 'GET' }], logger) as any;
// The built-in operation is untouched, and since that was the only
// declaration nothing was added — same document, byte for byte.
expect(out).toBe(doc);
expect(out.paths['/api/meta'].get.operationId).toBe('getMeta');
expect(JSON.stringify(out)).toBe(JSON.stringify(baseDoc()));
expect(logger.error.mock.calls[0][0]).toContain('one owner');
});

it('merges a new method into a path a built-in already describes', () => {
const out = enrichOpenApiWithEndpoints(baseDoc(), [
{ ...OBJECT_FIND, path: '/api/meta', method: 'POST', objectParams: { object: 'x', operation: 'create' } },
]) as any;
expect(out.paths['/api/meta'].get.operationId).toBe('getMeta');
expect(out.paths['/api/meta'].post.operationId).toBe('list_tasks');
});
});
Loading
Loading