@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
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
+
14
+ ## 1.10.6
15
+
16
+ ### Patch Changes
17
+
18
+ - Added a type tree for the `metadata-schema` rule.
19
+
3
20
  ## 1.10.5
4
21
 
5
22
  ### 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
  },
@@ -295,6 +295,11 @@ const ObjectRule = {
295
295
  additionalProperties: {},
296
296
  required: ['severity'],
297
297
  };
298
+ // TODO: add better type tree for this
299
+ const Schema = {
300
+ properties: {},
301
+ additionalProperties: {},
302
+ };
298
303
  const AssertionDefinitionSubject = {
299
304
  properties: {
300
305
  type: {
@@ -949,6 +954,7 @@ const CoreConfigTypes = {
949
954
  ButtonOverrides,
950
955
  Overrides,
951
956
  ObjectRule,
957
+ Schema,
952
958
  RightPanel,
953
959
  Rules,
954
960
  Shape,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redocly/openapi-core",
3
- "version": "1.10.5",
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: {