@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
|
@@ -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!,
|