From 728208826242e022c0087b8dca9aaf1b30fdeb2a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 10:30:04 +0000 Subject: [PATCH] fix(rest): the served openapi.json publishes the artifact's info.version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd --- ...napi-served-info-version-passes-through.md | 43 +++++++++ packages/rest/src/rest-openapi-route.test.ts | 96 ++++++++++++++----- packages/rest/src/rest-server.ts | 63 ++++-------- 3 files changed, 135 insertions(+), 67 deletions(-) create mode 100644 .changeset/openapi-served-info-version-passes-through.md diff --git a/.changeset/openapi-served-info-version-passes-through.md b/.changeset/openapi-served-info-version-passes-through.md new file mode 100644 index 0000000000..936920474a --- /dev/null +++ b/.changeset/openapi-served-info-version-passes-through.md @@ -0,0 +1,43 @@ +--- +'@objectstack/rest': minor +--- + +`GET {basePath}/openapi.json` no longer overwrites `info.version` — the served +document publishes the version `packages/spec` put in the artifact + +**FROM** the deployment's declared API version identifier (`api.version`, which +`normalizeConfig` defaults to `'v1'`) — **TO** the published artifact's own +version (`@objectstack/spec`'s `./openapi.json` export, `17.2.0` at the time of +this change, set from that package's version by `build-openapi.ts`). + +The `info` block is the half of the document `packages/spec` produces and owns, +and this route's own test twin has asserted that "serve-time enrichment must not +touch it" since #5588 — with the assertion narrowed to `info.title` alone, +precisely because `version` was overridden. The invariant was stated and then +excepted, in the same file. The override is deleted, the exception is gone, and +the twin's assertion now covers the whole `info` block. + +Nothing is lost. The declared API version identifier still exists and is still +observable where a caller can act on it: it builds the mount +(`${basePath}/${version}` gives `/api/v1`). The runtime version is still +answered by `{basePath}/discovery` and `/health`. OpenAPI 3.1 defines this field +as "the version of the OpenAPI document (which is distinct from the OpenAPI +Specification version or the API implementation version)" — the document being +served is the artifact, so its version is the artifact's. + +**Measured consumer pull: zero.** No consumer reads this document's +`info.version` by value, and nothing derives a route prefix from it or compares +it against `api.version` — the repo's one route-prefix derivation +(`packages/core/src/qa/http-adapter.ts`) reads the config directly and is +unaffected. That sweep covers `objectstack` only: `objectui`, `cloud` and +`cloud-v1` were not reachable where it ran, so the zero across those three is an +earlier reading carried forward, not re-measured here. + +The one deployment shape that changes is one scraping the served `info.version` +to learn its own `api.version`; it should read the mount, or +`{basePath}/discovery`, both of which state that fact on purpose. + +This supersedes the `info.version` half of the unreleased +`openapi-info-version-is-the-api-version` entry in the same cycle: that entry's +fallback removal stands, its statement that the served field carries the API +version identifier does not. diff --git a/packages/rest/src/rest-openapi-route.test.ts b/packages/rest/src/rest-openapi-route.test.ts index 4bd57215c2..230ea55ff4 100644 --- a/packages/rest/src/rest-openapi-route.test.ts +++ b/packages/rest/src/rest-openapi-route.test.ts @@ -322,6 +322,12 @@ describe('#5588 — built-in routes come from rest, not from the static artifact it('passes the half of the document `packages/spec` owns through serve untouched', async () => { // The artifact's surviving half — `components.schemas`, `securitySchemes`, // `info` — is the contract, and serve-time enrichment must not touch it. + // + // Until #11646 the `info` third of that claim was narrowed to `title` + // alone, precisely because the serve path overwrote `info.version` with + // the deployment's `api.version`: the invariant was stated and then + // excepted, in this same file. The override is deleted, so the exception + // is gone and the pin below covers the WHOLE block. const rest = makeRest(makeProtocol({ object: [], api: [] }).protocol); const artifact = await (rest as any).loadOpenApiSpec(); 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 const { body } = await serveOpenApiFrom(rest); expect(Object.keys(body.components.schemas)).toEqual(Object.keys(artifact.components.schemas)); expect(body.components.securitySchemes).toEqual(artifact.components.securitySchemes); - expect(body.info.title).toBe(artifact.info.title); + // Anti-vacuity: a whole-block `toEqual` over an `info` with no `version` + // would pass without ever reaching the field #11646 closed. + expect( + artifact.info.version, + 'the artifact must carry a version, or the whole-block pin below never reaches the field #11646 closed', + ).toBeTruthy(); + expect( + body.info, + '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.', + ).toEqual(artifact.info); }); 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 }); }); -describe('GET /openapi.json — what `info.version` carries (#11546)', () => { +describe('GET /openapi.json — what `info.version` carries (#11546, #11646)', () => { + // History, because this field has now been three different things. + // // The line under test used to read // `version: this.config.api.version || enriched.info.version` // under a comment promising "the runtime version so consumers don't pin to // the spec package's compile-time version". Both halves were false, and // nothing pinned either one, so the document could have drifted to any of - // three different facts without a test noticing. These four fix what the - // field means. + // three different facts without a test noticing. #11546 pinned it to the + // declared API version identifier and deleted the fallback. + // + // #11646 then deleted the serve-time override itself (maintainer ruling, + // 2026-08-25, option B): the served `info.version` is the ARTIFACT's, so + // the served document and `@objectstack/spec`'s published `./openapi.json` + // export state one fact instead of two. The declared identifier still + // exists and is still observable — it builds the mount — it just no longer + // rewrites a producer-owned contract field. The cases below are the + // inversion of the #11546 ones: each now asserts the value the override + // used to suppress. // // OpenAPI 3.1, Info Object: `version` is "the version of the OpenAPI // document (which is distinct from the OpenAPI Specification version or the // API implementation version)". The runtime version is the implementation // version, so it is the one value the field's own definition excludes — // which is why this is NOT the shape #11292 settled for `/discovery`, where - // `DiscoverySchema.version` means the serving artifact by #10993. + // `DiscoverySchema.version` means the serving artifact by #10993. That + // exclusion is unchanged by #11646: the artifact version is the document's + // version, not the runtime's. - it('serves the declared API version identifier, not the artifact version', async () => { + it('serves the artifact version, not the declared API version identifier', async () => { const rest = makeRest(makeProtocol({ object: [], api: [] }).protocol); const artifact = await (rest as any).loadOpenApiSpec(); const { body } = await serveOpenApiFrom(rest); - expect(body.info.version).toBe('v1'); - // The serve path deliberately overrides the producer here, so the pin is - // only meaningful while the two values actually differ — if they ever - // converge this assertion says so instead of passing vacuously. + expect(body.info.version).toBe(artifact.info.version); + // Only meaningful while the two candidate facts are actually different + // values — if they ever converge this says so instead of passing + // vacuously, exactly as the #11546 version of this case did in the other + // direction. expect( artifact.info.version, - 'the artifact must carry a DIFFERENT version for the override pin above to mean anything', + 'the artifact must carry a version DIFFERENT from the declared `api.version` for the pin above to mean anything', ).not.toBe('v1'); - expect(body.info.version).not.toBe(artifact.info.version); + expect(body.info.version).not.toBe('v1'); }); - it('tracks a custom `api.version`, which is also the mount segment', async () => { + it('does not track a custom `api.version` — that identifier moves the MOUNT, not `info.version`', async () => { const rest = makeRest(makeProtocol({ object: [], api: [] }).protocol, { version: 'v9' }); + const artifact = await (rest as any).loadOpenApiSpec(); const { body } = await serveOpenApiFrom(rest, '/api/v9'); - expect(body.info.version).toBe('v9'); + + // The identifier is still live and still observable — establish that + // FIRST, so a v9 that quietly stopped taking effect cannot make the + // `info.version` half below pass for the wrong reason. + expect( + Object.keys(body.paths).length, + 'the v9 mount published no paths — this case would then assert nothing about the identifier', + ).toBeGreaterThan(0); + for (const path of Object.keys(body.paths)) { + expect(path.startsWith('/api/v9'), `'${path}' did not follow the v9 mount`).toBe(true); + } + + // ...and it does not reach `info`, which is the artifact's. + expect(body.info.version).toBe(artifact.info.version); + expect(body.info.version).not.toBe('v9'); }); it('is not the runtime version — an `OS_RUNTIME_VERSION` stamp does not reach it', async () => { - // The anti-regression pin for the direction this card did NOT take. Were - // the field re-pointed at `resolveDiscoveryVersion()`, the sentinel below - // would land in the served document and this goes red. + // The anti-regression pin for the direction NEITHER card took. Were the + // field re-pointed at `resolveDiscoveryVersion()`, the sentinel below + // would land in the served document and this goes red. #11646 moved the + // expected value from the declared identifier to the artifact's version; + // the excluded value is unchanged. const SENTINEL = '9.9.9-openapi-info-version-sentinel'; const old = process.env.OS_RUNTIME_VERSION; process.env.OS_RUNTIME_VERSION = SENTINEL; try { const rest = makeRest(makeProtocol({ object: [], api: [] }).protocol); + const artifact = await (rest as any).loadOpenApiSpec(); const { body } = await serveOpenApiFrom(rest); - expect(body.info.version).toBe('v1'); + expect(body.info.version).toBe(artifact.info.version); expect(JSON.stringify(body.info)).not.toContain(SENTINEL); } finally { if (old === undefined) delete process.env.OS_RUNTIME_VERSION; @@ -435,10 +482,15 @@ describe('GET /openapi.json — what `info.version` carries (#11546)', () => { // instead of cast, so `RestServer` refuses the construction and the // doubled-slash mount is unreachable. // - // The fact the old pin protected is unchanged and still covered above: - // there is still NO `|| enriched.info.version` fallback, so a configured - // version is served as itself. What changed is that `''` is no longer a - // configurable version. + // What this pin protects is the CONSTRUCTION refusal, and that is + // unchanged: `''` is not a configurable version. + // + // The other half of this comment used to read "there is still NO + // `|| enriched.info.version` fallback, so a configured version is served + // as itself" — #11646 retired that sentence with the override it + // described. `api.version` no longer reaches `info.version` at all, by + // any path, fallback or otherwise; it builds the mount, which is what the + // refusal above keeps well-formed. expect(() => makeRest(makeProtocol({ object: [], api: [] }).protocol, { version: '' })) .toThrow(/api\.version/); }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index ec8252f3f3..f153673e5b 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3767,52 +3767,25 @@ export class RestServer { logError('[REST] openapi.json endpoint enrichment skipped:', err?.message ?? err); } - // `info.version` carries the API version identifier this - // deployment declares (`api.version`, which `normalizeConfig` - // defaults to `'v1'`) — the same value that builds the default - // mount (`${basePath}/${version}` -> `/api/v1`), though a - // deployment that sets `apiPath` moves the mount without - // moving this. + // `info` is passed through from the artifact UNTOUCHED — the + // whole block, version included. `packages/spec` produces it + // (`build-openapi.ts`, pinned by `openapi-self-consistency.test.ts`) + // and owns it, so the served document and the published + // `@objectstack/spec/openapi.json` export now state the same + // fact about the same field (#11646). This handler enriches + // `paths` and `servers`; it writes nothing into `info`. // - // It is deliberately NOT the runtime version, and the comment - // this replaces ("surface the runtime version so consumers - // don't pin to the spec package's compile-time version") had it - // backwards in both halves. OpenAPI 3.1 defines the field as - // "the version of the OpenAPI document (which is distinct from - // the OpenAPI Specification version or the API implementation - // version)" — the runtime version IS the implementation - // version, the one reading the field's own definition rules - // out. That fact is not lost: `{basePath}/discovery` and - // `/health` both answer with it, derived from - // `OS_RUNTIME_VERSION` (#10993/#11235/#11292), so a caller who - // wants the serving artifact asks a producer that means it. - // - // No `|| enriched.info.version` fallback. It was reachable, - // not dead — and NOT because the contract allows an empty - // version. `RestApiConfigSchema` declares - // `version: z.string().regex(/^[a-zA-Z0-9_\-\.]+$/)`, which - // refuses `''`. Nothing ever runs it: this config arrives - // through casts on both hops (`config.api as any` in - // `rest-api-plugin.ts`, then `as Partial` in - // `normalizeConfig` below), the plugin declares no - // `configSchema` for the kernel's validator to parse, and the - // repo's only `RestApiConfigSchema.parse` parses `{}` in a QA - // helper. So the regex never executes on a deployment path, - // `??` is the only guard left, and `''` walks past it — - // whereupon the fallback published the spec package's - // compile-time version, the one value the old comment claimed - // this line existed to keep off the wire. A falsy - // `api.version` now serves itself, so a misconfigured - // deployment reads as misconfigured instead of silently - // switching this field to a different kind of fact. The - // unenforced regex is a defect in its own right, filed - // separately rather than fixed here. - if (enriched.info) { - enriched.info = { - ...enriched.info, - version: this.config.api.version, - }; - } + // The API version identifier this deployment declares + // (`api.version`, which `normalizeConfig` defaults to `'v1'`) + // is not lost — it lives where it is observable, in the mount + // `${basePath}/${version}` -> `/api/v1`. The runtime version + // is answered by `{basePath}/discovery` and `/health`, derived + // from `OS_RUNTIME_VERSION` (#10993/#11235/#11292). OpenAPI + // 3.1 defines this field as "the version of the OpenAPI + // document (which is distinct from the OpenAPI Specification + // version or the API implementation version)" — the document + // being served IS the artifact, so its version is the + // artifact's. res.json(enriched); } catch (error: any) {