Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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

Expand Down
3 changes: 1 addition & 2 deletions test/overlay-110-copy/overlay.overlay.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,4 @@ actions:
update:
description: Applied by overlay 1.1.0
- target: $.info.title
copy: true
from: $.info.version
copy: $.info.version
45 changes: 29 additions & 16 deletions test/overlay.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand All @@ -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});
Expand All @@ -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});
Expand All @@ -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();
});

Expand All @@ -380,8 +394,7 @@ describe('openapi-format CLI overlay tests', () => {
target: '$',
remove: true,
update: {info: {title: 'Updated title'}},
copy: true,
from: '$.source'
copy: '$.source'
}
]
};
Expand Down Expand Up @@ -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});
Expand All @@ -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});
Expand All @@ -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});
Expand All @@ -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});
Expand Down
2 changes: 1 addition & 1 deletion types/openapi-format.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
31 changes: 20 additions & 11 deletions utils/overlay.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.`;
}
}

Expand All @@ -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)) {
Expand Down Expand Up @@ -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};
}

Expand Down
Loading