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
17 changes: 17 additions & 0 deletions .changeset/23083-openapi-read-decorations.md
Original file line number Diff line number Diff line change
@@ -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.
242 changes: 242 additions & 0 deletions packages/rest/src/openapi-endpoints-read-decorations.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>): Array<Record<string, unknown>> {
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<Record<string, unknown>>) {
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);
});
21 changes: 18 additions & 3 deletions packages/rest/src/openapi-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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[],
Expand All @@ -325,16 +337,19 @@ export function selectDocumentableEndpoints(
const byRoute = new Map<string, ApiEndpoint>();

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
: '<unnamed>';
// 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;
Expand Down
Loading