Skip to content

Commit 2cf2132

Browse files
authored
Sort & Filter OpenAPI 3.2 (#182)
* Sort & Filter OpenAPI 3.2
1 parent 7ef9885 commit 2cf2132

23 files changed

Lines changed: 403 additions & 100 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
## unreleased
22

3-
- CLI: convert an OpenAPI 3.0 document to an OpenAPI version 3.2
4-
- CLI: convert an OpenAPI 3.1 document to an OpenAPI version 3.2
3+
- Sort & Filter: Added support for OpenAPI 3.2 (#182)
4+
- CLI: convert an OpenAPI 3.0 document to an OpenAPI version 3.2 (#181)
5+
- CLI: convert an OpenAPI 3.1 document to an OpenAPI version 3.2 (#181)
56

67
## [1.28.0] - 2025-09-12
78

defaultSort.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"root": ["openapi", "info", "servers", "paths", "components", "tags", "x-tagGroups", "externalDocs"],
33
"get": ["operationId", "summary", "description", "parameters", "requestBody", "responses"],
4+
"query": ["operationId", "summary", "description", "parameters", "requestBody", "responses"],
45
"post": ["operationId", "summary", "description", "parameters", "requestBody", "responses"],
56
"put": ["operationId", "summary", "description", "parameters", "requestBody", "responses"],
67
"patch": ["operationId", "summary", "description", "parameters", "requestBody", "responses"],
@@ -9,7 +10,7 @@
910
"requestBody": ["description", "required", "content"],
1011
"responses": ["description", "headers", "content", "links"],
1112
"content": [],
12-
"components": ["parameters", "schemas"],
13+
"components": ["parameters", "schemas", "mediaTypes"],
1314
"schema": ["description", "type", "items", "properties", "format", "example", "default"],
1415
"schemas": ["description", "type", "items", "properties", "format", "example", "default"],
1516
"properties": ["description", "type", "items", "format", "example", "default", "enum"]

openapi-format.js

Lines changed: 44 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,9 @@ async function openapiFilter(oaObj, options) {
196196
let jsonObj = JSON.parse(JSON.stringify(oaObj)); // Deep copy of the schema object
197197
let defaultFilter = options.defaultFilter || (await parseFile(__dirname + '/defaultFilter.json'));
198198
let filterSet = Object.assign({}, defaultFilter, options.filterSet);
199-
const httpVerbs = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'];
199+
const httpVerbs = ['get', 'query', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'];
200200
const fixedFlags = ['x-openapi-format-filter'];
201+
const componentTypes = ['schemas', 'responses', 'parameters', 'examples', 'requestBodies', 'headers', 'mediaTypes'];
201202
options.unusedDepth = options.unusedDepth || 0;
202203

203204
// Merge object filters
@@ -232,26 +233,22 @@ async function openapiFilter(oaObj, options) {
232233
const inverseFilterFlagHash = inverseFilterFlagValues.map(o => JSON.stringify(o));
233234

234235
// Initiate components tracking
235-
const comps = {
236-
schemas: {},
237-
responses: {},
238-
parameters: {},
239-
examples: {},
240-
requestBodies: {},
241-
headers: {},
242-
meta: {total: 0}
243-
};
236+
const comps = componentTypes.reduce(
237+
(acc, type) => {
238+
acc[type] = {};
239+
return acc;
240+
},
241+
{meta: {total: 0}}
242+
);
244243

245244
// Prepare unused components
246-
let unusedComp = {
247-
schemas: [],
248-
responses: [],
249-
parameters: [],
250-
examples: [],
251-
requestBodies: [],
252-
headers: [],
253-
meta: {total: 0}
254-
};
245+
let unusedComp = componentTypes.reduce(
246+
(acc, type) => {
247+
acc[type] = [];
248+
return acc;
249+
},
250+
{meta: {total: 0}}
251+
);
255252
// Use options.unusedComp to collect unused components during multiple recursion
256253
if (!options.unusedComp) options.unusedComp = JSON.parse(JSON.stringify(unusedComp));
257254

@@ -268,7 +265,7 @@ async function openapiFilter(oaObj, options) {
268265

269266
// Register components usage
270267
if (this.key === '$ref' && typeof node === 'string') {
271-
for (let type of ['schemas', 'responses', 'parameters', 'examples', 'requestBodies', 'headers']) {
268+
for (let type of componentTypes) {
272269
const prefix = `#/components/${type}/`;
273270
if (node.startsWith(prefix)) {
274271
const name = node.slice(prefix.length);
@@ -639,14 +636,14 @@ async function openapiFilter(oaObj, options) {
639636
const optFs = get(options, 'filterSet.unusedComponents', []) || [];
640637

641638
// Identify components that are directly unused (not referenced anywhere)
642-
unusedComp.schemas = Object.keys(comps.schemas || {}).filter(key => !comps.schemas[key].used);
643-
unusedComp.responses = Object.keys(comps.responses || {}).filter(key => !comps.responses[key].used);
644-
unusedComp.parameters = Object.keys(comps.parameters || {}).filter(key => !comps.parameters[key].used);
645-
unusedComp.examples = Object.keys(comps.examples || {}).filter(key => !comps.examples[key].used);
646-
unusedComp.requestBodies = Object.keys(comps.requestBodies || {}).filter(key => !comps.requestBodies[key].used);
647-
unusedComp.headers = Object.keys(comps.headers || {}).filter(key => !comps.headers[key].used);
648-
649-
const refGraph = {schemas: {}, responses: {}, parameters: {}, examples: {}, requestBodies: {}, headers: {}};
639+
componentTypes.forEach(type => {
640+
unusedComp[type] = Object.keys(comps[type] || {}).filter(key => !comps[type][key].used);
641+
});
642+
643+
const refGraph = componentTypes.reduce((acc, type) => {
644+
acc[type] = {};
645+
return acc;
646+
}, {});
650647
const rootRefs = new Set();
651648

652649
// Traverse $ref in components
@@ -681,42 +678,28 @@ async function openapiFilter(oaObj, options) {
681678
}
682679

683680
// Mark not visited as unused
684-
for (const t of ['schemas', 'responses', 'parameters', 'examples', 'requestBodies', 'headers']) {
681+
for (const t of componentTypes) {
685682
unusedComp[t] = Object.keys(comps[t] || {}).filter(k => !visited.has(`${t}:${k}`));
686683
}
687684

688685
// TODO rework this logic
689686
unusedComp.meta = {
690-
total:
691-
unusedComp.schemas.length +
692-
unusedComp.responses.length +
693-
unusedComp.parameters.length +
694-
unusedComp.examples.length +
695-
unusedComp.requestBodies.length +
696-
unusedComp.headers.length
687+
total: componentTypes.reduce((acc, type) => acc + (unusedComp[type]?.length || 0), 0)
697688
};
698689

699690
// Update options.unusedComp with all identified unused components
700-
if (optFs.includes('schemas')) options.unusedComp.schemas = [...options.unusedComp.schemas, ...unusedComp.schemas];
701-
if (optFs.includes('responses'))
702-
options.unusedComp.responses = [...options.unusedComp.responses, ...unusedComp.responses];
703-
if (optFs.includes('parameters'))
704-
options.unusedComp.parameters = [...options.unusedComp.parameters, ...unusedComp.parameters];
705-
if (optFs.includes('examples'))
706-
options.unusedComp.examples = [...options.unusedComp.examples, ...unusedComp.examples];
707-
if (optFs.includes('requestBodies'))
708-
options.unusedComp.requestBodies = [...options.unusedComp.requestBodies, ...unusedComp.requestBodies];
709-
if (optFs.includes('headers')) options.unusedComp.headers = [...options.unusedComp.headers, ...unusedComp.headers];
691+
componentTypes.forEach(type => {
692+
if (optFs.includes(type)) {
693+
options.unusedComp[type] = [...options.unusedComp[type], ...unusedComp[type]];
694+
}
695+
});
710696

711697
// TODO rework this logic
712698
// Update unusedComp.meta.total after each recursion
713-
options.unusedComp.meta.total =
714-
options.unusedComp.schemas.length +
715-
options.unusedComp.responses.length +
716-
options.unusedComp.parameters.length +
717-
options.unusedComp.examples.length +
718-
options.unusedComp.requestBodies.length +
719-
options.unusedComp.headers.length;
699+
options.unusedComp.meta.total = componentTypes.reduce(
700+
(acc, type) => acc + (options.unusedComp[type]?.length || 0),
701+
0
702+
);
720703

721704
// Clean-up jsonObj
722705
traverse(jsonObj).forEach(function (node) {
@@ -806,15 +789,13 @@ async function openapiFilter(oaObj, options) {
806789
}
807790

808791
// Prepare totalComp for the final result
809-
const totalComp = {
810-
schemas: Object.keys(comps.schemas),
811-
responses: Object.keys(comps.responses),
812-
parameters: Object.keys(comps.parameters),
813-
examples: Object.keys(comps.examples),
814-
requestBodies: Object.keys(comps.requestBodies),
815-
headers: Object.keys(comps.headers),
816-
meta: {total: comps.meta.total}
817-
};
792+
const totalComp = componentTypes.reduce(
793+
(acc, type) => {
794+
acc[type] = Object.keys(comps[type]);
795+
return acc;
796+
},
797+
{meta: {total: comps.meta.total}}
798+
);
818799

819800
// Return result object
820801
return {data: jsonObj, resultData: {unusedComp: unusedComp, totalComp: totalComp}};
@@ -1099,7 +1080,7 @@ async function openapiSplit(oaObj, options = {}) {
10991080

11001081
/**
11011082
* OpenAPI convert version function
1102-
* Convert OpenAPI from version 3.0 to 3.1
1083+
* Convert OpenAPI from version 3.0 to 3.1 or 3.2
11031084
* @param {object} oaObj OpenAPI document
11041085
* @param {object} options OpenAPI-format convert options
11051086
* @returns {object} converted OpenAPI document

readme.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ Postman collections, test suites, ...
8989
- [x] Use as a Module
9090
- [x] Aligned YAML parsing style with Stoplight Studio style
9191
- [x] Support for OpenAPI 3.0
92-
- [x] Support for OpenAPI 3.1 (beta)
92+
- [x] Support for OpenAPI 3.1
93+
- [x] Support for OpenAPI 3.2
9394
- [x] Online playground (https://openapi-format-playground.vercel.app/)
9495

9596
## Online playground
@@ -395,7 +396,7 @@ Strict matching example: `"GET::/pets"`
395396
This will target only the "GET" method and the specific path "/pets"
396397

397398
Method wildcard matching example: `"*::/pets"`
398-
This will target all methods ('get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace') and the specific
399+
This will target all methods ('get', 'query', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace') and the specific
399400
path "/pets"
400401

401402
Path wildcard matching example: `"GET::/pets/*"`
@@ -638,6 +639,7 @@ Supported component types that can be marked as "unused":
638639
- headers
639640
- requestBodies
640641
- responses
642+
- mediaTypes
641643

642644
### Filter - textReplace
643645

@@ -1423,8 +1425,6 @@ which results in
14231425

14241426
## CLI convertTo usage
14251427

1426-
> 🏗 BETA NOTICE: This feature is considered BETA since we are investigating the configuration syntax and extra formatting/casing capabilities.
1427-
14281428
- Format & convert the OpenAPI document to OpenAPI version 3.1 or 3.2
14291429

14301430
openapi-format can help you to upgrade your current OpenAPI 3.0.x document to OpenAPI 3.1 or 3.2.

test/converting.test.js

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -223,11 +223,7 @@ describe('openapi-format CLI converting tests', () => {
223223

224224
it('convertTagGroups - should convert x-tagGroups to native tag relationships', async () => {
225225
const doc = {
226-
tags: [
227-
{name: 'products'},
228-
{name: 'books', description: 'Books operations'},
229-
{name: 'cds'}
230-
],
226+
tags: [{name: 'products'}, {name: 'books', description: 'Books operations'}, {name: 'cds'}],
231227
'x-tagGroups': [
232228
{
233229
name: 'Products',

test/filtering.test.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,26 @@ describe('openapi-format CLI filtering tests', () => {
136136
});
137137
});
138138

139+
describe('yaml-filter-query-operations', () => {
140+
it('yaml-filter-query-operations - should match expected output', async () => {
141+
const testName = 'yaml-filter-query-operations';
142+
const {result, input, outputBefore, outputAfter} = await testUtils.loadTest(testName);
143+
expect(result.code).toBe(0);
144+
expect(result.stdout).toContain('formatted successfully');
145+
expect(outputAfter).toStrictEqual(outputBefore);
146+
});
147+
});
148+
149+
describe('yaml-filter-query-methods', () => {
150+
it('yaml-filter-query-methods - should match expected output', async () => {
151+
const testName = 'yaml-filter-query-methods';
152+
const {result, input, outputBefore, outputAfter} = await testUtils.loadTest(testName);
153+
expect(result.code).toBe(0);
154+
expect(result.stdout).toContain('formatted successfully');
155+
expect(outputAfter).toStrictEqual(outputBefore);
156+
});
157+
});
158+
139159
describe('yaml-filter-custom-tags', () => {
140160
it('yaml-filter-custom-tags - should match expected output', async () => {
141161
const testName = 'yaml-filter-custom-tags';
@@ -246,6 +266,16 @@ describe('openapi-format CLI filtering tests', () => {
246266
});
247267
});
248268

269+
describe('yaml-filter-unused-components-mediatypes', () => {
270+
it('yaml-filter-unused-components-mediatypes - should match expected output', async () => {
271+
const testName = 'yaml-filter-unused-components-mediatypes';
272+
const {result, input, outputBefore, outputAfter} = await testUtils.loadTest(testName);
273+
expect(result.code).toBe(0);
274+
expect(result.stdout).toContain('formatted successfully');
275+
expect(outputAfter).toStrictEqual(outputBefore);
276+
});
277+
});
278+
249279
describe('yaml-strip-flags', () => {
250280
it('yaml-strip-flags - should match expected output', async () => {
251281
const testName = 'yaml-strip-flags';

test/sorting.test.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,16 @@ describe('openapi-format CLI sorting tests', () => {
203203
});
204204
});
205205

206+
describe('yaml-sort-query', () => {
207+
it('yaml-sort-query - should match expected output', async () => {
208+
const testName = 'yaml-sort-query';
209+
const {result, input, outputBefore, outputAfter} = await testUtils.loadTest(testName);
210+
expect(result.code).toBe(0);
211+
expect(result.stdout).toContain('formatted successfully');
212+
expect(outputAfter).toStrictEqual(outputBefore);
213+
});
214+
});
215+
206216
describe('yaml-sort-paths', () => {
207217
it('yaml-sort-paths by alphabet - should match expected output', async () => {
208218
const testName = 'yaml-sort-paths-alphabet';
@@ -240,6 +250,16 @@ describe('openapi-format CLI sorting tests', () => {
240250
expect(res).toEqual(false);
241251
});
242252

253+
it('isMatchOperationItem - should support QUERY operations', () => {
254+
const res = isMatchOperationItem('/items', 'query', 'QUERY::/items');
255+
expect(res).toEqual(true);
256+
});
257+
258+
it('isMatchOperationItem - wildcard should match QUERY operations', () => {
259+
const res = isMatchOperationItem('/items', 'query', '*::/items');
260+
expect(res).toEqual(true);
261+
});
262+
243263
it('arraySort - should sort an array of objects in ascending order', () => {
244264
const inputArray = [
245265
{

test/util-file.test.js

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -266,17 +266,11 @@ describe('openapi-format CLI file tests', () => {
266266
const invalidRemoteFileHttp = 'http://google.com/nonexistent-file.txt';
267267
const invalidRemoteFileHttps = 'https://google.com/nonexistent-file.txt';
268268

269-
test('should download remote file content successfully', async () => {
270-
const content = await getRemoteFile(validRemoteFilePath);
271-
const fileContent = fs.readFileSync(validFilePath, 'utf8');
272-
expect(content).toEqual(fileContent);
273-
});
274-
275-
test('should download remote file content successfully', async () => {
276-
const content = await getRemoteFile(validRemoteFilePath);
277-
const fileContent = fs.readFileSync(validFilePath, 'utf8');
278-
expect(content).toEqual(fileContent);
279-
});
269+
// test('should download remote file content successfully', async () => {
270+
// const content = await getRemoteFile(validRemoteFilePath);
271+
// const fileContent = fs.readFileSync(validFilePath, 'utf8');
272+
// expect(content).toEqual(fileContent);
273+
// });
280274

281275
test('should throw an error for https nonexistent remote file', async () => {
282276
try {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
openapi: 3.2.0
2+
info:
3+
title: Query operations filtering
4+
version: 1.0.0
5+
paths:
6+
/items:
7+
query:
8+
summary: Query items
9+
description: Search items with a request body
10+
operationId: queryItems
11+
responses:
12+
'200':
13+
description: Successful query
14+
get:
15+
summary: List items
16+
operationId: listItems
17+
responses:
18+
'200':
19+
description: Successful retrieval
20+
/search:
21+
query:
22+
summary: Search catalogue
23+
operationId: searchItems
24+
responses:
25+
'200':
26+
description: Search response
27+
components:
28+
mediaTypes:
29+
CollectionJson:
30+
schema:
31+
type: object
32+
properties:
33+
items:
34+
type: array
35+
items:
36+
type: string
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
verbose: true
2+
output: output.yaml
3+
filterSet:
4+
methods:
5+
- QUERY

0 commit comments

Comments
 (0)