Skip to content

Commit d81838c

Browse files
os-litantclaude
andauthored
fix(rest): the served openapi.json publishes the artifact's info.version (#12958)
Delete the serve-time override that rewrote `info.version` with the deployment's declared `api.version`. The `info` block is produced and owned by `packages/spec`; this route's own twin has asserted that serve-time enrichment must not touch it, with the assertion narrowed to `info.title` alone precisely because `version` was overridden — the invariant stated and then excepted in one file. The twin's assertion is widened to the whole `info` block, and the four cases that pinned the old meaning are inverted to pin the new one: the served version is the artifact's, a custom `api.version` moves the MOUNT and not the field, and the runtime version still never reaches it. Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd Co-authored-by: Claude <noreply@anthropic.com>
1 parent b745157 commit d81838c

3 files changed

Lines changed: 135 additions & 67 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
`GET {basePath}/openapi.json` no longer overwrites `info.version` — the served
6+
document publishes the version `packages/spec` put in the artifact
7+
8+
**FROM** the deployment's declared API version identifier (`api.version`, which
9+
`normalizeConfig` defaults to `'v1'`) — **TO** the published artifact's own
10+
version (`@objectstack/spec`'s `./openapi.json` export, `17.2.0` at the time of
11+
this change, set from that package's version by `build-openapi.ts`).
12+
13+
The `info` block is the half of the document `packages/spec` produces and owns,
14+
and this route's own test twin has asserted that "serve-time enrichment must not
15+
touch it" since #5588 — with the assertion narrowed to `info.title` alone,
16+
precisely because `version` was overridden. The invariant was stated and then
17+
excepted, in the same file. The override is deleted, the exception is gone, and
18+
the twin's assertion now covers the whole `info` block.
19+
20+
Nothing is lost. The declared API version identifier still exists and is still
21+
observable where a caller can act on it: it builds the mount
22+
(`${basePath}/${version}` gives `/api/v1`). The runtime version is still
23+
answered by `{basePath}/discovery` and `/health`. OpenAPI 3.1 defines this field
24+
as "the version of the OpenAPI document (which is distinct from the OpenAPI
25+
Specification version or the API implementation version)" — the document being
26+
served is the artifact, so its version is the artifact's.
27+
28+
**Measured consumer pull: zero.** No consumer reads this document's
29+
`info.version` by value, and nothing derives a route prefix from it or compares
30+
it against `api.version` — the repo's one route-prefix derivation
31+
(`packages/core/src/qa/http-adapter.ts`) reads the config directly and is
32+
unaffected. That sweep covers `objectstack` only: `objectui`, `cloud` and
33+
`cloud-v1` were not reachable where it ran, so the zero across those three is an
34+
earlier reading carried forward, not re-measured here.
35+
36+
The one deployment shape that changes is one scraping the served `info.version`
37+
to learn its own `api.version`; it should read the mount, or
38+
`{basePath}/discovery`, both of which state that fact on purpose.
39+
40+
This supersedes the `info.version` half of the unreleased
41+
`openapi-info-version-is-the-api-version` entry in the same cycle: that entry's
42+
fallback removal stands, its statement that the served field carries the API
43+
version identifier does not.

packages/rest/src/rest-openapi-route.test.ts

Lines changed: 74 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,12 @@ describe('#5588 — built-in routes come from rest, not from the static artifact
322322
it('passes the half of the document `packages/spec` owns through serve untouched', async () => {
323323
// The artifact's surviving half — `components.schemas`, `securitySchemes`,
324324
// `info` — is the contract, and serve-time enrichment must not touch it.
325+
//
326+
// Until #11646 the `info` third of that claim was narrowed to `title`
327+
// alone, precisely because the serve path overwrote `info.version` with
328+
// the deployment's `api.version`: the invariant was stated and then
329+
// excepted, in this same file. The override is deleted, so the exception
330+
// is gone and the pin below covers the WHOLE block.
325331
const rest = makeRest(makeProtocol({ object: [], api: [] }).protocol);
326332
const artifact = await (rest as any).loadOpenApiSpec();
327333
expect(artifact, 'the bundled artifact must be loadable for this pin to mean anything').toBeTruthy();
@@ -336,7 +342,16 @@ describe('#5588 — built-in routes come from rest, not from the static artifact
336342
const { body } = await serveOpenApiFrom(rest);
337343
expect(Object.keys(body.components.schemas)).toEqual(Object.keys(artifact.components.schemas));
338344
expect(body.components.securitySchemes).toEqual(artifact.components.securitySchemes);
339-
expect(body.info.title).toBe(artifact.info.title);
345+
// Anti-vacuity: a whole-block `toEqual` over an `info` with no `version`
346+
// would pass without ever reaching the field #11646 closed.
347+
expect(
348+
artifact.info.version,
349+
'the artifact must carry a version, or the whole-block pin below never reaches the field #11646 closed',
350+
).toBeTruthy();
351+
expect(
352+
body.info,
353+
'serve-time enrichment must not touch `info` — the WHOLE block is the artifact\'s, `version` included (#11646). A serve-time override of any `info` field lands here.',
354+
).toEqual(artifact.info);
340355
});
341356

342357
it('discards a `paths`-carrying artifact instead of merging it', async () => {
@@ -370,55 +385,87 @@ describe('#5588 — built-in routes come from rest, not from the static artifact
370385
});
371386
});
372387

373-
describe('GET /openapi.json — what `info.version` carries (#11546)', () => {
388+
describe('GET /openapi.json — what `info.version` carries (#11546, #11646)', () => {
389+
// History, because this field has now been three different things.
390+
//
374391
// The line under test used to read
375392
// `version: this.config.api.version || enriched.info.version`
376393
// under a comment promising "the runtime version so consumers don't pin to
377394
// the spec package's compile-time version". Both halves were false, and
378395
// nothing pinned either one, so the document could have drifted to any of
379-
// three different facts without a test noticing. These four fix what the
380-
// field means.
396+
// three different facts without a test noticing. #11546 pinned it to the
397+
// declared API version identifier and deleted the fallback.
398+
//
399+
// #11646 then deleted the serve-time override itself (maintainer ruling,
400+
// 2026-08-25, option B): the served `info.version` is the ARTIFACT's, so
401+
// the served document and `@objectstack/spec`'s published `./openapi.json`
402+
// export state one fact instead of two. The declared identifier still
403+
// exists and is still observable — it builds the mount — it just no longer
404+
// rewrites a producer-owned contract field. The cases below are the
405+
// inversion of the #11546 ones: each now asserts the value the override
406+
// used to suppress.
381407
//
382408
// OpenAPI 3.1, Info Object: `version` is "the version of the OpenAPI
383409
// document (which is distinct from the OpenAPI Specification version or the
384410
// API implementation version)". The runtime version is the implementation
385411
// version, so it is the one value the field's own definition excludes —
386412
// which is why this is NOT the shape #11292 settled for `/discovery`, where
387-
// `DiscoverySchema.version` means the serving artifact by #10993.
413+
// `DiscoverySchema.version` means the serving artifact by #10993. That
414+
// exclusion is unchanged by #11646: the artifact version is the document's
415+
// version, not the runtime's.
388416

389-
it('serves the declared API version identifier, not the artifact version', async () => {
417+
it('serves the artifact version, not the declared API version identifier', async () => {
390418
const rest = makeRest(makeProtocol({ object: [], api: [] }).protocol);
391419
const artifact = await (rest as any).loadOpenApiSpec();
392420
const { body } = await serveOpenApiFrom(rest);
393421

394-
expect(body.info.version).toBe('v1');
395-
// The serve path deliberately overrides the producer here, so the pin is
396-
// only meaningful while the two values actually differ — if they ever
397-
// converge this assertion says so instead of passing vacuously.
422+
expect(body.info.version).toBe(artifact.info.version);
423+
// Only meaningful while the two candidate facts are actually different
424+
// values — if they ever converge this says so instead of passing
425+
// vacuously, exactly as the #11546 version of this case did in the other
426+
// direction.
398427
expect(
399428
artifact.info.version,
400-
'the artifact must carry a DIFFERENT version for the override pin above to mean anything',
429+
'the artifact must carry a version DIFFERENT from the declared `api.version` for the pin above to mean anything',
401430
).not.toBe('v1');
402-
expect(body.info.version).not.toBe(artifact.info.version);
431+
expect(body.info.version).not.toBe('v1');
403432
});
404433

405-
it('tracks a custom `api.version`, which is also the mount segment', async () => {
434+
it('does not track a custom `api.version` — that identifier moves the MOUNT, not `info.version`', async () => {
406435
const rest = makeRest(makeProtocol({ object: [], api: [] }).protocol, { version: 'v9' });
436+
const artifact = await (rest as any).loadOpenApiSpec();
407437
const { body } = await serveOpenApiFrom(rest, '/api/v9');
408-
expect(body.info.version).toBe('v9');
438+
439+
// The identifier is still live and still observable — establish that
440+
// FIRST, so a v9 that quietly stopped taking effect cannot make the
441+
// `info.version` half below pass for the wrong reason.
442+
expect(
443+
Object.keys(body.paths).length,
444+
'the v9 mount published no paths — this case would then assert nothing about the identifier',
445+
).toBeGreaterThan(0);
446+
for (const path of Object.keys(body.paths)) {
447+
expect(path.startsWith('/api/v9'), `'${path}' did not follow the v9 mount`).toBe(true);
448+
}
449+
450+
// ...and it does not reach `info`, which is the artifact's.
451+
expect(body.info.version).toBe(artifact.info.version);
452+
expect(body.info.version).not.toBe('v9');
409453
});
410454

411455
it('is not the runtime version — an `OS_RUNTIME_VERSION` stamp does not reach it', async () => {
412-
// The anti-regression pin for the direction this card did NOT take. Were
413-
// the field re-pointed at `resolveDiscoveryVersion()`, the sentinel below
414-
// would land in the served document and this goes red.
456+
// The anti-regression pin for the direction NEITHER card took. Were the
457+
// field re-pointed at `resolveDiscoveryVersion()`, the sentinel below
458+
// would land in the served document and this goes red. #11646 moved the
459+
// expected value from the declared identifier to the artifact's version;
460+
// the excluded value is unchanged.
415461
const SENTINEL = '9.9.9-openapi-info-version-sentinel';
416462
const old = process.env.OS_RUNTIME_VERSION;
417463
process.env.OS_RUNTIME_VERSION = SENTINEL;
418464
try {
419465
const rest = makeRest(makeProtocol({ object: [], api: [] }).protocol);
466+
const artifact = await (rest as any).loadOpenApiSpec();
420467
const { body } = await serveOpenApiFrom(rest);
421-
expect(body.info.version).toBe('v1');
468+
expect(body.info.version).toBe(artifact.info.version);
422469
expect(JSON.stringify(body.info)).not.toContain(SENTINEL);
423470
} finally {
424471
if (old === undefined) delete process.env.OS_RUNTIME_VERSION;
@@ -435,10 +482,15 @@ describe('GET /openapi.json — what `info.version` carries (#11546)', () => {
435482
// instead of cast, so `RestServer` refuses the construction and the
436483
// doubled-slash mount is unreachable.
437484
//
438-
// The fact the old pin protected is unchanged and still covered above:
439-
// there is still NO `|| enriched.info.version` fallback, so a configured
440-
// version is served as itself. What changed is that `''` is no longer a
441-
// configurable version.
485+
// What this pin protects is the CONSTRUCTION refusal, and that is
486+
// unchanged: `''` is not a configurable version.
487+
//
488+
// The other half of this comment used to read "there is still NO
489+
// `|| enriched.info.version` fallback, so a configured version is served
490+
// as itself" — #11646 retired that sentence with the override it
491+
// described. `api.version` no longer reaches `info.version` at all, by
492+
// any path, fallback or otherwise; it builds the mount, which is what the
493+
// refusal above keeps well-formed.
442494
expect(() => makeRest(makeProtocol({ object: [], api: [] }).protocol, { version: '' }))
443495
.toThrow(/api\.version/);
444496
});

packages/rest/src/rest-server.ts

Lines changed: 18 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3767,52 +3767,25 @@ export class RestServer {
37673767
logError('[REST] openapi.json endpoint enrichment skipped:', err?.message ?? err);
37683768
}
37693769

3770-
// `info.version` carries the API version identifier this
3771-
// deployment declares (`api.version`, which `normalizeConfig`
3772-
// defaults to `'v1'`) — the same value that builds the default
3773-
// mount (`${basePath}/${version}` -> `/api/v1`), though a
3774-
// deployment that sets `apiPath` moves the mount without
3775-
// moving this.
3770+
// `info` is passed through from the artifact UNTOUCHED — the
3771+
// whole block, version included. `packages/spec` produces it
3772+
// (`build-openapi.ts`, pinned by `openapi-self-consistency.test.ts`)
3773+
// and owns it, so the served document and the published
3774+
// `@objectstack/spec/openapi.json` export now state the same
3775+
// fact about the same field (#11646). This handler enriches
3776+
// `paths` and `servers`; it writes nothing into `info`.
37763777
//
3777-
// It is deliberately NOT the runtime version, and the comment
3778-
// this replaces ("surface the runtime version so consumers
3779-
// don't pin to the spec package's compile-time version") had it
3780-
// backwards in both halves. OpenAPI 3.1 defines the field as
3781-
// "the version of the OpenAPI document (which is distinct from
3782-
// the OpenAPI Specification version or the API implementation
3783-
// version)" — the runtime version IS the implementation
3784-
// version, the one reading the field's own definition rules
3785-
// out. That fact is not lost: `{basePath}/discovery` and
3786-
// `/health` both answer with it, derived from
3787-
// `OS_RUNTIME_VERSION` (#10993/#11235/#11292), so a caller who
3788-
// wants the serving artifact asks a producer that means it.
3789-
//
3790-
// No `|| enriched.info.version` fallback. It was reachable,
3791-
// not dead — and NOT because the contract allows an empty
3792-
// version. `RestApiConfigSchema` declares
3793-
// `version: z.string().regex(/^[a-zA-Z0-9_\-\.]+$/)`, which
3794-
// refuses `''`. Nothing ever runs it: this config arrives
3795-
// through casts on both hops (`config.api as any` in
3796-
// `rest-api-plugin.ts`, then `as Partial<RestApiConfig>` in
3797-
// `normalizeConfig` below), the plugin declares no
3798-
// `configSchema` for the kernel's validator to parse, and the
3799-
// repo's only `RestApiConfigSchema.parse` parses `{}` in a QA
3800-
// helper. So the regex never executes on a deployment path,
3801-
// `??` is the only guard left, and `''` walks past it —
3802-
// whereupon the fallback published the spec package's
3803-
// compile-time version, the one value the old comment claimed
3804-
// this line existed to keep off the wire. A falsy
3805-
// `api.version` now serves itself, so a misconfigured
3806-
// deployment reads as misconfigured instead of silently
3807-
// switching this field to a different kind of fact. The
3808-
// unenforced regex is a defect in its own right, filed
3809-
// separately rather than fixed here.
3810-
if (enriched.info) {
3811-
enriched.info = {
3812-
...enriched.info,
3813-
version: this.config.api.version,
3814-
};
3815-
}
3778+
// The API version identifier this deployment declares
3779+
// (`api.version`, which `normalizeConfig` defaults to `'v1'`)
3780+
// is not lost — it lives where it is observable, in the mount
3781+
// `${basePath}/${version}` -> `/api/v1`. The runtime version
3782+
// is answered by `{basePath}/discovery` and `/health`, derived
3783+
// from `OS_RUNTIME_VERSION` (#10993/#11235/#11292). OpenAPI
3784+
// 3.1 defines this field as "the version of the OpenAPI
3785+
// document (which is distinct from the OpenAPI Specification
3786+
// version or the API implementation version)" — the document
3787+
// being served IS the artifact, so its version is the
3788+
// artifact's.
38163789

38173790
res.json(enriched);
38183791
} catch (error: any) {

0 commit comments

Comments
 (0)