From abc52c3bb7aa589ecfda3b7ea855efd2ebb6df21 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 7 Sep 2026 13:46:04 +0700 Subject: [PATCH 1/2] fix(downgrader): inline pathItems refs, convert cycles, map the 3.2 dialect Path Item `$ref`s into `components.pathItems` are now inlined on the 3.1 to 3.0 hop instead of dangling in the output, following alias chains and letting the referencing object's own fields win. Cyclic object graphs (dereferenced documents) are converted with their cycles preserved rather than deep-copied unconverted. A 3.2 `jsonSchemaDialect` is rewritten to the 3.1 base dialect, and a non-string response `summary` no longer lands in the required 3.1 `description`. Output keys are assigned directly unless the prototype chain already knows them, which roughly halves cloning time while keeping `__proto__` and frozen-intrinsics environments safe. --- packages/downgrader/README.md | 7 +- packages/downgrader/src/shared.test.ts | 88 ++++++++- packages/downgrader/src/shared.ts | 40 ++-- packages/downgrader/src/v3.1-to-v3.0.test.ts | 184 +++++++++++++++++-- packages/downgrader/src/v3.1-to-v3.0.ts | 150 ++++++++++----- packages/downgrader/src/v3.2-to-v3.1.test.ts | 44 ++++- packages/downgrader/src/v3.2-to-v3.1.ts | 19 +- packages/downgrader/tests/e2e.test.ts | 18 +- 8 files changed, 445 insertions(+), 105 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index fe88dd2..fe14099 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -2,7 +2,7 @@ Downgrade [OpenAPI Specification](https://spec.openapis.org/) documents one minor version at a time: 3.2 → 3.1 and 3.1 → 3.0. Each converter works on an entire document or on a single Schema Object. -- **Never throws**: malformed parts are deep-copied through unchanged instead of failing the whole conversion, and cyclic object graphs (e.g. the output of a `$ref` dereferencer) don't recurse forever — a subtree that cycles back into an ancestor is deep-copied with its cycle preserved instead of converted. Only pathologically deep nesting (thousands of levels) can still exhaust the call stack. +- **Never throws**: malformed parts are deep-copied through unchanged instead of failing the whole conversion, and cyclic object graphs (e.g. the output of a `$ref` dereferencer) don't recurse forever — they are converted with their cycles preserved, a subtree that cycles back into an ancestor pointing at that ancestor's converted form. Only pathologically deep nesting (thousands of levels) can still exhaust the call stack. - **Never mutates**: the input document is left untouched. - **Extension-preserving, never extension-inventing**: existing `x-` keys and unknown keys always survive, while constructs the target version cannot express are converted where an equivalent exists and removed otherwise. @@ -36,6 +36,7 @@ Converted: | 3.2 construct | 3.1 result | | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `openapi: 3.2.x` | `openapi: 3.1.2` | +| `jsonSchemaDialect` naming a 3.2 OAS dialect | `https://spec.openapis.org/oas/3.1/dialect/base` (the 3.2 dialect only extends the 3.1 base vocabulary); other dialects pass through | | `components.mediaTypes` and content-map `$ref`s to them | references inlined, the component map removed; content entries whose reference cannot be inlined (external, unknown, or cyclic targets) are removed, as 3.1 content maps cannot hold references — a parameter or header losing its entire `content` that way is removed with it (3.1 requires exactly one entry there) | | Media type `itemSchema` without a sibling `schema` | `schema: { type: "array", items: … }` (the 3.2 sequential media type data model) | | Response `summary` when no `description` exists | promoted to `description` (required in 3.1, so `""` is synthesized as a last resort) | @@ -44,7 +45,7 @@ Converted: Removed (no 3.1 equivalent): `$self`, server `name`, tag `summary`/`parent`/`kind`, the `query` operation and `additionalOperations` of Path Items, `in: "querystring"` parameters (from parameter lists and `components.parameters`, together with references to the removed component entries, following chains of reference aliases), `allowReserved` on non-query parameters, media type `description`, media type / encoding `prefixEncoding`, `itemEncoding`, and nested `encoding`, a media type `itemSchema` beside an existing `schema`, response `summary` beside an existing `description`, OAuth `deviceAuthorization` flows, and security scheme `oauth2MetadataUrl` and `deprecated`. -Known limitations: security requirements using URI keys and `$self`-relative reference resolution are passed through unchanged. +Known limitations: security requirements using URI keys and `$self`-relative reference resolution are passed through unchanged, and so is a `$schema` keyword inside a Schema Object that names the 3.2 dialect. ## 3.1 → 3.0 @@ -58,7 +59,7 @@ Converted: | Reference `summary` / `description` overrides | removed (3.0 references stand alone) | | Security requirement roles on non-OAuth schemes | emptied (`[]`) | -Removed (no 3.0 equivalent): `webhooks`, `components.pathItems` (local `$ref`s pointing at it are left untouched and will dangle), `jsonSchemaDialect`, `info.summary`, `license.identifier`, and `mutualTLS` security schemes (reference aliases to them included) — their names are stripped from every security requirement, requirements that referenced only such schemes are removed, and a `security` list emptied that way is removed entirely, since an explicit empty list means "no security required" and would make an operation public. +Removed (no 3.0 equivalent): `webhooks`, `components.pathItems` (Path Item `$ref`s pointing at it, in `paths` and in callbacks, are inlined instead — following chains of references, with the referencing Path Item's own fields winning over inlined ones where both define a field; a reference that cannot be inlined, such as an unknown or cyclic target, is left untouched and will dangle), `jsonSchemaDialect`, `info.summary`, `license.identifier`, and `mutualTLS` security schemes (reference aliases to them included) — their names are stripped from every security requirement, requirements that referenced only such schemes are removed, and a `security` list emptied that way is removed entirely, since an explicit empty list means "no security required" and would make an operation public. Schema Objects: diff --git a/packages/downgrader/src/shared.test.ts b/packages/downgrader/src/shared.test.ts index 1a0a7f1..0836bf0 100644 --- a/packages/downgrader/src/shared.test.ts +++ b/packages/downgrader/src/shared.test.ts @@ -9,7 +9,7 @@ import { mapArray, mapRecord, operationFields, - setKey, + setOwn, } from './shared' function identity(value: T): T { @@ -205,15 +205,36 @@ describe('convertRecord', () => { expect('polluted' in {}).toBe(false) }) - it('falls back to a cycle-preserving clone when re-entered for the same object', () => { + it('points a cyclic reference at the converted ancestor when re-entered for the same object', () => { const node: Record = { name: 'root' } node.self = node const result = convertNode(node) as Record expect(result.name).toBe('converted') - const inner = result.self as Record - expect(inner).not.toBe(node) - expect(inner.name).toBe('root') - expect(inner.self).toBe(inner) + expect(result.self).toBe(result) + expect(node.self).toBe(node) + }) + + it('converts a cycle that closes several levels down', () => { + const grandchild: Record = { name: 'grandchild' } + const child: Record = { name: 'child', self: grandchild } + const root: Record = { name: 'root', self: child } + grandchild.self = child + const result = convertNode(root) as Record + const convertedChild = result.self as Record + const convertedGrandchild = convertedChild.self as Record + expect(convertedChild.name).toBe('converted') + expect(convertedGrandchild.name).toBe('converted') + expect(convertedGrandchild.self).toBe(convertedChild) + expect(child.self).toBe(grandchild) + }) + + it('releases the cycle guard once a conversion finishes', () => { + const node: Record = { name: 'root' } + node.self = node + const first = convertNode(node) as Record + const second = convertNode(node) as Record + expect(second).not.toBe(first) + expect(second.self).toBe(second) }) it('converts shared acyclic references at every occurrence', () => { @@ -343,10 +364,10 @@ describe('getRef', () => { }) }) -describe('setKey', () => { +describe('setOwn', () => { it('defines an enumerable, writable, configurable own property', () => { const target: Record = {} - setKey(target, 'name', 'value') + setOwn(target, 'name', 'value') expect(Object.getOwnPropertyDescriptor(target, 'name')).toEqual({ configurable: true, enumerable: true, @@ -355,9 +376,58 @@ describe('setKey', () => { }) }) + it('shadows Object.prototype members with own data properties', () => { + const target: Record = {} + setOwn(target, 'constructor', 1) + setOwn(target, 'hasOwnProperty', 2) + expect(Object.getOwnPropertyDescriptor(target, 'constructor')?.value).toBe(1) + expect(Object.getOwnPropertyDescriptor(target, 'hasOwnProperty')?.value).toBe(2) + expect(Object.getPrototypeOf(target)).toBe(Object.prototype) + }) + + it('defines own properties for keys an accessor or read-only member on the prototype chain would intercept', () => { + let setterCalls = 0 + // eslint-disable-next-line no-extend-native + Object.defineProperty(Object.prototype, 'trapped', { + configurable: true, + get: () => 'inherited', + set: () => { + setterCalls += 1 + }, + }) + // eslint-disable-next-line no-extend-native + Object.defineProperty(Object.prototype, 'locked', { + configurable: true, + value: 'inherited', + writable: false, + }) + try { + const target: Record = {} + setOwn(target, 'trapped', 1) + setOwn(target, 'locked', 2) + expect(setterCalls).toBe(0) + expect(Object.getOwnPropertyDescriptor(target, 'trapped')?.value).toBe(1) + expect(Object.getOwnPropertyDescriptor(target, 'locked')?.value).toBe(2) + expect(deepClone({ locked: 3, trapped: 4 })).toEqual({ locked: 3, trapped: 4 }) + expect(convertRecord({ locked: 5, trapped: 6 }, {})).toEqual({ locked: 5, trapped: 6 }) + expect(mapRecord({ locked: 7, trapped: 8 }, identity)).toEqual({ locked: 7, trapped: 8 }) + } + finally { + delete (Object.prototype as Record).trapped + delete (Object.prototype as Record).locked + } + }) + + it('redefines a key already present on the target', () => { + const target: Record = {} + setOwn(target, 'name', 'first') + setOwn(target, 'name', 'second') + expect(target).toEqual({ name: 'second' }) + }) + it('sets a __proto__ key as a plain own property without prototype pollution', () => { const target: Record = {} - setKey(target, '__proto__', { polluted: true }) + setOwn(target, '__proto__', { polluted: true }) const descriptor = Object.getOwnPropertyDescriptor(target, '__proto__') expect(descriptor?.value).toEqual({ polluted: true }) expect(descriptor?.enumerable).toBe(true) diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index 8e335ef..b09397e 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -53,11 +53,17 @@ export function isRecord(value: unknown): value is Record { } /** - * Sets a key on the output record with define-property semantics, so hostile - * key names like `__proto__` become plain own properties. + * Sets a key as an own data property. Keys the prototype chain already knows + * (an accessor such as `__proto__`, or a read-only member once the + * intrinsics are frozen) are defined instead of assigned. */ -export function setKey(target: Record, key: string, value: unknown): void { - Object.defineProperty(target, key, { +export function setOwn(object: object, key: PropertyKey, value: unknown): void { + if (Object.hasOwn(object, key) || !(key in object)) { + // SAFETY: only widens the index signature. + (object as Record)[key] = value + return + } + Object.defineProperty(object, key, { configurable: true, enumerable: true, value, @@ -84,7 +90,7 @@ function cloneValue(value: unknown, seen: WeakMap): unknown { const out: Record = {} seen.set(value, out) for (const [key, item] of Object.entries(value)) { - setKey(out, key, cloneValue(item, seen)) + setOwn(out, key, cloneValue(item, seen)) } return out } @@ -104,25 +110,29 @@ export function deepClone(value: T): T { return cloneValue(value, new WeakMap()) as T } -/** Objects currently being converted somewhere up the call stack. */ -const converting = new WeakSet() +/** Objects being converted up the call stack, mapped to their output records. */ +const converting = new WeakMap>() /** * Rebuilds a plain object field by field: each key goes through its entry in * `fields` (or is deep-cloned when it has none), entries mapped to or * returning `DROP` are left out, and `finish` receives the result together * with the source for fix-ups that depend on several fields. Non-object input - * is deep-cloned unchanged, and so is an object already being converted - * higher up the call stack: a cyclic reference, which would otherwise recurse - * forever. + * is deep-cloned unchanged, and a cyclic reference back to an object still + * being converted yields that object's output record, so `finish` should + * mutate and return `out` rather than replace it. */ export function convertRecord(value: unknown, fields: FieldTable, finish?: (out: Record, source: Record) => unknown): unknown { - if (!isRecord(value) || converting.has(value)) { + if (!isRecord(value)) { return deepClone(value) } - converting.add(value) + const inProgress = converting.get(value) + if (inProgress !== undefined) { + return inProgress + } + const out: Record = {} + converting.set(value, out) try { - const out: Record = {} for (const [key, item] of Object.entries(value)) { const convert = Object.hasOwn(fields, key) ? fields[key] : undefined if (convert === DROP) { @@ -131,7 +141,7 @@ export function convertRecord(value: unknown, fields: FieldTable, finish?: (out: const converted = convert === undefined ? deepClone(item) : convert(item, value) if (converted !== DROP) { - setKey(out, key, converted) + setOwn(out, key, converted) } } return finish === undefined ? out : finish(out, value) @@ -149,7 +159,7 @@ export function mapRecord(value: unknown, convert: (item: unknown, key: string) for (const [key, item] of Object.entries(value)) { const converted = convert(item, key) if (converted !== DROP) { - setKey(out, key, converted) + setOwn(out, key, converted) } } return out diff --git a/packages/downgrader/src/v3.1-to-v3.0.test.ts b/packages/downgrader/src/v3.1-to-v3.0.test.ts index 1fa3860..a0e109b 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -123,16 +123,13 @@ describe('downgradeSpecV31ToV30', () => { }) }) - it('leaves a path item $ref field untouched, string or not', () => { + it('leaves a path item $ref that points outside components.pathItems untouched, string or not', () => { expect( - convertPathItem({ - $ref: '#/components/pathItems/Reusable', - summary: 's', - }), - ).toEqual({ - $ref: '#/components/pathItems/Reusable', - summary: 's', - }) + convertPathItem({ $ref: '#/paths/~1other', summary: 's' }), + ).toEqual({ $ref: '#/paths/~1other', summary: 's' }) + expect( + convertPathItem({ $ref: 'https://example.com/paths.json#/a' }), + ).toEqual({ $ref: 'https://example.com/paths.json#/a' }) expect(convertPathItem({ $ref: 42 })).toEqual({ $ref: 42 }) }) @@ -161,6 +158,149 @@ describe('downgradeSpecV31ToV30', () => { }) }) + describe('components.pathItems inlining', () => { + const reusable = { + get: { responses: { 200: { description: 'ok' } } }, + parameters: [{ in: 'query', name: 'q', schema: { type: ['string', 'null'] } }], + summary: 'Reusable', + } + const inlined = { + get: { responses: { 200: { description: 'ok' } } }, + parameters: [{ in: 'query', name: 'q', schema: { nullable: true, type: 'string' } }], + summary: 'Reusable', + } + + function convertWithPathItems(paths: unknown, pathItems: unknown, extra: Record = {}) { + return convertSpec({ components: { pathItems, ...extra }, paths }) + } + + it('inlines the converted entry and lets the referencing fields win', () => { + const result = convertWithPathItems( + { + '/a': { $ref: '#/components/pathItems/Reusable' }, + '/b': { + $ref: '#/components/pathItems/Reusable', + description: 'own', + summary: 'Own summary', + }, + }, + { Reusable: reusable }, + ) + expect(result.components).toEqual({}) + expect(result.paths).toEqual({ + '/a': inlined, + '/b': { ...inlined, description: 'own', summary: 'Own summary' }, + }) + }) + + it('follows chains of path item references', () => { + expect( + convertWithPathItems( + { '/a': { $ref: '#/components/pathItems/Alias', summary: 'Own' } }, + { + Alias: { $ref: '#/components/pathItems/Reusable', description: 'alias' }, + Reusable: reusable, + }, + ).paths, + ).toEqual({ '/a': { ...inlined, description: 'alias', summary: 'Own' } }) + }) + + it('inlines references inside callbacks', () => { + expect( + convertWithPathItems( + { + '/a': { + post: { + callbacks: { + onEvent: { '{$request.body#/url}': { $ref: '#/components/pathItems/Reusable' } }, + }, + responses: {}, + }, + }, + }, + { Reusable: reusable }, + ).paths, + ).toEqual({ + '/a': { + post: { + callbacks: { onEvent: { '{$request.body#/url}': inlined } }, + responses: {}, + }, + }, + }) + }) + + it.each([ + ['an unknown entry', '#/components/pathItems/Missing', { Reusable: reusable }], + ['a nested pointer', '#/components/pathItems/Reusable/get', { Reusable: reusable }], + ['an empty name', '#/components/pathItems/', { Reusable: reusable }], + ['a malformed entry', '#/components/pathItems/Junk', { Junk: 42 }], + ['a prototype member', '#/components/pathItems/hasOwnProperty', {}], + ['a malformed pathItems map', '#/components/pathItems/Reusable', 'junk'], + ])('leaves a reference to %s untouched', (_name, ref, pathItems) => { + expect( + convertWithPathItems({ '/a': { $ref: ref, summary: 's' } }, pathItems).paths, + ).toEqual({ '/a': { $ref: ref, summary: 's' } }) + }) + + it('leaves a reference untouched when components.pathItems is missing', () => { + expect( + convertPathItem({ $ref: '#/components/pathItems/Reusable' }), + ).toEqual({ $ref: '#/components/pathItems/Reusable' }) + }) + + it('stops at cyclic reference chains, leaving the innermost reference to dangle', () => { + expect( + convertWithPathItems( + { '/a': { $ref: '#/components/pathItems/Ping', summary: 'Own' } }, + { + Ping: { $ref: '#/components/pathItems/Pong', description: 'ping' }, + Pong: { $ref: '#/components/pathItems/Ping' }, + }, + ).paths, + ).toEqual({ + '/a': { + $ref: '#/components/pathItems/Ping', + description: 'ping', + summary: 'Own', + }, + }) + }) + + it('stops when a path item reaches itself through its callbacks', () => { + const result = convertWithPathItems( + { '/a': { $ref: '#/components/pathItems/Self' } }, + { + Self: { + post: { + callbacks: { loop: { expr: { $ref: '#/components/pathItems/Self' } } }, + responses: {}, + }, + }, + }, + ) + expect(result.paths).toEqual({ + '/a': { + post: { + callbacks: { loop: { expr: { $ref: '#/components/pathItems/Self' } } }, + responses: {}, + }, + }, + }) + }) + + it('applies mutualTLS removal inside inlined path items', () => { + const result = convertWithPathItems( + { '/a': { $ref: '#/components/pathItems/Secured' } }, + { Secured: { get: { responses: {}, security: [{ mtls: [] }, { api: ['r'] }] } } }, + { securitySchemes: { api: { in: 'header', name: 'k', type: 'apiKey' }, mtls: { type: 'mutualTLS' } } }, + ) + expect(result.paths).toEqual({ + '/a': { get: { responses: {}, security: [{ api: [] }] } }, + }) + }) + }) + describe('reference objects', () => { it('strips reference summary and description across components maps', () => { expect( @@ -619,13 +759,15 @@ describe('downgradeSpecV31ToV30', () => { expect(input).toEqual(before) }) - it('converts a path item that cycles through its callbacks without throwing', () => { + it('converts a path item that cycles through its callbacks, pointing the cycle at the converted path item', () => { const callback: Record = {} const pathItem: Record = { - get: { callbacks: { cb: callback }, responses: {} }, + get: { callbacks: { cb: callback } }, } callback.expr = pathItem - expect(() => convertPathItem(pathItem)).not.toThrow() + const result = convertPathItem(pathItem) + expect(dig(result, 'get', 'responses')).toEqual({ default: { description: '' } }) + expect(dig(result, 'get', 'callbacks', 'cb', 'expr')).toBe(result) }) }) }) @@ -1209,14 +1351,20 @@ describe('downgradeSchemaV31ToV30', () => { expect(() => downgradeSchemaV31ToV30(deep)).not.toThrow() }) - it('converts a schema whose subtree cycles back to itself without throwing', () => { + it('converts a dereferenced cyclic schema, pointing the cycle at the converted ancestor', () => { const properties: Record = {} - const node: Record = { properties, type: 'object' } + const node: Record = { + properties, + type: ['object', 'null'], + } properties.self = node - expect(convertSchema(node)).toHaveProperty( - ['properties', 'self', 'type'], - 'object', - ) + properties.children = { items: node, type: 'array' } + const result = convertSchema(node) as Record + expect(result.type).toBe('object') + expect(result.nullable).toBe(true) + expect(dig(result, 'properties', 'self')).toBe(result) + expect(dig(result, 'properties', 'children', 'items')).toBe(result) + expect(node.type).toEqual(['object', 'null']) }) }) }) diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 7385bbe..2fea1e8 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -4,9 +4,9 @@ * * The conversion never throws: parts that do not match the expected shape * are deep-copied through unchanged, a subtree that cycles back into an - * ancestor object is deep-copied with its cycle preserved instead of - * converted, and existing specification extensions (`x-` keys) as well as - * unknown keys are always preserved. Constructs 3.0 cannot express are + * ancestor object points at that ancestor's converted form, and existing + * specification extensions (`x-` keys) as well as unknown keys are always + * preserved. Constructs 3.0 cannot express are * converted where an equivalent exists and removed otherwise — the converter * never invents `x-` keys of its own. The README lists every mapping. * @@ -26,7 +26,7 @@ import { mapArray, mapRecord, operationFields, - setKey, + setOwn, } from './shared' /** @@ -130,13 +130,13 @@ function convertType(schema: Record, out: Record typeof item === 'string'))] if (types.length === 0 && type.length > 0) { // Only malformed entries: pass the array through unchanged. - setKey(out, 'type', deepClone(type)) + setOwn(out, 'type', deepClone(type)) return } applyTypes(types, schema, out) return } - setKey(out, 'type', deepClone(type)) + setOwn(out, 'type', deepClone(type)) } function convertConst(schema: Record, out: Record): void { @@ -245,7 +245,7 @@ function finishSchema(out: Record, schema: Record(schema: OpenAPIV3_1.SchemaO return converted as OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject } +const PATH_ITEMS_REF_PREFIX = '#/components/pathItems/' const SECURITY_SCHEMES_REF_PREFIX = '#/components/securitySchemes/' -interface SecuritySchemeIndex { - mutualTls: Set - types: Map +interface Context { + /** `components.pathItems` entries being inlined up the call stack. */ + inlining: Set + mutualTls: ReadonlySet + pathItems: Record | undefined + schemeTypes: ReadonlyMap } /** @@ -396,33 +400,39 @@ function resolveSchemeType(name: string, schemes: Record, seen: return undefined } -function indexSecuritySchemes(spec: unknown): SecuritySchemeIndex { - const index: SecuritySchemeIndex = { mutualTls: new Set(), types: new Map() } +function createContext(spec: unknown): Context { const components = isRecord(spec) ? spec.components : undefined + const pathItems = isRecord(components) ? components.pathItems : undefined const schemes = isRecord(components) ? components.securitySchemes : undefined - if (!isRecord(schemes)) { - return index - } - for (const name of Object.keys(schemes)) { - const type = resolveSchemeType(name, schemes, new Set()) - if (type !== undefined) { - index.types.set(name, type) - if (type === 'mutualTLS') { - // Reference aliases of mutualTLS schemes are removed as well, so no - // dangling references survive. - index.mutualTls.add(name) + const mutualTls = new Set() + const schemeTypes = new Map() + if (isRecord(schemes)) { + for (const name of Object.keys(schemes)) { + const type = resolveSchemeType(name, schemes, new Set()) + if (type !== undefined) { + schemeTypes.set(name, type) + if (type === 'mutualTLS') { + // Reference aliases of mutualTLS schemes are removed as well, so + // no dangling references survive. + mutualTls.add(name) + } } } } - return index + return { + inlining: new Set(), + mutualTls, + pathItems: isRecord(pathItems) ? pathItems : undefined, + schemeTypes, + } } -function convertRequirement(value: unknown, index: SecuritySchemeIndex): unknown { +function convertRequirement(value: unknown, context: Context): unknown { if (!isRecord(value)) { return deepClone(value) } const entries = Object.entries(value) - const kept = entries.filter(([name]) => !index.mutualTls.has(name)) + const kept = entries.filter(([name]) => !context.mutualTls.has(name)) if (kept.length === 0 && entries.length > 0) { // A requirement that only referenced mutualTLS schemes disappears; an // originally empty `{}` (optional security) is kept. @@ -432,7 +442,7 @@ function convertRequirement(value: unknown, index: SecuritySchemeIndex): unknown kept.map(([name, scopes]) => { // 3.0 allows roles only on OAuth-family schemes; roles on unknown // schemes are left alone. - const type = index.types.get(name) + const type = context.schemeTypes.get(name) const scoped = type === undefined || type === 'oauth2' || type === 'openIdConnect' return [name, Array.isArray(scopes) && !scoped ? [] : deepClone(scopes)] @@ -446,12 +456,12 @@ function convertRequirement(value: unknown, index: SecuritySchemeIndex): unknown * array means "no security required" and, on an operation, would override * the root declaration and silently make the operation public. */ -function convertSecurity(value: unknown, index: SecuritySchemeIndex): unknown { +function convertSecurity(value: unknown, context: Context): unknown { if (!Array.isArray(value)) { return deepClone(value) } const out = value - .map(item => convertRequirement(item, index)) + .map(item => convertRequirement(item, context)) .filter(item => item !== DROP) return value.length > 0 && out.length === 0 ? DROP : out } @@ -518,15 +528,15 @@ function convertResponses(item: unknown): unknown { : convertRefOr(entry, convertResponse)) } -function convertOperation(value: unknown, index: SecuritySchemeIndex): unknown { +function convertOperation(value: unknown, context: Context): unknown { return convertRecord( value, { - callbacks: refMap(item => convertCallback(item, index)), + callbacks: refMap(item => convertCallback(item, context)), parameters: refList(convertParameterOrHeader), requestBody: item => convertRefOr(item, convertRequestBody), responses: convertResponses, - security: item => convertSecurity(item, index), + security: item => convertSecurity(item, context), }, (out) => { if (out.responses === undefined) { @@ -539,26 +549,72 @@ function convertOperation(value: unknown, index: SecuritySchemeIndex): unknown { ) } -function convertCallback(value: unknown, index: SecuritySchemeIndex): unknown { +function convertCallback(value: unknown, context: Context): unknown { return mapRecord(value, (item, key) => - key.startsWith('x-') ? deepClone(item) : convertPathItem(item, index)) + key.startsWith('x-') ? deepClone(item) : convertPathItem(item, context)) } -function convertPathItem(value: unknown, index: SecuritySchemeIndex): unknown { - return convertRecord(value, { - ...operationFields(item => convertOperation(item, index)), +/** + * The `components.pathItems` entry a Path Item's `$ref` names, unless it is + * unknown, malformed, or already being inlined. + */ +function resolvePathItemRef(value: Record, context: Context): [name: string, target: Record] | undefined { + const ref = getRef(value) + if (ref === undefined || !ref.startsWith(PATH_ITEMS_REF_PREFIX)) { + return undefined + } + const name = ref.slice(PATH_ITEMS_REF_PREFIX.length) + if ( + name === '' + || name.includes('/') + || context.inlining.has(name) + || context.pathItems === undefined + || !Object.hasOwn(context.pathItems, name) + ) { + return undefined + } + const target = context.pathItems[name] + return isRecord(target) ? [name, target] : undefined +} + +/** + * 3.0 has no `components.pathItems`, so a reference into it is inlined, the + * referencing object's own fields winning over the referenced ones. + */ +function convertPathItem(value: unknown, context: Context): unknown { + if (!isRecord(value)) { + return deepClone(value) + } + const fields: FieldTable = { + ...operationFields(item => convertOperation(item, context)), parameters: refList(convertParameterOrHeader), - }) + } + const resolved = resolvePathItemRef(value, context) + if (resolved === undefined) { + return convertRecord(value, fields) + } + const [name, target] = resolved + context.inlining.add(name) + try { + // SAFETY: both convert a plain object into a plain object. + const inlined = convertPathItem(target, context) as Record + const own = convertRecord(value, { ...fields, $ref: DROP }) as Record + // Spread, unlike Object.assign, defines own properties without running setters. + return { ...inlined, ...own } + } + finally { + context.inlining.delete(name) + } } -function convertPaths(value: unknown, index: SecuritySchemeIndex): unknown { +function convertPaths(value: unknown, context: Context): unknown { return mapRecord(value, (item, key) => - key.startsWith('/') ? convertPathItem(item, index) : deepClone(item)) + key.startsWith('/') ? convertPathItem(item, context) : deepClone(item)) } -function convertComponents(value: unknown, index: SecuritySchemeIndex): unknown { +function convertComponents(value: unknown, context: Context): unknown { return convertRecord(value, { - callbacks: refMap(item => convertCallback(item, index)), + callbacks: refMap(item => convertCallback(item, context)), examples: refMap(deepClone), headers: refMap(convertParameterOrHeader), links: refMap(deepClone), @@ -569,20 +625,20 @@ function convertComponents(value: unknown, index: SecuritySchemeIndex): unknown schemas: item => mapRecord(item, convertSchema), securitySchemes: item => mapRecord(item, (scheme, name) => - index.mutualTls.has(name) ? DROP : convertRefOr(scheme, deepClone)), + context.mutualTls.has(name) ? DROP : convertRefOr(scheme, deepClone)), }) } function convertSpec(spec: unknown): unknown { - const index = indexSecuritySchemes(spec) + const context = createContext(spec) return convertRecord( spec, { - components: item => convertComponents(item, index), + components: item => convertComponents(item, context), info: convertInfo, jsonSchemaDialect: DROP, - paths: item => convertPaths(item, index), - security: item => convertSecurity(item, index), + paths: item => convertPaths(item, context), + security: item => convertSecurity(item, context), webhooks: DROP, }, (out) => { diff --git a/packages/downgrader/src/v3.2-to-v3.1.test.ts b/packages/downgrader/src/v3.2-to-v3.1.test.ts index 774c385..f53e682 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.test.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.test.ts @@ -57,6 +57,35 @@ describe('downgradeSpecV32ToV31', () => { }) }) + it.each([ + [ + 'rewrites the dated 3.2 OAS dialect to the 3.1 base dialect', + 'https://spec.openapis.org/oas/3.2/dialect/2025-09-17', + 'https://spec.openapis.org/oas/3.1/dialect/base', + ], + [ + 'rewrites a draft 3.2 OAS dialect to the 3.1 base dialect', + 'https://spec.openapis.org/oas/3.2/dialect/WORK-IN-PROGRESS', + 'https://spec.openapis.org/oas/3.1/dialect/base', + ], + [ + 'keeps a 3.1 OAS dialect', + 'https://spec.openapis.org/oas/3.1/dialect/base', + 'https://spec.openapis.org/oas/3.1/dialect/base', + ], + [ + 'keeps a custom dialect', + 'https://example.com/my-dialect', + 'https://example.com/my-dialect', + ], + ['clones a malformed dialect through', { junk: true }, { junk: true }], + ])('%s', (_name, dialect, expected) => { + expect(convertSpec({ jsonSchemaDialect: dialect })).toEqual({ + jsonSchemaDialect: expected, + openapi: '3.1.2', + }) + }) + it('returns non-object input unchanged', () => { expect(downgradeSpecV32ToV31(null as any)).toBeNull() expect(downgradeSpecV32ToV31('junk' as any)).toBe('junk') @@ -818,6 +847,11 @@ describe('downgradeSpecV32ToV31', () => { {}, { description: '' }, ], + [ + 'synthesizes an empty description instead of promoting a malformed summary', + { summary: 42 }, + { description: '' }, + ], [ 'clones a non-object headers value through', { description: 'ok', headers: 'junk' }, @@ -1211,13 +1245,17 @@ describe('downgradeSpecV32ToV31', () => { expect(spec).toEqual(before) }) - it('converts a path item that cycles through its callbacks without throwing', () => { + it('converts a path item that cycles through its callbacks, pointing the cycle at the converted path item', () => { const callback: Record = {} const pathItem: Record = { - get: { callbacks: { cb: callback }, responses: {} }, + get: { callbacks: { cb: callback }, responses: { 200: { summary: 'ok' } } }, + query: { description: 'q' }, } callback.expr = pathItem - expect(() => convertPathItem(pathItem)).not.toThrow() + const result = convertPathItem(pathItem) + expect(result).not.toHaveProperty('query') + expect(dig(result, 'get', 'responses', '200')).toEqual({ description: 'ok' }) + expect(dig(result, 'get', 'callbacks', 'cb', 'expr')).toBe(result) }) }) }) diff --git a/packages/downgrader/src/v3.2-to-v3.1.ts b/packages/downgrader/src/v3.2-to-v3.1.ts index 06bed7f..615798a 100644 --- a/packages/downgrader/src/v3.2-to-v3.1.ts +++ b/packages/downgrader/src/v3.2-to-v3.1.ts @@ -4,9 +4,9 @@ * * The conversion never throws: parts that do not match the expected shape * are deep-copied through unchanged, a subtree that cycles back into an - * ancestor object is deep-copied with its cycle preserved instead of - * converted, and existing specification extensions (`x-` keys) as well as - * unknown keys are always preserved. Constructs 3.1 cannot express are + * ancestor object points at that ancestor's converted form, and existing + * specification extensions (`x-` keys) as well as unknown keys are always + * preserved. Constructs 3.1 cannot express are * converted where an equivalent exists and removed otherwise — the converter * never invents `x-` keys of its own. The README lists every mapping. * @@ -32,6 +32,9 @@ const HEADERS_REF_PREFIX = '#/components/headers/' const MEDIA_TYPES_REF_PREFIX = '#/components/mediaTypes/' const PARAMETERS_REF_PREFIX = '#/components/parameters/' +const V32_DIALECT_PREFIX = 'https://spec.openapis.org/oas/3.2/dialect/' +const V31_DIALECT = 'https://spec.openapis.org/oas/3.1/dialect/base' + interface Context { mediaTypes: Record | undefined removedHeaderRefs: ReadonlySet @@ -245,7 +248,7 @@ function convertResponse(value: unknown, context: Context): unknown { if (out.description === undefined) { // Required in 3.1, optional in 3.2. out.description - = 'summary' in response ? deepClone(response.summary) : '' + = typeof response.summary === 'string' ? response.summary : '' } return out }, @@ -379,6 +382,13 @@ function createContext(spec: unknown): Context { } } +/** The 3.2 OAS dialect only extends the 3.1 base vocabulary; other dialects pass through. */ +function convertJsonSchemaDialect(value: unknown): unknown { + return typeof value === 'string' && value.startsWith(V32_DIALECT_PREFIX) + ? V31_DIALECT + : deepClone(value) +} + function convertSpec(spec: unknown): unknown { const context = createContext(spec) return convertRecord( @@ -386,6 +396,7 @@ function convertSpec(spec: unknown): unknown { { $self: DROP, components: item => convertComponents(item, context), + jsonSchemaDialect: convertJsonSchemaDialect, paths: item => convertPaths(item, context), servers: item => mapArray(item, convertServer), tags: item => mapArray(item, convertTag), diff --git a/packages/downgrader/tests/e2e.test.ts b/packages/downgrader/tests/e2e.test.ts index 5473269..4fcfc40 100644 --- a/packages/downgrader/tests/e2e.test.ts +++ b/packages/downgrader/tests/e2e.test.ts @@ -73,29 +73,35 @@ describe('3.1 example documents downgraded to 3.0', () => { expect(mega31).toEqual(before) }) - it('leaves $refs into the removed components.pathItems untouched, letting them dangle', () => { + it('inlines $refs into the removed components.pathItems so nothing dangles', async () => { const doc: OpenAPIV3_1.OpenAPIObject = { components: { pathItems: { shared: { get: { responses: { 200: { description: 'ok' } } }, + summary: 'Shared', }, }, }, - info: { title: 'Dangling', version: '1.0.0' }, + info: { title: 'Inlined', version: '1.0.0' }, openapi: '3.1.0', paths: { - '/shared': { $ref: '#/components/pathItems/shared' }, + '/shared': { + $ref: '#/components/pathItems/shared', + description: 'Overriding description', + }, }, } const before = structuredClone(doc) const converted = downgradeSpecV31ToV30(doc) expect(converted.components).not.toHaveProperty('pathItems') - // Documented limitation: the reference is passed through untouched and - // now dangles, so the (reference-resolving) validator is not consulted. expect(converted.paths?.['/shared']).toEqual({ - $ref: '#/components/pathItems/shared', + description: 'Overriding description', + get: { responses: { 200: { description: 'ok' } } }, + summary: 'Shared', }) + expect(JSON.stringify(converted)).not.toContain('#/components/pathItems/') + await expectValidAs(converted, '3.0') expect(doc).toEqual(before) }) From 1b4033d7350dbf8039eba8623f15e48457ad43d7 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 7 Sep 2026 13:54:27 +0700 Subject: [PATCH 2/2] refactor(downgrader): check only __proto__ in setOwn Plain assignment already creates own properties for every other key on a plain object; only `__proto__` would route through the inherited setter. The prototype-chain guard and its frozen-intrinsics test are dropped. --- packages/downgrader/src/shared.test.ts | 33 -------------------------- packages/downgrader/src/shared.ts | 24 +++++++++---------- 2 files changed, 12 insertions(+), 45 deletions(-) diff --git a/packages/downgrader/src/shared.test.ts b/packages/downgrader/src/shared.test.ts index 0836bf0..accaf0b 100644 --- a/packages/downgrader/src/shared.test.ts +++ b/packages/downgrader/src/shared.test.ts @@ -385,39 +385,6 @@ describe('setOwn', () => { expect(Object.getPrototypeOf(target)).toBe(Object.prototype) }) - it('defines own properties for keys an accessor or read-only member on the prototype chain would intercept', () => { - let setterCalls = 0 - // eslint-disable-next-line no-extend-native - Object.defineProperty(Object.prototype, 'trapped', { - configurable: true, - get: () => 'inherited', - set: () => { - setterCalls += 1 - }, - }) - // eslint-disable-next-line no-extend-native - Object.defineProperty(Object.prototype, 'locked', { - configurable: true, - value: 'inherited', - writable: false, - }) - try { - const target: Record = {} - setOwn(target, 'trapped', 1) - setOwn(target, 'locked', 2) - expect(setterCalls).toBe(0) - expect(Object.getOwnPropertyDescriptor(target, 'trapped')?.value).toBe(1) - expect(Object.getOwnPropertyDescriptor(target, 'locked')?.value).toBe(2) - expect(deepClone({ locked: 3, trapped: 4 })).toEqual({ locked: 3, trapped: 4 }) - expect(convertRecord({ locked: 5, trapped: 6 }, {})).toEqual({ locked: 5, trapped: 6 }) - expect(mapRecord({ locked: 7, trapped: 8 }, identity)).toEqual({ locked: 7, trapped: 8 }) - } - finally { - delete (Object.prototype as Record).trapped - delete (Object.prototype as Record).locked - } - }) - it('redefines a key already present on the target', () => { const target: Record = {} setOwn(target, 'name', 'first') diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index b09397e..fb7654b 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -53,22 +53,22 @@ export function isRecord(value: unknown): value is Record { } /** - * Sets a key as an own data property. Keys the prototype chain already knows - * (an accessor such as `__proto__`, or a read-only member once the - * intrinsics are frozen) are defined instead of assigned. + * Sets a key as an own data property. `__proto__` is defined rather than + * assigned, so it becomes a plain property instead of replacing the prototype. */ export function setOwn(object: object, key: PropertyKey, value: unknown): void { - if (Object.hasOwn(object, key) || !(key in object)) { + if (key === '__proto__') { + Object.defineProperty(object, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }) + } + else { // SAFETY: only widens the index signature. (object as Record)[key] = value - return - } - Object.defineProperty(object, key, { - configurable: true, - enumerable: true, - value, - writable: true, - }) + } } function cloneValue(value: unknown, seen: WeakMap): unknown {