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..accaf0b 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,25 @@ 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('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..fb7654b 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -53,16 +53,22 @@ 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. `__proto__` is defined rather than + * assigned, so it becomes a plain property instead of replacing the prototype. */ -export function setKey(target: Record, key: string, value: unknown): void { - Object.defineProperty(target, key, { - configurable: true, - enumerable: true, - value, - writable: true, - }) +export function setOwn(object: object, key: PropertyKey, value: unknown): void { + 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 + } } function cloneValue(value: unknown, seen: WeakMap): unknown { @@ -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) })