From dc0d474f35b873337a85f87a7f3b22ceda9fa3a7 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sun, 27 Sep 2026 15:06:32 +0700 Subject: [PATCH 1/4] fix(downgrader): inline $refs into webhooks and components.pathItems removed by 3.1 to 3.0 Co-Authored-By: Claude --- packages/downgrader/README.md | 25 +- packages/downgrader/src/v3.1-to-v3.0.test.ts | 594 ++++++++++++++++++- packages/downgrader/src/v3.1-to-v3.0.ts | 456 +++++++++----- packages/downgrader/tests/e2e.test.ts | 90 +++ 4 files changed, 1002 insertions(+), 163 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 9869d34..c00b458 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -92,21 +92,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 copy 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 every same-document reference into them is resolved: + - Reference Objects, Path Item `$ref`s, and Schema `$ref`s are replaced by a converted copy of their target, following reference chains. A chain that leads back out keeps the reference where it lands, and a Path Item's own fields win over inlined ones. + - A reference back to a target that is still being inlined is cut: a Schema Object becomes `{}`, a Path Item keeps only its own fields, and any other reference is removed. Inlining also stops after 100,000 copies, so targets that reference each other many times over cannot blow up the output. + - 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 (or not a Path Item, for a Path Item `$ref`), or is a reference loop is left as written. - `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,6 +137,7 @@ 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. +- A pointer into `webhooks` or `components.pathItems` that passes through another `$ref` is not followed and will dangle, and a Link naming a removed operation only by `operationId` is kept. 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/v3.1-to-v3.0.test.ts b/packages/downgrader/src/v3.1-to-v3.0.test.ts index b7f7462..1005e46 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -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,579 @@ 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: {}, + }, + }, + }, + }) + const leaf = { properties: { children: { items: {}, type: 'array' } }, type: 'object' } + expect(result.components).toEqual({ schemas: { Tree: leaf } }) + expect( + dig(result, 'paths', '/tree', 'post', 'requestBody', 'content', 'application/json', 'schema'), + ).toEqual({ properties: { children: { items: leaf, type: 'array' } }, type: 'object' }) + 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('stops inlining after a fixed number of copies instead of growing exponentially', () => { + const pointer = (index: number) => `#/webhooks/w${index}/post/requestBody/content/application~1json/schema` + const leaf = { content: { 'application/json': { schema: { type: 'string' } } } } + const webhooks: Record = { w40: { post: { requestBody: leaf } } } + for (let index = 0; index < 40; 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 } } } } } + } + const result = convertSpec({ components: { schemas: { Root: { $ref: pointer(0) } } }, webhooks }) + const text = JSON.stringify(result) + expect(text.length).toBeLessThan(20_000_000) + expect(text).not.toMatch(removedPointer) + }) + + 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: { + links: { + 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' }, + }, + webhooks: { + newPet: { + post: { + operationId: 'newPetHook', + responses: { + 200: { + description: 'ok', + links: { self: { operationRef: '#/webhooks/newPet/post' } }, + }, + }, + }, + }, + orphan: { post: { operationId: 'orphanHook', responses: {} } }, + }, + }) + expect(result.components).toEqual({ + links: { 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 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' }) + }) + }) + describe('reference objects', () => { it('strips reference summary and description across components maps', () => { expect( diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 4736ff0..6be80b9 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -12,17 +12,140 @@ import { operationFields, } from './shared' -function convertRefOr(value: unknown, convert: (item: unknown) => unknown): unknown { - const ref = getRef(value) - return ref === undefined ? convert(value) : { $ref: ref } +const INLINE_LIMIT = 100_000 +const ARRAY_INDEX = /^(?:0|[1-9]\d*)$/ +const DESCRIPTION = ['description'] +const SUMMARY_AND_DESCRIPTION = ['summary', 'description'] +const SECURITY_SCHEMES_REF_PREFIX = '#/components/securitySchemes/' + +interface Context { + document: Record | undefined + inlined: number + inlining: Set + linkChecks: [links: Record, name: string, operationId: string | typeof DROP][] + operationIds: Set + schemaFields: FieldTable + schemeTypes: ReadonlyMap +} + +type Convert = (item: unknown, context: Context) => unknown + +interface Chain { + fields: Record + target: unknown +} + +function decodeFragment(ref: string): string | undefined { + try { + return decodeURIComponent(ref.slice(1)) + } + catch { + return undefined + } +} + +function parsePointer(ref: string): string[] | undefined { + const pointer = ref.startsWith('#') ? decodeFragment(ref) : undefined + if (!pointer?.startsWith('/')) { + return undefined + } + return pointer.slice(1).split('/').map(token => token.replaceAll('~1', '/').replaceAll('~0', '~')) +} + +function parseRemovedPointer(ref: string, context: Context): string[] | undefined { + const tokens = context.document === undefined ? undefined : parsePointer(ref) + const removed = tokens?.[0] === 'webhooks' || (tokens?.[0] === 'components' && tokens[1] === 'pathItems') + return removed ? tokens : undefined +} + +function resolvePointer(tokens: readonly string[], document: unknown): unknown { + let current = document + for (const token of tokens) { + if (isRecord(current) && Object.hasOwn(current, token)) { + current = current[token] + } + else if (Array.isArray(current) && ARRAY_INDEX.test(token) && Number(token) < current.length) { + current = current[Number(token)] + } + else { + return undefined + } + } + return current } -function refMap(convert: (item: unknown) => unknown): FieldConverter { - return item => mapRecord(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 refList(convert: (item: unknown) => unknown): FieldConverter { - return item => mapArray(item, entry => convertRefOr(entry, convert)) +function isPathItemLocation(tokens: readonly string[]): boolean { + return tokens.length === (tokens[0] === 'webhooks' ? 2 : 3) || tokens.at(-3) === 'callbacks' +} + +function followRefs( + value: Record, + context: Context, + isHop: (item: Record) => boolean = () => true, + isLocation: (tokens: readonly string[]) => boolean = () => true, +): Chain | undefined { + const seen = new Set() + let fields: Record = {} + let target: unknown = value + while (isRecord(target) && typeof target.$ref === 'string' && isHop(target)) { + const tokens = parseRemovedPointer(target.$ref, context) + if (tokens === undefined) { + break + } + if (seen.has(target) || !isLocation(tokens)) { + return undefined + } + seen.add(target) + const { $ref: _, ...own } = target + fields = { ...own, ...fields } + target = resolvePointer(tokens, context.document) + } + return target === undefined ? undefined : { fields, target } +} + +function inline(target: Record, fields: Record, context: Context, convert: (copy: Record) => unknown): unknown { + if (context.inlining.has(target) || context.inlined >= INLINE_LIMIT) { + return DROP + } + context.inlined += 1 + context.inlining.add(target) + try { + return convert(deepClone({ ...target, ...fields })) + } + 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, 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) + if (chain === undefined || !isRecord(chain.target)) { + return { $ref: value.$ref } + } + const ref = getRef(chain.target) + if (ref !== undefined) { + return { $ref: ref } + } + return inline(chain.target, pickFields(chain.fields, overrides), context, copy => convert(copy, context)) +} + +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 { @@ -147,7 +270,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) @@ -158,7 +281,7 @@ function finishSchema(out: Record, schema: Record, schema: Record convertSchema(item, context) + 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' && parseRemovedPointer(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), + } } -const SCHEMA_FIELDS: FieldTable = { - $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 : 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 convertSchemaRef(value: { $ref: string }, context: Context): unknown { + const target = followRefs(value, context, isPureRef)?.target + if (typeof target === 'boolean') { + return convertSchema(target, context) + } + if (isPureRef(target)) { + return { $ref: target.$ref } + } + if (!isRecord(target)) { + return { $ref: value.$ref } + } + const inlined = inline(target, {}, context, copy => convertSchema(copy, context)) + return inlined === DROP ? {} : inlined } -function convertSchema(schema: unknown): unknown { +function convertSchema(schema: unknown, context: Context): unknown { if (schema === true) { return {} } if (schema === false) { return { not: {} } } - if (isRecord(schema) && typeof schema.$ref === 'string' && Object.keys(schema).length === 1) { - return { $ref: schema.$ref } + if (isPureRef(schema)) { + return convertSchemaRef(schema, context) } - return convertRecord(schema, SCHEMA_FIELDS, finishSchema) + return convertRecord(schema, context.schemaFields, (out, source) => finishSchema(out, source, context)) } export function downgradeSchemaV31ToV30(schema: OpenAPIV3_1.SchemaObject): OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject { - return convertSchema(schema) as OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject -} - -const PATH_ITEMS_REF_PREFIX = '#/components/pathItems/' -const SECURITY_SCHEMES_REF_PREFIX = '#/components/securitySchemes/' - -interface Context { - inlining: Set - pathItems: Record | undefined - schemeTypes: ReadonlyMap + return convertSchema(schema, createContext(undefined)) as OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject } function resolveSchemeType(name: string, schemes: Record, seen: Set): string | undefined { @@ -273,8 +405,8 @@ function resolveSchemeType(name: string, schemes: Record, seen: } 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)) { @@ -285,11 +417,17 @@ function createContext(spec: unknown): Context { } } } - return { + const context: Context = { + document, + inlined: 0, inlining: new Set(), - pathItems: isRecord(pathItems) ? pathItems : undefined, + linkChecks: [], + operationIds: new Set(), + schemaFields: {}, schemeTypes, } + context.schemaFields = createSchemaFields(context) + return context } function isMutualTls(name: string, context: Context): boolean { @@ -327,13 +465,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: item => convertSchema(item, context), }, (out, parameter) => { if (parameter.in === 'path') { @@ -344,49 +482,92 @@ 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: item => convertSchema(item, context), }) } -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): unknown { - return convertRecord(value, { content: convertContent }) +function convertRequestBody(value: unknown, context: Context): unknown { + return convertRecord(value, { content: item => convertContent(item, context) }) } -function convertResponse(value: unknown): unknown { +function linkedOperationId(link: unknown, context: Context): string | typeof DROP | undefined { + const seen = new Set() + let target = link + while (isRecord(target) && typeof target.$ref === 'string' && !seen.has(target)) { + seen.add(target) + const tokens = parsePointer(target.$ref) + target = tokens === undefined ? undefined : resolvePointer(tokens, context.document) + } + const tokens = isRecord(target) && typeof target.operationRef === 'string' ? parseRemovedPointer(target.operationRef, context) : undefined + if (tokens === undefined) { + return undefined + } + const operation = resolvePointer(tokens, context.document) + 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 convertLinks(value: unknown, context: Context): unknown { + const out = mapRecord(value, item => convertRefOr(item, context, convertLink)) + if (isRecord(value) && isRecord(out)) { + for (const [name, item] of Object.entries(value)) { + const operationId = linkedOperationId(item, context) + if (operationId !== undefined) { + context.linkChecks.push([out, name, operationId]) + } + } + } + return out +} + +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) => { + if (typeof out.operationId === 'string') { + context.operationIds.add(out.operationId) + } if (out.responses === undefined) { out.responses = { default: { description: '' } } } @@ -399,39 +580,24 @@ 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) + if (!isRecord(value) || typeof value.$ref !== 'string') { + return convertPathItemFields(value, context) } - const resolved = resolvePathItemRef(value, context) - if (resolved === undefined) { - return convertRecord(value, { - ...operationFields(item => convertOperation(item, context)), - parameters: refList(convertParameterOrHeader), - }) + const chain = followRefs(value, context, undefined, isPathItemLocation) + if (chain === undefined || !isRecord(chain.target) || chain.target === value) { + return convertPathItemFields(value, context) } - const [name, target] = resolved const { $ref: _, ...own } = value - context.inlining.add(name) - try { - return convertPathItem({ ...target, ...own }, context) - } - finally { - context.inlining.delete(name) - } + const inlined = inline(chain.target, chain.fields, context, copy => convertPathItem(copy, context)) + return inlined === DROP ? convertPathItemFields(own, context) : inlined } function convertPaths(value: unknown, context: Context): unknown { @@ -440,22 +606,22 @@ 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, entry => convertSchema(entry, context)), + securitySchemes: item => mapRecord(item, (scheme, name) => isMutualTls(name, context) ? DROP : convertRefOr(scheme, context, deepClone)), }) } 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), @@ -472,5 +638,11 @@ export function downgradeSpecV31ToV30(spec: OpenAPIV3_1.OpenAPIObject): OpenAPIV } return out }, - ) as OpenAPIV3_0.OpenAPIObject + ) + for (const [links, name, operationId] of context.linkChecks) { + if (operationId === DROP || !context.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 e88b5d0..28fb0a7 100644 --- a/packages/downgrader/tests/e2e.test.ts +++ b/packages/downgrader/tests/e2e.test.ts @@ -101,6 +101,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: { From 35157c54b562aedd6ca86ed0c4a2b8fda2470624 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sun, 27 Sep 2026 15:28:33 +0700 Subject: [PATCH 2/4] refactor(downgrader): build 3.1 to 3.0 ref inlining on the shared ref helpers Resolve refs with parseLocalRef/resolveLocalRef, guard recursion with isConverting and the pointers being inlined, and keep one schema finish function per call so converted schemas are reused. The inline cap is no longer needed. Co-Authored-By: Claude --- packages/downgrader/README.md | 20 +-- packages/downgrader/src/v3.1-to-v3.0.test.ts | 27 ++-- packages/downgrader/src/v3.1-to-v3.0.ts | 160 ++++++++----------- 3 files changed, 90 insertions(+), 117 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 51eed56..c29777c 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -93,20 +93,20 @@ 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` | applied to an inlined copy whose type has the field, removed otherwise (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` and `components.pathItems`, after every same-document reference into them is resolved: - - Reference Objects, Path Item `$ref`s, and Schema `$ref`s are replaced by a converted copy of their target, following reference chains. A chain that leads back out keeps the reference where it lands, and a Path Item's own fields win over inlined ones. - - A reference back to a target that is still being inlined is cut: a Schema Object becomes `{}`, a Path Item keeps only its own fields, and any other reference is removed. Inlining also stops after 100,000 copies, so targets that reference each other many times over cannot blow up the output. + - Reference Objects, Path Item `$ref`s, and Schema `$ref`s are replaced by their target in converted form, following reference chains. A chain that leads back out keeps the reference where it lands, and a Path Item's own fields win over inlined ones. + - A reference back to a target that is still being converted is cut: a Schema Object becomes `{}`, a Path Item keeps only its own fields, and any other reference is removed. A recursive schema keeps one level this way. - 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 (or not a Path Item, for a Path Item `$ref`), or is a reference loop is left as written. 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 e0c448f..5ce24ab 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': { @@ -663,11 +663,12 @@ describe('downgradeSpecV31ToV30', () => { }, }, }) - const leaf = { properties: { children: { items: {}, type: 'array' } }, type: 'object' } - expect(result.components).toEqual({ schemas: { Tree: leaf } }) + expect(result.components).toEqual({ + schemas: { Tree: { properties: { children: { items: {}, type: 'array' } }, type: 'object' } }, + }) expect( dig(result, 'paths', '/tree', 'post', 'requestBody', 'content', 'application/json', 'schema'), - ).toEqual({ properties: { children: { items: leaf, type: 'array' } }, type: 'object' }) + ).toBe(dig(result, 'components', 'schemas', 'Tree')) expect(dig(result, 'paths', '/ping', 'post', 'callbacks')).toEqual({ pong: { '{$request.body#/url}': {} }, self: { '{$request.body#/url}': {} }, @@ -676,18 +677,20 @@ describe('downgradeSpecV31ToV30', () => { expect(JSON.stringify(result)).not.toMatch(removedPointer) }) - it('stops inlining after a fixed number of copies instead of growing exponentially', () => { + 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' } } } } - const webhooks: Record = { w40: { post: { requestBody: leaf } } } - for (let index = 0; index < 40; index++) { + 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 } } } } } } - const result = convertSpec({ components: { schemas: { Root: { $ref: pointer(0) } } }, webhooks }) - const text = JSON.stringify(result) - expect(text.length).toBeLessThan(20_000_000) - expect(text).not.toMatch(removedPointer) + 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', () => { diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 8c2cb98..4bbc37e 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -7,25 +7,25 @@ import { deepClone, DROP, getRef, + isConverting, isRecord, mapArray, mapRecord, operationFields, + parseLocalRef, + resolveLocalRef, } from './shared' -const INLINE_LIMIT = 100_000 -const ARRAY_INDEX = /^(?:0|[1-9]\d*)$/ const DESCRIPTION = ['description'] const SUMMARY_AND_DESCRIPTION = ['summary', 'description'] const SECURITY_SCHEMES_REF_PREFIX = '#/components/securitySchemes/' interface Context { + convertSchema: (value: unknown) => unknown document: Record | undefined - inlined: number - inlining: Set + inlining: string[][] linkChecks: [links: Record, name: string, operationId: string | typeof DROP][] operationIds: Set - schemaFields: FieldTable schemeTypes: ReadonlyMap } @@ -34,47 +34,15 @@ type Convert = (item: unknown, context: Context) => unknown interface Chain { fields: Record target: unknown + tokens: string[] } -function decodeFragment(ref: string): string | undefined { - try { - return decodeURIComponent(ref.slice(1)) - } - catch { - return undefined - } -} - -function parsePointer(ref: string): string[] | undefined { - const pointer = ref.startsWith('#') ? decodeFragment(ref) : undefined - if (!pointer?.startsWith('/')) { - return undefined - } - return pointer.slice(1).split('/').map(token => token.replaceAll('~1', '/').replaceAll('~0', '~')) -} - -function parseRemovedPointer(ref: string, context: Context): string[] | undefined { - const tokens = context.document === undefined ? undefined : parsePointer(ref) +function parseRemovedRef(ref: string, context: Context): string[] | undefined { + const tokens = context.document === undefined ? undefined : parseLocalRef(ref) const removed = tokens?.[0] === 'webhooks' || (tokens?.[0] === 'components' && tokens[1] === 'pathItems') return removed ? tokens : undefined } -function resolvePointer(tokens: readonly string[], document: unknown): unknown { - let current = document - for (const token of tokens) { - if (isRecord(current) && Object.hasOwn(current, token)) { - current = current[token] - } - else if (Array.isArray(current) && ARRAY_INDEX.test(token) && Number(token) < current.length) { - current = current[Number(token)] - } - else { - return undefined - } - } - return current -} - function isPureRef(value: unknown): value is { $ref: string } { return isRecord(value) && typeof value.$ref === 'string' && Object.keys(value).length === 1 } @@ -92,33 +60,34 @@ function followRefs( const seen = new Set() let fields: Record = {} let target: unknown = value + let tokens: string[] | undefined while (isRecord(target) && typeof target.$ref === 'string' && isHop(target)) { - const tokens = parseRemovedPointer(target.$ref, context) - if (tokens === undefined) { + const next = parseRemovedRef(target.$ref, context) + if (next === undefined) { break } - if (seen.has(target) || !isLocation(tokens)) { + if (seen.has(target) || !isLocation(next)) { return undefined } seen.add(target) - const { $ref: _, ...own } = target + const { $ref: ref, ...own } = target fields = { ...own, ...fields } - target = resolvePointer(tokens, context.document) + tokens = next + target = resolveLocalRef(context.document, ref) } - return target === undefined ? undefined : { fields, target } + return tokens === undefined || target === undefined ? undefined : { fields, target, tokens } } -function inline(target: Record, fields: Record, context: Context, convert: (copy: Record) => unknown): unknown { - if (context.inlining.has(target) || context.inlined >= INLINE_LIMIT) { +function inline(target: unknown, tokens: string[], context: Context, convert: () => unknown): unknown { + if (isConverting(target) || context.inlining.some(inlined => tokens.every((token, index) => inlined[index] === token))) { return DROP } - context.inlined += 1 - context.inlining.add(target) + context.inlining.push(tokens) try { - return convert(deepClone({ ...target, ...fields })) + return convert() } finally { - context.inlining.delete(target) + context.inlining.pop() } } @@ -134,11 +103,12 @@ function convertRefOr(value: unknown, context: Context, convert: Convert, overri if (chain === undefined || !isRecord(chain.target)) { return { $ref: value.$ref } } - const ref = getRef(chain.target) + const { fields, target, tokens } = chain + const ref = getRef(target) if (ref !== undefined) { return { $ref: ref } } - return inline(chain.target, pickFields(chain.fields, overrides), context, copy => convert(copy, context)) + return inline(target, tokens, context, () => convert({ ...target, ...pickFields(fields, overrides) }, context)) } function refMap(context: Context, convert: Convert, overrides?: readonly string[]): FieldConverter { @@ -307,7 +277,7 @@ function finishSchema(out: Record, schema: Record convertSchema(item, context) + const convert = (item: unknown): unknown => context.convertSchema(item) const convertSubschemas = (item: unknown): unknown => mapArray(item, convert) return { $anchor: DROP, @@ -335,7 +305,7 @@ function createSchemaFields(context: Context): FieldTable { dependentRequired: DROP, dependentSchemas: DROP, discriminator: item => convertRecord(item, { - mapping: mapping => mapRecord(mapping, value => (typeof value === 'string' && parseRemovedPointer(value, context) !== undefined ? DROP : deepClone(value))), + 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)), @@ -367,35 +337,23 @@ function createSchemaFields(context: Context): FieldTable { } function convertSchemaRef(value: { $ref: string }, context: Context): unknown { - const target = followRefs(value, context, isPureRef)?.target - if (typeof target === 'boolean') { - return convertSchema(target, context) + const chain = followRefs(value, context, isPureRef) + if (chain === undefined) { + return { $ref: value.$ref } } + const { target, tokens } = chain if (isPureRef(target)) { return { $ref: target.$ref } } - if (!isRecord(target)) { + if (!isRecord(target) && typeof target !== 'boolean') { return { $ref: value.$ref } } - const inlined = inline(target, {}, context, copy => convertSchema(copy, context)) + const inlined = inline(target, tokens, context, () => context.convertSchema(target)) return inlined === DROP ? {} : inlined } -function convertSchema(schema: unknown, context: Context): unknown { - if (schema === true) { - return {} - } - if (schema === false) { - return { not: {} } - } - if (isPureRef(schema)) { - return convertSchemaRef(schema, context) - } - return convertRecord(schema, context.schemaFields, (out, source) => finishSchema(out, source, context)) -} - export function downgradeSchemaV31ToV30(schema: OpenAPIV3_1.SchemaObject): OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject { - return convertSchema(schema, createContext(undefined)) as OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject + return createContext(undefined).convertSchema(schema) as OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject } function resolveSchemeType(name: string, schemes: Record, seen: Set): string | undefined { @@ -434,15 +392,27 @@ function createContext(spec: unknown): Context { } } const context: Context = { + convertSchema, document, - inlined: 0, - inlining: new Set(), + inlining: [], linkChecks: [], operationIds: new Set(), - schemaFields: {}, schemeTypes, } - context.schemaFields = createSchemaFields(context) + 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) + } + return convertRecord(value, fields, finish) + } return context } @@ -487,7 +457,7 @@ function convertParameterOrHeader(value: unknown, context: Context): unknown { { content: item => convertContent(item, context), examples: refMap(context, deepClone, SUMMARY_AND_DESCRIPTION), - schema: item => convertSchema(item, context), + schema: context.convertSchema, }, (out, parameter) => { if (parameter.in === 'path') { @@ -506,7 +476,7 @@ function convertMediaType(value: unknown, context: Context): unknown { return convertRecord(value, { encoding: item => mapRecord(item, entry => convertEncoding(entry, context)), examples: refMap(context, deepClone, SUMMARY_AND_DESCRIPTION), - schema: item => convertSchema(item, context), + schema: context.convertSchema, }) } @@ -523,14 +493,13 @@ function linkedOperationId(link: unknown, context: Context): string | typeof DRO let target = link while (isRecord(target) && typeof target.$ref === 'string' && !seen.has(target)) { seen.add(target) - const tokens = parsePointer(target.$ref) - target = tokens === undefined ? undefined : resolvePointer(tokens, context.document) + target = resolveLocalRef(context.document, target.$ref) } - const tokens = isRecord(target) && typeof target.operationRef === 'string' ? parseRemovedPointer(target.operationRef, context) : undefined - if (tokens === undefined) { + const operationRef = isRecord(target) ? target.operationRef : undefined + if (typeof operationRef !== 'string' || parseRemovedRef(operationRef, context) === undefined) { return undefined } - const operation = resolvePointer(tokens, context.document) + const operation = resolveLocalRef(context.document, operationRef) return isRecord(operation) && typeof operation.operationId === 'string' ? operation.operationId : DROP } @@ -547,11 +516,11 @@ function convertLink(value: unknown, context: Context): unknown { function convertLinks(value: unknown, context: Context): unknown { const out = mapRecord(value, item => convertRefOr(item, context, convertLink)) - if (isRecord(value) && isRecord(out)) { + if (isRecord(value)) { for (const [name, item] of Object.entries(value)) { const operationId = linkedOperationId(item, context) if (operationId !== undefined) { - context.linkChecks.push([out, name, operationId]) + context.linkChecks.push([out as Record, name, operationId]) } } } @@ -604,16 +573,17 @@ function convertPathItemFields(value: unknown, context: Context): unknown { } function convertPathItem(value: unknown, context: Context): unknown { - if (!isRecord(value) || typeof value.$ref !== 'string') { + const chain = isRecord(value) ? followRefs(value, context, undefined, isPathItemLocation) : undefined + if (!isRecord(value) || chain === undefined || !isRecord(chain.target)) { return convertPathItemFields(value, context) } - const chain = followRefs(value, context, undefined, isPathItemLocation) - if (chain === undefined || !isRecord(chain.target) || chain.target === value) { - return convertPathItemFields(value, context) + const { fields, target, tokens } = chain + const inlined = inline(target, tokens, context, () => convertPathItem({ ...target, ...fields }, context)) + if (inlined !== DROP) { + return inlined } const { $ref: _, ...own } = value - const inlined = inline(chain.target, chain.fields, context, copy => convertPathItem(copy, context)) - return inlined === DROP ? convertPathItemFields(own, context) : inlined + return convertPathItemFields(own, context) } function convertPaths(value: unknown, context: Context): unknown { @@ -630,7 +600,7 @@ function convertComponents(value: unknown, context: Context): unknown { pathItems: DROP, requestBodies: refMap(context, convertRequestBody), responses: refMap(context, convertResponse), - schemas: item => mapRecord(item, entry => convertSchema(entry, context)), + schemas: item => mapRecord(item, context.convertSchema), securitySchemes: item => mapRecord(item, (scheme, name) => isMutualTls(name, context) ? DROP : convertRefOr(scheme, context, deepClone)), }) } From daa35c2ac77b90247a582c1f42eae0849c5bb25e Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sun, 27 Sep 2026 16:32:46 +0700 Subject: [PATCH 3/4] fix(downgrader): keep 3.1 to 3.0 ref inlining acyclic and convert each target once An in-progress conversion is reused only within the same inline, so a callback, own field, or shared object that leads back into content still being converted is cut instead of forming a cycle. Inlined targets are converted once per converter, Path Item $refs inline only at Path Item locations, and security scheme aliases resolve through any local ref. Co-Authored-By: Claude --- packages/downgrader/README.md | 14 +- packages/downgrader/src/shared.test.ts | 14 ++ packages/downgrader/src/shared.ts | 19 +- packages/downgrader/src/v3.1-to-v3.0.test.ts | 243 ++++++++++++++++++- packages/downgrader/src/v3.1-to-v3.0.ts | 153 ++++++------ 5 files changed, 354 insertions(+), 89 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index c29777c..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 @@ -104,12 +104,12 @@ Converted: Removed, with no 3.0 equivalent: -- `webhooks` and `components.pathItems`, after every same-document reference into them is 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 leads back out keeps the reference where it lands, and a Path Item's own fields win over inlined ones. - - A reference back to a target that is still being converted is cut: a Schema Object becomes `{}`, a Path Item keeps only its own fields, and any other reference is removed. A recursive schema keeps one level this way. +- `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 (or not a Path Item, for a Path Item `$ref`), or is a reference loop is left as written. + - 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` - `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. @@ -137,8 +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. -- A pointer into `webhooks` or `components.pathItems` that passes through another `$ref` is not followed and will dangle, and a Link naming a removed operation only by `operationId` is kept. A Path Item inlined in several places repeats its `operationId`s, which 3.0 requires to be unique. +- `$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..c4c575c 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,19 @@ 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')) + }) +}) + 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..94217d9 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -44,6 +44,7 @@ export function setOwn(object: object, key: PropertyKey, value: unknown): void { type Finish = (out: Record, source: Record) => unknown interface Conversion { + depth: number done: boolean fields: FieldTable finish: Finish | undefined @@ -52,6 +53,7 @@ interface Conversion { const conversions = new Map() const clones = new Map() +let depth = 0 function cloneValue(value: unknown, seen: Map): unknown { if (!(Array.isArray(value) || isRecord(value))) { @@ -89,11 +91,14 @@ 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) { + return known.depth === depth ? known.result : DROP + } + if (known !== undefined && known.fields === fields && known.finish === finish) { return known.result } const out: Record = {} - const conversion: Conversion = { done: false, fields, finish, result: out } + const conversion: Conversion = { depth, done: false, fields, finish, result: out } const outermost = conversions.size === 0 conversions.set(value, conversion) try { @@ -119,6 +124,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 5ce24ab..7275799 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -871,6 +871,247 @@ describe('downgradeSpecV31ToV30', () => { 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 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', () => { @@ -1126,7 +1367,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 4bbc37e..758ceb2 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -3,10 +3,12 @@ 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, @@ -16,14 +18,15 @@ import { resolveLocalRef, } from './shared' +const HTTP_METHODS = new Set(HTTP_METHODS_UP_TO_V31) const DESCRIPTION = ['description'] const SUMMARY_AND_DESCRIPTION = ['summary', 'description'] -const SECURITY_SCHEMES_REF_PREFIX = '#/components/securitySchemes/' interface Context { convertSchema: (value: unknown) => unknown document: Record | undefined - inlining: string[][] + inlined: Map> + inlining: Set linkChecks: [links: Record, name: string, operationId: string | typeof DROP][] operationIds: Set schemeTypes: ReadonlyMap @@ -34,11 +37,13 @@ type Convert = (item: unknown, context: Context) => unknown interface Chain { fields: Record target: unknown - tokens: string[] } function parseRemovedRef(ref: string, context: Context): string[] | undefined { - const tokens = context.document === undefined ? undefined : parseLocalRef(ref) + 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 } @@ -48,67 +53,85 @@ function isPureRef(value: unknown): value is { $ref: string } { } function isPathItemLocation(tokens: readonly string[]): boolean { - return tokens.length === (tokens[0] === 'webhooks' ? 2 : 3) || tokens.at(-3) === 'callbacks' + 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, - isHop: (item: Record) => boolean = () => true, - isLocation: (tokens: readonly string[]) => boolean = () => true, -): Chain | undefined { +function followRefs(value: Record, context: Context, kind: 'pathItem' | 'reference' | 'schema'): Chain | undefined { const seen = new Set() let fields: Record = {} let target: unknown = value - let tokens: string[] | undefined - while (isRecord(target) && typeof target.$ref === 'string' && isHop(target)) { - const next = parseRemovedRef(target.$ref, context) - if (next === undefined) { + 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) || !isLocation(next)) { + if (seen.has(target) || (kind === 'pathItem' && !isPathItemLocation(tokens))) { return undefined } seen.add(target) const { $ref: ref, ...own } = target fields = { ...own, ...fields } - tokens = next target = resolveLocalRef(context.document, ref) } - return tokens === undefined || target === undefined ? undefined : { fields, target, tokens } + return target === value || target === undefined ? undefined : { fields, target } } -function inline(target: unknown, tokens: string[], context: Context, convert: () => unknown): unknown { - if (isConverting(target) || context.inlining.some(inlined => tokens.every((token, index) => inlined[index] === token))) { +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.push(tokens) + context.inlining.add(target) try { - return convert() + const out = convertInlined(() => convert(target, context)) + cache.set(target, out) + return out } finally { - context.inlining.pop() + 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, fields[key]])) + 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) + const chain = followRefs(value, context, 'reference') if (chain === undefined || !isRecord(chain.target)) { return { $ref: value.$ref } } - const { fields, target, tokens } = chain - const ref = getRef(target) + const ref = getRef(chain.target) if (ref !== undefined) { return { $ref: ref } } - return inline(target, tokens, context, () => convert({ ...target, ...pickFields(fields, overrides) }, context)) + 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 { @@ -277,7 +300,7 @@ function finishSchema(out: Record, schema: Record context.convertSchema(item) + const convert = context.convertSchema const convertSubschemas = (item: unknown): unknown => mapArray(item, convert) return { $anchor: DROP, @@ -337,45 +360,21 @@ function createSchemaFields(context: Context): FieldTable { } function convertSchemaRef(value: { $ref: string }, context: Context): unknown { - const chain = followRefs(value, context, isPureRef) - if (chain === undefined) { - return { $ref: value.$ref } - } - const { target, tokens } = chain + const target = followRefs(value, context, 'schema')?.target if (isPureRef(target)) { return { $ref: target.$ref } } if (!isRecord(target) && typeof target !== 'boolean') { return { $ref: value.$ref } } - const inlined = inline(target, tokens, context, () => context.convertSchema(target)) - return inlined === DROP ? {} : inlined + const out = inline(target, context, context.convertSchema) + return out === DROP ? {} : out } -export function downgradeSchemaV31ToV30(schema: OpenAPIV3_1.SchemaObject): OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject { - return createContext(undefined).convertSchema(schema) as OpenAPIV3_0.ReferenceObject | OpenAPIV3_0.SchemaObject -} +const STANDALONE_CONTEXT = createContext(undefined) -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 { @@ -384,17 +383,18 @@ function createContext(spec: unknown): Context { 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) } } } const context: Context = { convertSchema, document, - inlining: [], + inlined: new Map(), + inlining: new Set(), linkChecks: [], operationIds: new Set(), schemeTypes, @@ -411,7 +411,8 @@ function createContext(spec: unknown): Context { if (isPureRef(value)) { return convertSchemaRef(value, context) } - return convertRecord(value, fields, finish) + const out = convertRecord(value, fields, finish) + return out === DROP ? {} : out } return context } @@ -489,12 +490,7 @@ function convertRequestBody(value: unknown, context: Context): unknown { } function linkedOperationId(link: unknown, context: Context): string | typeof DROP | undefined { - const seen = new Set() - let target = link - while (isRecord(target) && typeof target.$ref === 'string' && !seen.has(target)) { - seen.add(target) - target = resolveLocalRef(context.document, target.$ref) - } + const target = resolveRefChain(link, context.document) const operationRef = isRecord(target) ? target.operationRef : undefined if (typeof operationRef !== 'string' || parseRemovedRef(operationRef, context) === undefined) { return undefined @@ -573,17 +569,16 @@ function convertPathItemFields(value: unknown, context: Context): unknown { } function convertPathItem(value: unknown, context: Context): unknown { - const chain = isRecord(value) ? followRefs(value, context, undefined, isPathItemLocation) : undefined - if (!isRecord(value) || chain === undefined || !isRecord(chain.target)) { + const chain = isRecord(value) ? followRefs(value, context, 'pathItem') : undefined + if (chain === undefined || !isRecord(chain.target)) { return convertPathItemFields(value, context) } - const { fields, target, tokens } = chain - const inlined = inline(target, tokens, context, () => convertPathItem({ ...target, ...fields }, context)) - if (inlined !== DROP) { - return inlined + const own = Object.keys(chain.fields).length === 0 ? undefined : convertPathItemFields(chain.fields, context) + const out = inline(chain.target, context, convertPathItem) + if (out === DROP) { + return own ?? {} } - const { $ref: _, ...own } = value - return convertPathItemFields(own, context) + return own === undefined ? out : { ...out as Record, ...own as Record } } function convertPaths(value: unknown, context: Context): unknown { From 89972d7aaec3db5300ccb82b971a91ab27c272e7 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sun, 27 Sep 2026 17:12:40 +0700 Subject: [PATCH 4/4] fix(downgrader): close the last cycle and dangling-link gaps in 3.1 to 3.0 ref inlining Fields a Path Item inherits from a later $ref hop are converted inside the inline, a conversion cut inside an inline is redone when reached again outside it so object cycles survive, and links are checked against the operationIds that remain in the output. Co-Authored-By: Claude --- packages/downgrader/src/shared.test.ts | 13 ++++ packages/downgrader/src/shared.ts | 18 ++++- packages/downgrader/src/v3.1-to-v3.0.test.ts | 69 +++++++++++++++++++- packages/downgrader/src/v3.1-to-v3.0.ts | 54 +++++++++++---- 4 files changed, 137 insertions(+), 17 deletions(-) diff --git a/packages/downgrader/src/shared.test.ts b/packages/downgrader/src/shared.test.ts index c4c575c..347e83b 100644 --- a/packages/downgrader/src/shared.test.ts +++ b/packages/downgrader/src/shared.test.ts @@ -433,6 +433,19 @@ describe('convertInlined', () => { 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', () => { diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index 94217d9..caba5e8 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -44,6 +44,7 @@ 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 @@ -53,6 +54,7 @@ interface Conversion { const conversions = new Map() const clones = new Map() +const active: Conversion[] = [] let depth = 0 function cloneValue(value: unknown, seen: Map): unknown { @@ -92,15 +94,24 @@ export function convertRecord(value: unknown, fields: FieldTable, finish?: Finis } const known = conversions.get(value) if (known !== undefined && !known.done) { - return known.depth === depth ? known.result : DROP + 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) { + if (known !== undefined && known.fields === fields && known.finish === finish && depth > known.cutAt) { return known.result } const out: Record = {} - const conversion: Conversion = { depth, 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 @@ -117,6 +128,7 @@ export function convertRecord(value: unknown, fields: FieldTable, finish?: Finis return conversion.result } finally { + active.pop() if (outermost) { conversions.clear() clones.clear() 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 7275799..e9be57f 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -758,7 +758,9 @@ describe('downgradeSpecV31ToV30', () => { 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' }, }, @@ -795,8 +797,12 @@ describe('downgradeSpecV31ToV30', () => { }, '/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', @@ -812,7 +818,11 @@ describe('downgradeSpecV31ToV30', () => { }, }) expect(result.components).toEqual({ - links: { Kept: { description: 'kept', operationId: 'newPetHook' } }, + 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' }, @@ -830,6 +840,34 @@ describe('downgradeSpecV31ToV30', () => { 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({ @@ -987,6 +1025,35 @@ describe('downgradeSpecV31ToV30', () => { 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({ diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 758ceb2..3990b37 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -28,7 +28,6 @@ interface Context { inlined: Map> inlining: Set linkChecks: [links: Record, name: string, operationId: string | typeof DROP][] - operationIds: Set schemeTypes: ReadonlyMap } @@ -396,7 +395,6 @@ function createContext(spec: unknown): Context { inlined: new Map(), inlining: new Set(), linkChecks: [], - operationIds: new Set(), schemeTypes, } const fields = createSchemaFields(context) @@ -546,9 +544,6 @@ function convertOperation(value: unknown, context: Context): unknown { security: item => convertSecurity(item, context), }, (out) => { - if (typeof out.operationId === 'string') { - context.operationIds.add(out.operationId) - } if (out.responses === undefined) { out.responses = { default: { description: '' } } } @@ -570,15 +565,20 @@ function convertPathItemFields(value: unknown, context: Context): unknown { function convertPathItem(value: unknown, context: Context): unknown { const chain = isRecord(value) ? followRefs(value, context, 'pathItem') : undefined - if (chain === undefined || !isRecord(chain.target)) { + if (!isRecord(value) || chain === undefined || !isRecord(chain.target)) { return convertPathItemFields(value, context) } - const own = Object.keys(chain.fields).length === 0 ? undefined : convertPathItemFields(chain.fields, context) const out = inline(chain.target, context, convertPathItem) - if (out === DROP) { - return own ?? {} + if (Object.keys(chain.fields).length === 0) { + return out === DROP ? {} : out + } + const { $ref: _, ...own } = value + 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, } - return own === undefined ? out : { ...out as Record, ...own as Record } } function convertPaths(value: unknown, context: Context): unknown { @@ -600,6 +600,24 @@ function convertComponents(value: unknown, context: Context): unknown { }) } +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) const converted = convertRecord( @@ -620,9 +638,19 @@ export function downgradeSpecV31ToV30(spec: OpenAPIV3_1.OpenAPIObject): OpenAPIV return out }, ) - for (const [links, name, operationId] of context.linkChecks) { - if (operationId === DROP || !context.operationIds.has(operationId)) { - delete links[name] + 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