@redocly/openapi-core 1.10.5 → 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 +17 -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/lib/types/redocly-yaml.js +6 -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/src/types/redocly-yaml.ts +7 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
});
|
|
@@ -2,12 +2,12 @@ import { Location } from '../../ref-utils';
|
|
|
2
2
|
import { isEmptyObject } from '../../utils';
|
|
3
3
|
|
|
4
4
|
import type { Oas3Decorator } from '../../visitors';
|
|
5
|
-
import type { Oas3Components } from '../../typings/openapi';
|
|
5
|
+
import type { Oas3Components, Oas3Definition } from '../../typings/openapi';
|
|
6
6
|
|
|
7
7
|
export const RemoveUnusedComponents: Oas3Decorator = () => {
|
|
8
8
|
const components = new Map<
|
|
9
9
|
string,
|
|
10
|
-
{
|
|
10
|
+
{ usedIn: Location[]; componentType?: keyof Oas3Components; name: string }
|
|
11
11
|
>();
|
|
12
12
|
|
|
13
13
|
function registerComponent(
|
|
@@ -16,15 +16,45 @@ export const RemoveUnusedComponents: Oas3Decorator = () => {
|
|
|
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: Oas3Definition, 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
|
+
location.absolutePointer.startsWith(removed) &&
|
|
34
|
+
(location.absolutePointer.length === removed.length ||
|
|
35
|
+
location.absolutePointer[removed.length] === '/')
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
if (!used && componentType && root.components) {
|
|
40
|
+
removedPaths.push(path);
|
|
41
|
+
const componentChild = root.components[componentType];
|
|
42
|
+
delete componentChild![name];
|
|
43
|
+
components.delete(path);
|
|
44
|
+
if (isEmptyObject(componentChild)) {
|
|
45
|
+
delete root.components[componentType];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return removedPaths.length > removedLengthStart
|
|
51
|
+
? removeUnusedComponents(root, removedPaths)
|
|
52
|
+
: removedPaths.length;
|
|
53
|
+
}
|
|
54
|
+
|
|
25
55
|
return {
|
|
26
56
|
ref: {
|
|
27
|
-
leave(ref, { type, resolve, key }) {
|
|
57
|
+
leave(ref, { location, type, resolve, key }) {
|
|
28
58
|
if (
|
|
29
59
|
['Schema', 'Header', 'Parameter', 'Response', 'Example', 'RequestBody'].includes(
|
|
30
60
|
type.name
|
|
@@ -37,29 +67,24 @@ export const RemoveUnusedComponents: Oas3Decorator = () => {
|
|
|
37
67
|
const componentLevelLocalPointer = localPointer.split('/').slice(0, 4).join('/');
|
|
38
68
|
const pointer = `${fileLocation}#${componentLevelLocalPointer}`;
|
|
39
69
|
|
|
40
|
-
components.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
70
|
+
const registered = components.get(pointer);
|
|
71
|
+
|
|
72
|
+
if (registered) {
|
|
73
|
+
registered.usedIn.push(location);
|
|
74
|
+
} else {
|
|
75
|
+
components.set(pointer, {
|
|
76
|
+
usedIn: [location],
|
|
77
|
+
name: key.toString(),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
44
80
|
}
|
|
45
81
|
},
|
|
46
82
|
},
|
|
47
83
|
Root: {
|
|
48
84
|
leave(root, ctx) {
|
|
49
85
|
const data = ctx.getVisitorData() as { removedCount: number };
|
|
50
|
-
data.removedCount =
|
|
86
|
+
data.removedCount = removeUnusedComponents(root, []);
|
|
51
87
|
|
|
52
|
-
components.forEach((usageInfo) => {
|
|
53
|
-
const { used, componentType, name } = usageInfo;
|
|
54
|
-
if (!used && componentType && root.components) {
|
|
55
|
-
const componentChild = root.components[componentType];
|
|
56
|
-
delete componentChild![name];
|
|
57
|
-
data.removedCount++;
|
|
58
|
-
if (isEmptyObject(componentChild)) {
|
|
59
|
-
delete root.components[componentType];
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
88
|
if (isEmptyObject(root.components)) {
|
|
64
89
|
delete root.components;
|
|
65
90
|
}
|
package/src/format/format.ts
CHANGED
|
@@ -51,7 +51,8 @@ export type OutputFormat =
|
|
|
51
51
|
| 'json'
|
|
52
52
|
| 'checkstyle'
|
|
53
53
|
| 'codeclimate'
|
|
54
|
-
| 'summary'
|
|
54
|
+
| 'summary'
|
|
55
|
+
| 'github-actions';
|
|
55
56
|
|
|
56
57
|
export function getTotals(problems: (NormalizedProblem & { ignored?: boolean })[]): Totals {
|
|
57
58
|
let errors = 0;
|
|
@@ -155,6 +156,8 @@ export function formatProblems(
|
|
|
155
156
|
case 'summary':
|
|
156
157
|
formatSummary(problems);
|
|
157
158
|
break;
|
|
159
|
+
case 'github-actions':
|
|
160
|
+
outputForGithubActions(problems, cwd);
|
|
158
161
|
}
|
|
159
162
|
|
|
160
163
|
if (totalProblems - ignoredProblems > maxProblems) {
|
|
@@ -373,3 +376,60 @@ function xmlEscape(s: string): string {
|
|
|
373
376
|
}
|
|
374
377
|
});
|
|
375
378
|
}
|
|
379
|
+
|
|
380
|
+
function outputForGithubActions(problems: NormalizedProblem[], cwd: string): void {
|
|
381
|
+
for (const problem of problems) {
|
|
382
|
+
for (const location of problem.location.map(getLineColLocation)) {
|
|
383
|
+
let command;
|
|
384
|
+
switch (problem.severity) {
|
|
385
|
+
case 'error':
|
|
386
|
+
command = 'error';
|
|
387
|
+
break;
|
|
388
|
+
case 'warn':
|
|
389
|
+
command = 'warning';
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
const suggest = formatDidYouMean(problem);
|
|
393
|
+
const message = suggest !== '' ? problem.message + '\n\n' + suggest : problem.message;
|
|
394
|
+
const properties = {
|
|
395
|
+
title: problem.ruleId,
|
|
396
|
+
file: isAbsoluteUrl(location.source.absoluteRef)
|
|
397
|
+
? location.source.absoluteRef
|
|
398
|
+
: path.relative(cwd, location.source.absoluteRef),
|
|
399
|
+
line: location.start.line,
|
|
400
|
+
col: location.start.col,
|
|
401
|
+
endLine: location.end?.line,
|
|
402
|
+
endColumn: location.end?.col,
|
|
403
|
+
};
|
|
404
|
+
output.write(`::${command} ${formatProperties(properties)}::${escapeMessage(message)}\n`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function formatProperties(props: Record<string, any>): string {
|
|
409
|
+
return Object.entries(props)
|
|
410
|
+
.filter(([, v]) => v !== null && v !== undefined)
|
|
411
|
+
.map(([k, v]) => `${k}=${escapeProperty(v)}`)
|
|
412
|
+
.join(',');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function toString(v: any): string {
|
|
416
|
+
if (v === null || v === undefined) {
|
|
417
|
+
return '';
|
|
418
|
+
} else if (typeof v === 'string' || v instanceof String) {
|
|
419
|
+
return v as string;
|
|
420
|
+
}
|
|
421
|
+
return JSON.stringify(v);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function escapeMessage(v: any): string {
|
|
425
|
+
return toString(v).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
|
|
426
|
+
}
|
|
427
|
+
function escapeProperty(v: any): string {
|
|
428
|
+
return toString(v)
|
|
429
|
+
.replace(/%/g, '%25')
|
|
430
|
+
.replace(/\r/g, '%0D')
|
|
431
|
+
.replace(/\n/g, '%0A')
|
|
432
|
+
.replace(/:/g, '%3A')
|
|
433
|
+
.replace(/,/g, '%2C');
|
|
434
|
+
}
|
|
435
|
+
}
|
|
@@ -470,4 +470,149 @@ describe('no-invalid-media-type-examples', () => {
|
|
|
470
470
|
|
|
471
471
|
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`);
|
|
472
472
|
});
|
|
473
|
+
|
|
474
|
+
it('should not report if only externalValue is set', async () => {
|
|
475
|
+
const document = parseYamlToDocument(
|
|
476
|
+
outdent`
|
|
477
|
+
openapi: 3.0.0
|
|
478
|
+
paths:
|
|
479
|
+
/pet:
|
|
480
|
+
get:
|
|
481
|
+
responses:
|
|
482
|
+
'200':
|
|
483
|
+
content:
|
|
484
|
+
application/json:
|
|
485
|
+
schema:
|
|
486
|
+
type: object
|
|
487
|
+
properties:
|
|
488
|
+
a:
|
|
489
|
+
type: string
|
|
490
|
+
b:
|
|
491
|
+
type: number
|
|
492
|
+
examples:
|
|
493
|
+
first:
|
|
494
|
+
externalValue: "https://example.com/example.json"
|
|
495
|
+
`,
|
|
496
|
+
'foobar.yaml'
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
const results = await lintDocument({
|
|
500
|
+
externalRefResolver: new BaseResolver(),
|
|
501
|
+
document,
|
|
502
|
+
config: await makeConfig({ 'no-invalid-media-type-examples': 'error' }),
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('should not report if value is valid and externalValue is also set', async () => {
|
|
509
|
+
const document = parseYamlToDocument(
|
|
510
|
+
outdent`
|
|
511
|
+
openapi: 3.0.0
|
|
512
|
+
paths:
|
|
513
|
+
/pet:
|
|
514
|
+
get:
|
|
515
|
+
responses:
|
|
516
|
+
'200':
|
|
517
|
+
content:
|
|
518
|
+
application/json:
|
|
519
|
+
schema:
|
|
520
|
+
type: object
|
|
521
|
+
properties:
|
|
522
|
+
a:
|
|
523
|
+
type: string
|
|
524
|
+
b:
|
|
525
|
+
type: number
|
|
526
|
+
examples:
|
|
527
|
+
first:
|
|
528
|
+
externalValue: "https://example.com/example.json"
|
|
529
|
+
value:
|
|
530
|
+
a: "A"
|
|
531
|
+
b: 0
|
|
532
|
+
`,
|
|
533
|
+
'foobar.yaml'
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
const results = await lintDocument({
|
|
537
|
+
externalRefResolver: new BaseResolver(),
|
|
538
|
+
document,
|
|
539
|
+
config: await makeConfig({ 'no-invalid-media-type-examples': 'error' }),
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('should report invalid value when externalValue is also set', async () => {
|
|
546
|
+
const document = parseYamlToDocument(
|
|
547
|
+
outdent`
|
|
548
|
+
openapi: 3.0.0
|
|
549
|
+
paths:
|
|
550
|
+
/pet:
|
|
551
|
+
get:
|
|
552
|
+
responses:
|
|
553
|
+
'200':
|
|
554
|
+
content:
|
|
555
|
+
application/json:
|
|
556
|
+
schema:
|
|
557
|
+
type: object
|
|
558
|
+
properties:
|
|
559
|
+
a:
|
|
560
|
+
type: string
|
|
561
|
+
b:
|
|
562
|
+
type: number
|
|
563
|
+
examples:
|
|
564
|
+
first:
|
|
565
|
+
externalValue: "https://example.com/example.json"
|
|
566
|
+
value:
|
|
567
|
+
a: 0
|
|
568
|
+
b: "0"
|
|
569
|
+
`,
|
|
570
|
+
'foobar.yaml'
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
const results = await lintDocument({
|
|
574
|
+
externalRefResolver: new BaseResolver(),
|
|
575
|
+
document,
|
|
576
|
+
config: await makeConfig({ 'no-invalid-media-type-examples': 'error' }),
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
|
|
580
|
+
[
|
|
581
|
+
{
|
|
582
|
+
"from": {
|
|
583
|
+
"pointer": "#/paths/~1pet/get/responses/200/content/application~1json",
|
|
584
|
+
"source": "foobar.yaml",
|
|
585
|
+
},
|
|
586
|
+
"location": [
|
|
587
|
+
{
|
|
588
|
+
"pointer": "#/paths/~1pet/get/responses/200/content/application~1json/examples/first/value/a",
|
|
589
|
+
"reportOnKey": false,
|
|
590
|
+
"source": "foobar.yaml",
|
|
591
|
+
},
|
|
592
|
+
],
|
|
593
|
+
"message": "Example value must conform to the schema: \`a\` property type must be string.",
|
|
594
|
+
"ruleId": "no-invalid-media-type-examples",
|
|
595
|
+
"severity": "error",
|
|
596
|
+
"suggest": [],
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
"from": {
|
|
600
|
+
"pointer": "#/paths/~1pet/get/responses/200/content/application~1json",
|
|
601
|
+
"source": "foobar.yaml",
|
|
602
|
+
},
|
|
603
|
+
"location": [
|
|
604
|
+
{
|
|
605
|
+
"pointer": "#/paths/~1pet/get/responses/200/content/application~1json/examples/first/value/b",
|
|
606
|
+
"reportOnKey": false,
|
|
607
|
+
"source": "foobar.yaml",
|
|
608
|
+
},
|
|
609
|
+
],
|
|
610
|
+
"message": "Example value must conform to the schema: \`b\` property type must be number.",
|
|
611
|
+
"ruleId": "no-invalid-media-type-examples",
|
|
612
|
+
"severity": "error",
|
|
613
|
+
"suggest": [],
|
|
614
|
+
},
|
|
615
|
+
]
|
|
616
|
+
`);
|
|
617
|
+
});
|
|
473
618
|
});
|
|
@@ -35,6 +35,9 @@ export const ValidContentExamples: Oas3Rule = (opts) => {
|
|
|
35
35
|
location = isMultiple ? resolved.location.child('value') : resolved.location;
|
|
36
36
|
example = resolved.node;
|
|
37
37
|
}
|
|
38
|
+
if (isMultiple && typeof example.value === 'undefined') {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
38
41
|
validateExample(
|
|
39
42
|
isMultiple ? example.value : example,
|
|
40
43
|
mediaType.schema!,
|
|
@@ -353,6 +353,12 @@ const ObjectRule: NodeType = {
|
|
|
353
353
|
required: ['severity'],
|
|
354
354
|
};
|
|
355
355
|
|
|
356
|
+
// TODO: add better type tree for this
|
|
357
|
+
const Schema: NodeType = {
|
|
358
|
+
properties: {},
|
|
359
|
+
additionalProperties: {},
|
|
360
|
+
};
|
|
361
|
+
|
|
356
362
|
const AssertionDefinitionSubject: NodeType = {
|
|
357
363
|
properties: {
|
|
358
364
|
type: {
|
|
@@ -1120,6 +1126,7 @@ const CoreConfigTypes: Record<string, NodeType> = {
|
|
|
1120
1126
|
ButtonOverrides,
|
|
1121
1127
|
Overrides,
|
|
1122
1128
|
ObjectRule,
|
|
1129
|
+
Schema,
|
|
1123
1130
|
RightPanel,
|
|
1124
1131
|
Rules,
|
|
1125
1132
|
Shape,
|