Skip to content

Commit 3738b91

Browse files
committed
feat: OpenAPI Overlay 1.1.0
1 parent b4b87bc commit 3738b91

12 files changed

Lines changed: 518 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
## unreleased
22

3+
- Overlay: add OpenAPI Overlay 1.1.0 support
4+
- Overlay: add `copy` action support with strict `from` validation
5+
- Overlay: enforce strict action validation and type compatibility for update/copy actions
6+
- Types: extend overlay typings with `copy`, `from`, and overlay metadata fields
7+
- Docs: update overlay examples and guidance to 1.1.0
8+
39
## [1.29.5] - 2026-02-28
410

511
- Filter: combine inverseTags and inverseFlag (#192)
@@ -431,4 +437,4 @@ Initial commit with features
431437
- Format via CLI
432438
- Format via config files
433439
- Use via as Module
434-
- Support for OpenAPI 3.0
440+
- Support for OpenAPI 3.0

bin/cli.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -420,9 +420,8 @@ async function run(oaFile, options) {
420420

421421
const cliOut = [];
422422
cliLog.unusedActions.forEach(action => {
423-
const description = action.description || 'No description provided';
424423
cliOut.push(
425-
`- Target: ${action.target}\n Type: ${action.update ? 'update' : action.remove ? 'remove' : 'unknown'}`
424+
`- Target: ${action.target}\n Type: ${action.update ? 'update' : action.remove ? 'remove' : action.copy ? 'copy' : 'unknown'}`
426425
);
427426
});
428427

readme.md

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1201,22 +1201,25 @@ operationIds:
12011201
The OpenAPI Overlay functionality allows users to apply actions such as updates and removals to an OpenAPI Specification (OAS). This feature is useful for dynamically modifying OAS documents during development, testing, or publishing workflows.
12021202

12031203
### What is an OpenAPI Overlay?
1204-
An [OpenAPI Overlay](https://spec.openapis.org/overlay/v1.0.0.html) is a specification that defines a structured set of actions to be applied to an existing OpenAPI document. It enables:
1204+
An [OpenAPI Overlay](https://spec.openapis.org/overlay/v1.1.0.html) is a specification that defines a structured set of actions to be applied to an existing OpenAPI document. It enables:
12051205

12061206
- Updating existing fields, such as descriptions, parameters, or endpoints.
12071207
- Adding new fields or objects to the OpenAPI document.
12081208
- Removing fields or objects that are no longer relevant.
1209+
- Copying values from one location in the source document to another location.
12091210

12101211
An overlay document follows the structure below:
12111212

1212-
```
1213-
overlay: 1.0.0
1213+
```yaml
1214+
overlay: 1.1.0
12141215
info:
12151216
title: Example Overlay
1217+
description: Overlay to add docs metadata and copy values
12161218
version: 1.0.0
1219+
x-overlay-owner: docs-team
12171220
actions:
1218-
- target: "$" // JSONPath definition of the targetted element of the document
1219-
update: // The action to be applied: update or remove
1221+
- target: "$" # JSONPath definition of the targeted element of the document
1222+
update: # The action to be applied: update, remove, or copy
12201223
info:
12211224
description: "Updated description for the OpenAPI specification."
12221225
- target: "$.paths['/example']"
@@ -1225,9 +1228,12 @@ actions:
12251228
description: "Updated GET description for /example endpoint."
12261229
- target: "$.paths['/example'].get.parameters"
12271230
remove: true # Example of removing an element
1231+
- target: "$.info.title"
1232+
copy: true
1233+
from: "$.info.version"
12281234
```
12291235

1230-
Fore more information about the OpenAPI Overlay options, see [OpenAPI Overlay Specification 1.0.0](https://www.openapis.org/blog/2024/10/22/announcing-overlay-specification)
1236+
Fore more information about the OpenAPI Overlay options, see [OpenAPI Overlay Specification 1.1.0](https://spec.openapis.org/overlay/v1.1.0.html).
12311237

12321238
Use the `--overlayFile` option to specify the overlay file and apply it to your OpenAPI document.
12331239

@@ -1239,10 +1245,11 @@ $ openapi-format openapi.yaml --overlayFile overlay.yaml -o openapi-updated.yaml
12391245
You can also let the overlay declare the base OpenAPI document using the top-level `extends` property. When `extends` is present, the input file argument becomes optional:
12401246

12411247
```yaml
1242-
overlay: 1.0.0
1248+
overlay: 1.1.0
12431249
info:
12441250
title: Overlay for Tic Tac Toe
12451251
version: 1.0.0
1252+
description: Overlay for docs-only adjustments
12461253
extends: 'https://raw.githubusercontent.com/OAI/learn.openapis.org/refs/heads/main/examples/v3.1/tictactoe.yaml'
12471254
# actions: [...] # optional
12481255
```
@@ -1256,6 +1263,11 @@ $ openapi-format --overlayFile overlay.yaml -o openapi-updated.yaml
12561263
Notes:
12571264
- `extends` supports both local paths and remote `http(s)` URLs.
12581265
- Local relative paths are resolved relative to the overlay file’s location.
1266+
- `.overlay.yaml` is the recommended file naming convention for overlays.
1267+
- Overlay actions are processed in strict mode and validated before applying:
1268+
- `target` must be valid JSONPath.
1269+
- At least one of `update`, `remove`, or `copy` must be present.
1270+
- `copy: true` requires `from`, and `from` must resolve to exactly one source node.
12591271

12601272
## CLI generate usage
12611273

test/__utils__/test-utils.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,12 @@ async function loadTest(folder, inputType = 'yaml', outType = 'yaml') {
4444

4545
function cli(args, cwd) {
4646
return new Promise(resolve => {
47-
exec(`node ${path.resolve('./bin/cli')} ${args.join(' ')}`, {cwd}, (error, stderr, stdout) => {
47+
const env = {...process.env};
48+
if (env.FORCE_COLOR && env.NO_COLOR) {
49+
delete env.NO_COLOR;
50+
}
51+
52+
exec(`node ${path.resolve('./bin/cli')} ${args.join(' ')}`, {cwd, env}, (error, stderr, stdout) => {
4853
resolve({
4954
code: error && error.code ? error.code : 0,
5055
error,

test/overlay-110-copy/input.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
openapi: 3.0.3
2+
info:
3+
title: Sample API
4+
version: 1.0.0
5+
paths: {}
6+
components:
7+
schemas:
8+
Source:
9+
type: object
10+
properties:
11+
id:
12+
type: string

test/overlay-110-copy/options.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
output: output.yaml
2+
overlayFile: overlay.overlay.yaml

test/overlay-110-copy/output.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
openapi: 3.0.3
2+
info:
3+
title: 1.0.0
4+
version: 1.0.0
5+
description: Applied by overlay 1.1.0
6+
paths: {}
7+
components:
8+
schemas:
9+
Source:
10+
type: object
11+
properties:
12+
id:
13+
type: string
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
overlay: 1.1.0
2+
info:
3+
title: Overlay 1.1 copy
4+
version: 1.0.0
5+
description: Updates and copies metadata
6+
x-overlay-owner: tooling-team
7+
actions:
8+
- target: $.info
9+
update:
10+
description: Applied by overlay 1.1.0
11+
- target: $.info.title
12+
copy: true
13+
from: $.info.version

test/overlay-combi/overlay.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ actions:
1111
remove: true
1212
- target: $.servers
1313
update:
14-
- url: 'https://api.new-example.com'
15-
description: New server
14+
url: 'https://api.new-example.com'
15+
description: New server

test/overlay.test.js

Lines changed: 169 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ describe('openapi-format CLI overlay tests', () => {
1616
};
1717

1818
await openapiOverlay(baseOAS, {overlaySet});
19-
expect(consoleSpy).toHaveBeenCalledWith('Action with missing target');
19+
expect(consoleSpy).toHaveBeenCalledWith(
20+
'Overlay action #1: action target must be a JSONPath string starting with "$".'
21+
);
2022
consoleSpy.mockRestore();
2123
});
2224

@@ -81,7 +83,7 @@ describe('openapi-format CLI overlay tests', () => {
8183
};
8284

8385
await openapiOverlay(baseOAS, {overlaySet});
84-
expect(consoleSpy).toHaveBeenCalledWith('Remove operations are not supported at the root level.');
86+
expect(consoleSpy).toHaveBeenCalledWith('Overlay action #1: remove is not supported at target "$".');
8587
consoleSpy.mockRestore();
8688
});
8789

@@ -216,6 +218,171 @@ describe('openapi-format CLI overlay tests', () => {
216218
});
217219
});
218220

221+
it('should append update values to array targets', async () => {
222+
const baseOAS = {servers: [{url: 'https://api.example.com'}]};
223+
const overlaySet = {
224+
actions: [{target: '$.servers', update: {url: 'https://api.backup.example.com'}}]
225+
};
226+
227+
const result = await openapiOverlay(baseOAS, {overlaySet});
228+
expect(result.data.servers).toEqual([
229+
{url: 'https://api.example.com'},
230+
{url: 'https://api.backup.example.com'}
231+
]);
232+
});
233+
234+
it('should replace primitive values when using update', async () => {
235+
const baseOAS = {info: {title: 'Old title'}};
236+
const overlaySet = {
237+
actions: [{target: '$.info.title', update: 'New title'}]
238+
};
239+
240+
const result = await openapiOverlay(baseOAS, {overlaySet});
241+
expect(result.data.info.title).toBe('New title');
242+
});
243+
244+
it('should reject type mismatch for primitive target update', async () => {
245+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
246+
const baseOAS = {info: {title: 'Old title'}};
247+
const overlaySet = {
248+
actions: [{target: '$.info.title', update: {value: 'New title'}}]
249+
};
250+
251+
const result = await openapiOverlay(baseOAS, {overlaySet});
252+
expect(result.data.info.title).toBe('Old title');
253+
expect(result.resultData.totalUsedActions).toBe(0);
254+
expect(consoleSpy).toHaveBeenCalledWith(
255+
'Overlay action #1: update type mismatch - primitive target requires primitive value.'
256+
);
257+
consoleSpy.mockRestore();
258+
});
259+
260+
it('should copy object values into object targets', async () => {
261+
const baseOAS = {
262+
info: {title: 'My API', contact: {name: 'Support'}},
263+
components: {}
264+
};
265+
const overlaySet = {
266+
actions: [{target: '$.components', copy: true, from: '$.info'}]
267+
};
268+
269+
const result = await openapiOverlay(baseOAS, {overlaySet});
270+
expect(result.data.components).toEqual({
271+
title: 'My API',
272+
contact: {name: 'Support'}
273+
});
274+
expect(result.resultData.totalUsedActions).toBe(1);
275+
});
276+
277+
it('should append copied values to array targets', async () => {
278+
const baseOAS = {
279+
servers: [{url: 'https://api.example.com'}],
280+
sourceServer: {url: 'https://api.backup.example.com'}
281+
};
282+
const overlaySet = {
283+
actions: [{target: '$.servers', copy: true, from: '$.sourceServer'}]
284+
};
285+
286+
const result = await openapiOverlay(baseOAS, {overlaySet});
287+
expect(result.data.servers).toEqual([
288+
{url: 'https://api.example.com'},
289+
{url: 'https://api.backup.example.com'}
290+
]);
291+
});
292+
293+
it('should replace primitive targets when copying primitive values', async () => {
294+
const baseOAS = {
295+
info: {title: 'Old title', description: 'New title'}
296+
};
297+
const overlaySet = {
298+
actions: [{target: '$.info.title', copy: true, from: '$.info.description'}]
299+
};
300+
301+
const result = await openapiOverlay(baseOAS, {overlaySet});
302+
expect(result.data.info.title).toBe('New title');
303+
});
304+
305+
it('should reject copy action when from resolves zero nodes', async () => {
306+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
307+
const baseOAS = {
308+
info: {title: 'API'},
309+
components: {}
310+
};
311+
const overlaySet = {
312+
actions: [{target: '$.components', copy: true, from: '$.missing'}]
313+
};
314+
315+
const result = await openapiOverlay(baseOAS, {overlaySet});
316+
expect(result.resultData.totalUsedActions).toBe(0);
317+
expect(result.resultData.totalUnusedActions).toBe(1);
318+
expect(consoleSpy).toHaveBeenCalledWith(
319+
'Overlay action #1: "from" must resolve to exactly one node, resolved 0.'
320+
);
321+
consoleSpy.mockRestore();
322+
});
323+
324+
it('should reject copy action when from resolves multiple nodes', async () => {
325+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
326+
const baseOAS = {
327+
servers: [{url: 'https://api.example.com'}, {url: 'https://api.backup.example.com'}],
328+
components: {}
329+
};
330+
const overlaySet = {
331+
actions: [{target: '$.components', copy: true, from: '$.servers[*]'}]
332+
};
333+
334+
const result = await openapiOverlay(baseOAS, {overlaySet});
335+
expect(result.resultData.totalUsedActions).toBe(0);
336+
expect(result.resultData.totalUnusedActions).toBe(1);
337+
expect(consoleSpy).toHaveBeenCalledWith(
338+
'Overlay action #1: "from" must resolve to exactly one node, resolved 2.'
339+
);
340+
consoleSpy.mockRestore();
341+
});
342+
343+
it('should apply remove then update then copy in action order', async () => {
344+
const baseOAS = {
345+
info: {title: 'Old title'},
346+
source: {description: 'Copied description'}
347+
};
348+
const overlaySet = {
349+
actions: [
350+
{
351+
target: '$',
352+
remove: true,
353+
update: {info: {title: 'Updated title'}},
354+
copy: true,
355+
from: '$.source'
356+
}
357+
]
358+
};
359+
360+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
361+
const result = await openapiOverlay(baseOAS, {overlaySet});
362+
expect(result.data.info.title).toBe('Updated title');
363+
expect(result.data.description).toBe('Copied description');
364+
expect(result.resultData.totalUsedActions).toBe(1);
365+
expect(consoleSpy).toHaveBeenCalledWith('Overlay action #1: remove is not supported at target "$".');
366+
consoleSpy.mockRestore();
367+
});
368+
369+
it('should reject unsupported overlay version', async () => {
370+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
371+
const baseOAS = {openapi: '3.0.0', info: {title: 'Test API'}};
372+
const overlaySet = {
373+
overlay: '2.0.0',
374+
actions: [{target: '$.info', update: {description: 'Ignored'}}]
375+
};
376+
377+
const result = await openapiOverlay(baseOAS, {overlaySet});
378+
expect(result.data.info.description).toBeUndefined();
379+
expect(result.resultData.totalUsedActions).toBe(0);
380+
expect(consoleSpy).toHaveBeenCalledWith(
381+
'Unsupported overlay version "2.0.0". Supported versions are 1.0.x and 1.1.x.'
382+
);
383+
consoleSpy.mockRestore();
384+
});
385+
219386
it('should add a new unique item to the array during deepMerge', () => {
220387
const target = [{name: 'param1', in: 'query', description: 'First parameter'}];
221388
const source = [{name: 'param2', in: 'query', description: 'Second parameter'}];

0 commit comments

Comments
 (0)