@redocly/openapi-core 1.10.6 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/lib/decorators/oas2/remove-unused-components.js +36 -22
- package/lib/decorators/oas3/remove-unused-components.js +34 -19
- package/lib/format/format.d.ts +1 -1
- package/lib/format/format.js +57 -0
- package/lib/rules/oas3/no-invalid-media-type-examples.js +3 -0
- package/package.json +2 -2
- package/src/__tests__/format.test.ts +34 -0
- package/src/decorators/oas2/__tests__/remove-unused-components.test.ts +74 -1
- package/src/decorators/oas2/remove-unused-components.ts +46 -24
- package/src/decorators/oas3/__tests__/remove-unused-components.test.ts +142 -0
- package/src/decorators/oas3/remove-unused-components.ts +45 -20
- package/src/format/format.ts +61 -1
- package/src/rules/oas3/__tests__/no-invalid-media-type-examples.test.ts +145 -0
- package/src/rules/oas3/no-invalid-media-type-examples.ts +3 -0
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# @redocly/openapi-core
|
|
2
2
|
|
|
3
|
+
## 1.11.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Added support for a `github-actions` output format for the `lint` command to annotate reported problems on files when used in a GitHub Actions workflow.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Fixed [`no-invalid-media-type-examples`](https://redocly.com/docs/cli/rules/no-invalid-media-type-examples/) rule `externalValue` example validation.
|
|
12
|
+
- Process remove-unused-components rule transitively; components are now removed if they were previously referenced by a removed component.
|
|
13
|
+
|
|
3
14
|
## 1.10.6
|
|
4
15
|
|
|
5
16
|
### Patch Changes
|
|
@@ -5,16 +5,38 @@ const utils_1 = require("../../utils");
|
|
|
5
5
|
const RemoveUnusedComponents = () => {
|
|
6
6
|
const components = new Map();
|
|
7
7
|
function registerComponent(location, componentType, name) {
|
|
8
|
-
var _a;
|
|
8
|
+
var _a, _b;
|
|
9
9
|
components.set(location.absolutePointer, {
|
|
10
|
-
|
|
10
|
+
usedIn: (_b = (_a = components.get(location.absolutePointer)) === null || _a === void 0 ? void 0 : _a.usedIn) !== null && _b !== void 0 ? _b : [],
|
|
11
11
|
componentType,
|
|
12
12
|
name,
|
|
13
13
|
});
|
|
14
14
|
}
|
|
15
|
+
function removeUnusedComponents(root, removedPaths) {
|
|
16
|
+
const removedLengthStart = removedPaths.length;
|
|
17
|
+
for (const [path, { usedIn, name, componentType }] of components) {
|
|
18
|
+
const used = usedIn.some((location) => !removedPaths.some((removed) =>
|
|
19
|
+
// Check if the current location's absolute pointer starts with the 'removed' path
|
|
20
|
+
// and either its length matches exactly with 'removed' or the character after the 'removed' path is a '/'
|
|
21
|
+
location.absolutePointer.startsWith(removed) &&
|
|
22
|
+
(location.absolutePointer.length === removed.length ||
|
|
23
|
+
location.absolutePointer[removed.length] === '/')));
|
|
24
|
+
if (!used && componentType) {
|
|
25
|
+
removedPaths.push(path);
|
|
26
|
+
delete root[componentType][name];
|
|
27
|
+
components.delete(path);
|
|
28
|
+
if ((0, utils_1.isEmptyObject)(root[componentType])) {
|
|
29
|
+
delete root[componentType];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return removedPaths.length > removedLengthStart
|
|
34
|
+
? removeUnusedComponents(root, removedPaths)
|
|
35
|
+
: removedPaths.length;
|
|
36
|
+
}
|
|
15
37
|
return {
|
|
16
38
|
ref: {
|
|
17
|
-
leave(ref, { type, resolve, key }) {
|
|
39
|
+
leave(ref, { location, type, resolve, key }) {
|
|
18
40
|
if (['Schema', 'Parameter', 'Response', 'SecurityScheme'].includes(type.name)) {
|
|
19
41
|
const resolvedRef = resolve(ref);
|
|
20
42
|
if (!resolvedRef.location)
|
|
@@ -22,31 +44,23 @@ const RemoveUnusedComponents = () => {
|
|
|
22
44
|
const [fileLocation, localPointer] = resolvedRef.location.absolutePointer.split('#', 2);
|
|
23
45
|
const componentLevelLocalPointer = localPointer.split('/').slice(0, 3).join('/');
|
|
24
46
|
const pointer = `${fileLocation}#${componentLevelLocalPointer}`;
|
|
25
|
-
components.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
47
|
+
const registered = components.get(pointer);
|
|
48
|
+
if (registered) {
|
|
49
|
+
registered.usedIn.push(location);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
components.set(pointer, {
|
|
53
|
+
usedIn: [location],
|
|
54
|
+
name: key.toString(),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
29
57
|
}
|
|
30
58
|
},
|
|
31
59
|
},
|
|
32
60
|
Root: {
|
|
33
61
|
leave(root, ctx) {
|
|
34
62
|
const data = ctx.getVisitorData();
|
|
35
|
-
data.removedCount =
|
|
36
|
-
const rootComponents = new Set();
|
|
37
|
-
components.forEach((usageInfo) => {
|
|
38
|
-
const { used, name, componentType } = usageInfo;
|
|
39
|
-
if (!used && componentType) {
|
|
40
|
-
rootComponents.add(componentType);
|
|
41
|
-
delete root[componentType][name];
|
|
42
|
-
data.removedCount++;
|
|
43
|
-
}
|
|
44
|
-
});
|
|
45
|
-
for (const component of rootComponents) {
|
|
46
|
-
if ((0, utils_1.isEmptyObject)(root[component])) {
|
|
47
|
-
delete root[component];
|
|
48
|
-
}
|
|
49
|
-
}
|
|
63
|
+
data.removedCount = removeUnusedComponents(root, []);
|
|
50
64
|
},
|
|
51
65
|
},
|
|
52
66
|
NamedSchemas: {
|
|
@@ -5,16 +5,36 @@ const utils_1 = require("../../utils");
|
|
|
5
5
|
const RemoveUnusedComponents = () => {
|
|
6
6
|
const components = new Map();
|
|
7
7
|
function registerComponent(location, componentType, name) {
|
|
8
|
-
var _a;
|
|
8
|
+
var _a, _b;
|
|
9
9
|
components.set(location.absolutePointer, {
|
|
10
|
-
|
|
10
|
+
usedIn: (_b = (_a = components.get(location.absolutePointer)) === null || _a === void 0 ? void 0 : _a.usedIn) !== null && _b !== void 0 ? _b : [],
|
|
11
11
|
componentType,
|
|
12
12
|
name,
|
|
13
13
|
});
|
|
14
14
|
}
|
|
15
|
+
function removeUnusedComponents(root, removedPaths) {
|
|
16
|
+
const removedLengthStart = removedPaths.length;
|
|
17
|
+
for (const [path, { usedIn, name, componentType }] of components) {
|
|
18
|
+
const used = usedIn.some((location) => !removedPaths.some((removed) => location.absolutePointer.startsWith(removed) &&
|
|
19
|
+
(location.absolutePointer.length === removed.length ||
|
|
20
|
+
location.absolutePointer[removed.length] === '/')));
|
|
21
|
+
if (!used && componentType && root.components) {
|
|
22
|
+
removedPaths.push(path);
|
|
23
|
+
const componentChild = root.components[componentType];
|
|
24
|
+
delete componentChild[name];
|
|
25
|
+
components.delete(path);
|
|
26
|
+
if ((0, utils_1.isEmptyObject)(componentChild)) {
|
|
27
|
+
delete root.components[componentType];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return removedPaths.length > removedLengthStart
|
|
32
|
+
? removeUnusedComponents(root, removedPaths)
|
|
33
|
+
: removedPaths.length;
|
|
34
|
+
}
|
|
15
35
|
return {
|
|
16
36
|
ref: {
|
|
17
|
-
leave(ref, { type, resolve, key }) {
|
|
37
|
+
leave(ref, { location, type, resolve, key }) {
|
|
18
38
|
if (['Schema', 'Header', 'Parameter', 'Response', 'Example', 'RequestBody'].includes(type.name)) {
|
|
19
39
|
const resolvedRef = resolve(ref);
|
|
20
40
|
if (!resolvedRef.location)
|
|
@@ -22,28 +42,23 @@ const RemoveUnusedComponents = () => {
|
|
|
22
42
|
const [fileLocation, localPointer] = resolvedRef.location.absolutePointer.split('#', 2);
|
|
23
43
|
const componentLevelLocalPointer = localPointer.split('/').slice(0, 4).join('/');
|
|
24
44
|
const pointer = `${fileLocation}#${componentLevelLocalPointer}`;
|
|
25
|
-
components.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
45
|
+
const registered = components.get(pointer);
|
|
46
|
+
if (registered) {
|
|
47
|
+
registered.usedIn.push(location);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
components.set(pointer, {
|
|
51
|
+
usedIn: [location],
|
|
52
|
+
name: key.toString(),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
29
55
|
}
|
|
30
56
|
},
|
|
31
57
|
},
|
|
32
58
|
Root: {
|
|
33
59
|
leave(root, ctx) {
|
|
34
60
|
const data = ctx.getVisitorData();
|
|
35
|
-
data.removedCount =
|
|
36
|
-
components.forEach((usageInfo) => {
|
|
37
|
-
const { used, componentType, name } = usageInfo;
|
|
38
|
-
if (!used && componentType && root.components) {
|
|
39
|
-
const componentChild = root.components[componentType];
|
|
40
|
-
delete componentChild[name];
|
|
41
|
-
data.removedCount++;
|
|
42
|
-
if ((0, utils_1.isEmptyObject)(componentChild)) {
|
|
43
|
-
delete root.components[componentType];
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
});
|
|
61
|
+
data.removedCount = removeUnusedComponents(root, []);
|
|
47
62
|
if ((0, utils_1.isEmptyObject)(root.components)) {
|
|
48
63
|
delete root.components;
|
|
49
64
|
}
|
package/lib/format/format.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export type Totals = {
|
|
|
4
4
|
warnings: number;
|
|
5
5
|
ignored: number;
|
|
6
6
|
};
|
|
7
|
-
export type OutputFormat = 'codeframe' | 'stylish' | 'json' | 'checkstyle' | 'codeclimate' | 'summary';
|
|
7
|
+
export type OutputFormat = 'codeframe' | 'stylish' | 'json' | 'checkstyle' | 'codeclimate' | 'summary' | 'github-actions';
|
|
8
8
|
export declare function getTotals(problems: (NormalizedProblem & {
|
|
9
9
|
ignored?: boolean;
|
|
10
10
|
})[]): Totals;
|
package/lib/format/format.js
CHANGED
|
@@ -103,6 +103,8 @@ function formatProblems(problems, opts) {
|
|
|
103
103
|
case 'summary':
|
|
104
104
|
formatSummary(problems);
|
|
105
105
|
break;
|
|
106
|
+
case 'github-actions':
|
|
107
|
+
outputForGithubActions(problems, cwd);
|
|
106
108
|
}
|
|
107
109
|
if (totalProblems - ignoredProblems > maxProblems) {
|
|
108
110
|
logger_1.logger.info(`< ... ${totalProblems - maxProblems} more problems hidden > ${logger_1.colorize.gray('increase with `--max-problems N`')}\n`);
|
|
@@ -266,3 +268,58 @@ function xmlEscape(s) {
|
|
|
266
268
|
}
|
|
267
269
|
});
|
|
268
270
|
}
|
|
271
|
+
function outputForGithubActions(problems, cwd) {
|
|
272
|
+
var _a, _b;
|
|
273
|
+
for (const problem of problems) {
|
|
274
|
+
for (const location of problem.location.map(codeframes_1.getLineColLocation)) {
|
|
275
|
+
let command;
|
|
276
|
+
switch (problem.severity) {
|
|
277
|
+
case 'error':
|
|
278
|
+
command = 'error';
|
|
279
|
+
break;
|
|
280
|
+
case 'warn':
|
|
281
|
+
command = 'warning';
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
const suggest = formatDidYouMean(problem);
|
|
285
|
+
const message = suggest !== '' ? problem.message + '\n\n' + suggest : problem.message;
|
|
286
|
+
const properties = {
|
|
287
|
+
title: problem.ruleId,
|
|
288
|
+
file: (0, ref_utils_1.isAbsoluteUrl)(location.source.absoluteRef)
|
|
289
|
+
? location.source.absoluteRef
|
|
290
|
+
: path.relative(cwd, location.source.absoluteRef),
|
|
291
|
+
line: location.start.line,
|
|
292
|
+
col: location.start.col,
|
|
293
|
+
endLine: (_a = location.end) === null || _a === void 0 ? void 0 : _a.line,
|
|
294
|
+
endColumn: (_b = location.end) === null || _b === void 0 ? void 0 : _b.col,
|
|
295
|
+
};
|
|
296
|
+
output_1.output.write(`::${command} ${formatProperties(properties)}::${escapeMessage(message)}\n`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function formatProperties(props) {
|
|
300
|
+
return Object.entries(props)
|
|
301
|
+
.filter(([, v]) => v !== null && v !== undefined)
|
|
302
|
+
.map(([k, v]) => `${k}=${escapeProperty(v)}`)
|
|
303
|
+
.join(',');
|
|
304
|
+
}
|
|
305
|
+
function toString(v) {
|
|
306
|
+
if (v === null || v === undefined) {
|
|
307
|
+
return '';
|
|
308
|
+
}
|
|
309
|
+
else if (typeof v === 'string' || v instanceof String) {
|
|
310
|
+
return v;
|
|
311
|
+
}
|
|
312
|
+
return JSON.stringify(v);
|
|
313
|
+
}
|
|
314
|
+
function escapeMessage(v) {
|
|
315
|
+
return toString(v).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
|
|
316
|
+
}
|
|
317
|
+
function escapeProperty(v) {
|
|
318
|
+
return toString(v)
|
|
319
|
+
.replace(/%/g, '%25')
|
|
320
|
+
.replace(/\r/g, '%0D')
|
|
321
|
+
.replace(/\n/g, '%0A')
|
|
322
|
+
.replace(/:/g, '%3A')
|
|
323
|
+
.replace(/,/g, '%2C');
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -28,6 +28,9 @@ const ValidContentExamples = (opts) => {
|
|
|
28
28
|
location = isMultiple ? resolved.location.child('value') : resolved.location;
|
|
29
29
|
example = resolved.node;
|
|
30
30
|
}
|
|
31
|
+
if (isMultiple && typeof example.value === 'undefined') {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
31
34
|
(0, utils_1.validateExample)(isMultiple ? example.value : example, mediaType.schema, location, ctx, allowAdditionalProperties);
|
|
32
35
|
}
|
|
33
36
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redocly/openapi-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"engines": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
],
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@redocly/ajv": "^8.11.0",
|
|
38
|
-
"@redocly/config": "^0.
|
|
38
|
+
"@redocly/config": "^0.2.0",
|
|
39
39
|
"colorette": "^1.2.0",
|
|
40
40
|
"js-levenshtein": "^1.1.6",
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { outdent } from 'outdent';
|
|
2
2
|
|
|
3
3
|
import { formatProblems, getTotals } from '../format/format';
|
|
4
|
+
import { LocationObject, NormalizedProblem } from '../walk';
|
|
5
|
+
import { Source } from '../resolve';
|
|
4
6
|
|
|
5
7
|
describe('format', () => {
|
|
6
8
|
function replaceColors(log: string) {
|
|
@@ -40,6 +42,10 @@ describe('format', () => {
|
|
|
40
42
|
output += str;
|
|
41
43
|
return true;
|
|
42
44
|
});
|
|
45
|
+
jest.spyOn(process.stdout, 'write').mockImplementation((str: string | Uint8Array) => {
|
|
46
|
+
output += str;
|
|
47
|
+
return true;
|
|
48
|
+
});
|
|
43
49
|
});
|
|
44
50
|
|
|
45
51
|
it('should correctly format summary output', () => {
|
|
@@ -73,4 +79,32 @@ describe('format', () => {
|
|
|
73
79
|
"
|
|
74
80
|
`);
|
|
75
81
|
});
|
|
82
|
+
|
|
83
|
+
it('should format problems using github-actions', () => {
|
|
84
|
+
const problems = [
|
|
85
|
+
{
|
|
86
|
+
ruleId: 'spec',
|
|
87
|
+
message: 'message',
|
|
88
|
+
severity: 'error' as const,
|
|
89
|
+
location: [
|
|
90
|
+
{
|
|
91
|
+
source: { absoluteRef: 'openapi.yaml' } as Source,
|
|
92
|
+
start: { line: 1, col: 2 },
|
|
93
|
+
end: { line: 3, col: 4 },
|
|
94
|
+
} as LocationObject,
|
|
95
|
+
],
|
|
96
|
+
suggest: [],
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
formatProblems(problems, {
|
|
101
|
+
format: 'github-actions',
|
|
102
|
+
version: '1.0.0',
|
|
103
|
+
totals: getTotals(problems),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(output).toEqual(
|
|
107
|
+
'::error title=spec,file=openapi.yaml,line=1,col=2,endLine=3,endColumn=4::message\n'
|
|
108
|
+
);
|
|
109
|
+
});
|
|
76
110
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { outdent } from 'outdent';
|
|
2
|
-
import { parseYamlToDocument,
|
|
2
|
+
import { parseYamlToDocument, makeConfig } from '../../../../__tests__/utils';
|
|
3
3
|
import { bundleDocument } from '../../../bundle';
|
|
4
4
|
import { BaseResolver } from '../../../resolve';
|
|
5
5
|
|
|
@@ -152,4 +152,77 @@ describe('oas2 remove-unused-components', () => {
|
|
|
152
152
|
},
|
|
153
153
|
});
|
|
154
154
|
});
|
|
155
|
+
|
|
156
|
+
it('should remove transitively unused components', async () => {
|
|
157
|
+
const document = parseYamlToDocument(
|
|
158
|
+
outdent`
|
|
159
|
+
swagger: '2.0'
|
|
160
|
+
paths:
|
|
161
|
+
/pets:
|
|
162
|
+
get:
|
|
163
|
+
produces:
|
|
164
|
+
- application/json
|
|
165
|
+
parameters: []
|
|
166
|
+
responses:
|
|
167
|
+
'200':
|
|
168
|
+
schema:
|
|
169
|
+
$ref: '#/definitions/Used'
|
|
170
|
+
operationId: listPets
|
|
171
|
+
summary: List all pets
|
|
172
|
+
definitions:
|
|
173
|
+
Unused:
|
|
174
|
+
enum:
|
|
175
|
+
- 1
|
|
176
|
+
- 2
|
|
177
|
+
type: integer
|
|
178
|
+
UnusedTransitive:
|
|
179
|
+
type: object
|
|
180
|
+
properties:
|
|
181
|
+
link:
|
|
182
|
+
$ref: '#/definitions/Unused'
|
|
183
|
+
Used:
|
|
184
|
+
properties:
|
|
185
|
+
link:
|
|
186
|
+
type: string
|
|
187
|
+
type: object
|
|
188
|
+
`,
|
|
189
|
+
'foobar.yaml'
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const results = await bundleDocument({
|
|
193
|
+
externalRefResolver: new BaseResolver(),
|
|
194
|
+
document,
|
|
195
|
+
config: await makeConfig({}),
|
|
196
|
+
removeUnusedComponents: true,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(results.bundle.parsed).toEqual({
|
|
200
|
+
swagger: '2.0',
|
|
201
|
+
definitions: {
|
|
202
|
+
Used: {
|
|
203
|
+
properties: {
|
|
204
|
+
link: { type: 'string' },
|
|
205
|
+
},
|
|
206
|
+
type: 'object',
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
paths: {
|
|
210
|
+
'/pets': {
|
|
211
|
+
get: {
|
|
212
|
+
produces: ['application/json'],
|
|
213
|
+
parameters: [],
|
|
214
|
+
summary: 'List all pets',
|
|
215
|
+
operationId: 'listPets',
|
|
216
|
+
responses: {
|
|
217
|
+
'200': {
|
|
218
|
+
schema: {
|
|
219
|
+
$ref: '#/definitions/Used',
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
});
|
|
155
228
|
});
|
|
@@ -2,12 +2,12 @@ import { Location } from '../../ref-utils';
|
|
|
2
2
|
import { isEmptyObject } from '../../utils';
|
|
3
3
|
|
|
4
4
|
import type { Oas2Decorator } from '../../visitors';
|
|
5
|
-
import type { Oas2Components } from '../../typings/swagger';
|
|
5
|
+
import type { Oas2Components, Oas2Definition } from '../../typings/swagger';
|
|
6
6
|
|
|
7
7
|
export const RemoveUnusedComponents: Oas2Decorator = () => {
|
|
8
8
|
const components = new Map<
|
|
9
9
|
string,
|
|
10
|
-
{
|
|
10
|
+
{ usedIn: Location[]; componentType?: keyof Oas2Components; name: string }
|
|
11
11
|
>();
|
|
12
12
|
|
|
13
13
|
function registerComponent(
|
|
@@ -16,15 +16,46 @@ export const RemoveUnusedComponents: Oas2Decorator = () => {
|
|
|
16
16
|
name: string
|
|
17
17
|
): void {
|
|
18
18
|
components.set(location.absolutePointer, {
|
|
19
|
-
|
|
19
|
+
usedIn: components.get(location.absolutePointer)?.usedIn ?? [],
|
|
20
20
|
componentType,
|
|
21
21
|
name,
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function removeUnusedComponents(root: Oas2Definition, removedPaths: string[]): number {
|
|
26
|
+
const removedLengthStart = removedPaths.length;
|
|
27
|
+
|
|
28
|
+
for (const [path, { usedIn, name, componentType }] of components) {
|
|
29
|
+
const used = usedIn.some(
|
|
30
|
+
(location) =>
|
|
31
|
+
!removedPaths.some(
|
|
32
|
+
(removed) =>
|
|
33
|
+
// Check if the current location's absolute pointer starts with the 'removed' path
|
|
34
|
+
// and either its length matches exactly with 'removed' or the character after the 'removed' path is a '/'
|
|
35
|
+
location.absolutePointer.startsWith(removed) &&
|
|
36
|
+
(location.absolutePointer.length === removed.length ||
|
|
37
|
+
location.absolutePointer[removed.length] === '/')
|
|
38
|
+
)
|
|
39
|
+
);
|
|
40
|
+
if (!used && componentType) {
|
|
41
|
+
removedPaths.push(path);
|
|
42
|
+
delete root[componentType]![name];
|
|
43
|
+
components.delete(path);
|
|
44
|
+
|
|
45
|
+
if (isEmptyObject(root[componentType])) {
|
|
46
|
+
delete root[componentType];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return removedPaths.length > removedLengthStart
|
|
52
|
+
? removeUnusedComponents(root, removedPaths)
|
|
53
|
+
: removedPaths.length;
|
|
54
|
+
}
|
|
55
|
+
|
|
25
56
|
return {
|
|
26
57
|
ref: {
|
|
27
|
-
leave(ref, { type, resolve, key }) {
|
|
58
|
+
leave(ref, { location, type, resolve, key }) {
|
|
28
59
|
if (['Schema', 'Parameter', 'Response', 'SecurityScheme'].includes(type.name)) {
|
|
29
60
|
const resolvedRef = resolve(ref);
|
|
30
61
|
if (!resolvedRef.location) return;
|
|
@@ -33,32 +64,23 @@ export const RemoveUnusedComponents: Oas2Decorator = () => {
|
|
|
33
64
|
const componentLevelLocalPointer = localPointer.split('/').slice(0, 3).join('/');
|
|
34
65
|
const pointer = `${fileLocation}#${componentLevelLocalPointer}`;
|
|
35
66
|
|
|
36
|
-
components.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
67
|
+
const registered = components.get(pointer);
|
|
68
|
+
|
|
69
|
+
if (registered) {
|
|
70
|
+
registered.usedIn.push(location);
|
|
71
|
+
} else {
|
|
72
|
+
components.set(pointer, {
|
|
73
|
+
usedIn: [location],
|
|
74
|
+
name: key.toString(),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
40
77
|
}
|
|
41
78
|
},
|
|
42
79
|
},
|
|
43
80
|
Root: {
|
|
44
81
|
leave(root, ctx) {
|
|
45
82
|
const data = ctx.getVisitorData() as { removedCount: number };
|
|
46
|
-
data.removedCount =
|
|
47
|
-
|
|
48
|
-
const rootComponents = new Set<keyof Oas2Components>();
|
|
49
|
-
components.forEach((usageInfo) => {
|
|
50
|
-
const { used, name, componentType } = usageInfo;
|
|
51
|
-
if (!used && componentType) {
|
|
52
|
-
rootComponents.add(componentType);
|
|
53
|
-
delete root[componentType]![name];
|
|
54
|
-
data.removedCount++;
|
|
55
|
-
}
|
|
56
|
-
});
|
|
57
|
-
for (const component of rootComponents) {
|
|
58
|
-
if (isEmptyObject(root[component])) {
|
|
59
|
-
delete root[component];
|
|
60
|
-
}
|
|
61
|
-
}
|
|
83
|
+
data.removedCount = removeUnusedComponents(root, []);
|
|
62
84
|
},
|
|
63
85
|
},
|
|
64
86
|
NamedSchemas: {
|
|
@@ -168,4 +168,146 @@ describe('oas3 remove-unused-components', () => {
|
|
|
168
168
|
},
|
|
169
169
|
});
|
|
170
170
|
});
|
|
171
|
+
|
|
172
|
+
it('should remove transitively unused components', async () => {
|
|
173
|
+
const document = parseYamlToDocument(
|
|
174
|
+
outdent`
|
|
175
|
+
openapi: "3.0.0"
|
|
176
|
+
paths:
|
|
177
|
+
/pets:
|
|
178
|
+
get:
|
|
179
|
+
summary: List all pets
|
|
180
|
+
operationId: listPets
|
|
181
|
+
parameters:
|
|
182
|
+
- $ref: '#/components/parameters/used'
|
|
183
|
+
components:
|
|
184
|
+
parameters:
|
|
185
|
+
used:
|
|
186
|
+
name: used
|
|
187
|
+
unused:
|
|
188
|
+
name: unused
|
|
189
|
+
schemas:
|
|
190
|
+
Unused:
|
|
191
|
+
type: integer
|
|
192
|
+
enum:
|
|
193
|
+
- 1
|
|
194
|
+
- 2
|
|
195
|
+
Transitive:
|
|
196
|
+
type: object
|
|
197
|
+
properties:
|
|
198
|
+
link:
|
|
199
|
+
$ref: '#/components/schemas/Unused'
|
|
200
|
+
`,
|
|
201
|
+
'foobar.yaml'
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const results = await bundleDocument({
|
|
205
|
+
externalRefResolver: new BaseResolver(),
|
|
206
|
+
document,
|
|
207
|
+
config: await makeConfig({}),
|
|
208
|
+
removeUnusedComponents: true,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(results.bundle.parsed).toEqual({
|
|
212
|
+
openapi: '3.0.0',
|
|
213
|
+
paths: {
|
|
214
|
+
'/pets': {
|
|
215
|
+
get: {
|
|
216
|
+
summary: 'List all pets',
|
|
217
|
+
operationId: 'listPets',
|
|
218
|
+
parameters: [
|
|
219
|
+
{
|
|
220
|
+
$ref: '#/components/parameters/used',
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
components: {
|
|
227
|
+
parameters: {
|
|
228
|
+
used: {
|
|
229
|
+
name: 'used',
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should remove transitively unused components with colliding paths', async () => {
|
|
237
|
+
const document = parseYamlToDocument(
|
|
238
|
+
outdent`
|
|
239
|
+
openapi: "3.0.0"
|
|
240
|
+
paths:
|
|
241
|
+
/pets:
|
|
242
|
+
get:
|
|
243
|
+
responses:
|
|
244
|
+
200:
|
|
245
|
+
content:
|
|
246
|
+
application/json:
|
|
247
|
+
schema:
|
|
248
|
+
$ref: "#/components/schemas/Transitive2"
|
|
249
|
+
components:
|
|
250
|
+
schemas:
|
|
251
|
+
Unused: # <-- this will be removed correctly
|
|
252
|
+
type: integer
|
|
253
|
+
Transitive: # <-- this will be removed correctly
|
|
254
|
+
type: object
|
|
255
|
+
properties:
|
|
256
|
+
link:
|
|
257
|
+
$ref: '#/components/schemas/Unused'
|
|
258
|
+
Used:
|
|
259
|
+
type: integer
|
|
260
|
+
Transitive2:
|
|
261
|
+
type: object
|
|
262
|
+
properties:
|
|
263
|
+
link:
|
|
264
|
+
$ref: '#/components/schemas/Used'
|
|
265
|
+
`,
|
|
266
|
+
|
|
267
|
+
'foobar.yaml'
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const results = await bundleDocument({
|
|
271
|
+
externalRefResolver: new BaseResolver(),
|
|
272
|
+
document,
|
|
273
|
+
config: await makeConfig({}),
|
|
274
|
+
removeUnusedComponents: true,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(results.bundle.parsed).toEqual({
|
|
278
|
+
openapi: '3.0.0',
|
|
279
|
+
paths: {
|
|
280
|
+
'/pets': {
|
|
281
|
+
get: {
|
|
282
|
+
responses: {
|
|
283
|
+
200: {
|
|
284
|
+
content: {
|
|
285
|
+
'application/json': {
|
|
286
|
+
schema: {
|
|
287
|
+
$ref: '#/components/schemas/Transitive2',
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
components: {
|
|
297
|
+
schemas: {
|
|
298
|
+
Transitive2: {
|
|
299
|
+
type: 'object',
|
|
300
|
+
properties: {
|
|
301
|
+
link: {
|
|
302
|
+
$ref: '#/components/schemas/Used',
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
Used: {
|
|
307
|
+
type: 'integer',
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
});
|
|
171
313
|
});
|