From 76f507c0eed7c72717a0d808ed9299ce53bceaef Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:40:01 +0000 Subject: [PATCH] fix(rest): openapi.json strips the read-time decorations before its closed parse selectDocumentableEndpoints re-parses SERVED api items: where no endpoint matcher is reachable, the openapi.json handler hands it getMetaItems output, which carries _diagnostics. ApiEndpointSchema is closed, so every valid declaration was omitted and logged at error as one to fix. Strip exactly METADATA_READ_DECORATIONS (stripReadDecorations from @objectstack/spec/kernel) before the parse, the shape the seed door uses. The omission line no longer claims the endpoint matcher excludes the item too: measured, the matcher serves items this parse refuses. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude --- .changeset/23083-openapi-read-decorations.md | 17 ++ ...openapi-endpoints-read-decorations.test.ts | 242 ++++++++++++++++++ packages/rest/src/openapi-endpoints.ts | 21 +- 3 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 .changeset/23083-openapi-read-decorations.md create mode 100644 packages/rest/src/openapi-endpoints-read-decorations.test.ts diff --git a/.changeset/23083-openapi-read-decorations.md b/.changeset/23083-openapi-read-decorations.md new file mode 100644 index 00000000000..0bedbcd0197 --- /dev/null +++ b/.changeset/23083-openapi-read-decorations.md @@ -0,0 +1,17 @@ +--- +'@objectstack/rest': patch +--- + +fix(rest): `GET /api/v1/openapi.json` documents a valid declared endpoint that the served read decorated, and stops logging it as a declaration to fix (#23083) + +Clause-②: no + +**What was wrong.** On a composition with no endpoint matcher (no `metadata` service with `matchEndpoint`, for example `@objectstack/verify`'s `bootStack`), the document is built from the `api` items `getMetaItems` serves. That read adds `_diagnostics` to every item, and `ApiEndpointSchema` is closed, so the document's own parse refused every valid declaration by that key. The endpoints were left out of the document. For each one, the server logged at `error` that the declaration did not satisfy `ApiEndpointSchema` and should be fixed. Measured on the showcase booted through `bootStack`: 0 of its 2 declared endpoints in the document, and 2 such `error` lines. + +**What changes.** + +- The document's parse first removes the read-time decorations (`METADATA_READ_DECORATIONS` from `@objectstack/spec/kernel`: `_diagnostics`, `_draft`). It is the same strip the seed door applies. On that boot the document now lists both endpoints, and no line about them is logged. +- The ADR-0010 envelope keys (`_packageId`, `_provenance`, …) are allowlisted by the schema and parse as before. Any other unknown key is still refused. +- A declaration that really is invalid is still left out of the document and logged at `error`. The log line no longer says that the endpoint matcher excludes the item too. The document's parse does not know that: the matcher judges its own stored copy after removing the storage envelope, and where no matcher is reachable there is none to ask. + +**Upgrading.** Nothing to change. `ApiEndpointSchema` is unchanged. diff --git a/packages/rest/src/openapi-endpoints-read-decorations.test.ts b/packages/rest/src/openapi-endpoints-read-decorations.test.ts new file mode 100644 index 00000000000..e731aa25e22 --- /dev/null +++ b/packages/rest/src/openapi-endpoints-read-decorations.test.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #23083 — the OpenAPI reader re-parses a SERVED `api` item, so it strips the + * read-time decorations before its closed parse. + * + * `GET /openapi.json` enumerates `api` items through `protocol.getMetaItems`, + * whose served audience exits through `decorateMetadataItems` and stamps + * `_diagnostics` on every item (`api` has a registered schema). Where no + * endpoint matcher is reachable — a composition whose `metadata` slot is the + * kernel's in-memory fallback, `@objectstack/verify`'s `bootStack` among them — + * the handler hands those served items to `selectDocumentableEndpoints` as they + * are, and the closed `ApiEndpointSchema` refused the platform's own badge by + * name, `unrecognized_keys: ["_diagnostics"]`: every VALID declaration was + * omitted from the document and logged at `error` as a declaration to fix. + * + * The fixtures are the platform's own output: `decorateMetadataItems` from + * `@objectstack/metadata-protocol` is the producer the served read calls, so a + * decoration it stamps is in the fixture without anyone editing this file. + * + * Controls, each the other half of a pin above it: + * - a truly invalid item is still omitted and logged — the strip launders + * nothing, and the refusal names the item's real defect; + * - the ADR-0010 envelope (`_packageId`, `_provenance`) parses exactly as it + * did — the closed schema allowlists it, so it is not a decoration; + * - an underscore key that is NOT a declared read decoration is still refused: + * the strip is `METADATA_READ_DECORATIONS`, never a blanket `_` prefix. + */ + +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; +import { ApiEndpointSchema } from '@objectstack/spec/api'; +import { decorateMetadataItems } from '@objectstack/metadata-protocol'; +import { enrichOpenApiWithEndpoints, selectDocumentableEndpoints } from './openapi-endpoints'; +import { RestServer } from './rest-server'; + +// [#17865] The handler-level pin below observes the REST fault log, so this +// file declares the level it asserts against instead of inheriting the suite's +// quiet one. 'info' is the SHIPPED default — what a real caller gets. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); +afterEach(() => { vi.restoreAllMocks(); }); + +// --------------------------------------------------------------------------- +// The showcase's two declarations, restated (not imported — an example app is +// not this package's dependency) +// --------------------------------------------------------------------------- + +const TASK_FEED = { + name: 'showcase_task_feed', + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + summary: 'Task feed', + type: 'object_operation', + objectParams: { object: 'showcase_task', operation: 'find' }, + authRequired: true, + cacheTtlSeconds: 30, +}; + +const INQUIRY_PURGE = { + name: 'showcase_inquiry_purge_api', + path: '/api/v1/apps/showcase/inquiries/purge', + method: 'POST', + summary: 'Purge closed inquiries', + type: 'flow', + target: 'showcase_inquiry_purge', + authRequired: true, +}; + +/** What the served read hands a reader: the producer's own decoration. */ +function servedRead(items: Array>): Array> { + return decorateMetadataItems('api', items.map((item) => ({ ...item }))); +} + +function collectingLogger() { + return { error: vi.fn() }; +} + +function baseDoc() { + return { + openapi: '3.1.0', + info: { title: 'ObjectStack API', version: '17.0.0' }, + paths: { '/api/meta': { get: { operationId: 'getMeta' } } }, + components: { schemas: {}, securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } } }, + security: [{ bearerAuth: [] }], + }; +} + +// --------------------------------------------------------------------------- +// Pins +// --------------------------------------------------------------------------- + +describe('a valid declaration is documented however the read decorated it (#23083)', () => { + it('documents both items exactly as the served read decorates them, and logs nothing', () => { + const served = servedRead([TASK_FEED, INQUIRY_PURGE]); + // Precondition: the fixture really carries the decoration, and the producer + // itself calls both items valid — so a refusal here can only be the reader's. + for (const item of served) expect(item._diagnostics).toEqual({ valid: true }); + + const logger = collectingLogger(); + const kept = selectDocumentableEndpoints(served, logger); + + expect(kept.map((e) => e.name).sort()).toEqual(['showcase_inquiry_purge_api', 'showcase_task_feed']); + expect(logger.error).not.toHaveBeenCalled(); + for (const endpoint of kept) expect(endpoint).not.toHaveProperty('_diagnostics'); + }); + + it('documents an item carrying the draft badge, and logs nothing', () => { + const logger = collectingLogger(); + const kept = selectDocumentableEndpoints([{ ...TASK_FEED, _draft: true }], logger); + + expect(kept.map((e) => e.name)).toEqual(['showcase_task_feed']); + expect(logger.error).not.toHaveBeenCalled(); + expect(kept[0]).not.toHaveProperty('_draft'); + }); + + it('folds the decorated declarations into the document as path entries', () => { + const logger = collectingLogger(); + const out = enrichOpenApiWithEndpoints(baseDoc(), servedRead([TASK_FEED, INQUIRY_PURGE]), logger) as any; + + expect(out.paths['/api/v1/apps/showcase/tasks'].get.operationId).toBe('showcase_task_feed'); + expect(out.paths['/api/v1/apps/showcase/inquiries/purge'].post.operationId).toBe('showcase_inquiry_purge_api'); + expect(logger.error).not.toHaveBeenCalled(); + }); +}); + +describe('controls — the strip removes the declared decorations and nothing else', () => { + it('still omits and logs a truly invalid item, naming its real defect', () => { + const served = servedRead([{ ...TASK_FEED, name: 'bad_type', type: 'nope' }]); + // The producer agrees it is invalid — the decoration rides on a bad item. + expect((served[0]._diagnostics as { valid: boolean }).valid).toBe(false); + + const logger = collectingLogger(); + const kept = selectDocumentableEndpoints(served, logger); + + expect(kept).toEqual([]); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error.mock.calls[0][0]).toContain("'bad_type'"); + const { issues } = logger.error.mock.calls[0][1] as { issues: Array<{ code: string; path: unknown[]; keys?: string[] }> }; + // The refusal is about the item, not about our own badge. + expect(issues.some((i) => i.path[0] === 'type')).toBe(true); + expect(issues.some((i) => i.code === 'unrecognized_keys')).toBe(false); + }); + + it('parses the ADR-0010 envelope exactly as before, decorated or not', () => { + const enveloped = { ...TASK_FEED, _packageId: 'com.example.showcase', _provenance: 'package' }; + const expected = ApiEndpointSchema.parse(enveloped); + + const logger = collectingLogger(); + const [plain] = selectDocumentableEndpoints([enveloped], logger); + const [decorated] = selectDocumentableEndpoints(servedRead([enveloped]), logger); + + expect(plain).toEqual(expected); + expect(decorated).toEqual(expected); + expect(decorated._packageId).toBe('com.example.showcase'); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('still refuses an underscore key that is not a declared read decoration', () => { + const logger = collectingLogger(); + const kept = selectDocumentableEndpoints([{ ...TASK_FEED, _unexpected: true }], logger); + + expect(kept).toEqual([]); + expect(logger.error).toHaveBeenCalledTimes(1); + const { issues } = logger.error.mock.calls[0][1] as { issues: Array<{ code: string; keys?: string[] }> }; + expect(issues.find((i) => i.code === 'unrecognized_keys')?.keys).toEqual(['_unexpected']); + }); +}); + +// --------------------------------------------------------------------------- +// The production caller, on a composition with no endpoint matcher +// --------------------------------------------------------------------------- + +function createMockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn(() => res); res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn(); + return res; +} + +/** + * The REST server with NO metadata service wired — the shape of a composition + * whose `metadata` slot cannot answer `matchEndpoint` — over a protocol whose + * `getMetaItems` answers what the served read answers. + */ +function mountWithoutMatcher(served: Array>) { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn(async ({ type }: any) => { + const t = String(type ?? ''); + if (t === 'api' || t === 'apis') return { type: 'api', items: served }; + return { type: t, items: [] }; + }), + getMetaItem: vi.fn().mockResolvedValue({}), + findData: vi.fn().mockResolvedValue([]), + }; + const rest = new RestServer( + createMockServer() as any, + protocol, + { api: { requireAuth: false, version: 'v1' } } as any, + ); + // The description document denies anonymous callers; an identity is a + // precondition of reaching the handler, not the subject here. + (rest as any).resolveExecCtx = async () => ({ userId: 'u1' }); + rest.registerRoutes(); + (rest as any).registerOpenApiEndpoints('/api/v1'); + return rest; +} + +async function getOpenApi(rest: any) { + const entry = rest.routeManager.get('GET', '/api/v1/openapi.json'); + if (!entry) throw new Error('openapi.json route not registered'); + const res = makeRes(); + await entry.handler({ method: 'GET', headers: { host: 'example.test' }, params: {}, query: {}, path: '/api/v1/openapi.json' }, res); + return res; +} + +describe('GET /openapi.json with no endpoint matcher reachable', () => { + it('lists both declared endpoints, and no error line names either of them', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const rest = mountWithoutMatcher(servedRead([TASK_FEED, INQUIRY_PURGE])); + + const res = await getOpenApi(rest); + + expect(res.statusCode).toBe(200); + const declared = Object.keys(res.body?.paths ?? {}).filter((p) => p.startsWith('/api/v1/apps/')).sort(); + expect(declared).toEqual(['/api/v1/apps/showcase/inquiries/purge', '/api/v1/apps/showcase/tasks']); + + const lines = errorSpy.mock.calls.map((args) => args.map(String).join(' ')); + expect(lines.filter((l) => l.includes('showcase_task_feed') || l.includes('showcase_inquiry_purge_api'))).toEqual([]); + }, 60_000); +}); diff --git a/packages/rest/src/openapi-endpoints.ts b/packages/rest/src/openapi-endpoints.ts index 0ca36b414fe..45e2bea3bfc 100644 --- a/packages/rest/src/openapi-endpoints.ts +++ b/packages/rest/src/openapi-endpoints.ts @@ -90,6 +90,7 @@ */ import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api'; +import { stripReadDecorations } from '@objectstack/spec/kernel'; /** The slice of an OpenAPI document this module reads and rewrites. */ export interface OpenApiDocumentLike { @@ -317,6 +318,17 @@ export function buildEndpointOperation( * 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. + * + * The items may be SERVED documents, so the read-time decorations come off + * before the closed parse (#23083). Where no endpoint matcher is reachable the + * caller hands over `protocol.getMetaItems` output as-is, and that read stamps + * `_diagnostics` on every `api` item: `ApiEndpointSchema` is closed, so it + * refused the platform's own badge by name and every valid declaration was + * omitted and logged as one to fix. The strip is the spec's declared list + * (`METADATA_READ_DECORATIONS`, the same one the seed door strips for #15591), + * ⛔ never a blanket `_` prefix: the ADR-0010 envelope (`_packageId`, + * `_provenance`, …) is allowlisted by the schema and parses as it always did, + * and any other unknown key is still refused. */ export function selectDocumentableEndpoints( items: readonly unknown[], @@ -325,16 +337,19 @@ export function selectDocumentableEndpoints( const byRoute = new Map(); for (const item of items) { - const parsed = ApiEndpointSchema.safeParse(item); + const parsed = ApiEndpointSchema.safeParse(stripReadDecorations(item)); if (!parsed.success) { const declaredName = item && typeof item === 'object' && typeof (item as { name?: unknown }).name === 'string' ? (item as { name: string }).name : ''; + // Says only what this reader knows. It does not speak for the endpoint + // matcher: that reader judges its own stored item after peeling the + // storage envelope, so it can serve what this parse refuses — and where + // the caller found no matcher to ask, there is none to speak for. 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.`, + `from the OpenAPI document; fix the declaration rather than the document.`, { issues: parsed.error.issues }, ); continue;