@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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # @redocly/openapi-core
2
2
 
3
+ ## 1.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Added support for a `github-actions` output format for the `lint` command to annotate reported problems on files when used in a GitHub Actions workflow.
8
+
9
+ ### Patch Changes
10
+
11
+ - Fixed [`no-invalid-media-type-examples`](https://redocly.com/docs/cli/rules/no-invalid-media-type-examples/) rule `externalValue` example validation.
12
+ - Process remove-unused-components rule transitively; components are now removed if they were previously referenced by a removed component.
13
+
3
14
  ## 1.10.6
4
15
 
5
16
  ### Patch Changes
@@ -5,16 +5,38 @@ const utils_1 = require("../../utils");
5
5
  const RemoveUnusedComponents = () => {
6
6
  const components = new Map();
7
7
  function registerComponent(location, componentType, name) {
8
- var _a;
8
+ var _a, _b;
9
9
  components.set(location.absolutePointer, {
10
- used: ((_a = components.get(location.absolutePointer)) === null || _a === void 0 ? void 0 : _a.used) || false,
10
+ usedIn: (_b = (_a = components.get(location.absolutePointer)) === null || _a === void 0 ? void 0 : _a.usedIn) !== null && _b !== void 0 ? _b : [],
11
11
  componentType,
12
12
  name,
13
13
  });
14
14
  }
15
+ function removeUnusedComponents(root, removedPaths) {
16
+ const removedLengthStart = removedPaths.length;
17
+ for (const [path, { usedIn, name, componentType }] of components) {
18
+ const used = usedIn.some((location) => !removedPaths.some((removed) =>
19
+ // Check if the current location's absolute pointer starts with the 'removed' path
20
+ // and either its length matches exactly with 'removed' or the character after the 'removed' path is a '/'
21
+ location.absolutePointer.startsWith(removed) &&
22
+ (location.absolutePointer.length === removed.length ||
23
+ location.absolutePointer[removed.length] === '/')));
24
+ if (!used && componentType) {
25
+ removedPaths.push(path);
26
+ delete root[componentType][name];
27
+ components.delete(path);
28
+ if ((0, utils_1.isEmptyObject)(root[componentType])) {
29
+ delete root[componentType];
30
+ }
31
+ }
32
+ }
33
+ return removedPaths.length > removedLengthStart
34
+ ? removeUnusedComponents(root, removedPaths)
35
+ : removedPaths.length;
36
+ }
15
37
  return {
16
38
  ref: {
17
- leave(ref, { type, resolve, key }) {
39
+ leave(ref, { location, type, resolve, key }) {
18
40
  if (['Schema', 'Parameter', 'Response', 'SecurityScheme'].includes(type.name)) {
19
41
  const resolvedRef = resolve(ref);
20
42
  if (!resolvedRef.location)
@@ -22,31 +44,23 @@ const RemoveUnusedComponents = () => {
22
44
  const [fileLocation, localPointer] = resolvedRef.location.absolutePointer.split('#', 2);
23
45
  const componentLevelLocalPointer = localPointer.split('/').slice(0, 3).join('/');
24
46
  const pointer = `${fileLocation}#${componentLevelLocalPointer}`;
25
- components.set(pointer, {
26
- used: true,
27
- name: key.toString(),
28
- });
47
+ const registered = components.get(pointer);
48
+ if (registered) {
49
+ registered.usedIn.push(location);
50
+ }
51
+ else {
52
+ components.set(pointer, {
53
+ usedIn: [location],
54
+ name: key.toString(),
55
+ });
56
+ }
29
57
  }
30
58
  },
31
59
  },
32
60
  Root: {
33
61
  leave(root, ctx) {
34
62
  const data = ctx.getVisitorData();
35
- data.removedCount = 0;
36
- const rootComponents = new Set();
37
- components.forEach((usageInfo) => {
38
- const { used, name, componentType } = usageInfo;
39
- if (!used && componentType) {
40
- rootComponents.add(componentType);
41
- delete root[componentType][name];
42
- data.removedCount++;
43
- }
44
- });
45
- for (const component of rootComponents) {
46
- if ((0, utils_1.isEmptyObject)(root[component])) {
47
- delete root[component];
48
- }
49
- }
63
+ data.removedCount = removeUnusedComponents(root, []);
50
64
  },
51
65
  },
52
66
  NamedSchemas: {
@@ -5,16 +5,36 @@ const utils_1 = require("../../utils");
5
5
  const RemoveUnusedComponents = () => {
6
6
  const components = new Map();
7
7
  function registerComponent(location, componentType, name) {
8
- var _a;
8
+ var _a, _b;
9
9
  components.set(location.absolutePointer, {
10
- used: ((_a = components.get(location.absolutePointer)) === null || _a === void 0 ? void 0 : _a.used) || false,
10
+ usedIn: (_b = (_a = components.get(location.absolutePointer)) === null || _a === void 0 ? void 0 : _a.usedIn) !== null && _b !== void 0 ? _b : [],
11
11
  componentType,
12
12
  name,
13
13
  });
14
14
  }
15
+ function removeUnusedComponents(root, removedPaths) {
16
+ const removedLengthStart = removedPaths.length;
17
+ for (const [path, { usedIn, name, componentType }] of components) {
18
+ const used = usedIn.some((location) => !removedPaths.some((removed) => location.absolutePointer.startsWith(removed) &&
19
+ (location.absolutePointer.length === removed.length ||
20
+ location.absolutePointer[removed.length] === '/')));
21
+ if (!used && componentType && root.components) {
22
+ removedPaths.push(path);
23
+ const componentChild = root.components[componentType];
24
+ delete componentChild[name];
25
+ components.delete(path);
26
+ if ((0, utils_1.isEmptyObject)(componentChild)) {
27
+ delete root.components[componentType];
28
+ }
29
+ }
30
+ }
31
+ return removedPaths.length > removedLengthStart
32
+ ? removeUnusedComponents(root, removedPaths)
33
+ : removedPaths.length;
34
+ }
15
35
  return {
16
36
  ref: {
17
- leave(ref, { type, resolve, key }) {
37
+ leave(ref, { location, type, resolve, key }) {
18
38
  if (['Schema', 'Header', 'Parameter', 'Response', 'Example', 'RequestBody'].includes(type.name)) {
19
39
  const resolvedRef = resolve(ref);
20
40
  if (!resolvedRef.location)
@@ -22,28 +42,23 @@ const RemoveUnusedComponents = () => {
22
42
  const [fileLocation, localPointer] = resolvedRef.location.absolutePointer.split('#', 2);
23
43
  const componentLevelLocalPointer = localPointer.split('/').slice(0, 4).join('/');
24
44
  const pointer = `${fileLocation}#${componentLevelLocalPointer}`;
25
- components.set(pointer, {
26
- used: true,
27
- name: key.toString(),
28
- });
45
+ const registered = components.get(pointer);
46
+ if (registered) {
47
+ registered.usedIn.push(location);
48
+ }
49
+ else {
50
+ components.set(pointer, {
51
+ usedIn: [location],
52
+ name: key.toString(),
53
+ });
54
+ }
29
55
  }
30
56
  },
31
57
  },
32
58
  Root: {
33
59
  leave(root, ctx) {
34
60
  const data = ctx.getVisitorData();
35
- data.removedCount = 0;
36
- components.forEach((usageInfo) => {
37
- const { used, componentType, name } = usageInfo;
38
- if (!used && componentType && root.components) {
39
- const componentChild = root.components[componentType];
40
- delete componentChild[name];
41
- data.removedCount++;
42
- if ((0, utils_1.isEmptyObject)(componentChild)) {
43
- delete root.components[componentType];
44
- }
45
- }
46
- });
61
+ data.removedCount = removeUnusedComponents(root, []);
47
62
  if ((0, utils_1.isEmptyObject)(root.components)) {
48
63
  delete root.components;
49
64
  }
@@ -4,7 +4,7 @@ export type Totals = {
4
4
  warnings: number;
5
5
  ignored: number;
6
6
  };
7
- export type OutputFormat = 'codeframe' | 'stylish' | 'json' | 'checkstyle' | 'codeclimate' | 'summary';
7
+ export type OutputFormat = 'codeframe' | 'stylish' | 'json' | 'checkstyle' | 'codeclimate' | 'summary' | 'github-actions';
8
8
  export declare function getTotals(problems: (NormalizedProblem & {
9
9
  ignored?: boolean;
10
10
  })[]): Totals;
@@ -103,6 +103,8 @@ function formatProblems(problems, opts) {
103
103
  case 'summary':
104
104
  formatSummary(problems);
105
105
  break;
106
+ case 'github-actions':
107
+ outputForGithubActions(problems, cwd);
106
108
  }
107
109
  if (totalProblems - ignoredProblems > maxProblems) {
108
110
  logger_1.logger.info(`< ... ${totalProblems - maxProblems} more problems hidden > ${logger_1.colorize.gray('increase with `--max-problems N`')}\n`);
@@ -266,3 +268,58 @@ function xmlEscape(s) {
266
268
  }
267
269
  });
268
270
  }
271
+ function outputForGithubActions(problems, cwd) {
272
+ var _a, _b;
273
+ for (const problem of problems) {
274
+ for (const location of problem.location.map(codeframes_1.getLineColLocation)) {
275
+ let command;
276
+ switch (problem.severity) {
277
+ case 'error':
278
+ command = 'error';
279
+ break;
280
+ case 'warn':
281
+ command = 'warning';
282
+ break;
283
+ }
284
+ const suggest = formatDidYouMean(problem);
285
+ const message = suggest !== '' ? problem.message + '\n\n' + suggest : problem.message;
286
+ const properties = {
287
+ title: problem.ruleId,
288
+ file: (0, ref_utils_1.isAbsoluteUrl)(location.source.absoluteRef)
289
+ ? location.source.absoluteRef
290
+ : path.relative(cwd, location.source.absoluteRef),
291
+ line: location.start.line,
292
+ col: location.start.col,
293
+ endLine: (_a = location.end) === null || _a === void 0 ? void 0 : _a.line,
294
+ endColumn: (_b = location.end) === null || _b === void 0 ? void 0 : _b.col,
295
+ };
296
+ output_1.output.write(`::${command} ${formatProperties(properties)}::${escapeMessage(message)}\n`);
297
+ }
298
+ }
299
+ function formatProperties(props) {
300
+ return Object.entries(props)
301
+ .filter(([, v]) => v !== null && v !== undefined)
302
+ .map(([k, v]) => `${k}=${escapeProperty(v)}`)
303
+ .join(',');
304
+ }
305
+ function toString(v) {
306
+ if (v === null || v === undefined) {
307
+ return '';
308
+ }
309
+ else if (typeof v === 'string' || v instanceof String) {
310
+ return v;
311
+ }
312
+ return JSON.stringify(v);
313
+ }
314
+ function escapeMessage(v) {
315
+ return toString(v).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
316
+ }
317
+ function escapeProperty(v) {
318
+ return toString(v)
319
+ .replace(/%/g, '%25')
320
+ .replace(/\r/g, '%0D')
321
+ .replace(/\n/g, '%0A')
322
+ .replace(/:/g, '%3A')
323
+ .replace(/,/g, '%2C');
324
+ }
325
+ }
@@ -28,6 +28,9 @@ const ValidContentExamples = (opts) => {
28
28
  location = isMultiple ? resolved.location.child('value') : resolved.location;
29
29
  example = resolved.node;
30
30
  }
31
+ if (isMultiple && typeof example.value === 'undefined') {
32
+ return;
33
+ }
31
34
  (0, utils_1.validateExample)(isMultiple ? example.value : example, mediaType.schema, location, ctx, allowAdditionalProperties);
32
35
  }
33
36
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redocly/openapi-core",
3
- "version": "1.10.6",
3
+ "version": "1.11.0",
4
4
  "description": "",
5
5
  "main": "lib/index.js",
6
6
  "engines": {
@@ -35,7 +35,7 @@
35
35
  ],
36
36
  "dependencies": {
37
37
  "@redocly/ajv": "^8.11.0",
38
- "@redocly/config": "^0.1.4",
38
+ "@redocly/config": "^0.2.0",
39
39
  "colorette": "^1.2.0",
40
40
  "js-levenshtein": "^1.1.6",
41
41
  "js-yaml": "^4.1.0",
@@ -1,6 +1,8 @@
1
1
  import { outdent } from 'outdent';
2
2
 
3
3
  import { formatProblems, getTotals } from '../format/format';
4
+ import { LocationObject, NormalizedProblem } from '../walk';
5
+ import { Source } from '../resolve';
4
6
 
5
7
  describe('format', () => {
6
8
  function replaceColors(log: string) {
@@ -40,6 +42,10 @@ describe('format', () => {
40
42
  output += str;
41
43
  return true;
42
44
  });
45
+ jest.spyOn(process.stdout, 'write').mockImplementation((str: string | Uint8Array) => {
46
+ output += str;
47
+ return true;
48
+ });
43
49
  });
44
50
 
45
51
  it('should correctly format summary output', () => {
@@ -73,4 +79,32 @@ describe('format', () => {
73
79
  "
74
80
  `);
75
81
  });
82
+
83
+ it('should format problems using github-actions', () => {
84
+ const problems = [
85
+ {
86
+ ruleId: 'spec',
87
+ message: 'message',
88
+ severity: 'error' as const,
89
+ location: [
90
+ {
91
+ source: { absoluteRef: 'openapi.yaml' } as Source,
92
+ start: { line: 1, col: 2 },
93
+ end: { line: 3, col: 4 },
94
+ } as LocationObject,
95
+ ],
96
+ suggest: [],
97
+ },
98
+ ];
99
+
100
+ formatProblems(problems, {
101
+ format: 'github-actions',
102
+ version: '1.0.0',
103
+ totals: getTotals(problems),
104
+ });
105
+
106
+ expect(output).toEqual(
107
+ '::error title=spec,file=openapi.yaml,line=1,col=2,endLine=3,endColumn=4::message\n'
108
+ );
109
+ });
76
110
  });
@@ -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
  });