Skip to content

Commit 76f507c

Browse files
committed
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 <noreply@anthropic.com>
1 parent 62ce45d commit 76f507c

3 files changed

Lines changed: 277 additions & 3 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
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)
6+
7+
Clause-②: no
8+
9+
**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.
10+
11+
**What changes.**
12+
13+
- 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.
14+
- The ADR-0010 envelope keys (`_packageId`, `_provenance`, …) are allowlisted by the schema and parse as before. Any other unknown key is still refused.
15+
- 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.
16+
17+
**Upgrading.** Nothing to change. `ApiEndpointSchema` is unchanged.
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #23083 — the OpenAPI reader re-parses a SERVED `api` item, so it strips the
5+
* read-time decorations before its closed parse.
6+
*
7+
* `GET /openapi.json` enumerates `api` items through `protocol.getMetaItems`,
8+
* whose served audience exits through `decorateMetadataItems` and stamps
9+
* `_diagnostics` on every item (`api` has a registered schema). Where no
10+
* endpoint matcher is reachable — a composition whose `metadata` slot is the
11+
* kernel's in-memory fallback, `@objectstack/verify`'s `bootStack` among them —
12+
* the handler hands those served items to `selectDocumentableEndpoints` as they
13+
* are, and the closed `ApiEndpointSchema` refused the platform's own badge by
14+
* name, `unrecognized_keys: ["_diagnostics"]`: every VALID declaration was
15+
* omitted from the document and logged at `error` as a declaration to fix.
16+
*
17+
* The fixtures are the platform's own output: `decorateMetadataItems` from
18+
* `@objectstack/metadata-protocol` is the producer the served read calls, so a
19+
* decoration it stamps is in the fixture without anyone editing this file.
20+
*
21+
* Controls, each the other half of a pin above it:
22+
* - a truly invalid item is still omitted and logged — the strip launders
23+
* nothing, and the refusal names the item's real defect;
24+
* - the ADR-0010 envelope (`_packageId`, `_provenance`) parses exactly as it
25+
* did — the closed schema allowlists it, so it is not a decoration;
26+
* - an underscore key that is NOT a declared read decoration is still refused:
27+
* the strip is `METADATA_READ_DECORATIONS`, never a blanket `_` prefix.
28+
*/
29+
30+
import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest';
31+
import { ApiEndpointSchema } from '@objectstack/spec/api';
32+
import { decorateMetadataItems } from '@objectstack/metadata-protocol';
33+
import { enrichOpenApiWithEndpoints, selectDocumentableEndpoints } from './openapi-endpoints';
34+
import { RestServer } from './rest-server';
35+
36+
// [#17865] The handler-level pin below observes the REST fault log, so this
37+
// file declares the level it asserts against instead of inheriting the suite's
38+
// quiet one. 'info' is the SHIPPED default — what a real caller gets.
39+
beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); });
40+
afterAll(() => { vi.unstubAllEnvs(); });
41+
afterEach(() => { vi.restoreAllMocks(); });
42+
43+
// ---------------------------------------------------------------------------
44+
// The showcase's two declarations, restated (not imported — an example app is
45+
// not this package's dependency)
46+
// ---------------------------------------------------------------------------
47+
48+
const TASK_FEED = {
49+
name: 'showcase_task_feed',
50+
path: '/api/v1/apps/showcase/tasks',
51+
method: 'GET',
52+
summary: 'Task feed',
53+
type: 'object_operation',
54+
objectParams: { object: 'showcase_task', operation: 'find' },
55+
authRequired: true,
56+
cacheTtlSeconds: 30,
57+
};
58+
59+
const INQUIRY_PURGE = {
60+
name: 'showcase_inquiry_purge_api',
61+
path: '/api/v1/apps/showcase/inquiries/purge',
62+
method: 'POST',
63+
summary: 'Purge closed inquiries',
64+
type: 'flow',
65+
target: 'showcase_inquiry_purge',
66+
authRequired: true,
67+
};
68+
69+
/** What the served read hands a reader: the producer's own decoration. */
70+
function servedRead(items: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
71+
return decorateMetadataItems('api', items.map((item) => ({ ...item })));
72+
}
73+
74+
function collectingLogger() {
75+
return { error: vi.fn() };
76+
}
77+
78+
function baseDoc() {
79+
return {
80+
openapi: '3.1.0',
81+
info: { title: 'ObjectStack API', version: '17.0.0' },
82+
paths: { '/api/meta': { get: { operationId: 'getMeta' } } },
83+
components: { schemas: {}, securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer' } } },
84+
security: [{ bearerAuth: [] }],
85+
};
86+
}
87+
88+
// ---------------------------------------------------------------------------
89+
// Pins
90+
// ---------------------------------------------------------------------------
91+
92+
describe('a valid declaration is documented however the read decorated it (#23083)', () => {
93+
it('documents both items exactly as the served read decorates them, and logs nothing', () => {
94+
const served = servedRead([TASK_FEED, INQUIRY_PURGE]);
95+
// Precondition: the fixture really carries the decoration, and the producer
96+
// itself calls both items valid — so a refusal here can only be the reader's.
97+
for (const item of served) expect(item._diagnostics).toEqual({ valid: true });
98+
99+
const logger = collectingLogger();
100+
const kept = selectDocumentableEndpoints(served, logger);
101+
102+
expect(kept.map((e) => e.name).sort()).toEqual(['showcase_inquiry_purge_api', 'showcase_task_feed']);
103+
expect(logger.error).not.toHaveBeenCalled();
104+
for (const endpoint of kept) expect(endpoint).not.toHaveProperty('_diagnostics');
105+
});
106+
107+
it('documents an item carrying the draft badge, and logs nothing', () => {
108+
const logger = collectingLogger();
109+
const kept = selectDocumentableEndpoints([{ ...TASK_FEED, _draft: true }], logger);
110+
111+
expect(kept.map((e) => e.name)).toEqual(['showcase_task_feed']);
112+
expect(logger.error).not.toHaveBeenCalled();
113+
expect(kept[0]).not.toHaveProperty('_draft');
114+
});
115+
116+
it('folds the decorated declarations into the document as path entries', () => {
117+
const logger = collectingLogger();
118+
const out = enrichOpenApiWithEndpoints(baseDoc(), servedRead([TASK_FEED, INQUIRY_PURGE]), logger) as any;
119+
120+
expect(out.paths['/api/v1/apps/showcase/tasks'].get.operationId).toBe('showcase_task_feed');
121+
expect(out.paths['/api/v1/apps/showcase/inquiries/purge'].post.operationId).toBe('showcase_inquiry_purge_api');
122+
expect(logger.error).not.toHaveBeenCalled();
123+
});
124+
});
125+
126+
describe('controls — the strip removes the declared decorations and nothing else', () => {
127+
it('still omits and logs a truly invalid item, naming its real defect', () => {
128+
const served = servedRead([{ ...TASK_FEED, name: 'bad_type', type: 'nope' }]);
129+
// The producer agrees it is invalid — the decoration rides on a bad item.
130+
expect((served[0]._diagnostics as { valid: boolean }).valid).toBe(false);
131+
132+
const logger = collectingLogger();
133+
const kept = selectDocumentableEndpoints(served, logger);
134+
135+
expect(kept).toEqual([]);
136+
expect(logger.error).toHaveBeenCalledTimes(1);
137+
expect(logger.error.mock.calls[0][0]).toContain("'bad_type'");
138+
const { issues } = logger.error.mock.calls[0][1] as { issues: Array<{ code: string; path: unknown[]; keys?: string[] }> };
139+
// The refusal is about the item, not about our own badge.
140+
expect(issues.some((i) => i.path[0] === 'type')).toBe(true);
141+
expect(issues.some((i) => i.code === 'unrecognized_keys')).toBe(false);
142+
});
143+
144+
it('parses the ADR-0010 envelope exactly as before, decorated or not', () => {
145+
const enveloped = { ...TASK_FEED, _packageId: 'com.example.showcase', _provenance: 'package' };
146+
const expected = ApiEndpointSchema.parse(enveloped);
147+
148+
const logger = collectingLogger();
149+
const [plain] = selectDocumentableEndpoints([enveloped], logger);
150+
const [decorated] = selectDocumentableEndpoints(servedRead([enveloped]), logger);
151+
152+
expect(plain).toEqual(expected);
153+
expect(decorated).toEqual(expected);
154+
expect(decorated._packageId).toBe('com.example.showcase');
155+
expect(logger.error).not.toHaveBeenCalled();
156+
});
157+
158+
it('still refuses an underscore key that is not a declared read decoration', () => {
159+
const logger = collectingLogger();
160+
const kept = selectDocumentableEndpoints([{ ...TASK_FEED, _unexpected: true }], logger);
161+
162+
expect(kept).toEqual([]);
163+
expect(logger.error).toHaveBeenCalledTimes(1);
164+
const { issues } = logger.error.mock.calls[0][1] as { issues: Array<{ code: string; keys?: string[] }> };
165+
expect(issues.find((i) => i.code === 'unrecognized_keys')?.keys).toEqual(['_unexpected']);
166+
});
167+
});
168+
169+
// ---------------------------------------------------------------------------
170+
// The production caller, on a composition with no endpoint matcher
171+
// ---------------------------------------------------------------------------
172+
173+
function createMockServer() {
174+
return {
175+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(),
176+
listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
177+
};
178+
}
179+
180+
function makeRes() {
181+
const res: any = { statusCode: 200, body: undefined };
182+
res.status = vi.fn((c: number) => { res.statusCode = c; return res; });
183+
res.json = vi.fn((b: any) => { res.body = b; return res; });
184+
res.header = vi.fn(() => res); res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn();
185+
return res;
186+
}
187+
188+
/**
189+
* The REST server with NO metadata service wired — the shape of a composition
190+
* whose `metadata` slot cannot answer `matchEndpoint` — over a protocol whose
191+
* `getMetaItems` answers what the served read answers.
192+
*/
193+
function mountWithoutMatcher(served: Array<Record<string, unknown>>) {
194+
const protocol: any = {
195+
getDiscovery: vi.fn().mockResolvedValue({
196+
version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' },
197+
}),
198+
getMetaTypes: vi.fn().mockResolvedValue([]),
199+
getMetaItems: vi.fn(async ({ type }: any) => {
200+
const t = String(type ?? '');
201+
if (t === 'api' || t === 'apis') return { type: 'api', items: served };
202+
return { type: t, items: [] };
203+
}),
204+
getMetaItem: vi.fn().mockResolvedValue({}),
205+
findData: vi.fn().mockResolvedValue([]),
206+
};
207+
const rest = new RestServer(
208+
createMockServer() as any,
209+
protocol,
210+
{ api: { requireAuth: false, version: 'v1' } } as any,
211+
);
212+
// The description document denies anonymous callers; an identity is a
213+
// precondition of reaching the handler, not the subject here.
214+
(rest as any).resolveExecCtx = async () => ({ userId: 'u1' });
215+
rest.registerRoutes();
216+
(rest as any).registerOpenApiEndpoints('/api/v1');
217+
return rest;
218+
}
219+
220+
async function getOpenApi(rest: any) {
221+
const entry = rest.routeManager.get('GET', '/api/v1/openapi.json');
222+
if (!entry) throw new Error('openapi.json route not registered');
223+
const res = makeRes();
224+
await entry.handler({ method: 'GET', headers: { host: 'example.test' }, params: {}, query: {}, path: '/api/v1/openapi.json' }, res);
225+
return res;
226+
}
227+
228+
describe('GET /openapi.json with no endpoint matcher reachable', () => {
229+
it('lists both declared endpoints, and no error line names either of them', async () => {
230+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
231+
const rest = mountWithoutMatcher(servedRead([TASK_FEED, INQUIRY_PURGE]));
232+
233+
const res = await getOpenApi(rest);
234+
235+
expect(res.statusCode).toBe(200);
236+
const declared = Object.keys(res.body?.paths ?? {}).filter((p) => p.startsWith('/api/v1/apps/')).sort();
237+
expect(declared).toEqual(['/api/v1/apps/showcase/inquiries/purge', '/api/v1/apps/showcase/tasks']);
238+
239+
const lines = errorSpy.mock.calls.map((args) => args.map(String).join(' '));
240+
expect(lines.filter((l) => l.includes('showcase_task_feed') || l.includes('showcase_inquiry_purge_api'))).toEqual([]);
241+
}, 60_000);
242+
});

‎packages/rest/src/openapi-endpoints.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
*/
9191

9292
import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api';
93+
import { stripReadDecorations } from '@objectstack/spec/kernel';
9394

9495
/** The slice of an OpenAPI document this module reads and rewrites. */
9596
export interface OpenApiDocumentLike {
@@ -317,6 +318,17 @@ export function buildEndpointOperation(
317318
* was chosen there: it is total, deterministic across nodes and boots, and it
318319
* must produce the SAME winner, or the document would name an endpoint the
319320
* runtime does not run.
321+
*
322+
* The items may be SERVED documents, so the read-time decorations come off
323+
* before the closed parse (#23083). Where no endpoint matcher is reachable the
324+
* caller hands over `protocol.getMetaItems` output as-is, and that read stamps
325+
* `_diagnostics` on every `api` item: `ApiEndpointSchema` is closed, so it
326+
* refused the platform's own badge by name and every valid declaration was
327+
* omitted and logged as one to fix. The strip is the spec's declared list
328+
* (`METADATA_READ_DECORATIONS`, the same one the seed door strips for #15591),
329+
* ⛔ never a blanket `_` prefix: the ADR-0010 envelope (`_packageId`,
330+
* `_provenance`, …) is allowlisted by the schema and parses as it always did,
331+
* and any other unknown key is still refused.
320332
*/
321333
export function selectDocumentableEndpoints(
322334
items: readonly unknown[],
@@ -325,16 +337,19 @@ export function selectDocumentableEndpoints(
325337
const byRoute = new Map<string, ApiEndpoint>();
326338

327339
for (const item of items) {
328-
const parsed = ApiEndpointSchema.safeParse(item);
340+
const parsed = ApiEndpointSchema.safeParse(stripReadDecorations(item));
329341
if (!parsed.success) {
330342
const declaredName =
331343
item && typeof item === 'object' && typeof (item as { name?: unknown }).name === 'string'
332344
? (item as { name: string }).name
333345
: '<unnamed>';
346+
// Says only what this reader knows. It does not speak for the endpoint
347+
// matcher: that reader judges its own stored item after peeling the
348+
// storage envelope, so it can serve what this parse refuses — and where
349+
// the caller found no matcher to ask, there is none to speak for.
334350
logger.error(
335351
`[REST] stored api item '${declaredName}' does not satisfy ApiEndpointSchema — it is OMITTED ` +
336-
`from the OpenAPI document. The endpoint matcher excludes it too, so the route it declares ` +
337-
`answers 404; fix the declaration rather than the document.`,
352+
`from the OpenAPI document; fix the declaration rather than the document.`,
338353
{ issues: parsed.error.issues },
339354
);
340355
continue;

0 commit comments

Comments
 (0)