diff --git a/.changeset/openapi-declared-endpoints.md b/.changeset/openapi-declared-endpoints.md new file mode 100644 index 0000000000..71635ac189 --- /dev/null +++ b/.changeset/openapi-declared-endpoints.md @@ -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 原样返回同一个文档对象 —— 服务出去的字节与本次改动前逐字节相同,并有测试钉住。 diff --git a/packages/rest/src/openapi-endpoints.test.ts b/packages/rest/src/openapi-endpoints.test.ts new file mode 100644 index 0000000000..908ac50ae4 --- /dev/null +++ b/packages/rest/src/openapi-endpoints.test.ts @@ -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): 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>; + 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)) { + 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; + 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'); + }); +}); diff --git a/packages/rest/src/openapi-endpoints.ts b/packages/rest/src/openapi-endpoints.ts new file mode 100644 index 0000000000..a016f10a4a --- /dev/null +++ b/packages/rest/src/openapi-endpoints.ts @@ -0,0 +1,374 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * DECLARED ENDPOINTS → OpenAPI path entries (#5040 E6, closing #5078). + * + * ## Why this lives in `packages/rest` + * + * `GET {basePath}/openapi.json` has exactly ONE owner, and a real boot proved + * which: `rest-server.ts` answers it with a 355KB enriched document (Host- + * injected `servers[0]`, 199 paths after `{object}` expansion, two `x-template` + * fingerprints — all three are rest-server behaviours). The dispatcher carried + * a `metaSvc.generateOpenApi` probe for the same path that was DOUBLY dead: + * no implementation of the method has ever existed in this repo or its two + * siblings, and no route delivered `/openapi.json` into `dispatch()` in the + * first place. #5078 recorded both facts; the E6 design amendment on #5040 drew + * the ADR-0076 conclusion — the documentation face joins the owner that already + * serves the route, and the shadow branch is deleted rather than implemented. + * Implementing `generateOpenApi` on a metadata service would have created the + * second owner that ADR-0076 §1 exists to forbid. + * + * So: this module extends the SAME enrichment pipeline that expands `{object}` + * placeholders. Same request, same document, same best-effort posture. + * + * ## What it will and will not say + * + * The `ApiEndpointSchema` vocabulary is FROZEN (#5040 §0) and carries exactly + * two documentation fields — `summary` and `description`. Everything else this + * module emits is either the declaration read back verbatim (`path`, `method`, + * `name`→`operationId`) or a fact about how the executor will treat that + * declaration, mirrored from `endpoint-executor.ts` with the line cited. There + * is no third category: no invented request/response schemas, no guessed + * summaries, no `x-` extensions. A published OpenAPI document is a contract + * consumers generate clients from — a fabricated schema in it is worse than an + * absent one, because absence is visible and fiction is not. + * + * ## The mirrored facts, and why they are mirrored rather than imported + * + * The execution semantics live in `@objectstack/runtime` (`planEndpointTarget`, + * `executeObjectOperation`) and the documentation face lives here, in a package + * that does not — and per the layering should not — depend on the runtime. The + * three facts restated below are each one line, each cited to its authority, + * and each covered by a test that fails if this file drifts: + * + * 1. `object_operation` takes its record id from `query.id` for `get` / + * `update` / `delete` (`endpoint-executor.ts` `requireRecordId`) — the + * frozen vocabulary has no path-template syntax, so a declared endpoint + * cannot express `/{id}`. + * 2. `create` answers 201, everything else 200 (`successAnswer` call sites). + * 3. `script` / `proxy`, and an `object_operation` missing its `objectParams`, + * answer 501 (`planEndpointTarget`'s `unsupported` arm). Documenting those + * as if they worked is the declared≠enforced defect this program exists to + * remove, so they are documented as what they are. + * + * A shared, spec-level description of an endpoint's HTTP contract would delete + * the mirror; it is not built here because Prime Directive #2 keeps logic out + * of `packages/spec` and the frozen vocabulary is not this unit's to widen. + * + * ## Today it emits nothing + * + * Publish/validate still rejects a non-empty `apis:` until the E7 flip, so the + * enumeration yields an empty set and {@link enrichOpenApiWithEndpoints} + * returns its input document BY REFERENCE — the served bytes are identical to + * before this change. That invariant is pinned by a test rather than argued. + */ + +import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api'; + +/** The slice of an OpenAPI document this module reads and rewrites. */ +export interface OpenApiDocumentLike { + paths?: Record; + security?: unknown; + [key: string]: unknown; +} + +/** + * Where a rejected declaration is reported. + * + * Same posture as the endpoint matcher's load gate (`buildEndpointIndex`): a + * stored item that fails `ApiEndpointSchema` is skipped LOUDLY and named, never + * documented half-parsed. A document that describes a declaration the matcher + * refuses to serve is the same class of lie as the ledger note #5078 was filed + * about. + */ +export interface EndpointDocLogger { + error(message: string, meta?: unknown): void; +} + +const DEFAULT_LOGGER: EndpointDocLogger = { + error: (message, meta) => + meta === undefined + ? (globalThis as { console?: Console }).console?.error(message) + : (globalThis as { console?: Console }).console?.error(message, meta), +}; + +/** + * Trim exactly one trailing slash, never from a lone `/`. + * + * Mirrors `normalizeEndpointPath` (`@objectstack/metadata` endpoint-matcher), + * restated rather than imported so the HTTP face does not take a runtime + * dependency on the metadata engine for one line. It matters that the two + * agree: the matcher treats `/x` and `/x/` as one route, so a document that + * listed both would advertise a route nobody serves. + */ +function normalizeDeclaredPath(path: string): string { + const raw = String(path ?? ''); + if (raw.length > 1 && raw.endsWith('/')) return raw.slice(0, -1); + return raw; +} + +/** OpenAPI Path Item keys are the lower-cased method names. */ +function operationKey(method: string): string { + return String(method ?? '').toLowerCase(); +} + +/** + * The security requirement an authenticated endpoint points at. + * + * Read OFF THE DOCUMENT (its own `security`, else its first declared scheme) + * instead of hard-coding a scheme name here. The document is generated from + * `packages/spec`; naming `bearerAuth` in this file would create a second + * place that has to be right, and it would go stale silently — the document + * would still parse, the operation would just point at a scheme that no longer + * exists. Returns `undefined` when the document declares no security at all, + * in which case authenticated operations simply say nothing (and inherit + * whatever the document-level default is, per the OpenAPI object model). + */ +export function resolveSecurityRequirement(doc: OpenApiDocumentLike): unknown[] | undefined { + const docSecurity = doc.security; + if (Array.isArray(docSecurity) && docSecurity.length > 0) { + return docSecurity.map((req) => (req && typeof req === 'object' ? { ...(req as object) } : req)); + } + const schemes = (doc.components as { securitySchemes?: Record } | undefined)?.securitySchemes; + const first = schemes && typeof schemes === 'object' ? Object.keys(schemes)[0] : undefined; + return first ? [{ [first]: [] }] : undefined; +} + +/** How the executor will treat this declaration — the mirrored facts, in one place. */ +interface EndpointHttpFacts { + /** Status the executor answers on success (or 501 when it cannot execute). */ + successStatus: number; + /** `true` when the executor reads a record id off `query.id`. */ + requiresRecordId: boolean; + /** `true` when the executor reads the request body. */ + readsBody: boolean; + /** Set when the runtime refuses to execute the declaration at all. */ + unsupportedReason?: string; +} + +/** + * Mirror of `planEndpointTarget` + the `object_operation` operation table + * (`endpoint-executor.ts`), reduced to the four facts a path entry needs. + */ +function httpFactsFor(endpoint: ApiEndpoint): EndpointHttpFacts { + if (endpoint.type === 'object_operation') { + const object = endpoint.objectParams?.object; + const operation = endpoint.objectParams?.operation; + if (!object || !operation) { + return { + successStatus: 501, + requiresRecordId: false, + readsBody: false, + unsupportedReason: + "declares type 'object_operation' without both `objectParams.object` and `objectParams.operation`", + }; + } + return { + successStatus: operation === 'create' ? 201 : 200, + requiresRecordId: operation === 'get' || operation === 'update' || operation === 'delete', + readsBody: operation === 'create' || operation === 'update', + }; + } + + if (endpoint.type === 'flow') { + if (!endpoint.target) { + return { + successStatus: 501, + requiresRecordId: false, + readsBody: false, + unsupportedReason: "declares type 'flow' but names no target flow", + }; + } + // The request body is the flow input, exactly as on + // `POST /automation/:name/trigger`. + return { successStatus: 200, requiresRecordId: false, readsBody: true }; + } + + return { + successStatus: 501, + requiresRecordId: false, + readsBody: false, + unsupportedReason: `declares type '${endpoint.type}', which this runtime does not execute in 17.x`, + }; +} + +/** Methods that carry a request body in HTTP, and therefore in the document. */ +const BODY_METHODS = new Set(['POST', 'PUT', 'PATCH']); + +/** + * One OpenAPI Operation Object for one declaration. + * + * Exported for the tests that assert the shape per endpoint type; the enricher + * below is the only production caller. + */ +export function buildEndpointOperation( + endpoint: ApiEndpoint, + securityRequirement: unknown[] | undefined, +): Record { + const facts = httpFactsFor(endpoint); + const operation: Record = { operationId: endpoint.name }; + + // `summary` / `description` are the ONLY documentation the frozen vocabulary + // carries. Absent means absent — no derived stand-in, because a generated + // sentence reads exactly like an authored one and cannot be told apart later. + if (endpoint.summary) operation.summary = endpoint.summary; + if (endpoint.description) operation.description = endpoint.description; + + if (facts.requiresRecordId) { + operation.parameters = [ + { + name: 'id', + in: 'query', + required: true, + schema: { type: 'string' }, + description: + 'Record id. Declared endpoints take it from the query string — the endpoint vocabulary defines no path templates.', + }, + ]; + } + + 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. + operation.requestBody = { + required: true, + content: { 'application/json': { schema: { type: 'object' } } }, + }; + } + + const responses: Record = {}; + if (facts.unsupportedReason) { + responses['501'] = { + description: `Not implemented — this endpoint ${facts.unsupportedReason}.`, + }; + } else { + responses[String(facts.successStatus)] = { + description: facts.successStatus === 201 ? 'Created' : 'Success', + }; + } + if (endpoint.authRequired) responses['401'] = { description: 'Unauthorized' }; + operation.responses = responses; + + // `authRequired` is `true` by default and materialised by the parse above, so + // there is no "unset" state to document. An explicit `security: []` is the + // OpenAPI way to say "this operation takes no credentials" — the one shape a + // reviewer should be able to spot in a diff. + if (endpoint.authRequired) { + if (securityRequirement) operation.security = securityRequirement; + } else { + operation.security = []; + } + + return operation; +} + +/** + * Parse the stored `api` items, dropping (loudly) everything that is not a + * valid declaration, and resolve duplicate route claims the way the matcher + * does. + * + * Exported for tests. The duplicate rule — lexicographically-first `name` keeps + * the route — is `buildEndpointIndex`'s, restated here for the same reason it + * was chosen there: it is total, deterministic across nodes and boots, and it + * must produce the SAME winner, or the document would name an endpoint the + * runtime does not run. + */ +export function selectDocumentableEndpoints( + items: readonly unknown[], + logger: EndpointDocLogger = DEFAULT_LOGGER, +): ApiEndpoint[] { + const byRoute = new Map(); + + for (const item of items) { + const parsed = ApiEndpointSchema.safeParse(item); + if (!parsed.success) { + const declaredName = + item && typeof item === 'object' && typeof (item as { name?: unknown }).name === 'string' + ? (item as { name: string }).name + : ''; + logger.error( + `[REST] stored api item '${declaredName}' does not satisfy ApiEndpointSchema — it is OMITTED ` + + `from the OpenAPI document. The endpoint matcher excludes it too, so the route it declares ` + + `answers 404; fix the declaration rather than the document.`, + { issues: parsed.error.issues }, + ); + continue; + } + + const endpoint = parsed.data; + const key = `${endpoint.method.toUpperCase()} ${normalizeDeclaredPath(endpoint.path)}`; + const incumbent = byRoute.get(key); + if (!incumbent) { + byRoute.set(key, endpoint); + continue; + } + + const challengerWins = endpoint.name < incumbent.name; + if (challengerWins) byRoute.set(key, endpoint); + const winner = challengerWins ? endpoint : incumbent; + const loser = challengerWins ? incumbent : endpoint; + logger.error( + `[REST] duplicate endpoint claim on '${key}': api items '${incumbent.name}' and '${endpoint.name}' ` + + `both declare it. '${winner.name}' is documented and '${loser.name}' is OMITTED — the rule is ` + + `lexicographically-first \`name\` wins, the same one the endpoint matcher applies, so the ` + + `document and the runtime name the same owner.`, + { key, documented: winner.name, omitted: loser.name }, + ); + } + + return [...byRoute.values()]; +} + +/** + * Fold declared endpoints into an OpenAPI document's `paths`. + * + * Returns `doc` ITSELF when there is nothing to add — that is what makes the + * empty-set case byte-identical rather than merely equivalent, and it is the + * state of the world until the E7 flip lets a non-empty `apis:` publish. + * + * A declaration never displaces a built-in: if the document already describes + * the same path+method, the built-in keeps it and the declaration is reported. + * ADR-0076's rule is one route, one owner; where the two could disagree, the + * package that actually mounts the route wins, in the document as on the wire. + */ +export function enrichOpenApiWithEndpoints( + doc: T, + items: readonly unknown[], + logger: EndpointDocLogger = DEFAULT_LOGGER, +): T { + if (!Array.isArray(items) || items.length === 0) return doc; + + const endpoints = selectDocumentableEndpoints(items, logger); + if (endpoints.length === 0) return doc; + + const securityRequirement = resolveSecurityRequirement(doc); + const paths: Record = { ...(doc.paths ?? {}) }; + let added = 0; + + for (const endpoint of endpoints) { + const path = normalizeDeclaredPath(endpoint.path); + const key = operationKey(endpoint.method); + const existing = paths[path] as Record | undefined; + + if (existing && typeof existing === 'object' && key in existing) { + logger.error( + `[REST] declared endpoint '${endpoint.name}' claims '${endpoint.method} ${path}', which this ` + + `document already describes as a built-in route. The built-in KEEPS it and the declaration is ` + + `OMITTED from the document — a path has one owner (ADR-0076), and the owner is whoever mounts it.`, + { endpoint: endpoint.name, method: endpoint.method, path }, + ); + continue; + } + + paths[path] = { ...(existing ?? {}), [key]: buildEndpointOperation(endpoint, securityRequirement) }; + added += 1; + } + + if (added === 0) return doc; + return { ...doc, paths }; +} diff --git a/packages/rest/src/rest-openapi-route.test.ts b/packages/rest/src/rest-openapi-route.test.ts new file mode 100644 index 0000000000..33488b2e13 --- /dev/null +++ b/packages/rest/src/rest-openapi-route.test.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `GET {basePath}/openapi.json` at the route level (#5040 E6, #5078). + * + * The pure enrichment is unit-tested in `openapi-endpoints.test.ts`; what this + * file covers is the part only the server can answer — that the handler really + * asks the protocol for `api` items alongside `object` items, and that with the + * empty set the world's response is unchanged. + * + * This route has ONE owner. #5078 established it with a real boot after a + * shadow `generateOpenApi` branch in the dispatcher had spent months looking + * like a second one; these assertions are the cheap standing version of that + * boot, so the ownership claim stops depending on somebody re-running it. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +function makeServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn(), close: vi.fn(), + } as any; +} + +/** + * A protocol that records which metadata types were enumerated and answers + * each from `items`, in the `{ type, items }` envelope `getMetaItems` declares. + */ +function makeProtocol(items: Record) { + const asked: string[] = []; + const protocol: any = { + getMetaItems: vi.fn(async ({ type }: { type: string }) => { + asked.push(type); + return { type, items: items[type] ?? [] }; + }), + }; + return { protocol, asked }; +} + +/** Drive the registered `GET {base}/openapi.json` handler and read the body. */ +async function serveOpenApi(protocol: any) { + const rest = new RestServer(makeServer(), protocol, { api: { requireAuth: false, version: 'v1' } } as any); + (rest as any).registerOpenApiEndpoints('/api/v1'); + const entry = (rest as any).routeManager.get('GET', '/api/v1/openapi.json'); + expect(entry, 'the openapi.json route must be registered by this package').toBeDefined(); + + let status = 200; + let body: any; + const res: any = { + status: (c: number) => { status = c; return res; }, + json: (b: any) => { body = b; }, + setHeader: () => {}, + send: () => {}, + }; + await entry.handler({ headers: { host: 'example.test' }, params: {}, path: '/api/v1/openapi.json' }, res); + return { status, body }; +} + +const TASKS_ENDPOINT = { + name: 'list_tasks', + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' }, + summary: 'List showcase tasks', +}; + +describe('GET /api/v1/openapi.json — endpoint enrichment', () => { + it('enumerates `api` items alongside `object` items', async () => { + const { protocol, asked } = makeProtocol({ object: [], api: [] }); + await serveOpenApi(protocol); + expect(asked).toContain('object'); + expect(asked).toContain('api'); + }); + + it('serves a document identical to the pre-#5093 one while no endpoint is declared', async () => { + // The load-bearing invariant: publish rejects a non-empty `apis:` until the + // E7 flip, so this is the ONLY state that exists in production today, and + // the change is required to be invisible in it. Compared against the same + // handler fed a protocol with no `api` capability at all — i.e. the world + // exactly as it was before the enrichment step existed. + const withEmptyApis = await serveOpenApi(makeProtocol({ object: [], api: [] }).protocol); + const withoutApiSupport = await serveOpenApi({ getMetaItems: vi.fn(async () => ({ type: 'object', items: [] })) }); + + expect(withEmptyApis.status).toBe(200); + expect(JSON.stringify(withEmptyApis.body)).toBe(JSON.stringify(withoutApiSupport.body)); + }); + + it('adds one path entry per declared endpoint', async () => { + const { protocol } = makeProtocol({ object: [], api: [TASKS_ENDPOINT] }); + const { body } = await serveOpenApi(protocol); + const item = body.paths['/api/v1/apps/showcase/tasks']; + expect(item).toBeDefined(); + expect(item.get.operationId).toBe('list_tasks'); + expect(item.get.summary).toBe('List showcase tasks'); + // `authRequired` defaults to true, so it points at the document's scheme. + expect(item.get.security).toEqual(body.security); + }); + + it('still serves the document when the api enumeration throws', async () => { + // A metadata store outage must cost the endpoint section, never the + // document — the base spec and the object expansion are independent of it. + const protocol: any = { + getMetaItems: vi.fn(async ({ type }: { type: string }) => { + if (type === 'api') throw new Error('store unavailable'); + return { type, items: [] }; + }), + }; + const { status, body } = await serveOpenApi(protocol); + expect(status).toBe(200); + expect(body.openapi).toBe('3.1.0'); + }); + + it('keeps serving `{object}` expansion unchanged alongside the new step', async () => { + const { protocol } = makeProtocol({ object: [{ name: 'showcase_task' }], api: [] }); + const { body } = await serveOpenApi(protocol); + const expanded = Object.keys(body.paths).filter((p) => p.includes('showcase_task')); + expect(expanded.length).toBeGreaterThan(0); + // The template row survives, marked, exactly as before. + expect(body.paths['/api/{object}']['x-template']).toBe(true); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index f55dbaac58..b64d0899ae 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -43,6 +43,7 @@ import { } from './export-format.js'; import { runImport } from './import-runner.js'; import { prepareImportRequest, isMetaEnvelope } from './import-prepare.js'; +import { enrichOpenApiWithEndpoints } from './openapi-endpoints.js'; // Node-safe logger — avoids importing 'console' which is absent from ES2020 lib typings. const logError = (...args: unknown[]) => (globalThis as any).console?.error(...args); @@ -2528,6 +2529,14 @@ export class RestServer { * - paths — `{object}` placeholders expanded into * one concrete path per registered object * from the protocol's discovery metadata + * - paths — one entry per declared `api` endpoint + * (#5040 E6, see openapi-endpoints.ts) + * + * This package is the SOLE owner of the route (ADR-0076, proven by the + * real boot in #5078), which is why the endpoint documentation joins this + * pipeline instead of a `generateOpenApi` on some metadata service — that + * would have been the second owner ADR-0076 forbids. The dispatcher's + * probe for such a method was deleted in the same change. * * The base spec is loaded lazily from @objectstack/spec/openapi.json * (shipped pre-generated by spec's build pipeline) so we don't pay @@ -2550,7 +2559,7 @@ export class RestServer { // Clone shallowly so per-request mutations (server URL, // expanded paths) don't bleed into the cached base spec. - const enriched: any = { ...spec, servers: [...(spec.servers ?? [])] }; + let enriched: any = { ...spec, servers: [...(spec.servers ?? [])] }; // 1) Override servers[0] with the actual request origin so // "Try it" works straight from the docs viewer. @@ -2565,12 +2574,22 @@ export class RestServer { ]; } + // Metadata-driven enrichment (steps 2 and 3) reads through one + // resolved protocol, but each step carries its own `try`: they + // describe different surfaces, and a failure to enumerate one + // must not silently blank the other. + let protocol: RestProtocol | undefined; + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + protocol = await this.resolveProtocol(environmentId, req); + } catch { + // Enrichment is best-effort — never fail the spec serve. + } + // 2) Expand `{object}` path placeholders into concrete // routes for every registered data object. Falls back // silently if discovery isn't available. try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const protocol = await this.resolveProtocol(environmentId, req); const items = await protocol?.getMetaItems?.({ type: 'object' }).catch(() => null) as any; const objects: string[] = Array.isArray(items?.items) ? items.items.map((i: any) => i?.name).filter(Boolean) @@ -2598,6 +2617,33 @@ export class RestServer { // Enrichment is best-effort — never fail the spec serve. } + // 3) Fold in the endpoints declared as `api` metadata (#5040 + // E6). Same enumeration root as the `{object}` expansion + // above — this document is the ONE documentation face for + // `/openapi.json`, which this package alone serves (#5078, + // ADR-0076): declared endpoints join it here rather than + // growing a second generator somewhere else. + // + // Until the E7 flip a non-empty `apis:` cannot publish, so + // the enumeration is empty and `enrichOpenApiWithEndpoints` + // hands `enriched` straight back — the served bytes today + // are exactly the ones served before this change. + try { + const apiResult = await protocol?.getMetaItems?.({ type: 'api' }); + const apiItems: unknown[] = Array.isArray((apiResult as any)?.items) + ? (apiResult as any).items + : Array.isArray(apiResult) ? apiResult as unknown[] : []; + enriched = enrichOpenApiWithEndpoints(enriched, apiItems, { + error: (message: string, meta?: unknown) => + meta === undefined ? logError(message) : logError(message, meta), + }); + } catch (err: any) { + // A store that cannot be read must not take the document + // down with it — but say so, because a silently endpoint- + // less document looks exactly like a correct one. + logError('[REST] openapi.json endpoint enrichment skipped:', err?.message ?? err); + } + // Surface the runtime version so consumers don't pin to // the spec package's compile-time version. if (enriched.info) { diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 41c9ee9676..dfaf9ff429 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1658,18 +1658,32 @@ export class HttpDispatcher { // /share-links moved to the domain registry (D11 step ③). - // OpenAPI Specification - if (cleanPath === '/openapi.json' && method === 'GET') { - try { - const metaSvc = await this.resolveService('metadata', context.environmentId); - if (metaSvc && typeof (metaSvc as any).generateOpenApi === 'function') { - const result = await (metaSvc as any).generateOpenApi({}); - return { handled: true, response: this.success(result) }; - } - } catch (e) { - // If not implemented, fall through or return 404 - } - } + // OpenAPI specification — REMOVED in #5093 (#5078), NOT relocated. + // + // A `GET /openapi.json` branch used to sit here. It resolved the + // metadata service and duck-typed a `generateOpenApi` method that no + // implementation has ever provided — not `MetadataManager`, not + // `NodeMetadataManager`, not a plugin, not either sibling repo. The + // only other repo-wide hits for the name are an unrelated boolean + // config key (`api/ApiDocumentationConfig.generateOpenApi`) and its + // tests. So the `if` was constant-false on every request ever served. + // + // It was dead a second way too, which is what settles the disposition: + // a real boot (#5078 — showcase, 47 plugins, RestAPI and Dispatcher + // co-mounted) showed NOTHING routes `/openapi.json` into `dispatch()` + // at all. `packages/rest` owns and answers it — `rest-server.ts` + // returned a 355KB OpenAPI 3.1 document with a Host-injected + // `servers[0]`, 199 expanded paths and two `x-template` markers, every + // one of them a rest-server fingerprint. + // + // Deleted rather than implemented, per ADR-0076 "one route, one owner": + // a second implementation of a path another package already serves is + // code `grep` finds and the runtime never runs — the exact input that + // makes the next reader reason confidently from dead code. The declared + // endpoints' `summary`/`description` reach the document through the + // owner's own enrichment pipeline instead (#5040 E6, + // `packages/rest/src/openapi-endpoints.ts`). Do not re-add a branch + // here; the ledger row went with it. // 2. Metadata-declared custom endpoints (`apis:`) — REMOVED in #4936. // diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 7ef4ee187e..3a5b57f439 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -102,7 +102,17 @@ export const LEGACY_CHAIN_PREFIXES = [ '/mcp/skill', '/mcp', '/actions', - '/openapi.json', + // `/openapi.json` was REMOVED in #5093 (closing #5078). It was pinned here + // for a `dispatch()` branch that duck-typed a `generateOpenApi` method + // nothing implements, over a path nothing routes into `dispatch()` — dead + // twice over, and deleted with the branch. The route is real and healthy; + // `packages/rest` owns it end to end (`rest-server.ts`, and the row in + // `rest-route-ledger.ts` that describes it truthfully). Leaving the prefix + // pinned here would have made THIS list say "a branch of the if-chain" + // about something that is not one — the same way the row's note said the + // route "falls through when metadata service lacks a generator" when in + // fact no generator has ever existed. That is the failure #5078 was filed + // about; a list is not allowed to lie in the same PR that stops one. // `/__api-endpoint` (the `handleApiEndpoint` catch-all for metadata-declared // `apis:`) was REMOVED in #4936. It never named a mounted route: the branch // it stood for resolved a `matchEndpoint` method no implementation in this @@ -292,7 +302,17 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ }, // ── misc legacy ─────────────────────────────────────────────────────────── - { route: 'GET /openapi.json', domain: '/openapi.json', disposition: 'server-only', note: 'docs tooling; falls through when metadata service lacks a generator' }, + // `GET /openapi.json` removed in #5093 (closing #5078). This ledger + // enumerates the routes THIS package's dispatcher serves, and after the dead + // `generateOpenApi` branch was deleted it serves none at that path. The route + // itself is alive and owned by `packages/rest` — see the `GET + // /api/v1/openapi.json` row in `packages/rest/src/rest-route-ledger.ts`, + // which is the one place that describes it. The removed note here claimed the + // path "falls through when metadata service lacks a generator", implying a + // generator sometimes exists; none ever has, in this repo or its two + // siblings, so the row was 100% fall-through wearing a conditional. Per + // ADR-0076 §4 a machine-readable surface must not lie, and per §1 a path has + // one owner: do not re-add a row (or a dispatcher branch) for this path. // `* (unmatched)` / `/__api-endpoint` removed in #4936 — see LEGACY_CHAIN_PREFIXES // above. It was the ledger's only row for a surface nothing served; an // unmatched path now falls to the semantic 404 with no pretence otherwise.