@redocly/openapi-core 1.0.0-beta.109 → 1.0.0-beta.110

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 (40) hide show
  1. package/README.md +2 -2
  2. package/lib/config/config-resolvers.js +21 -3
  3. package/lib/config/config.d.ts +1 -0
  4. package/lib/config/config.js +1 -0
  5. package/lib/config/load.d.ts +8 -2
  6. package/lib/config/load.js +4 -2
  7. package/lib/config/types.d.ts +10 -0
  8. package/lib/config/utils.js +2 -2
  9. package/lib/rules/ajv.d.ts +1 -1
  10. package/lib/rules/ajv.js +5 -5
  11. package/lib/rules/common/assertions/asserts.d.ts +3 -5
  12. package/lib/rules/common/assertions/asserts.js +137 -97
  13. package/lib/rules/common/assertions/index.js +2 -6
  14. package/lib/rules/common/assertions/utils.d.ts +12 -6
  15. package/lib/rules/common/assertions/utils.js +33 -20
  16. package/lib/rules/utils.js +1 -1
  17. package/lib/types/redocly-yaml.js +16 -1
  18. package/package.json +3 -5
  19. package/src/__tests__/lint.test.ts +88 -0
  20. package/src/config/__tests__/config-resolvers.test.ts +37 -1
  21. package/src/config/__tests__/config.test.ts +5 -0
  22. package/src/config/__tests__/fixtures/resolve-config/local-config-with-custom-function.yaml +16 -0
  23. package/src/config/__tests__/fixtures/resolve-config/local-config-with-wrong-custom-function.yaml +16 -0
  24. package/src/config/__tests__/fixtures/resolve-config/plugin.js +11 -0
  25. package/src/config/__tests__/load.test.ts +1 -1
  26. package/src/config/__tests__/resolve-plugins.test.ts +3 -3
  27. package/src/config/config-resolvers.ts +28 -5
  28. package/src/config/config.ts +2 -0
  29. package/src/config/load.ts +10 -4
  30. package/src/config/types.ts +13 -0
  31. package/src/config/utils.ts +1 -0
  32. package/src/rules/ajv.ts +4 -4
  33. package/src/rules/common/assertions/__tests__/asserts.test.ts +491 -428
  34. package/src/rules/common/assertions/asserts.ts +155 -97
  35. package/src/rules/common/assertions/index.ts +2 -11
  36. package/src/rules/common/assertions/utils.ts +66 -36
  37. package/src/rules/oas3/__tests__/no-invalid-media-type-examples.test.ts +51 -2
  38. package/src/rules/utils.ts +2 -1
  39. package/src/types/redocly-yaml.ts +16 -0
  40. package/tsconfig.tsbuildinfo +1 -1
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.regexFromString = exports.isOrdered = exports.getIntersectionLength = exports.buildSubjectVisitor = exports.buildVisitorObject = void 0;
4
+ const logger_1 = require("../../../logger");
4
5
  const ref_utils_1 = require("../../../ref-utils");
5
6
  const asserts_1 = require("./asserts");
6
7
  function buildVisitorObject(subject, context, subjectVisitor) {
@@ -44,14 +45,15 @@ function buildVisitorObject(subject, context, subjectVisitor) {
44
45
  return visitor;
45
46
  }
46
47
  exports.buildVisitorObject = buildVisitorObject;
47
- function buildSubjectVisitor(properties, asserts, context) {
48
+ function buildSubjectVisitor(assertId, assertion, asserts) {
48
49
  return (node, { report, location, rawLocation, key, type, resolve, rawNode }) => {
49
50
  var _a;
51
+ let properties = assertion.property;
50
52
  // We need to check context's last node if it has the same type as subject node;
51
53
  // if yes - that means we didn't create context's last node visitor,
52
54
  // so we need to handle 'matchParentKeys' and 'excludeParentKeys' conditions here;
53
- if (context) {
54
- const lastContextNode = context[context.length - 1];
55
+ if (assertion.context) {
56
+ const lastContextNode = assertion.context[assertion.context.length - 1];
55
57
  if (lastContextNode.type === type.name) {
56
58
  const matchParentKeys = lastContextNode.matchParentKeys;
57
59
  const excludeParentKeys = lastContextNode.excludeParentKeys;
@@ -66,35 +68,55 @@ function buildSubjectVisitor(properties, asserts, context) {
66
68
  if (properties) {
67
69
  properties = Array.isArray(properties) ? properties : [properties];
68
70
  }
71
+ const defaultMessage = `${logger_1.colorize.blue(assertId)} failed because the ${logger_1.colorize.blue(assertion.subject)}${logger_1.colorize.blue(properties ? ` ${properties.join(', ')}` : '')} didn't meet the assertions: {{problems}}`;
72
+ const assertResults = [];
69
73
  for (const assert of asserts) {
70
74
  const currentLocation = assert.name === 'ref' ? rawLocation : location;
71
75
  if (properties) {
72
76
  for (const property of properties) {
73
77
  // we can have resolvable scalar so need to resolve value here.
74
78
  const value = ref_utils_1.isRef(node[property]) ? (_a = resolve(node[property])) === null || _a === void 0 ? void 0 : _a.node : node[property];
75
- runAssertion({
79
+ assertResults.push(runAssertion({
76
80
  values: value,
77
81
  rawValues: rawNode[property],
78
82
  assert,
79
83
  location: currentLocation.child(property),
80
- report,
81
- });
84
+ }));
82
85
  }
83
86
  }
84
87
  else {
85
88
  const value = assert.name === 'ref' ? rawNode : Object.keys(node);
86
- runAssertion({
89
+ assertResults.push(runAssertion({
87
90
  values: Object.keys(node),
88
91
  rawValues: value,
89
92
  assert,
90
93
  location: currentLocation,
91
- report,
92
- });
94
+ }));
93
95
  }
94
96
  }
97
+ const problems = assertResults.flat();
98
+ if (problems.length) {
99
+ const message = assertion.message || defaultMessage;
100
+ report({
101
+ message: message.replace('{{problems}}', getProblemsMessage(problems)),
102
+ location: getProblemsLocation(problems) || location,
103
+ forceSeverity: assertion.severity || 'error',
104
+ suggest: assertion.suggest || [],
105
+ ruleId: assertId,
106
+ });
107
+ }
95
108
  };
96
109
  }
97
110
  exports.buildSubjectVisitor = buildSubjectVisitor;
111
+ function getProblemsLocation(problems) {
112
+ return problems.length ? problems[0].location : undefined;
113
+ }
114
+ function getProblemsMessage(problems) {
115
+ var _a;
116
+ return problems.length === 1
117
+ ? (_a = problems[0].message) !== null && _a !== void 0 ? _a : ''
118
+ : problems.map((problem) => { var _a; return `\n- ${(_a = problem.message) !== null && _a !== void 0 ? _a : ''}`; }).join('');
119
+ }
98
120
  function getIntersectionLength(keys, properties) {
99
121
  const props = new Set(properties);
100
122
  let count = 0;
@@ -127,17 +149,8 @@ function isOrdered(value, options) {
127
149
  return true;
128
150
  }
129
151
  exports.isOrdered = isOrdered;
130
- function runAssertion({ values, rawValues, assert, location, report }) {
131
- const lintResult = asserts_1.asserts[assert.name](values, assert.conditions, location, rawValues);
132
- if (!lintResult.isValid) {
133
- report({
134
- message: assert.message || `The ${assert.assertId} doesn't meet required conditions`,
135
- location: lintResult.location || location,
136
- forceSeverity: assert.severity,
137
- suggest: assert.suggest,
138
- ruleId: assert.assertId,
139
- });
140
- }
152
+ function runAssertion({ values, rawValues, assert, location }) {
153
+ return asserts_1.asserts[assert.name](values, assert.conditions, location, rawValues);
141
154
  }
142
155
  function regexFromString(input) {
143
156
  const matches = input.match(/^\/(.*)\/(.*)|(.*)/);
@@ -93,7 +93,7 @@ function validateExample(example, schema, dataLoc, { resolve, location, report }
93
93
  for (const error of errors) {
94
94
  report({
95
95
  message: `Example value must conform to the schema: ${error.message}.`,
96
- location: Object.assign(Object.assign({}, new ref_utils_1.Location(dataLoc.source, error.instancePath)), { reportOnKey: error.keyword === 'additionalProperties' }),
96
+ location: Object.assign(Object.assign({}, new ref_utils_1.Location(dataLoc.source, error.instancePath)), { reportOnKey: error.keyword === 'unevaluatedProperties' || error.keyword === 'additionalProperties' }),
97
97
  from: location,
98
98
  suggest: error.suggest,
99
99
  });
@@ -149,6 +149,11 @@ const ConfigRoot = {
149
149
  http: 'ConfigHTTP',
150
150
  doNotResolveExamples: { type: 'boolean' },
151
151
  },
152
+ }, files: {
153
+ type: 'array',
154
+ items: {
155
+ type: 'string',
156
+ },
152
157
  } }),
153
158
  };
154
159
  const ConfigApis = {
@@ -161,7 +166,12 @@ const ConfigApisProperties = {
161
166
  items: {
162
167
  type: 'string',
163
168
  },
164
- }, lint: 'ConfigStyleguide', styleguide: 'ConfigStyleguide' }, ConfigStyleguide.properties), { 'features.openapi': 'ConfigReferenceDocs', 'features.mockServer': 'ConfigMockServer' }),
169
+ }, lint: 'ConfigStyleguide', styleguide: 'ConfigStyleguide' }, ConfigStyleguide.properties), { 'features.openapi': 'ConfigReferenceDocs', 'features.mockServer': 'ConfigMockServer', files: {
170
+ type: 'array',
171
+ items: {
172
+ type: 'string',
173
+ },
174
+ } }),
165
175
  required: ['root'],
166
176
  };
167
177
  const ConfigHTTP = {
@@ -249,6 +259,11 @@ const Assert = {
249
259
  maxLength: { type: 'integer' },
250
260
  ref: (value) => typeof value === 'string' ? { type: 'string' } : { type: 'boolean' },
251
261
  },
262
+ additionalProperties: (_value, key) => {
263
+ if (/^\w+\/\w+$/.test(key))
264
+ return { type: 'object' };
265
+ return;
266
+ },
252
267
  required: ['subject'],
253
268
  };
254
269
  const Context = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redocly/openapi-core",
3
- "version": "1.0.0-beta.109",
3
+ "version": "1.0.0-beta.110",
4
4
  "description": "",
5
5
  "main": "lib/index.js",
6
6
  "engines": {
@@ -29,12 +29,10 @@
29
29
  "oas"
30
30
  ],
31
31
  "contributors": [
32
- "Sergey Dubovyk <serhii@redoc.ly> (https://redoc.ly/)",
33
- "Roman Hotsiy <roman@redoc.ly> (https://redoc.ly/)",
34
- "Andriy Leliv <andriy@redoc.ly> (https://redoc.ly/)"
32
+ "Roman Hotsiy <roman@redoc.ly> (https://redoc.ly/)"
35
33
  ],
36
34
  "dependencies": {
37
- "@redocly/ajv": "^8.6.5",
35
+ "@redocly/ajv": "^8.11.0",
38
36
  "@types/node": "^14.11.8",
39
37
  "colorette": "^1.2.0",
40
38
  "js-levenshtein": "^1.1.6",
@@ -60,6 +60,22 @@ describe('lint', () => {
60
60
  no-invalid-media-type-examples: error
61
61
  path-http-verbs-order: error
62
62
  boolean-parameter-prefixes: off
63
+ assert/operation-summary-length:
64
+ subject: Operation
65
+ property: summary
66
+ message: Operation summary should start with an active verb
67
+ local/checkWordsCount:
68
+ min: 3
69
+ features.openapi:
70
+ showConsole: true
71
+ layout:
72
+ scope: section
73
+ routingStrategy: browser
74
+ theme:
75
+ rightPanel:
76
+ backgroundColor: '#263238'
77
+ links:
78
+ color: '#6CC496'
63
79
  features.openapi:
64
80
  showConsole: true
65
81
  layout:
@@ -77,6 +93,78 @@ describe('lint', () => {
77
93
 
78
94
  expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
79
95
  Array [
96
+ Object {
97
+ "from": undefined,
98
+ "location": Array [
99
+ Object {
100
+ "pointer": "#/atures.openapi",
101
+ "reportOnKey": true,
102
+ "source": "",
103
+ },
104
+ ],
105
+ "message": "Property \`atures.openapi\` is not expected here.",
106
+ "ruleId": "configuration spec",
107
+ "severity": "error",
108
+ "suggest": Array [
109
+ "features.openapi",
110
+ ],
111
+ },
112
+ Object {
113
+ "from": undefined,
114
+ "location": Array [
115
+ Object {
116
+ "pointer": "#/showConsole",
117
+ "reportOnKey": true,
118
+ "source": "",
119
+ },
120
+ ],
121
+ "message": "Property \`showConsole\` is not expected here.",
122
+ "ruleId": "configuration spec",
123
+ "severity": "error",
124
+ "suggest": Array [],
125
+ },
126
+ Object {
127
+ "from": undefined,
128
+ "location": Array [
129
+ Object {
130
+ "pointer": "#/layout",
131
+ "reportOnKey": true,
132
+ "source": "",
133
+ },
134
+ ],
135
+ "message": "Property \`layout\` is not expected here.",
136
+ "ruleId": "configuration spec",
137
+ "severity": "error",
138
+ "suggest": Array [],
139
+ },
140
+ Object {
141
+ "from": undefined,
142
+ "location": Array [
143
+ Object {
144
+ "pointer": "#/routingStrategy",
145
+ "reportOnKey": true,
146
+ "source": "",
147
+ },
148
+ ],
149
+ "message": "Property \`routingStrategy\` is not expected here.",
150
+ "ruleId": "configuration spec",
151
+ "severity": "error",
152
+ "suggest": Array [],
153
+ },
154
+ Object {
155
+ "from": undefined,
156
+ "location": Array [
157
+ Object {
158
+ "pointer": "#/theme",
159
+ "reportOnKey": true,
160
+ "source": "",
161
+ },
162
+ ],
163
+ "message": "Property \`theme\` is not expected here.",
164
+ "ruleId": "configuration spec",
165
+ "severity": "error",
166
+ "suggest": Array [],
167
+ },
80
168
  Object {
81
169
  "from": undefined,
82
170
  "location": Array [
@@ -1,3 +1,5 @@
1
+ import { colorize } from '../../logger';
2
+ import { asserts } from '../../rules/common/assertions/asserts';
1
3
  import { resolveStyleguideConfig, resolveApis, resolveConfig } from '../config-resolvers';
2
4
  const path = require('path');
3
5
 
@@ -130,6 +132,40 @@ describe('resolveStyleguideConfig', () => {
130
132
  expect(styleguide).toMatchSnapshot();
131
133
  });
132
134
 
135
+ it('should resolve custom assertion from plugin', async () => {
136
+ const styleguideConfig = {
137
+ extends: ['local-config-with-custom-function.yaml'],
138
+ };
139
+ const { plugins } = await resolveStyleguideConfig({
140
+ styleguideConfig,
141
+ configPath,
142
+ });
143
+
144
+ expect(plugins).toBeDefined();
145
+ expect(plugins?.length).toBe(2);
146
+ expect(asserts['test-plugin/checkWordsCount']).toBeDefined();
147
+ });
148
+
149
+ it('should throw error when custom assertion load not exist plugin', async () => {
150
+ const styleguideConfig = {
151
+ extends: ['local-config-with-wrong-custom-function.yaml'],
152
+ };
153
+ try {
154
+ await resolveStyleguideConfig({
155
+ styleguideConfig,
156
+ configPath,
157
+ });
158
+ } catch (e) {
159
+ expect(e.message.toString()).toContain(
160
+ `Plugin ${colorize.red(
161
+ 'test-plugin'
162
+ )} doesn't export assertions function with name ${colorize.red('checkWordsCount2')}.`
163
+ );
164
+ }
165
+
166
+ expect(asserts['test-plugin/checkWordsCount']).toBeDefined();
167
+ });
168
+
133
169
  it('should correctly merge assertions from nested config', async () => {
134
170
  const styleguideConfig = {
135
171
  extends: ['local-config-with-file.yaml'],
@@ -165,7 +201,7 @@ describe('resolveStyleguideConfig', () => {
165
201
  const styleguideConfig = {
166
202
  // This points to ./fixtures/resolve-remote-configs/remote-config.yaml
167
203
  extends: [
168
- 'https://raw.githubusercontent.com/Redocly/redocly-cli/master/packages/core/src/config/__tests__/fixtures/resolve-remote-configs/remote-config.yaml',
204
+ 'https://raw.githubusercontent.com/Redocly/redocly-cli/main/packages/core/src/config/__tests__/fixtures/resolve-remote-configs/remote-config.yaml',
169
205
  ],
170
206
  };
171
207
 
@@ -48,6 +48,7 @@ const testConfig: Config = {
48
48
  'features.mockServer': {},
49
49
  resolve: { http: { headers: [] } },
50
50
  organization: 'redocly-test',
51
+ files: [],
51
52
  };
52
53
 
53
54
  describe('getMergedConfig', () => {
@@ -67,6 +68,7 @@ describe('getMergedConfig', () => {
67
68
  "configFile": "redocly.yaml",
68
69
  "features.mockServer": Object {},
69
70
  "features.openapi": Object {},
71
+ "files": Array [],
70
72
  "organization": "redocly-test",
71
73
  "rawConfig": Object {
72
74
  "apis": Object {
@@ -81,6 +83,7 @@ describe('getMergedConfig', () => {
81
83
  },
82
84
  "features.mockServer": Object {},
83
85
  "features.openapi": Object {},
86
+ "files": Array [],
84
87
  "organization": "redocly-test",
85
88
  "styleguide": Object {
86
89
  "extendPaths": Array [],
@@ -163,6 +166,7 @@ describe('getMergedConfig', () => {
163
166
  "configFile": "redocly.yaml",
164
167
  "features.mockServer": Object {},
165
168
  "features.openapi": Object {},
169
+ "files": Array [],
166
170
  "organization": "redocly-test",
167
171
  "rawConfig": Object {
168
172
  "apis": Object {
@@ -177,6 +181,7 @@ describe('getMergedConfig', () => {
177
181
  },
178
182
  "features.mockServer": Object {},
179
183
  "features.openapi": Object {},
184
+ "files": Array [],
180
185
  "organization": "redocly-test",
181
186
  "styleguide": Object {
182
187
  "extendPaths": Array [],
@@ -0,0 +1,16 @@
1
+ lint:
2
+ rules:
3
+ no-invalid-media-type-examples: warn
4
+ operation-4xx-response: off
5
+ assert/tag-description:
6
+ subject: Tag
7
+ property: description
8
+ message: Tag description must have at least 3 words.
9
+ severity: error
10
+ test-plugin/checkWordsCount:
11
+ min: 3
12
+ plugins:
13
+ - plugin.js
14
+ extends:
15
+ - recommended
16
+ - test-plugin/all
@@ -0,0 +1,16 @@
1
+ lint:
2
+ rules:
3
+ no-invalid-media-type-examples: warn
4
+ operation-4xx-response: off
5
+ assert/tag-description:
6
+ subject: Tag
7
+ property: description
8
+ message: Tag description must have at least 3 words.
9
+ severity: error
10
+ test-plugin/checkWordsCount2:
11
+ min: 3
12
+ plugins:
13
+ - plugin.js
14
+ extends:
15
+ - recommended
16
+ - test-plugin/all
@@ -51,6 +51,16 @@ const decorators = {
51
51
  },
52
52
  };
53
53
 
54
+ const assertions = {
55
+ checkWordsCount: (value, opts, location) => {
56
+ const words = value.split(' ');
57
+ if (words.length >= opts.min) {
58
+ return { isValid: true };
59
+ }
60
+ return { isValid: false, location };
61
+ },
62
+ };
63
+
54
64
  const configs = {
55
65
  all: {
56
66
  rules: {
@@ -66,4 +76,5 @@ module.exports = {
66
76
  rules,
67
77
  decorators,
68
78
  configs,
79
+ assertions,
69
80
  };
@@ -49,7 +49,7 @@ describe('loadConfig', () => {
49
49
 
50
50
  it('should call callback if such passed', async () => {
51
51
  const mockFn = jest.fn();
52
- await loadConfig(undefined, undefined, mockFn);
52
+ await loadConfig({ processRawConfig: mockFn });
53
53
  expect(mockFn).toHaveBeenCalled();
54
54
  });
55
55
  });
@@ -5,21 +5,21 @@ describe('resolving a plugin', () => {
5
5
  const configPath = path.join(__dirname, 'fixtures/plugin-config.yaml');
6
6
 
7
7
  it('should prefix rule names with the plugin id', async () => {
8
- const config = await loadConfig(configPath);
8
+ const config = await loadConfig({ configPath });
9
9
  const plugin = config.styleguide.plugins[0];
10
10
 
11
11
  expect(plugin.rules?.oas3).toHaveProperty('test-plugin/openid-connect-url-well-known');
12
12
  });
13
13
 
14
14
  it('should prefix preprocessor names with the plugin id', async () => {
15
- const config = await loadConfig(configPath);
15
+ const config = await loadConfig({ configPath });
16
16
  const plugin = config.styleguide.plugins[0];
17
17
 
18
18
  expect(plugin.preprocessors?.oas2).toHaveProperty('test-plugin/description-preprocessor');
19
19
  });
20
20
 
21
21
  it('should prefix decorator names with the plugin id', async () => {
22
- const config = await loadConfig(configPath);
22
+ const config = await loadConfig({ configPath });
23
23
  const plugin = config.styleguide.plugins[0];
24
24
 
25
25
  expect(plugin.decorators?.oas3).toHaveProperty('test-plugin/inject-x-stats');
@@ -24,6 +24,7 @@ import { isBrowser } from '../env';
24
24
  import { isNotString, isString, isDefined, parseYaml } from '../utils';
25
25
  import { Config } from './config';
26
26
  import { colorize, logger } from '../logger';
27
+ import { asserts, buildAssertCustomFunction } from '../rules/common/assertions/asserts';
27
28
 
28
29
  export async function resolveConfig(rawConfig: RawConfig, configPath?: string): Promise<Config> {
29
30
  if (rawConfig.styleguide?.extends?.some(isNotString)) {
@@ -175,6 +176,10 @@ export function resolvePlugins(
175
176
  }
176
177
  }
177
178
 
179
+ if (pluginModule.assertions) {
180
+ plugin.assertions = pluginModule.assertions;
181
+ }
182
+
178
183
  return plugin;
179
184
  })
180
185
  .filter(isDefined);
@@ -299,8 +304,7 @@ export async function resolveStyleguideConfig(
299
304
  return {
300
305
  ...resolvedStyleguideConfig,
301
306
  rules:
302
- resolvedStyleguideConfig.rules &&
303
- groupStyleguideAssertionRules(resolvedStyleguideConfig.rules),
307
+ resolvedStyleguideConfig.rules && groupStyleguideAssertionRules(resolvedStyleguideConfig),
304
308
  };
305
309
  }
306
310
 
@@ -392,9 +396,10 @@ function getMergedRawStyleguideConfig(
392
396
  return resultLint;
393
397
  }
394
398
 
395
- function groupStyleguideAssertionRules(
396
- rules: Record<string, RuleConfig> | undefined
397
- ): Record<string, RuleConfig> | undefined {
399
+ function groupStyleguideAssertionRules({
400
+ rules,
401
+ plugins,
402
+ }: ResolvedStyleguideConfig): Record<string, RuleConfig> | undefined {
398
403
  if (!rules) {
399
404
  return rules;
400
405
  }
@@ -407,6 +412,24 @@ function groupStyleguideAssertionRules(
407
412
  for (const [ruleKey, rule] of Object.entries(rules)) {
408
413
  if (ruleKey.startsWith('assert/') && typeof rule === 'object' && rule !== null) {
409
414
  const assertion = rule;
415
+ if (plugins) {
416
+ for (const field of Object.keys(assertion)) {
417
+ const [pluginId, fn] = field.split('/');
418
+ if (!pluginId || !fn) continue;
419
+ const plugin = plugins.find((plugin) => plugin.id === pluginId);
420
+ if (!plugin) {
421
+ throw Error(colorize.red(`Plugin ${colorize.blue(pluginId)} isn't found.`));
422
+ }
423
+ if (!plugin.assertions || !plugin.assertions[fn]) {
424
+ throw Error(
425
+ `Plugin ${colorize.red(
426
+ pluginId
427
+ )} doesn't export assertions function with name ${colorize.red(fn)}.`
428
+ );
429
+ }
430
+ asserts[field] = buildAssertCustomFunction(plugin.assertions[fn]);
431
+ }
432
+ }
410
433
  assertions.push({
411
434
  ...assertion,
412
435
  assertionId: ruleKey.replace('assert/', ''),
@@ -306,6 +306,7 @@ export class Config {
306
306
  'features.openapi': Record<string, any>;
307
307
  'features.mockServer'?: Record<string, any>;
308
308
  organization?: string;
309
+ files: string[];
309
310
  constructor(public rawConfig: ResolvedConfig, public configFile?: string) {
310
311
  this.apis = rawConfig.apis || {};
311
312
  this.styleguide = new StyleguideConfig(rawConfig.styleguide || {}, configFile);
@@ -314,5 +315,6 @@ export class Config {
314
315
  this.resolve = getResolveConfig(rawConfig?.resolve);
315
316
  this.region = rawConfig.region;
316
317
  this.organization = rawConfig.organization;
318
+ this.files = rawConfig.files || [];
317
319
  }
318
320
  }
@@ -62,11 +62,17 @@ async function addConfigMetadata({
62
62
  }
63
63
 
64
64
  export async function loadConfig(
65
- configPath: string | undefined = findConfig(),
66
- customExtends?: string[],
67
- processRawConfig?: (rawConfig: RawConfig) => void | Promise<void>
65
+ options: {
66
+ configPath?: string;
67
+ customExtends?: string[];
68
+ processRawConfig?: (rawConfig: RawConfig) => void | Promise<void>;
69
+ files?: string[];
70
+ region?: Region;
71
+ } = {}
68
72
  ): Promise<Config> {
69
- const rawConfig = await getConfig(configPath);
73
+ const { configPath = findConfig(), customExtends, processRawConfig, files, region } = options;
74
+ const config = await getConfig(configPath);
75
+ const rawConfig = { ...config, files: files ?? config.files, region: region ?? config.region };
70
76
  if (typeof processRawConfig === 'function') {
71
77
  await processRawConfig(rawConfig);
72
78
  }
@@ -10,6 +10,7 @@ import type {
10
10
  OasVersion,
11
11
  } from '../oas-types';
12
12
  import type { NodeType } from '../types';
13
+ import { Location } from '../ref-utils';
13
14
 
14
15
  export type RuleSeverity = ProblemSeverity | 'off';
15
16
 
@@ -83,6 +84,15 @@ export type CustomRulesConfig = {
83
84
  oas2?: Oas2RuleSet;
84
85
  };
85
86
 
87
+ export type AssertResult = { message?: string; location?: Location };
88
+ export type CustomFunction = (
89
+ value: any,
90
+ options: unknown,
91
+ baseLocation: Location
92
+ ) => AssertResult[];
93
+
94
+ export type AssertionsConfig = Record<string, CustomFunction>;
95
+
86
96
  export type Plugin = {
87
97
  id: string;
88
98
  configs?: Record<string, PluginStyleguideConfig>;
@@ -90,6 +100,7 @@ export type Plugin = {
90
100
  preprocessors?: PreprocessorsConfig;
91
101
  decorators?: DecoratorsConfig;
92
102
  typeExtension?: TypeExtensionsConfig;
103
+ assertions?: AssertionsConfig;
93
104
  };
94
105
 
95
106
  export type PluginStyleguideConfig = Omit<StyleguideRawConfig, 'plugins' | 'extends'>;
@@ -145,6 +156,7 @@ export type DeprecatedInApi = {
145
156
 
146
157
  export type ResolvedApi = Omit<Api, 'styleguide'> & {
147
158
  styleguide: ResolvedStyleguideConfig;
159
+ files?: string[];
148
160
  };
149
161
 
150
162
  export type RawConfig = {
@@ -153,6 +165,7 @@ export type RawConfig = {
153
165
  resolve?: RawResolveConfig;
154
166
  region?: Region;
155
167
  organization?: string;
168
+ files?: string[];
156
169
  } & FeaturesConfig;
157
170
 
158
171
  export type FlatApi = Omit<Api, 'styleguide'> &
@@ -233,6 +233,7 @@ export function getMergedConfig(config: Config, apiName?: string): Config {
233
233
  ...config['features.mockServer'],
234
234
  ...config.apis[apiName]?.['features.mockServer'],
235
235
  },
236
+ files: [...config.files, ...(config.apis?.[apiName]?.files ?? [])],
236
237
  // TODO: merge everything else here
237
238
  },
238
239
  config.configFile
package/src/rules/ajv.ts CHANGED
@@ -1,4 +1,4 @@
1
- import Ajv, { ValidateFunction, ErrorObject } from '@redocly/ajv';
1
+ import Ajv, { ValidateFunction, ErrorObject } from '@redocly/ajv/dist/2020';
2
2
  import { Location, escapePointer } from '../ref-utils';
3
3
  import { ResolveFn } from '../walk';
4
4
 
@@ -20,7 +20,7 @@ function getAjv(resolve: ResolveFn, allowAdditionalProperties: boolean) {
20
20
  discriminator: true,
21
21
  allowUnionTypes: true,
22
22
  validateFormats: false, // TODO: fix it
23
- defaultAdditionalProperties: allowAdditionalProperties,
23
+ defaultUnevaluatedProperties: allowAdditionalProperties,
24
24
  loadSchemaSync(base: string, $ref: string) {
25
25
  const resolvedRef = resolve({ $ref }, base.split('#')[0]);
26
26
  if (!resolvedRef || !resolvedRef.location) return false;
@@ -87,8 +87,8 @@ export function validateJsonSchema(
87
87
  if (propName) {
88
88
  message = `\`${propName}\` property ${message}`;
89
89
  }
90
- if (error.keyword === 'additionalProperties') {
91
- const property = error.params.additionalProperty;
90
+ if (error.keyword === 'additionalProperties' || error.keyword === 'unevaluatedProperties') {
91
+ const property = error.params.additionalProperty || error.params.unevaluatedProperty;
92
92
  message = `${message} \`${property}\``;
93
93
  error.instancePath += '/' + escapePointer(property);
94
94
  }