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