@redocly/openapi-core 1.10.6 → 1.12.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/lib/config/config-resolvers.d.ts +5 -1
  3. package/lib/config/config-resolvers.js +4 -4
  4. package/lib/config/load.d.ts +1 -0
  5. package/lib/config/load.js +7 -2
  6. package/lib/config/utils.js +1 -1
  7. package/lib/decorators/oas2/remove-unused-components.js +36 -22
  8. package/lib/decorators/oas3/remove-unused-components.js +34 -19
  9. package/lib/format/format.d.ts +1 -1
  10. package/lib/format/format.js +57 -0
  11. package/lib/index.d.ts +1 -1
  12. package/lib/index.js +3 -2
  13. package/lib/rules/common/assertions/utils.js +2 -2
  14. package/lib/rules/oas3/no-invalid-media-type-examples.js +3 -0
  15. package/lib/utils.d.ts +1 -0
  16. package/lib/utils.js +7 -1
  17. package/package.json +2 -2
  18. package/src/__tests__/format.test.ts +34 -0
  19. package/src/bundle.ts +1 -1
  20. package/src/config/__tests__/config-resolvers.test.ts +4 -4
  21. package/src/config/__tests__/fixtures/load-external.yaml +2 -0
  22. package/src/config/__tests__/load.test.ts +14 -0
  23. package/src/config/config-resolvers.ts +13 -7
  24. package/src/config/load.ts +13 -4
  25. package/src/config/utils.ts +1 -1
  26. package/src/decorators/oas2/__tests__/remove-unused-components.test.ts +74 -1
  27. package/src/decorators/oas2/remove-unused-components.ts +46 -24
  28. package/src/decorators/oas3/__tests__/remove-unused-components.test.ts +142 -0
  29. package/src/decorators/oas3/remove-unused-components.ts +45 -20
  30. package/src/format/format.ts +62 -1
  31. package/src/index.ts +8 -1
  32. package/src/rules/common/assertions/utils.ts +2 -2
  33. package/src/rules/oas3/__tests__/no-invalid-media-type-examples.test.ts +145 -0
  34. package/src/rules/oas3/no-invalid-media-type-examples.ts +3 -0
  35. package/src/utils.ts +4 -0
  36. package/tsconfig.tsbuildinfo +1 -1
@@ -20,6 +20,7 @@ async function addConfigMetadata({
20
20
  tokens,
21
21
  files,
22
22
  region,
23
+ externalRefResolver,
23
24
  }: {
24
25
  rawConfig: RawConfig;
25
26
  customExtends?: string[];
@@ -27,6 +28,7 @@ async function addConfigMetadata({
27
28
  tokens?: RegionalTokenWithValidity[];
28
29
  files?: string[];
29
30
  region?: Region;
31
+ externalRefResolver?: BaseResolver;
30
32
  }): Promise<Config> {
31
33
  if (customExtends !== undefined) {
32
34
  rawConfig.styleguide = rawConfig.styleguide || {};
@@ -64,10 +66,15 @@ async function addConfigMetadata({
64
66
  }
65
67
  }
66
68
 
67
- return resolveConfig(
68
- { ...rawConfig, files: files ?? rawConfig.files, region: region ?? rawConfig.region },
69
- configPath
70
- );
69
+ return resolveConfig({
70
+ rawConfig: {
71
+ ...rawConfig,
72
+ files: files ?? rawConfig.files,
73
+ region: region ?? rawConfig.region,
74
+ },
75
+ configPath,
76
+ externalRefResolver,
77
+ });
71
78
  }
72
79
 
73
80
  export type RawConfigProcessor = (
@@ -105,6 +112,7 @@ export async function loadConfig(
105
112
  tokens,
106
113
  files,
107
114
  region,
115
+ externalRefResolver,
108
116
  });
109
117
  }
110
118
 
@@ -156,6 +164,7 @@ type CreateConfigOptions = {
156
164
  extends?: string[];
157
165
  tokens?: RegionalTokenWithValidity[];
158
166
  configPath?: string;
167
+ externalRefResolver?: BaseResolver;
159
168
  };
160
169
 
161
170
  export async function createConfig(
@@ -173,7 +173,7 @@ export function mergeExtends(rulesConfList: ResolvedStyleguideConfig[]) {
173
173
  for (const rulesConf of rulesConfList) {
174
174
  if (rulesConf.extends) {
175
175
  throw new Error(
176
- `'extends' is not supported in shared configs yet: ${JSON.stringify(rulesConf, null, 2)}.`
176
+ `'extends' is not supported in shared configs yet:\n${JSON.stringify(rulesConf, null, 2)}`
177
177
  );
178
178
  }
179
179
 
@@ -1,5 +1,5 @@
1
1
  import { outdent } from 'outdent';
2
- import { parseYamlToDocument, replaceSourceWithRef, makeConfig } from '../../../../__tests__/utils';
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
- { used: boolean; componentType?: keyof Oas2Components; name: string }
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
- 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: 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.set(pointer, {
37
- used: true,
38
- name: key.toString(),
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 = 0;
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
  });
@@ -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,9 @@ export type OutputFormat =
51
51
  | 'json'
52
52
  | 'checkstyle'
53
53
  | 'codeclimate'
54
- | 'summary';
54
+ | 'summary'
55
+ | 'github-actions'
56
+ | 'markdown';
55
57
 
56
58
  export function getTotals(problems: (NormalizedProblem & { ignored?: boolean })[]): Totals {
57
59
  let errors = 0;
@@ -155,6 +157,8 @@ export function formatProblems(
155
157
  case 'summary':
156
158
  formatSummary(problems);
157
159
  break;
160
+ case 'github-actions':
161
+ outputForGithubActions(problems, cwd);
158
162
  }
159
163
 
160
164
  if (totalProblems - ignoredProblems > maxProblems) {
@@ -373,3 +377,60 @@ function xmlEscape(s: string): string {
373
377
  }
374
378
  });
375
379
  }
380
+
381
+ function outputForGithubActions(problems: NormalizedProblem[], cwd: string): void {
382
+ for (const problem of problems) {
383
+ for (const location of problem.location.map(getLineColLocation)) {
384
+ let command;
385
+ switch (problem.severity) {
386
+ case 'error':
387
+ command = 'error';
388
+ break;
389
+ case 'warn':
390
+ command = 'warning';
391
+ break;
392
+ }
393
+ const suggest = formatDidYouMean(problem);
394
+ const message = suggest !== '' ? problem.message + '\n\n' + suggest : problem.message;
395
+ const properties = {
396
+ title: problem.ruleId,
397
+ file: isAbsoluteUrl(location.source.absoluteRef)
398
+ ? location.source.absoluteRef
399
+ : path.relative(cwd, location.source.absoluteRef),
400
+ line: location.start.line,
401
+ col: location.start.col,
402
+ endLine: location.end?.line,
403
+ endColumn: location.end?.col,
404
+ };
405
+ output.write(`::${command} ${formatProperties(properties)}::${escapeMessage(message)}\n`);
406
+ }
407
+ }
408
+
409
+ function formatProperties(props: Record<string, any>): string {
410
+ return Object.entries(props)
411
+ .filter(([, v]) => v !== null && v !== undefined)
412
+ .map(([k, v]) => `${k}=${escapeProperty(v)}`)
413
+ .join(',');
414
+ }
415
+
416
+ function toString(v: any): string {
417
+ if (v === null || v === undefined) {
418
+ return '';
419
+ } else if (typeof v === 'string' || v instanceof String) {
420
+ return v as string;
421
+ }
422
+ return JSON.stringify(v);
423
+ }
424
+
425
+ function escapeMessage(v: any): string {
426
+ return toString(v).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
427
+ }
428
+ function escapeProperty(v: any): string {
429
+ return toString(v)
430
+ .replace(/%/g, '%25')
431
+ .replace(/\r/g, '%0D')
432
+ .replace(/\n/g, '%0A')
433
+ .replace(/:/g, '%3A')
434
+ .replace(/,/g, '%2C');
435
+ }
436
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,11 @@
1
- export { BundleOutputFormat, readFileFromUrl, slash, doesYamlFileExist, isTruthy } from './utils';
1
+ export {
2
+ BundleOutputFormat,
3
+ readFileFromUrl,
4
+ slash,
5
+ doesYamlFileExist,
6
+ isTruthy,
7
+ pause,
8
+ } from './utils';
2
9
  export { Oas3_1Types } from './types/oas3_1';
3
10
  export { Oas3Types } from './types/oas3';
4
11
  export { Oas2Types } from './types/oas2';
@@ -77,13 +77,13 @@ export function getAssertsToApply(assertion: AssertionDefinition): AssertToApply
77
77
 
78
78
  if (shouldRunOnValues && !assertion.subject.property) {
79
79
  throw new Error(
80
- `${shouldRunOnValues.name} can't be used on all keys. Please provide a single property`
80
+ `The '${shouldRunOnValues.name}' assertion can't be used on all keys. Please provide a single property.`
81
81
  );
82
82
  }
83
83
 
84
84
  if (shouldRunOnKeys && assertion.subject.property) {
85
85
  throw new Error(
86
- `${shouldRunOnKeys.name} can't be used on a single property. Please use 'property'.`
86
+ `The '${shouldRunOnKeys.name}' assertion can't be used on properties. Please remove the 'property' key.`
87
87
  );
88
88
  }
89
89