diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 3d83809..3887a3e 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -293,17 +293,24 @@ const DOCUMENT_FIELDS = defineFields({ // or `additionalProperties`, which the conversion can replace with another // schema, is replaced by its target, unless it already dangles. So is one // that percent-encodes a character, which `find` decodes, as it can hide -// those segments, such as `#/%24defs/Pet`. +// those segments, such as `#/%24defs/Pet`. The segment after +// `#/components//` is a name, so a component named `items` is not one. function isInlined(ref: string, ctx: Context): boolean { + let keywords = ref + if (ref.startsWith('#/components/')) { + const name = ref.indexOf('/', 13) + 1 + const end = ref.indexOf('/', name) + keywords = name === 0 || end === -1 ? '' : ref.slice(end) + } return ( (ref.includes('%') || - ref.includes('/$defs/') || - ref.includes('/definitions/') || + keywords.includes('/$defs/') || + keywords.includes('/definitions/') || // The removed parts, spelled out, as a callback for each `$ref` costs // about 3% of the instructions of a conversion. ref.startsWith('#/webhooks/') || ref.startsWith('#/components/pathItems/') || - INTO_REPLACED.test(ref)) && + INTO_REPLACED.test(keywords)) && resolvePointer(ref, ctx) !== undefined ) } diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index 5cdd311..6164684 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -2932,6 +2932,33 @@ describe('downgradeSchemaV31ToV30', () => { expect(out?.Count).toEqual(ref('Bag/properties/itemsLeft')) }) + it('keeps a $ref to a component named like a keyword it replaces', () => { + // Celigo names a recursive mapping row `items`: replacing the $ref cut + // the recursion to {} one level down. + const ref = (path: string) => ({ $ref: `#/components/schemas/${path}` }) + const schemas = { + items: { type: 'object', properties: { rows: { type: 'array', items: ref('items') } } }, + additionalProperties: { type: 'string' }, + $defs: { type: 'object', properties: { a: { type: 'integer' } } }, + definitions: { type: 'object', properties: { b: { type: 'integer' } } }, + Map: { type: 'object', additionalProperties: ref('additionalProperties') }, + Defs: ref('$defs/properties/a'), + Definitions: ref('definitions/properties/b'), + // A keyword after the name still counts. + Row: ref('items/properties/rows/items'), + } as const + const out = convert({ + components: { schemas, responses: { items: { description: 'Rows' } } }, + paths: { + '/rows': { get: { responses: { 200: { $ref: '#/components/responses/items' } } } }, + }, + }) + expect(out.components?.schemas).toEqual({ ...schemas, Row: ref('items') }) + expect(out.paths?.['/rows']?.get?.responses?.[200]).toEqual({ + $ref: '#/components/responses/items', + }) + }) + it('replaces a percent-encoded $ref with its target', () => { // find decodes it, so it can point into $defs, a removed part, or items // that the conversion replaces, unseen.