From ae6a132bbc1bae707f306ad545b1f2ab3c2bf8ea Mon Sep 17 00:00:00 2001 From: Tim Haselaars Date: Wed, 3 Jun 2026 19:55:45 +0200 Subject: [PATCH] feat: Align overlay copy action with Overlay 1.1 --- readme.md | 6 +-- test/overlay-110-copy/overlay.overlay.yaml | 3 +- test/overlay.test.js | 45 ++++++++++++++-------- types/openapi-format.d.ts | 2 +- utils/overlay.js | 31 +++++++++------ 5 files changed, 54 insertions(+), 33 deletions(-) diff --git a/readme.md b/readme.md index 831ec8f..7773f16 100644 --- a/readme.md +++ b/readme.md @@ -1222,8 +1222,7 @@ actions: - target: "$.paths['/example'].get.parameters" remove: true # Example of removing an element - target: "$.info.title" - copy: true - from: "$.info.version" + copy: "$.info.version" ``` For more information about the OpenAPI Overlay options, see [OpenAPI Overlay Specification 1.1.0](https://spec.openapis.org/overlay/v1.1.0.html). @@ -1260,7 +1259,8 @@ Notes: - Overlay actions are processed in strict mode and validated before applying: - `target` must be valid JSONPath. - At least one of `update`, `remove`, or `copy` must be present. - - `copy: true` requires `from`, and `from` must resolve to exactly one source node. + - `copy` must be a JSONPath string and resolve to exactly one source node. + - Legacy overlays using `copy: true` with `from` remain supported for compatibility, but new overlays should use `copy: "$.source.path"`. ## CLI generate usage diff --git a/test/overlay-110-copy/overlay.overlay.yaml b/test/overlay-110-copy/overlay.overlay.yaml index a758517..61468ce 100644 --- a/test/overlay-110-copy/overlay.overlay.yaml +++ b/test/overlay-110-copy/overlay.overlay.yaml @@ -9,5 +9,4 @@ actions: update: description: Applied by overlay 1.1.0 - target: $.info.title - copy: true - from: $.info.version + copy: $.info.version diff --git a/test/overlay.test.js b/test/overlay.test.js index 35fa835..7675992 100644 --- a/test/overlay.test.js +++ b/test/overlay.test.js @@ -282,7 +282,7 @@ describe('openapi-format CLI overlay tests', () => { components: {} }; const overlaySet = { - actions: [{target: '$.components', copy: true, from: '$.info'}] + actions: [{target: '$.components', copy: '$.info'}] }; const result = await openapiOverlay(baseOAS, {overlaySet}); @@ -299,7 +299,7 @@ describe('openapi-format CLI overlay tests', () => { sourceServer: {url: 'https://api.backup.example.com'} }; const overlaySet = { - actions: [{target: '$.servers', copy: true, from: '$.sourceServer'}] + actions: [{target: '$.servers', copy: '$.sourceServer'}] }; const result = await openapiOverlay(baseOAS, {overlaySet}); @@ -312,7 +312,7 @@ describe('openapi-format CLI overlay tests', () => { serverPool: [{url: 'https://api.eu.example.com'}, {url: 'https://api.us.example.com'}] }; const overlaySet = { - actions: [{target: '$.servers', copy: true, from: '$.serverPool'}] + actions: [{target: '$.servers', copy: '$.serverPool'}] }; const result = await openapiOverlay(baseOAS, {overlaySet}); @@ -328,44 +328,58 @@ describe('openapi-format CLI overlay tests', () => { info: {title: 'Old title', description: 'New title'} }; const overlaySet = { - actions: [{target: '$.info.title', copy: true, from: '$.info.description'}] + actions: [{target: '$.info.title', copy: '$.info.description'}] }; const result = await openapiOverlay(baseOAS, {overlaySet}); expect(result.data.info.title).toBe('New title'); }); - it('should reject copy action when from resolves zero nodes', async () => { + it('should preserve legacy copy true with from source path', async () => { + const baseOAS = { + info: {title: 'Old title', description: 'Legacy title'} + }; + const overlaySet = { + overlay: '1.1.0', + actions: [{target: '$.info.title', copy: true, from: '$.info.description'}] + }; + + const result = await openapiOverlay(baseOAS, {overlaySet}); + expect(result.data.info.title).toBe('Legacy title'); + expect(result.resultData.totalUsedActions).toBe(1); + }); + + it('should reject copy action when copy resolves zero nodes', async () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); const baseOAS = { info: {title: 'API'}, components: {} }; const overlaySet = { - actions: [{target: '$.components', copy: true, from: '$.missing'}] + actions: [{target: '$.components', copy: '$.missing'}] }; const result = await openapiOverlay(baseOAS, {overlaySet}); expect(result.resultData.totalUsedActions).toBe(0); expect(result.resultData.totalUnusedActions).toBe(1); - expect(consoleSpy).toHaveBeenCalledWith('Overlay action #1: "from" must resolve to exactly one node, resolved 0.'); + expect(consoleSpy).toHaveBeenCalledWith('Overlay action #1: "copy" must resolve to exactly one node, resolved 0.'); consoleSpy.mockRestore(); }); - it('should reject copy action when from resolves multiple nodes', async () => { + it('should reject copy action when copy resolves multiple nodes', async () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); const baseOAS = { servers: [{url: 'https://api.example.com'}, {url: 'https://api.backup.example.com'}], components: {} }; const overlaySet = { - actions: [{target: '$.components', copy: true, from: '$.servers[*]'}] + actions: [{target: '$.components', copy: '$.servers[*]'}] }; const result = await openapiOverlay(baseOAS, {overlaySet}); expect(result.resultData.totalUsedActions).toBe(0); expect(result.resultData.totalUnusedActions).toBe(1); - expect(consoleSpy).toHaveBeenCalledWith('Overlay action #1: "from" must resolve to exactly one node, resolved 2.'); + expect(consoleSpy).toHaveBeenCalledWith('Overlay action #1: "copy" must resolve to exactly one node, resolved 2.'); consoleSpy.mockRestore(); }); @@ -380,8 +394,7 @@ describe('openapi-format CLI overlay tests', () => { target: '$', remove: true, update: {info: {title: 'Updated title'}}, - copy: true, - from: '$.source' + copy: '$.source' } ] }; @@ -421,7 +434,7 @@ describe('openapi-format CLI overlay tests', () => { }; const overlaySet = { overlay: '1.1.0', - actions: [{target: '$.components.schemas[*]', copy: true, from: '$.info.version'}] + actions: [{target: '$.components.schemas[*]', copy: '$.info.version'}] }; const result = await openapiOverlay(baseOAS, {overlaySet}); @@ -439,7 +452,7 @@ describe('openapi-format CLI overlay tests', () => { const baseOAS = {info: {title: 'Sample API', version: '1.0.0'}}; const overlaySet = { overlay: '1.0.0', - actions: [{target: '$.info.title', copy: true, from: '$.info.version'}] + actions: [{target: '$.info.title', copy: '$.info.version'}] }; const result = await openapiOverlay(baseOAS, {overlaySet}); @@ -456,7 +469,7 @@ describe('openapi-format CLI overlay tests', () => { const baseOAS = {info: {title: 'Sample API', version: '1.0.0'}}; const overlaySet = { overlay: '1.1.0', - actions: [{target: '$.info.title', copy: true, from: '$.info.version'}] + actions: [{target: '$.info.title', copy: '$.info.version'}] }; const result = await openapiOverlay(baseOAS, {overlaySet}); @@ -469,7 +482,7 @@ describe('openapi-format CLI overlay tests', () => { const baseOAS = {info: {title: 'Sample API', version: '1.0.0'}}; const overlaySet = { overlay: ' ', - actions: [{target: '$.info.title', copy: true, from: '$.info.version'}] + actions: [{target: '$.info.title', copy: '$.info.version'}] }; const result = await openapiOverlay(baseOAS, {overlaySet}); diff --git a/types/openapi-format.d.ts b/types/openapi-format.d.ts index 29ff985..dbd61ab 100644 --- a/types/openapi-format.d.ts +++ b/types/openapi-format.d.ts @@ -127,7 +127,7 @@ declare module 'openapi-format' { target: string; update?: unknown; remove?: boolean; - copy?: boolean; + copy?: string | boolean; from?: string; description?: string; [key: `x-${string}`]: unknown; diff --git a/utils/overlay.js b/utils/overlay.js index e7a71dc..378af68 100644 --- a/utils/overlay.js +++ b/utils/overlay.js @@ -30,7 +30,7 @@ async function openapiOverlay(oaObj, options) { } (overlayDoc?.actions || []).forEach((action, index) => { - const {target, update, remove, copy, from} = action || {}; + const {target, update, remove, copy} = action || {}; const actionLabel = `Overlay action #${index + 1}`; const validationError = validateOverlayAction(action, actionLabel, overlayVersion); @@ -67,11 +67,11 @@ async function openapiOverlay(oaObj, options) { } // copy (third) - if (copy === true) { + if (hasOwn(action, 'copy')) { const copied = applyCopyAction({ root: oaObj, target, - from, + sourcePath: getCopySourcePath(action), actionLabel }); actionUsed = actionUsed || copied.used; @@ -170,11 +170,8 @@ function validateOverlayAction(action, actionLabel, overlayVersion) { if (!isOverlay11x(overlayVersion)) { return `${actionLabel}: "copy" is only supported for overlay 1.1.x documents.`; } - if (action.copy !== true) { - return `${actionLabel}: "copy" must be set to true when present.`; - } - if (typeof action.from !== 'string' || !action.from.startsWith('$')) { - return `${actionLabel}: "from" must be a JSONPath string starting with "$" when "copy" is enabled.`; + if (typeof getCopySourcePath(action) !== 'string') { + return `${actionLabel}: "copy" must be a JSONPath string starting with "$" when present.`; } } @@ -199,6 +196,18 @@ function applyRemoveAction(root, targetPath) { return true; } +function getCopySourcePath(action) { + if (typeof action.copy === 'string' && action.copy.startsWith('$')) { + return action.copy; + } + + if (action.copy === true && typeof action.from === 'string' && action.from.startsWith('$')) { + return action.from; + } + + return undefined; +} + function applyUpdateAction({root, target, update, actionLabel, overlayVersion}) { if (target === '$') { if (isPlainObject(root) && isPlainObject(update)) { @@ -241,10 +250,10 @@ function applyUpdateAction({root, target, update, actionLabel, overlayVersion}) return {root, used}; } -function applyCopyAction({root, target, from, actionLabel}) { - const fromNodes = resolveJsonPath(root, from); +function applyCopyAction({root, target, sourcePath, actionLabel}) { + const fromNodes = resolveJsonPath(root, sourcePath); if (fromNodes.length !== 1) { - console.error(`${actionLabel}: "from" must resolve to exactly one node, resolved ${fromNodes.length}.`); + console.error(`${actionLabel}: "copy" must resolve to exactly one node, resolved ${fromNodes.length}.`); return {root, used: false}; }