@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.
@@ -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
- { used: boolean; componentType?: keyof Oas3Components; name: string }
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
- used: components.get(location.absolutePointer)?.used || false,
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.set(pointer, {
41
- used: true,
42
- name: key.toString(),
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 = 0;
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
  }
@@ -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!,