diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index f481c1b..d746931 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -23,7 +23,7 @@ Every converter follows the same contract: - **Never throws.** Malformed parts are deep-copied through unchanged instead of failing the whole conversion. Cyclic object graphs, such as the output of a `$ref` dereferencer, convert with their cycles preserved. Only pathologically deep nesting (thousands of levels) can still exhaust the call stack. -- **Never mutates.** The input is left untouched and the result is a new object. Objects shared within the input, such as a dereferenced schema used in several places, may stay shared within the result. +- **Never mutates.** The input is left untouched and the result is a new object. Objects shared within the input, such as a dereferenced schema used in several places, may stay shared within the result, and so may a target inlined at several references. - **Preserves extensions, never invents them.** `x-` keys and unknown keys survive. Constructs the target version cannot express are converted where an equivalent exists and removed otherwise. ## Usage @@ -93,21 +93,25 @@ Known limitations: security requirements keyed by URI, `$self`-relative referenc Converted: -| 3.1 construct | 3.0 result | -| ---------------------------------------------------------- | ---------------------------------------------------------------------- | -| `openapi: 3.1.x` | `openapi: 3.0.4` | -| missing `paths` | `{}` (required in 3.0) | -| missing operation `responses` | `{ "default": { "description": "" } }` (required and non-empty in 3.0) | -| path parameters without `required: true` | `required: true` added (mandatory for `in: "path"`) | -| Reference Object `summary` / `description` | removed (3.0 references carry no overrides) | -| security requirement scopes on `apiKey` and `http` schemes | emptied to `[]` | +| 3.1 construct | 3.0 result | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `openapi: 3.1.x` | `openapi: 3.0.4` | +| missing `paths` | `{}` (required in 3.0) | +| missing operation `responses` | `{ "default": { "description": "" } }` (required and non-empty in 3.0) | +| path parameters without `required: true` | `required: true` added (mandatory for `in: "path"`) | +| Reference Object `summary` / `description` | applied to an inlined target whose type has the field, removed otherwise (3.0 references carry no overrides) | +| security requirement scopes on `apiKey` and `http` schemes | emptied to `[]` | Removed, with no 3.0 equivalent: -- `webhooks` +- `webhooks` and `components.pathItems`, after same-document references into them are resolved: + - Reference Objects, Path Item `$ref`s, and Schema `$ref`s are replaced by their target in converted form, following reference chains. A chain that reaches a `$ref` outside them ends at that `$ref`, and a Path Item's own fields win over inlined ones. + - A target referenced from several places is converted once and shared. Anything reached again while it is still being converted, through a reference or an object shared within the input, is cut: a Schema Object becomes `{}`, a Path Item reference keeps only its own fields, and anything else is removed. A recursive schema keeps one level, and where a cycle is cut can depend on document order. + - A Link `operationRef` into them becomes the target operation's `operationId` when an operation with that `operationId` remains, such as one inlined into `paths`. Otherwise the link is removed, together with Link references that lead to it. + - `discriminator.mapping` entries pointing into them are removed. + - A reference whose target is missing, is not an object (a boolean Schema target converts as usual), or forms a reference loop is left as written, and so is a Path Item `$ref` with a hop that is not a `webhooks` or `components.pathItems` entry or a callback expression. - `jsonSchemaDialect` - `info.summary` and `license.identifier` -- `components.pathItems`. Path Item `$ref`s to it, in `paths` and in callbacks, are inlined first, following reference chains, with the referencing Path Item's own fields winning over inlined ones. A reference that cannot be inlined (unknown or cyclic target) is left as is and will dangle. - `mutualTLS` security schemes, reference aliases included. Their names are stripped from every security requirement, a requirement left empty is removed, and a `security` list left empty is removed entirely, since an explicit empty list means "no security required" and would make the operation public. Schema Objects: @@ -133,7 +137,8 @@ Removed, with no 3.0 equivalent: `$schema`, `$id`, `$defs`, `$anchor`, `$dynamic Known limitations: -- `$ref`s into dropped keywords (`#/…/$defs/…` pointers, `$anchor` targets, `$id`-based bases) will dangle. Hoist reusable subschemas into `components.schemas` before downgrading. +- `$ref`s into dropped keywords outside `webhooks` and `components.pathItems` (`#/…/$defs/…` pointers, `$anchor` targets, `$id`-based bases) will dangle. Hoist reusable subschemas into `components.schemas` before downgrading. +- A pointer into `webhooks` or `components.pathItems` that passes through another `$ref` is not followed: a `$ref` keeps it and dangles, while a Link `operationRef` or `discriminator.mapping` entry of that shape is removed. A Link naming a removed operation only by `operationId` is kept, and a Path Item inlined in several places repeats its `operationId`s, which 3.0 requires to be unique. - Non-standard schema keywords are preserved per the extension contract, even though the official 3.0 schema forbids unknown Schema Object fields. - Dropping keywords inside `not`, where loosening the operand tightens the whole, or inside `oneOf` branches, where loosening one branch can break exclusivity, can change what validates. diff --git a/packages/downgrader/src/shared.test.ts b/packages/downgrader/src/shared.test.ts index e04ee89..347e83b 100644 --- a/packages/downgrader/src/shared.test.ts +++ b/packages/downgrader/src/shared.test.ts @@ -2,6 +2,7 @@ import type { FieldTable } from './shared' import { dig } from '../tests/helpers' import { + convertInlined, convertRecord, deepClone, DROP, @@ -421,6 +422,32 @@ describe('getRef', () => { }) }) +describe('convertInlined', () => { + it('drops a conversion still in progress outside the inline and keeps cycles inside it', () => { + const node: Record = { name: 'root' } + node.self = node + const source = { child: 'x' } + const result = convertRecord(source, { + child: () => convertInlined(() => ({ back: convertRecord(source, {}), node: convertNode(node) })), + }) + expect(dig(result, 'child', 'back')).toBe(DROP) + expect(dig(result, 'child', 'node', 'self')).toBe(dig(result, 'child', 'node')) + }) + + it('converts again, outside the inline, a result that was cut inside it', () => { + const node: Record = { name: 'root' } + const child = { self: node } + node.self = child + const convertChild = (item: unknown): unknown => convertRecord(item, { self: convertNode }) + const result = convertRecord(node, { + name: () => convertInlined(() => convertChild(child)), + self: convertChild, + }) + expect(dig(result, 'name')).toEqual({}) + expect(dig(result, 'self', 'self')).toBe(result) + }) +}) + describe('isConverting', () => { it('reports only source records whose conversion is still in progress', () => { const child = { a: 1 } diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index c152b3d..caba5e8 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -44,6 +44,8 @@ export function setOwn(object: object, key: PropertyKey, value: unknown): void { type Finish = (out: Record, source: Record) => unknown interface Conversion { + cutAt: number + depth: number done: boolean fields: FieldTable finish: Finish | undefined @@ -52,6 +54,8 @@ interface Conversion { const conversions = new Map() const clones = new Map() +const active: Conversion[] = [] +let depth = 0 function cloneValue(value: unknown, seen: Map): unknown { if (!(Array.isArray(value) || isRecord(value))) { @@ -89,13 +93,25 @@ export function convertRecord(value: unknown, fields: FieldTable, finish?: Finis return deepClone(value) } const known = conversions.get(value) - if (known !== undefined && (!known.done || (known.fields === fields && known.finish === finish))) { + if (known !== undefined && !known.done) { + if (known.depth === depth) { + return known.result + } + for (const conversion of active) { + if (conversion.depth > known.depth) { + conversion.cutAt = Math.max(conversion.cutAt, known.depth) + } + } + return DROP + } + if (known !== undefined && known.fields === fields && known.finish === finish && depth > known.cutAt) { return known.result } const out: Record = {} - const conversion: Conversion = { done: false, fields, finish, result: out } + const conversion: Conversion = { cutAt: -1, depth, done: false, fields, finish, result: out } const outermost = conversions.size === 0 conversions.set(value, conversion) + active.push(conversion) try { for (const [key, item] of Object.entries(value)) { const convert = Object.hasOwn(fields, key) ? fields[key] : undefined @@ -112,6 +128,7 @@ export function convertRecord(value: unknown, fields: FieldTable, finish?: Finis return conversion.result } finally { + active.pop() if (outermost) { conversions.clear() clones.clear() @@ -119,6 +136,16 @@ export function convertRecord(value: unknown, fields: FieldTable, finish?: Finis } } +export function convertInlined(convert: () => T): T { + depth += 1 + try { + return convert() + } + finally { + depth -= 1 + } +} + export function isConverting(value: unknown): boolean { return isRecord(value) && conversions.get(value)?.done === false } 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 4aa859e..e9be57f 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -134,7 +134,7 @@ describe('downgradeSpecV31ToV30', () => { expect(convertSpec({ paths: 'junk' }).paths).toBe('junk') const paths = { '/a': { - get: { requestBody: 42, responses: { 200: 'junk' } }, + get: { requestBody: 42, responses: { 200: 'junk', 201: { description: 'ok', links: 'junk' } } }, parameters: [42], }, '/b': { @@ -246,7 +246,7 @@ describe('downgradeSpecV31ToV30', () => { ).toEqual({ $ref: '#/components/pathItems/Reusable' }) }) - it('stops at cyclic reference chains, leaving the innermost reference to dangle', () => { + it('leaves a reference chain that loops without reaching a path item as written', () => { expect( convertWithPathItems( { '/a': { $ref: '#/components/pathItems/Ping', summary: 'Own' } }, @@ -255,22 +255,21 @@ describe('downgradeSpecV31ToV30', () => { Pong: { $ref: '#/components/pathItems/Ping' }, }, ).paths, - ).toEqual({ - '/a': { - $ref: '#/components/pathItems/Ping', - description: 'ping', - summary: 'Own', - }, - }) + ).toEqual({ '/a': { $ref: '#/components/pathItems/Ping', summary: 'Own' } }) }) - it('stops when a path item reaches itself through its callbacks', () => { + it('cuts a path item that reaches itself through its callbacks down to its own fields', () => { const result = convertWithPathItems( { '/a': { $ref: '#/components/pathItems/Self' } }, { Self: { post: { - callbacks: { loop: { expr: { $ref: '#/components/pathItems/Self' } } }, + callbacks: { + loop: { + bare: { $ref: '#/components/pathItems/Self' }, + own: { $ref: '#/components/pathItems/Self', summary: 'own' }, + }, + }, responses: {}, }, }, @@ -279,7 +278,7 @@ describe('downgradeSpecV31ToV30', () => { expect(result.paths).toEqual({ '/a': { post: { - callbacks: { loop: { expr: { $ref: '#/components/pathItems/Self' } } }, + callbacks: { loop: { bare: {}, own: { summary: 'own' } } }, responses: {}, }, }, @@ -298,6 +297,890 @@ describe('downgradeSpecV31ToV30', () => { }) }) + describe('references into webhooks and components.pathItems', () => { + const removedPointer = /#\/(?:webhooks|components\/pathItems)/ + const schemaPointer = '#/webhooks/newPet/post/requestBody/content/application~1json/schema' + const hook = { + post: { + operationId: 'newPetHook', + parameters: [{ description: 'orig', in: 'header', name: 'X-Hook', schema: { type: ['string', 'null'] } }], + requestBody: { + content: { + 'application/json': { + schema: { properties: { name: { type: 'string' } }, type: 'object' }, + }, + }, + }, + responses: { 200: { description: 'ok' } }, + }, + } + const hookParameter = { description: 'orig', in: 'header', name: 'X-Hook', schema: { nullable: true, type: 'string' } } + const item = { + get: { operationId: 'getItem', responses: { 200: { description: 'item' } } }, + parameters: [{ in: 'query', name: 'q', schema: { const: 'x' } }], + } + + it('inlines the reported references without mutating the input', () => { + const input = { + ...base, + components: { + pathItems: { Item: item }, + schemas: { Pet: { $ref: schemaPointer } }, + }, + paths: { + '/a': { + get: { + parameters: [ + { $ref: '#/webhooks/newPet/post/parameters/0' }, + { $ref: '#/components/pathItems/Item/parameters/0' }, + ], + responses: { + 200: { $ref: '#/webhooks/newPet/post/responses/200' }, + 201: { + description: 'created', + links: { + l1: { operationRef: '#/webhooks/newPet/post' }, + l2: { operationRef: '#/components/pathItems/Item/get' }, + }, + }, + }, + }, + }, + '/b': { $ref: '#/webhooks/newPet' }, + }, + webhooks: { newPet: hook }, + } + const before = structuredClone(input) + const result = downgradeSpecV31ToV30(input as any) + expect(result.components).toEqual({ + schemas: { Pet: { properties: { name: { type: 'string' } }, type: 'object' } }, + }) + expect(result.paths).toEqual({ + '/a': { + get: { + parameters: [hookParameter, { in: 'query', name: 'q', schema: { enum: ['x'] } }], + responses: { + 200: { description: 'ok' }, + 201: { description: 'created', links: { l1: { operationId: 'newPetHook' } } }, + }, + }, + }, + '/b': { post: { ...hook.post, parameters: [hookParameter] } }, + }) + expect(JSON.stringify(result)).not.toMatch(removedPointer) + expect(input).toEqual(before) + }) + + it('converts each inlined target for its position, in every component map', () => { + const pointer = (path: string) => `#/webhooks/full/post/${path}` + const response = { + content: { 'application/json': { examples: { e: { value: 1 } } } }, + description: 'ok', + headers: { H: { schema: { const: 1 } } }, + } + const result = convertSpec({ + components: { + callbacks: { C: { $ref: pointer('callbacks/cb') } }, + examples: { E: { $ref: pointer('responses/200/content/application~1json/examples/e') } }, + headers: { H: { $ref: pointer('responses/200/headers/H') } }, + parameters: { P: { $ref: pointer('parameters/0') } }, + requestBodies: { B: { $ref: pointer('requestBody') } }, + responses: { R: { $ref: pointer('responses/200') } }, + securitySchemes: { S: { $ref: pointer('x-scheme') } }, + }, + webhooks: { + full: { + post: { + 'callbacks': { cb: { '{$url}': { get: {} } } }, + 'parameters': [{ in: 'path', name: 'id' }], + 'requestBody': { + content: { 'application/json': { schema: { type: ['string', 'null'] } } }, + }, + 'responses': { 200: response }, + 'x-scheme': { in: 'header', name: 'k', type: 'apiKey' }, + }, + }, + }, + }) + expect(result.components).toEqual({ + callbacks: { C: { '{$url}': { get: { responses: { default: { description: '' } } } } } }, + examples: { E: { value: 1 } }, + headers: { H: { schema: { enum: [1] } } }, + parameters: { P: { in: 'path', name: 'id', required: true } }, + requestBodies: { + B: { content: { 'application/json': { schema: { nullable: true, type: 'string' } } } }, + }, + responses: { R: { ...response, headers: { H: { schema: { enum: [1] } } } } }, + securitySchemes: { S: { in: 'header', name: 'k', type: 'apiKey' } }, + }) + }) + + it('follows chains through the removed parts and keeps the reference where a chain leaves them', () => { + const result = convertSpec({ + components: { + parameters: { Shared: { in: 'query', name: 'shared' } }, + pathItems: { Deep: { parameters: [{ in: 'query', name: 'deep' }] } }, + schemas: { + Exit: { $ref: '#/webhooks/chain/post/requestBody/content/application~1json/schema' }, + Name: { type: 'string' }, + }, + }, + paths: { + '/a': { + post: { + parameters: [ + { $ref: '#/webhooks/chain/post/parameters/0' }, + { $ref: '#/webhooks/chain/post/parameters/1', description: 'dropped' }, + ], + responses: {}, + }, + }, + '/b': { $ref: '#/webhooks/alias', description: 'own' }, + }, + webhooks: { + alias: { $ref: '#/paths/~1a', summary: 'alias' }, + chain: { + post: { + parameters: [ + { $ref: '#/components/pathItems/Deep/parameters/0' }, + { $ref: '#/components/parameters/Shared' }, + ], + requestBody: { + content: { 'application/json': { schema: { $ref: '#/components/schemas/Name' } } }, + }, + }, + }, + }, + }) + expect(result.components?.schemas?.Exit).toEqual({ $ref: '#/components/schemas/Name' }) + expect(result.paths).toEqual({ + '/a': { + post: { + parameters: [{ in: 'query', name: 'deep' }, { $ref: '#/components/parameters/Shared' }], + responses: {}, + }, + }, + '/b': { $ref: '#/paths/~1a', description: 'own', summary: 'alias' }, + }) + }) + + it('follows long chains without growing the stack', () => { + const webhooks: Record = { w10000: { get: { responses: {} } } } + for (let index = 0; index < 10_000; index++) { + webhooks[`w${index}`] = { $ref: `#/webhooks/w${index + 1}` } + } + expect(convertSpec({ paths: { '/a': { $ref: '#/webhooks/w0' } }, webhooks }).paths).toEqual({ + '/a': { get: { responses: {} } }, + }) + }) + + it('applies the outermost summary and description override where the target has that field', () => { + const result = convertSpec({ + components: { + callbacks: { C: { $ref: '#/webhooks/newPet/x-callback', description: 'ignored' } }, + examples: { E: { $ref: '#/webhooks/newPet/x-example', summary: 'outer' } }, + parameters: { P: { $ref: '#/webhooks/newPet/x-alias', description: 'outer' } }, + }, + webhooks: { + newPet: { + ...hook, + 'x-alias': { $ref: '#/webhooks/newPet/post/parameters/0', description: 'inner' }, + 'x-callback': { '{$url}': { summary: 's' } }, + 'x-example': { description: 'd', summary: 's', value: 1 }, + }, + }, + }) + expect(result.components).toEqual({ + callbacks: { C: { '{$url}': { summary: 's' } } }, + examples: { E: { description: 'd', summary: 'outer', value: 1 } }, + parameters: { P: { ...hookParameter, description: 'outer' } }, + }) + }) + + it.each([ + ['a missing target', '#/webhooks/newPet/post/parameters/9'], + ['a non-object target', '#/webhooks/newPet/post/operationId'], + ['a looping chain', '#/components/pathItems/Loop/parameters/0'], + ['a malformed percent escape', '#/webhooks/%E0%A4%A'], + ])('leaves a reference to %s as written, in every position', (_name, ref) => { + const reference = { $ref: ref, description: 'd' } + const bare = { $ref: ref } + const result = convertSpec({ + components: { + callbacks: { C: reference }, + examples: { E: reference }, + headers: { H: reference }, + links: { L: reference }, + parameters: { P: reference }, + pathItems: { + Loop: { + parameters: [ + { $ref: '#/components/pathItems/Loop/parameters/1' }, + { $ref: '#/components/pathItems/Loop/parameters/0' }, + ], + }, + }, + requestBodies: { B: reference }, + responses: { R: reference }, + schemas: { S: bare, T: { $ref: ref, type: 'string' } }, + securitySchemes: { S: reference }, + }, + paths: { + '/a': { + get: { + callbacks: { cb: reference }, + parameters: [reference, { in: 'query', name: 'kept' }], + requestBody: reference, + responses: { + 200: { + content: { + 'application/json': { + encoding: { f: { headers: { H: reference } } }, + examples: { e: reference }, + schema: bare, + }, + }, + description: 'ok', + headers: { H: reference }, + links: { l: reference }, + }, + 201: reference, + }, + }, + parameters: [reference], + }, + '/b': reference, + }, + webhooks: { newPet: hook }, + }) + expect(result.components).toEqual({ + callbacks: { C: bare }, + examples: { E: bare }, + headers: { H: bare }, + links: { L: bare }, + parameters: { P: bare }, + requestBodies: { B: bare }, + responses: { R: bare }, + schemas: { S: bare, T: { allOf: [bare], type: 'string' } }, + securitySchemes: { S: bare }, + }) + expect(result.paths).toEqual({ + '/a': { + get: { + callbacks: { cb: bare }, + parameters: [bare, { in: 'query', name: 'kept' }], + requestBody: bare, + responses: { + 200: { + content: { + 'application/json': { + encoding: { f: { headers: { H: bare } } }, + examples: { e: bare }, + schema: bare, + }, + }, + description: 'ok', + headers: { H: bare }, + links: { l: bare }, + }, + 201: bare, + }, + }, + parameters: [bare], + }, + '/b': reference, + }) + }) + + it('inlines Schema $refs with or without siblings and converts boolean targets', () => { + const pointer = (path: string) => `#/components/pathItems/Schemas/x-schemas/${path}` + const result = convertSpec({ + components: { + pathItems: { + Schemas: { + 'x-schemas': { + alias: { $ref: pointer('nullable') }, + never: false, + nullable: { type: ['string', 'null'] }, + withSiblings: { $ref: pointer('nullable'), description: 'wrapped' }, + }, + }, + }, + schemas: { + Alias: { $ref: pointer('alias') }, + Never: { $ref: pointer('never') }, + NotNever: { not: { $ref: pointer('never') } }, + Siblings: { $ref: pointer('nullable'), description: 'd' }, + WithSiblings: { $ref: pointer('withSiblings') }, + }, + }, + }) + const nullable = { nullable: true, type: 'string' } + expect(result.components).toEqual({ + schemas: { + Alias: nullable, + Never: { not: {} }, + NotNever: { not: { not: {} } }, + Siblings: { allOf: [nullable], description: 'd' }, + WithSiblings: { allOf: [nullable], description: 'wrapped' }, + }, + }) + }) + + it('cuts recursion into {} for schemas and into own fields for path items, keeping the output acyclic', () => { + const result = convertSpec({ + components: { schemas: { Tree: { $ref: '#/webhooks/tree/post/requestBody/content/application~1json/schema' } } }, + paths: { + '/ping': { $ref: '#/webhooks/ping' }, + '/tree': { $ref: '#/webhooks/tree' }, + }, + webhooks: { + ping: { + post: { + callbacks: { + pong: { $ref: '#/webhooks/ping/post/callbacks/self' }, + self: { '{$request.body#/url}': { $ref: '#/webhooks/ping' } }, + }, + responses: {}, + }, + }, + tree: { + post: { + requestBody: { + content: { + 'application/json': { + schema: { + properties: { + children: { items: { $ref: '#/webhooks/tree/post/requestBody/content/application~1json/schema' }, type: 'array' }, + }, + type: 'object', + }, + }, + }, + }, + responses: {}, + }, + }, + }, + }) + expect(result.components).toEqual({ + schemas: { Tree: { properties: { children: { items: {}, type: 'array' } }, type: 'object' } }, + }) + expect( + dig(result, 'paths', '/tree', 'post', 'requestBody', 'content', 'application/json', 'schema'), + ).toBe(dig(result, 'components', 'schemas', 'Tree')) + expect(dig(result, 'paths', '/ping', 'post', 'callbacks')).toEqual({ + pong: { '{$request.body#/url}': {} }, + self: { '{$request.body#/url}': {} }, + }) + expect(JSON.parse(JSON.stringify(result))).toEqual(result) + expect(JSON.stringify(result)).not.toMatch(removedPointer) + }) + + it('converts a target reached through many references once', () => { + const pointer = (index: number) => `#/webhooks/w${index}/post/requestBody/content/application~1json/schema` + const leaf = { content: { 'application/json': { schema: { type: ['string', 'null'] } } } } + const webhooks: Record = { w64: { post: { requestBody: leaf } } } + for (let index = 0; index < 64; index++) { + const schema = { properties: { a: { $ref: pointer(index + 1) }, b: { $ref: pointer(index + 1) } }, type: 'object' } + webhooks[`w${index}`] = { post: { requestBody: { content: { 'application/json': { schema } } } } } + } + let node = dig(convertSpec({ components: { schemas: { Root: { $ref: pointer(0) } } }, webhooks }), 'components', 'schemas', 'Root') + for (let index = 0; index < 64; index++) { + expect(dig(node, 'properties', 'a')).toBe(dig(node, 'properties', 'b')) + node = dig(node, 'properties', 'a') + } + expect(node).toEqual({ nullable: true, type: 'string' }) + }) + + it('resolves percent-encoded and tilde-escaped pointers', () => { + const result = convertSpec({ + paths: { + '/a': { + get: { + parameters: [ + { $ref: '#/webhooks/new%20pet/post/parameters/0' }, + { $ref: '#/webhooks/a~0b~1c/post/parameters/0' }, + { $ref: '#%2Fwebhooks%2Fnew%20pet%2Fpost%2Fparameters%2F1' }, + ], + responses: {}, + }, + }, + '/b': { $ref: '#/webhooks/new%20pet/post/callbacks/cb/%7B$request.body%23~1url%7D' }, + }, + webhooks: { + 'a~b/c': { post: { parameters: [{ in: 'query', name: 'tilde' }] } }, + 'new pet': { + post: { + callbacks: { cb: { '{$request.body#/url}': { summary: 'callback' } } }, + parameters: [ + { in: 'query', name: 'space' }, + { in: 'query', name: 'encoded' }, + ], + }, + }, + }, + }) + expect(result.paths).toEqual({ + '/a': { + get: { + parameters: [ + { in: 'query', name: 'space' }, + { in: 'query', name: 'tilde' }, + { in: 'query', name: 'encoded' }, + ], + responses: {}, + }, + }, + '/b': { summary: 'callback' }, + }) + }) + + it('resolves pointer tokens only against keys and indices the document owns', () => { + const refs = [ + '#/webhooks/__proto__/post/parameters/0', + '#/webhooks/__proto__/post/parameters/length', + '#/webhooks/__proto__/post/parameters/00', + '#/webhooks/__proto__/post/parameters/-', + '#/webhooks/constructor', + '#/webhooks/hasOwnProperty', + ] + const result = convertSpec({ + paths: { '/a': { get: { parameters: refs.map($ref => ({ $ref })), responses: {} } } }, + webhooks: JSON.parse('{"__proto__":{"post":{"parameters":[{"in":"query","name":"own"}]}}}'), + }) + expect(dig(result, 'paths', '/a', 'get', 'parameters')).toEqual([ + { in: 'query', name: 'own' }, + ...refs.slice(1).map($ref => ({ $ref })), + ]) + }) + + it('rewrites links into the removed parts to the operationId of an operation still in the output and removes the rest', () => { + const result = convertSpec({ + components: { + callbacks: { Hook: { '{$url}': { $ref: '#/webhooks/callbackHook' } } }, + links: { + ByComponentCallback: { operationRef: '#/webhooks/callbackHook/post' }, + Gone: { operationRef: '#/webhooks/orphan/post' }, + Kept: { description: 'kept', operationRef: '#/webhooks/newPet/post' }, + }, + pathItems: { Item: item, NoId: { get: { responses: {} } } }, + }, + paths: { + '/a': { + get: { + callbacks: { + cb: { '{$request.body#/url}': { $ref: '#/components/pathItems/Item' } }, + }, + responses: { + 200: { + description: 'ok', + links: { + both: { operationId: 'stale', operationRef: '#/webhooks/newPet/post' }, + byCallback: { + operationRef: '#/components/pathItems/Item/get', + parameters: { id: '$response.body#/id' }, + }, + byId: { operationId: 'orphanHook' }, + byPath: { operationRef: '#/paths/~1b/post' }, + external: { $ref: 'https://example.com/links.json#/Kept' }, + inlined: { $ref: '#/webhooks/newPet/post/responses/200/links/self' }, + missing: { operationRef: '#/webhooks/missing/post' }, + noId: { operationRef: '#/components/pathItems/NoId/get' }, + refGone: { $ref: '#/components/links/Gone' }, + refKept: { $ref: '#/components/links/Kept' }, + refUnknown: { $ref: '#/components/links/Unknown' }, + }, + }, + }, + }, + }, + '/b': { $ref: '#/webhooks/newPet' }, + '/c': { $ref: '#/components/pathItems/NoId' }, + '/d': { $ref: '#/webhooks/newPet' }, + '/junk': 'junk', + 'x-orphan': { post: { operationId: 'orphanHook' } }, + }, + webhooks: { + callbackHook: { post: { operationId: 'callbackHookOp', responses: {} } }, + newPet: { + post: { + operationId: 'newPetHook', + responses: { + 200: { + description: 'ok', + links: { self: { operationRef: '#/webhooks/newPet/post' } }, + }, + }, + }, + }, + orphan: { post: { operationId: 'orphanHook', responses: {} } }, + }, + }) + expect(result.components).toEqual({ + callbacks: { Hook: { '{$url}': { post: { operationId: 'callbackHookOp', responses: {} } } } }, + links: { + ByComponentCallback: { operationId: 'callbackHookOp' }, + Kept: { description: 'kept', operationId: 'newPetHook' }, + }, + }) + expect(dig(result, 'paths', '/a', 'get', 'responses', '200', 'links')).toEqual({ + both: { operationId: 'newPetHook' }, + byCallback: { operationId: 'getItem', parameters: { id: '$response.body#/id' } }, + byId: { operationId: 'orphanHook' }, + byPath: { operationRef: '#/paths/~1b/post' }, + external: { $ref: 'https://example.com/links.json#/Kept' }, + inlined: { operationId: 'newPetHook' }, + refKept: { $ref: '#/components/links/Kept' }, + refUnknown: { $ref: '#/components/links/Unknown' }, + }) + expect(dig(result, 'paths', '/b', 'post', 'responses', '200', 'links')).toEqual({ + self: { operationId: 'newPetHook' }, + }) + expect(JSON.stringify(result)).not.toMatch(removedPointer) + }) + + it('removes a link to an operation that an own field of the referencing path item replaces', () => { + expect( + convertSpec({ + components: { links: { L: { operationRef: '#/webhooks/w/post' } } }, + paths: { '/a': { $ref: '#/webhooks/w', post: { responses: {} } } }, + webhooks: { w: { post: { operationId: 'hidden', responses: {} } } }, + }).components, + ).toEqual({ links: {} }) + }) + + it('removes a link to a removed operation in a document without components', () => { + expect( + convertSpec({ + paths: { + '/a': { + get: { + callbacks: { junk: 42 }, + responses: { 200: { description: 'ok', links: { l: { operationRef: '#/webhooks/w/post' } } } }, + }, + }, + }, + webhooks: { w: { post: { operationId: 'hook', responses: {} } } }, + }).paths, + ).toEqual({ + '/a': { get: { callbacks: { junk: 42 }, responses: { 200: { description: 'ok', links: {} } } } }, + }) + }) + + it('removes discriminator mapping entries into the removed parts', () => { + expect( + convertSpec({ + components: { + schemas: { + Junk: { discriminator: { mapping: 'junk', propertyName: 'kind' } }, + Pet: { + discriminator: { + mapping: { + cat: '#/components/schemas/Cat', + dog: schemaPointer, + fish: 'Fish', + hamster: '#/components/pathItems/Item', + }, + propertyName: 'kind', + }, + }, + }, + }, + }).components, + ).toEqual({ + schemas: { + Junk: { discriminator: { mapping: 'junk', propertyName: 'kind' } }, + Pet: { + discriminator: { + mapping: { cat: '#/components/schemas/Cat', fish: 'Fish' }, + propertyName: 'kind', + }, + }, + }, + }) + }) + + it('inlines into a cyclic input graph, preserving its cycle', () => { + const node: Record = { type: 'object' } + node.properties = { hook: { $ref: schemaPointer }, self: node } + const result = convertSpec({ components: { schemas: { Node: node } }, webhooks: { newPet: hook } }) + const converted = dig(result, 'components', 'schemas', 'Node') + expect(dig(converted, 'properties', 'self')).toBe(converted) + expect(dig(converted, 'properties', 'hook')).toEqual({ properties: { name: { type: 'string' } }, type: 'object' }) + }) + + it('inlines references in operation, path item, media type, parameter, and encoding positions', () => { + const pointer = (path: string) => `#/webhooks/full/post/${path}` + const result = convertSpec({ + paths: { + '/a': { + get: { + parameters: [{ + examples: { e: { $ref: pointer('x-example') } }, + in: 'query', + name: 'q', + }], + requestBody: { $ref: pointer('requestBody') }, + responses: { + 200: { + content: { + 'application/json': { + encoding: { f: { headers: { H: { $ref: pointer('x-header') } } } }, + examples: { e: { $ref: pointer('x-example') } }, + }, + }, + description: 'ok', + }, + }, + }, + parameters: [{ $ref: pointer('x-parameter') }], + }, + }, + webhooks: { + full: { + post: { + 'requestBody': { content: { 'text/plain': { schema: { const: 'x' } } } }, + 'x-example': { value: 1 }, + 'x-header': { schema: { type: ['string', 'null'] } }, + 'x-parameter': { in: 'path', name: 'id' }, + }, + }, + }, + }) + expect(result.paths).toEqual({ + '/a': { + get: { + parameters: [{ examples: { e: { value: 1 } }, in: 'query', name: 'q' }], + requestBody: { content: { 'text/plain': { schema: { enum: ['x'] } } } }, + responses: { + 200: { + content: { + 'application/json': { + encoding: { f: { headers: { H: { schema: { nullable: true, type: 'string' } } } } }, + examples: { e: { value: 1 } }, + }, + }, + description: 'ok', + }, + }, + }, + parameters: [{ in: 'path', name: 'id', required: true }], + }, + }) + }) + + it('cuts callbacks that reach back into an enclosing callback, keeping the output acyclic', () => { + const responses = { 200: { description: 'ok' } } + const result = convertSpec({ + components: { + pathItems: { + Item: { + post: { + callbacks: { + A: { '{$url}': { post: { callbacks: { toB: { $ref: '#/components/pathItems/Item/post/callbacks/B' } }, responses } } }, + B: { '{$url}': { post: { callbacks: { toA: { $ref: '#/components/pathItems/Item/post/callbacks/A' } }, responses } } }, + }, + responses, + }, + }, + }, + }, + paths: { + '/item': { $ref: '#/components/pathItems/Item' }, + '/self': { $ref: '#/webhooks/w' }, + }, + webhooks: { + w: { + post: { + callbacks: { cb: { '{$url}': { post: { callbacks: { again: { $ref: '#/webhooks/w/post/callbacks/cb' } }, responses } } } }, + responses, + }, + }, + }, + }) + expect(dig(result, 'paths', '/self', 'post', 'callbacks', 'cb', '{$url}', 'post', 'callbacks')).toEqual({ again: {} }) + expect(dig(result, 'paths', '/item', 'post', 'callbacks', 'A', '{$url}', 'post', 'callbacks', 'toB', '{$url}', 'post', 'callbacks')).toEqual({ toA: {} }) + expect(JSON.parse(JSON.stringify(result))).toEqual(result) + }) + + it('cuts own fields that lead back into a path item still being converted', () => { + const responses = { 200: { description: 'ok' } } + const loop = { + $ref: '#/components/pathItems/T', + get: { callbacks: { d: { '{$url}': { $ref: '#/components/pathItems/A' } } }, responses }, + } + const result = convertSpec({ + components: { + callbacks: { C: { '{$url}': { $ref: '#/components/pathItems/A/post/callbacks/c/{$url}' } } }, + pathItems: { + A: { post: { callbacks: { c: { '{$url}': loop } }, responses } }, + T: { summary: 't' }, + }, + }, + }) + expect(dig(result, 'components', 'callbacks', 'C', '{$url}', 'get', 'callbacks', 'd', '{$url}', 'post', 'callbacks')).toEqual({ + c: { '{$url}': { summary: 't' } }, + }) + expect(JSON.parse(JSON.stringify(result))).toEqual(result) + }) + + it('cuts fields inherited from a later hop that lead back into it', () => { + const responses = { 200: { description: 'ok' } } + const result = convertSpec({ + components: { + pathItems: { + A: { $ref: '#/components/pathItems/T', post: { callbacks: { c: { '{$url}': { $ref: '#/components/pathItems/A' } } }, responses } }, + T: { summary: 't' }, + }, + }, + paths: { '/p': { $ref: '#/components/pathItems/A' } }, + }) + expect(result.paths).toEqual({ + '/p': { post: { callbacks: { c: { '{$url}': { summary: 't' } } }, responses }, summary: 't' }, + }) + }) + + it('keeps an object cycle that an inlined target also reaches', () => { + const a: Record = { properties: {}, type: 'object' } + const b = { properties: { back: a }, type: 'object' } + a.properties = { hook: { $ref: schemaPointer }, b } + const result = convertSpec({ + components: { schemas: { A: a } }, + webhooks: { newPet: { post: { requestBody: { content: { 'application/json': { schema: { properties: { b }, type: 'object' } } } } } } }, + }) + const converted = dig(result, 'components', 'schemas', 'A') + expect(dig(converted, 'properties', 'b', 'properties', 'back')).toBe(converted) + expect(dig(converted, 'properties', 'hook', 'properties', 'b', 'properties', 'back')).toEqual({}) + }) + + it('cuts a reference that comes back to an object shared within the input', () => { + const shared: Record = { properties: { a: { $ref: schemaPointer } }, type: 'object' } + const result = convertSpec({ + components: { schemas: { S: shared } }, + webhooks: { + newPet: { post: { requestBody: { content: { 'application/json': { schema: { properties: { b: shared }, type: 'object' } } } } } }, + }, + }) + expect(dig(result, 'components', 'schemas', 'S')).toEqual({ + properties: { a: { properties: { b: {} }, type: 'object' } }, + type: 'object', + }) + }) + + it('expands an enclosing path item once before cutting the reference back into it', () => { + const result = convertSpec({ + components: { callbacks: { C: { $ref: '#/webhooks/ping/post/callbacks/self' } } }, + webhooks: { + ping: { post: { callbacks: { self: { expr: { $ref: '#/webhooks/ping' } } }, responses: {} } }, + }, + }) + expect(dig(result, 'components', 'callbacks', 'C')).toEqual({ + expr: { post: { callbacks: { self: { expr: {} } }, responses: {} } }, + }) + }) + + it('keeps the fields of every hop when it cuts a recursive path item', () => { + const result = convertSpec({ + paths: { '/a': { $ref: '#/webhooks/a' } }, + webhooks: { + a: { post: { callbacks: { cb: { expr: { $ref: '#/webhooks/alias', summary: 'outer' } } }, responses: {} } }, + alias: { $ref: '#/webhooks/a', description: 'alias' }, + }, + }) + expect(dig(result, 'paths', '/a', 'post', 'callbacks', 'cb', 'expr')).toEqual({ description: 'alias', summary: 'outer' }) + }) + + it('converts path items and headers reached through many references once', () => { + const webhooks: Record = { w30: { 'get': { responses: {} }, 'x-header': { schema: { type: 'string' } } } } + for (let index = 0; index < 30; index++) { + const next = { $ref: `#/webhooks/w${index + 1}` } + const header = { $ref: `#/webhooks/w${index + 1}/x-header` } + webhooks[`w${index}`] = { + 'get': { callbacks: { a: { expr: next }, b: { expr: next } }, responses: {} }, + 'x-header': { content: { 'text/plain': { encoding: { e: { headers: { a: header, b: header } } } } } }, + } + } + const result = convertSpec({ + components: { headers: { H: { $ref: '#/webhooks/w0/x-header' } } }, + paths: { '/a': { $ref: '#/webhooks/w0' } }, + webhooks, + }) + let pathItem = dig(result, 'paths', '/a') + let header = dig(result, 'components', 'headers', 'H') + for (let index = 0; index < 30; index++) { + expect(dig(pathItem, 'get', 'callbacks', 'a', 'expr')).toBe(dig(pathItem, 'get', 'callbacks', 'b', 'expr')) + pathItem = dig(pathItem, 'get', 'callbacks', 'a', 'expr') + const headers = dig(header, 'content', 'text/plain', 'encoding', 'e', 'headers') + expect(dig(headers, 'a')).toBe(dig(headers, 'b')) + header = dig(headers, 'a') + } + expect(pathItem).toEqual({ 'get': { responses: {} }, 'x-header': { schema: { type: 'string' } } }) + expect(header).toEqual({ schema: { type: 'string' } }) + }) + + it.each([ + ['a schema property named callbacks', '#/webhooks/w/post/requestBody/content/a~1b/schema/properties/callbacks/properties/x'], + ['a webhook named callbacks', '#/webhooks/callbacks/get/responses'], + ['a callback extension', '#/webhooks/w/post/callbacks/c/x-note'], + ])('leaves a Path Item $ref to %s as written', (_name, ref) => { + expect( + convertSpec({ + paths: { '/a': { $ref: ref } }, + webhooks: { + callbacks: { get: { responses: { 200: { description: 'ok' } } } }, + w: { + post: { + callbacks: { c: { 'x-note': { get: {} } } }, + requestBody: { + content: { 'a/b': { schema: { properties: { callbacks: { properties: { x: { get: 'prop', type: 'string' } } } } } } }, + }, + }, + }, + }, + }).paths, + ).toEqual({ '/a': { $ref: ref } }) + }) + + it('inlines a Path Item $ref to a callback nested in another callback', () => { + expect( + convertSpec({ + paths: { '/a': { $ref: '#/webhooks/w/post/callbacks/c/{$url}/get/callbacks/d/{$url}' } }, + webhooks: { + w: { post: { callbacks: { c: { '{$url}': { get: { callbacks: { d: { '{$url}': { summary: 'nested' } } } } } } } } }, + }, + }).paths, + ).toEqual({ '/a': { summary: 'nested' } }) + }) + + it('removes security schemes aliased into the removed parts by type', () => { + const result = convertSpec({ + components: { + securitySchemes: { + 'Escaped': { $ref: '#/components/securitySchemes/m~1tls' }, + 'Http': { $ref: '#/webhooks/w/x-http' }, + 'm/tls': { type: 'mutualTLS' }, + 'Tls': { $ref: '#/webhooks/w/x-tls' }, + }, + }, + paths: { '/a': { get: { responses: {}, security: [{ Tls: [] }, { Escaped: [] }, { Http: ['read'] }] } } }, + security: [{ Tls: [] }], + webhooks: { w: { 'x-http': { scheme: 'bearer', type: 'http' }, 'x-tls': { type: 'mutualTLS' } } }, + }) + expect(result.components).toEqual({ securitySchemes: { Http: { scheme: 'bearer', type: 'http' } } }) + expect(result.security).toBeUndefined() + expect(dig(result, 'paths', '/a', 'get', 'security')).toEqual([{ Http: [] }]) + }) + + it('leaves references and mapping entries in a standalone schema untouched', () => { + const schema = { + discriminator: { mapping: { a: schemaPointer }, propertyName: 'kind' }, + properties: { a: { $ref: schemaPointer } }, + } + expect(downgradeSchemaV31ToV30(schema as any)).toEqual(schema) + }) + }) + describe('reference objects', () => { it('strips reference summary and description across components maps', () => { expect( @@ -551,7 +1434,7 @@ describe('downgradeSpecV31ToV30', () => { expect(result.components).not.toHaveProperty('x-pathItems') }) - it('leaves references into components.pathItems intact apart from override stripping', () => { + it('strips overrides from a callback reference to a missing path item', () => { expect( convertComponent('callbacks', { $ref: '#/components/pathItems/Reusable', diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 2aef2aa..3990b37 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -3,27 +3,142 @@ import type * as OpenAPIV3_1 from '@openapi-spec/types/v3.1' import type { FieldConverter, FieldTable } from './shared' import { + convertInlined, convertRecord, deepClone, DROP, getRef, + HTTP_METHODS_UP_TO_V31, + isConverting, isRecord, mapArray, mapRecord, operationFields, + parseLocalRef, + resolveLocalRef, } from './shared' -function convertRefOr(value: unknown, convert: (item: unknown) => unknown): unknown { - const ref = getRef(value) - return ref === undefined ? convert(value) : { $ref: ref } +const HTTP_METHODS = new Set(HTTP_METHODS_UP_TO_V31) +const DESCRIPTION = ['description'] +const SUMMARY_AND_DESCRIPTION = ['summary', 'description'] + +interface Context { + convertSchema: (value: unknown) => unknown + document: Record | undefined + inlined: Map> + inlining: Set + linkChecks: [links: Record, name: string, operationId: string | typeof DROP][] + schemeTypes: ReadonlyMap +} + +type Convert = (item: unknown, context: Context) => unknown + +interface Chain { + fields: Record + target: unknown } -function refMap(convert: (item: unknown) => unknown): FieldConverter { - return item => mapRecord(item, entry => convertRefOr(entry, convert)) +function parseRemovedRef(ref: string, context: Context): string[] | undefined { + if (context.document === undefined || !(ref.startsWith('#/webhooks') || ref.startsWith('#/components/pathItems') || ref.includes('%'))) { + return undefined + } + const tokens = parseLocalRef(ref) + const removed = tokens?.[0] === 'webhooks' || (tokens?.[0] === 'components' && tokens[1] === 'pathItems') + return removed ? tokens : undefined } -function refList(convert: (item: unknown) => unknown): FieldConverter { - return item => mapArray(item, entry => convertRefOr(entry, convert)) +function isPureRef(value: unknown): value is { $ref: string } { + return isRecord(value) && typeof value.$ref === 'string' && Object.keys(value).length === 1 +} + +function isPathItemLocation(tokens: readonly string[]): boolean { + if (tokens.length === (tokens[0] === 'webhooks' ? 2 : 3)) { + return true + } + const [method = '', callbacks, , expression = 'x-'] = tokens.slice(-4) + return callbacks === 'callbacks' + && HTTP_METHODS.has(method) + && !expression.startsWith('x-') + && isPathItemLocation(tokens.slice(0, -4)) +} + +function followRefs(value: Record, context: Context, kind: 'pathItem' | 'reference' | 'schema'): Chain | undefined { + const seen = new Set() + let fields: Record = {} + let target: unknown = value + while (isRecord(target) && typeof target.$ref === 'string') { + const tokens = parseRemovedRef(target.$ref, context) + if (tokens === undefined || (kind === 'schema' && !isPureRef(target))) { + break + } + if (seen.has(target) || (kind === 'pathItem' && !isPathItemLocation(tokens))) { + return undefined + } + seen.add(target) + const { $ref: ref, ...own } = target + fields = { ...own, ...fields } + target = resolveLocalRef(context.document, ref) + } + return target === value || target === undefined ? undefined : { fields, target } +} + +function resolveRefChain(value: unknown, document: unknown): unknown { + const seen = new Set() + let target = value + while (isRecord(target) && typeof target.$ref === 'string' && !seen.has(target)) { + seen.add(target) + target = resolveLocalRef(document, target.$ref) + } + return target +} + +function inline(target: unknown, context: Context, convert: Convert): unknown { + const cache = context.inlined.get(convert) ?? new Map() + context.inlined.set(convert, cache) + if (cache.has(target)) { + return cache.get(target) + } + if (isConverting(target) || context.inlining.has(target)) { + return DROP + } + context.inlining.add(target) + try { + const out = convertInlined(() => convert(target, context)) + cache.set(target, out) + return out + } + finally { + context.inlining.delete(target) + } +} + +function pickFields(fields: Record, keys: readonly string[]): Record { + return Object.fromEntries(keys.filter(key => Object.hasOwn(fields, key)).map(key => [key, deepClone(fields[key])])) +} + +function convertRefOr(value: unknown, context: Context, convert: Convert, overrides: readonly string[] = DESCRIPTION): unknown { + if (!isRecord(value) || typeof value.$ref !== 'string') { + return convert(value, context) + } + const chain = followRefs(value, context, 'reference') + if (chain === undefined || !isRecord(chain.target)) { + return { $ref: value.$ref } + } + const ref = getRef(chain.target) + if (ref !== undefined) { + return { $ref: ref } + } + const out = inline(chain.target, context, convert) + const own = pickFields(chain.fields, overrides) + return out === DROP || Object.keys(own).length === 0 ? out : { ...out as Record, ...own } +} + +function refMap(context: Context, convert: Convert, overrides?: readonly string[]): FieldConverter { + return item => mapRecord(item, entry => convertRefOr(entry, context, convert, overrides)) +} + +function refList(context: Context, convert: Convert): FieldConverter { + return item => mapArray(item, entry => convertRefOr(entry, context, convert)) } function applyTypes(types: string[], schema: Record, out: Record): void { @@ -163,7 +278,7 @@ function convertXml(value: unknown, schemaType: unknown): unknown { }) } -function finishSchema(out: Record, schema: Record): Record { +function finishSchema(out: Record, schema: Record, context: Context): Record { convertType(schema, out) convertConst(schema, out) convertExamples(schema, out) @@ -174,7 +289,7 @@ function finishSchema(out: Record, schema: Record, schema: Record (typeof item === 'string' ? DROP : deepClone(item)), - $schema: DROP, - $vocabulary: DROP, - additionalProperties: (item, schema) => { - if ('patternProperties' in schema) { - return DROP - } - return typeof item === 'boolean' ? item : convertSchema(item) - }, - allOf: convertSubschemas, - anyOf: convertSubschemas, - const: DROP, - contains: DROP, - contentEncoding: DROP, - contentMediaType: DROP, - contentSchema: DROP, - dependentRequired: DROP, - dependentSchemas: DROP, - else: DROP, - enum: item => (Array.isArray(item) && item.length === 0 ? DROP : deepClone(item)), - examples: DROP, - exclusiveMaximum: item => (typeof item === 'number' ? DROP : deepClone(item)), - exclusiveMinimum: item => (typeof item === 'number' ? DROP : deepClone(item)), - if: DROP, - items: (item, schema) => ('prefixItems' in schema ? DROP : convertSchema(item)), - maxContains: DROP, - minContains: DROP, - not: convertSchema, - oneOf: convertSubschemas, - patternProperties: DROP, - prefixItems: DROP, - properties: item => mapRecord(item, convertSchema), - propertyNames: DROP, - required: (item) => { - if (!Array.isArray(item)) { - return deepClone(item) - } - return item.length === 0 ? DROP : deepClone([...new Set(item)]) - }, - then: DROP, - type: DROP, - unevaluatedItems: DROP, - unevaluatedProperties: DROP, - xml: (item, schema) => convertXml(item, schema.type), +function createSchemaFields(context: Context): FieldTable { + const convert = context.convertSchema + const convertSubschemas = (item: unknown): unknown => mapArray(item, convert) + return { + $anchor: DROP, + $comment: DROP, + $defs: DROP, + $dynamicAnchor: DROP, + $dynamicRef: DROP, + $id: DROP, + $ref: item => (typeof item === 'string' ? DROP : deepClone(item)), + $schema: DROP, + $vocabulary: DROP, + additionalProperties: (item, schema) => { + if ('patternProperties' in schema) { + return DROP + } + return typeof item === 'boolean' ? item : convert(item) + }, + allOf: convertSubschemas, + anyOf: convertSubschemas, + const: DROP, + contains: DROP, + contentEncoding: DROP, + contentMediaType: DROP, + contentSchema: DROP, + dependentRequired: DROP, + dependentSchemas: DROP, + discriminator: item => convertRecord(item, { + mapping: mapping => mapRecord(mapping, value => (typeof value === 'string' && parseRemovedRef(value, context) !== undefined ? DROP : deepClone(value))), + }), + else: DROP, + enum: item => (Array.isArray(item) && item.length === 0 ? DROP : deepClone(item)), + examples: DROP, + exclusiveMaximum: item => (typeof item === 'number' ? DROP : deepClone(item)), + exclusiveMinimum: item => (typeof item === 'number' ? DROP : deepClone(item)), + if: DROP, + items: (item, schema) => ('prefixItems' in schema ? DROP : convert(item)), + maxContains: DROP, + minContains: DROP, + not: convert, + oneOf: convertSubschemas, + patternProperties: DROP, + prefixItems: DROP, + properties: item => mapRecord(item, convert), + propertyNames: DROP, + required: (item) => { + if (!Array.isArray(item)) { + return deepClone(item) + } + return item.length === 0 ? DROP : deepClone([...new Set(item)]) + }, + then: DROP, + type: DROP, + unevaluatedItems: DROP, + unevaluatedProperties: DROP, + xml: (item, schema) => convertXml(item, schema.type), + } } -function convertSchema(schema: unknown): unknown { - if (schema === true) { - return {} +function convertSchemaRef(value: { $ref: string }, context: Context): unknown { + const target = followRefs(value, context, 'schema')?.target + if (isPureRef(target)) { + return { $ref: target.$ref } } - if (schema === false) { - return { not: {} } + if (!isRecord(target) && typeof target !== 'boolean') { + return { $ref: value.$ref } } - if (isRecord(schema) && typeof schema.$ref === 'string' && Object.keys(schema).length === 1) { - return { $ref: schema.$ref } - } - return convertRecord(schema, SCHEMA_FIELDS, finishSchema) -} - -export function downgradeSchemaV31ToV30(schema: OpenAPIV3_1.SchemaObject): OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject { - return convertSchema(schema) as OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject + const out = inline(target, context, context.convertSchema) + return out === DROP ? {} : out } -const PATH_ITEMS_REF_PREFIX = '#/components/pathItems/' -const SECURITY_SCHEMES_REF_PREFIX = '#/components/securitySchemes/' +const STANDALONE_CONTEXT = createContext(undefined) -interface Context { - inlining: Set - pathItems: Record | undefined - schemeTypes: ReadonlyMap -} - -function resolveSchemeType(name: string, schemes: Record, seen: Set): string | undefined { - if (seen.has(name) || !Object.hasOwn(schemes, name)) { - return undefined - } - const scheme = schemes[name] - if (!isRecord(scheme)) { - return undefined - } - if (typeof scheme.type === 'string') { - return scheme.type - } - const ref = getRef(scheme) - if (ref !== undefined && ref.startsWith(SECURITY_SCHEMES_REF_PREFIX)) { - const target = ref.slice(SECURITY_SCHEMES_REF_PREFIX.length) - if (target !== '' && !target.includes('/')) { - seen.add(name) - return resolveSchemeType(target, schemes, seen) - } - } - return undefined +export function downgradeSchemaV31ToV30(schema: OpenAPIV3_1.SchemaObject): OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject { + return STANDALONE_CONTEXT.convertSchema(schema) as OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject } function createContext(spec: unknown): Context { - const components = isRecord(spec) ? spec.components : undefined - const pathItems = isRecord(components) ? components.pathItems : undefined + const document = isRecord(spec) ? spec : undefined + const components = document?.components const schemes = isRecord(components) ? components.securitySchemes : undefined 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) + for (const [name, scheme] of Object.entries(schemes)) { + const target = resolveRefChain(scheme, document) + if (isRecord(target) && typeof target.type === 'string') { + schemeTypes.set(name, target.type) } } } - return { + const context: Context = { + convertSchema, + document, + inlined: new Map(), inlining: new Set(), - pathItems: isRecord(pathItems) ? pathItems : undefined, + linkChecks: [], schemeTypes, } + const fields = createSchemaFields(context) + const finish = (out: Record, schema: Record): unknown => finishSchema(out, schema, context) + function convertSchema(value: unknown): unknown { + if (value === true) { + return {} + } + if (value === false) { + return { not: {} } + } + if (isPureRef(value)) { + return convertSchemaRef(value, context) + } + const out = convertRecord(value, fields, finish) + return out === DROP ? {} : out + } + return context } function isMutualTls(name: string, context: Context): boolean { @@ -343,13 +450,13 @@ function convertInfo(value: unknown): unknown { }) } -function convertParameterOrHeader(value: unknown): unknown { +function convertParameterOrHeader(value: unknown, context: Context): unknown { return convertRecord( value, { - content: convertContent, - examples: refMap(deepClone), - schema: convertSchema, + content: item => convertContent(item, context), + examples: refMap(context, deepClone, SUMMARY_AND_DESCRIPTION), + schema: context.convertSchema, }, (out, parameter) => { if (parameter.in === 'path') { @@ -360,46 +467,80 @@ function convertParameterOrHeader(value: unknown): unknown { ) } -function convertEncoding(value: unknown): unknown { - return convertRecord(value, { headers: refMap(convertParameterOrHeader) }) +function convertEncoding(value: unknown, context: Context): unknown { + return convertRecord(value, { headers: refMap(context, convertParameterOrHeader) }) } -function convertMediaType(value: unknown): unknown { +function convertMediaType(value: unknown, context: Context): unknown { return convertRecord(value, { - encoding: item => mapRecord(item, convertEncoding), - examples: refMap(deepClone), - schema: convertSchema, + encoding: item => mapRecord(item, entry => convertEncoding(entry, context)), + examples: refMap(context, deepClone, SUMMARY_AND_DESCRIPTION), + schema: context.convertSchema, }) } -function convertContent(item: unknown): unknown { - return mapRecord(item, convertMediaType) +function convertContent(item: unknown, context: Context): unknown { + return mapRecord(item, entry => convertMediaType(entry, context)) +} + +function convertRequestBody(value: unknown, context: Context): unknown { + return convertRecord(value, { content: item => convertContent(item, context) }) +} + +function linkedOperationId(link: unknown, context: Context): string | typeof DROP | undefined { + const target = resolveRefChain(link, context.document) + const operationRef = isRecord(target) ? target.operationRef : undefined + if (typeof operationRef !== 'string' || parseRemovedRef(operationRef, context) === undefined) { + return undefined + } + const operation = resolveLocalRef(context.document, operationRef) + return isRecord(operation) && typeof operation.operationId === 'string' ? operation.operationId : DROP +} + +function convertLink(value: unknown, context: Context): unknown { + const operationId = linkedOperationId(value, context) + if (typeof operationId !== 'string') { + return deepClone(value) + } + return convertRecord(value, { operationRef: DROP }, (out) => { + out.operationId = operationId + return out + }) } -function convertRequestBody(value: unknown): unknown { - return convertRecord(value, { content: convertContent }) +function convertLinks(value: unknown, context: Context): unknown { + const out = mapRecord(value, item => convertRefOr(item, context, convertLink)) + if (isRecord(value)) { + for (const [name, item] of Object.entries(value)) { + const operationId = linkedOperationId(item, context) + if (operationId !== undefined) { + context.linkChecks.push([out as Record, name, operationId]) + } + } + } + return out } -function convertResponse(value: unknown): unknown { +function convertResponse(value: unknown, context: Context): unknown { return convertRecord(value, { - content: convertContent, - headers: refMap(convertParameterOrHeader), - links: refMap(deepClone), + content: item => convertContent(item, context), + headers: refMap(context, convertParameterOrHeader), + links: item => convertLinks(item, context), }) } -function convertResponses(item: unknown): unknown { - return mapRecord(item, (entry, key) => key.startsWith('x-') ? deepClone(entry) : convertRefOr(entry, convertResponse)) +function convertResponses(item: unknown, context: Context): unknown { + return mapRecord(item, (entry, key) => key.startsWith('x-') ? deepClone(entry) : convertRefOr(entry, context, convertResponse)) } function convertOperation(value: unknown, context: Context): unknown { return convertRecord( value, { - callbacks: refMap(item => convertCallback(item, context)), - parameters: refList(convertParameterOrHeader), - requestBody: item => convertRefOr(item, convertRequestBody), - responses: convertResponses, + callbacks: refMap(context, convertCallback, []), + parameters: refList(context, convertParameterOrHeader), + requestBody: item => convertRefOr(item, context, convertRequestBody), + responses: item => convertResponses(item, context), security: item => convertSecurity(item, context), }, (out) => { @@ -415,38 +556,28 @@ function convertCallback(value: unknown, context: Context): unknown { return mapRecord(value, (item, key) => key.startsWith('x-') ? deepClone(item) : convertPathItem(item, context)) } -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 +function convertPathItemFields(value: unknown, context: Context): unknown { + return convertRecord(value, { + ...operationFields(item => convertOperation(item, context)), + parameters: refList(context, convertParameterOrHeader), + }) } function convertPathItem(value: unknown, context: Context): unknown { - if (!isRecord(value)) { - return deepClone(value) + const chain = isRecord(value) ? followRefs(value, context, 'pathItem') : undefined + if (!isRecord(value) || chain === undefined || !isRecord(chain.target)) { + return convertPathItemFields(value, context) } - const resolved = resolvePathItemRef(value, context) - if (resolved === undefined) { - return convertRecord(value, { - ...operationFields(item => convertOperation(item, context)), - parameters: refList(convertParameterOrHeader), - }) + const out = inline(chain.target, context, convertPathItem) + if (Object.keys(chain.fields).length === 0) { + return out === DROP ? {} : out } - const [name, target] = resolved const { $ref: _, ...own } = value - context.inlining.add(name) - try { - return convertPathItem({ ...target, ...own }, context) - } - finally { - context.inlining.delete(name) + const inherited = Object.fromEntries(Object.entries(chain.fields).filter(([key]) => !Object.hasOwn(own, key))) + return { + ...(out === DROP ? {} : out) as Record, + ...convertInlined(() => convertPathItemFields(inherited, context)) as Record, + ...convertPathItemFields(own, context) as Record, } } @@ -456,22 +587,40 @@ function convertPaths(value: unknown, context: Context): unknown { function convertComponents(value: unknown, context: Context): unknown { return convertRecord(value, { - callbacks: refMap(item => convertCallback(item, context)), - examples: refMap(deepClone), - headers: refMap(convertParameterOrHeader), - links: refMap(deepClone), - parameters: refMap(convertParameterOrHeader), + callbacks: refMap(context, convertCallback, []), + examples: refMap(context, deepClone, SUMMARY_AND_DESCRIPTION), + headers: refMap(context, convertParameterOrHeader), + links: item => convertLinks(item, context), + parameters: refMap(context, convertParameterOrHeader), pathItems: DROP, - requestBodies: refMap(convertRequestBody), - responses: refMap(convertResponse), - schemas: item => mapRecord(item, convertSchema), - securitySchemes: item => mapRecord(item, (scheme, name) => isMutualTls(name, context) ? DROP : convertRefOr(scheme, deepClone)), + requestBodies: refMap(context, convertRequestBody), + responses: refMap(context, convertResponse), + schemas: item => mapRecord(item, context.convertSchema), + securitySchemes: item => mapRecord(item, (scheme, name) => isMutualTls(name, context) ? DROP : convertRefOr(scheme, context, deepClone)), }) } +function collectOperationIds(pathItems: unknown, ids: Set, seen: WeakSet): void { + for (const [key, pathItem] of isRecord(pathItems) ? Object.entries(pathItems) : []) { + if (key.startsWith('x-') || !isRecord(pathItem) || seen.has(pathItem)) { + continue + } + seen.add(pathItem) + for (const method of HTTP_METHODS_UP_TO_V31) { + const operation = pathItem[method] + if (isRecord(operation) && typeof operation.operationId === 'string') { + ids.add(operation.operationId) + } + for (const callback of isRecord(operation) && isRecord(operation.callbacks) ? Object.values(operation.callbacks) : []) { + collectOperationIds(callback, ids, seen) + } + } + } +} + export function downgradeSpecV31ToV30(spec: OpenAPIV3_1.OpenAPIObject): OpenAPIV3_0.OpenAPIObject { const context = createContext(spec) - return convertRecord( + const converted = convertRecord( spec, { components: item => convertComponents(item, context), @@ -488,5 +637,21 @@ export function downgradeSpecV31ToV30(spec: OpenAPIV3_1.OpenAPIObject): OpenAPIV } return out }, - ) as OpenAPIV3_0.OpenAPIObject + ) + if (context.linkChecks.length > 0) { + const { components, paths } = converted as Record + const operationIds = new Set() + const seen = new WeakSet() + collectOperationIds(paths, operationIds, seen) + const callbacks = isRecord(components) ? components.callbacks : undefined + for (const callback of isRecord(callbacks) ? Object.values(callbacks) : []) { + collectOperationIds(callback, operationIds, seen) + } + for (const [links, name, operationId] of context.linkChecks) { + if (operationId === DROP || !operationIds.has(operationId)) { + delete links[name] + } + } + } + return converted as OpenAPIV3_0.OpenAPIObject } diff --git a/packages/downgrader/tests/e2e.test.ts b/packages/downgrader/tests/e2e.test.ts index 0e93f0a..2fb1ca1 100644 --- a/packages/downgrader/tests/e2e.test.ts +++ b/packages/downgrader/tests/e2e.test.ts @@ -102,6 +102,96 @@ describe('3.1 example documents downgraded to 3.0', () => { expect(doc).toEqual(before) }) + it('resolves $refs and link operationRefs into the removed webhooks and components.pathItems so nothing dangles', async () => { + const petSchema = '#/webhooks/newPet/post/requestBody/content/application~1json/schema' + const doc: OpenAPIV3_1.OpenAPIObject = { + components: { + pathItems: { + item: { + get: { operationId: 'getItem', responses: { 200: { description: 'item' } } }, + parameters: [{ in: 'query', name: 'q', schema: { type: ['string', 'null'] } }], + }, + }, + schemas: { Pet: { $ref: petSchema } }, + }, + info: { title: 'Webhook references', version: '1.0.0' }, + openapi: '3.1.0', + paths: { + '/items': { $ref: '#/components/pathItems/item' }, + '/pets': { + get: { + parameters: [ + { $ref: '#/webhooks/newPet/post/parameters/0' }, + { $ref: '#/components/pathItems/item/parameters/0', description: 'Filter' }, + ], + responses: { + 200: { + content: { 'application/json': { schema: { items: { $ref: petSchema }, type: 'array' } } }, + description: 'ok', + links: { + hook: { operationRef: '#/webhooks/newPet/post' }, + item: { operationRef: '#/components/pathItems/item/get' }, + }, + }, + 201: { $ref: '#/webhooks/newPet/post/responses/200' }, + }, + }, + }, + }, + webhooks: { + newPet: { + post: { + operationId: 'newPetHook', + parameters: [{ in: 'header', name: 'X-Signature', schema: { type: 'string' } }], + requestBody: { + content: { + 'application/json': { + schema: { + properties: { name: { type: 'string' }, parent: { $ref: petSchema } }, + type: 'object', + }, + }, + }, + }, + responses: { 200: { description: 'received' } }, + }, + }, + }, + } + await expectValidAs(doc, '3.1') + const before = structuredClone(doc) + const converted = downgradeSpecV31ToV30(doc) + const pet = { properties: { name: { type: 'string' }, parent: {} }, type: 'object' } + expect(converted.components).toEqual({ schemas: { Pet: pet } }) + expect(converted.paths).toEqual({ + '/items': { + get: { operationId: 'getItem', responses: { 200: { description: 'item' } } }, + parameters: [{ in: 'query', name: 'q', schema: { nullable: true, type: 'string' } }], + }, + '/pets': { + get: { + parameters: [ + { in: 'header', name: 'X-Signature', schema: { type: 'string' } }, + { description: 'Filter', in: 'query', name: 'q', schema: { nullable: true, type: 'string' } }, + ], + responses: { + 200: { + content: { 'application/json': { schema: { items: pet, type: 'array' } } }, + description: 'ok', + links: { item: { operationId: 'getItem' } }, + }, + 201: { description: 'received' }, + }, + }, + }, + }) + const serialized = JSON.stringify(converted) + expect(serialized).not.toContain('#/webhooks/') + expect(serialized).not.toContain('#/components/pathItems/') + await expectValidAs(converted, '3.0') + expect(doc).toEqual(before) + }) + it('clones a discriminator with defaultMapping as-is into the 3.0 document', async () => { const doc = { components: {