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
43 changes: 43 additions & 0 deletions .changeset/openapi-served-info-version-passes-through.md
Original file line number Diff line number Diff line change
@@ -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.
96 changes: 74 additions & 22 deletions packages/rest/src/rest-openapi-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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;
Expand All @@ -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/);
});
Expand Down
63 changes: 18 additions & 45 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RestApiConfig>` 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) {
Expand Down
Loading