api-quality-spectral-ruleset 1.0.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/LICENSE +674 -0
- package/README.md +69 -0
- package/apq-spectral.yaml +1119 -0
- package/functions/apq-alternate-paths.js +35 -0
- package/functions/apq-at-most-one-body-parameter.js +24 -0
- package/functions/apq-compare-insensitive.js +33 -0
- package/functions/apq-custom-schema.js +88 -0
- package/functions/apq-default-value.js +23 -0
- package/functions/apq-naming-convention.js +21 -0
- package/functions/apq-parameter-naming-convention.js +76 -0
- package/functions/apq-properties-schema-format.js +29 -0
- package/functions/apq-resources-by-verb.js +117 -0
- package/functions/apq-responses.js +26 -0
- package/functions/apq-schema-format.js +98 -0
- package/functions/apq-standard-response-codes.js +76 -0
- package/functions/apq-truthy-insensitive.js +18 -0
- package/package.json +47 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
|
|
2
|
+
const isVariable = (part) => {
|
|
3
|
+
return (part.startsWith('{') && part.endsWith('}'));
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
module.exports = (given, { except }, context) => {
|
|
7
|
+
const result = [];
|
|
8
|
+
const paths = given || [];
|
|
9
|
+
if (paths.length === 0) return result;
|
|
10
|
+
|
|
11
|
+
const parts = paths.substr(1).split('/');
|
|
12
|
+
let previousIsVar = isVariable(parts.shift());
|
|
13
|
+
if (previousIsVar) {
|
|
14
|
+
return [{
|
|
15
|
+
message: context.rule.message,
|
|
16
|
+
}];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
for (const part of parts) {
|
|
20
|
+
if (except && except.includes(part)) {
|
|
21
|
+
previousIsVar = true;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const currentIsVariable = isVariable(part);
|
|
26
|
+
if (currentIsVariable === previousIsVar) {
|
|
27
|
+
return [{
|
|
28
|
+
message: context.rule.message,
|
|
29
|
+
}];
|
|
30
|
+
}
|
|
31
|
+
previousIsVar = currentIsVariable;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return result;
|
|
35
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {string} given
|
|
3
|
+
* @param {object} options
|
|
4
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
5
|
+
*/
|
|
6
|
+
module.exports = (parameters, options, context) => {
|
|
7
|
+
if (!parameters || !Array.isArray(parameters) || parameters.length === 0) {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const bodyParams = parameters.filter(param => {
|
|
12
|
+
return param && typeof param === 'object' && param.in === 'body';
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
if (bodyParams.length > 1) {
|
|
16
|
+
return [
|
|
17
|
+
{
|
|
18
|
+
message: context.rule.message,
|
|
19
|
+
}
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return [];
|
|
24
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {object} given
|
|
3
|
+
* @param {object} options
|
|
4
|
+
* @param {string} options.property
|
|
5
|
+
* @param {string} options.equalTo
|
|
6
|
+
* @param {string} options.result
|
|
7
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
8
|
+
*
|
|
9
|
+
*/
|
|
10
|
+
module.exports = (given, options, context) => {
|
|
11
|
+
const errors = [];
|
|
12
|
+
if (!given) {
|
|
13
|
+
return errors;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const propA = given[options.property];
|
|
17
|
+
const propB = given[options.equalTo];
|
|
18
|
+
|
|
19
|
+
if (
|
|
20
|
+
typeof propA === 'string' &&
|
|
21
|
+
typeof propB === 'string' &&
|
|
22
|
+
options.result === 'falsy' &&
|
|
23
|
+
propA.trim().toUpperCase() === propB.trim().toUpperCase()
|
|
24
|
+
) {
|
|
25
|
+
errors.push({
|
|
26
|
+
message: context.rule.message,
|
|
27
|
+
path: [...context.path, options.property]
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return errors;
|
|
32
|
+
};
|
|
33
|
+
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// options:
|
|
2
|
+
// except: 'array<string>'
|
|
3
|
+
// schema: object
|
|
4
|
+
|
|
5
|
+
function DeepCompare() {
|
|
6
|
+
var i, l, leftChain, rightChain;
|
|
7
|
+
|
|
8
|
+
function Objects(x, y, required = []) {
|
|
9
|
+
let p;
|
|
10
|
+
|
|
11
|
+
// Quick checking of one object being a subset of another.
|
|
12
|
+
// todo: cache the structure of arguments[0] for performance
|
|
13
|
+
for (p in y) {
|
|
14
|
+
if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) {
|
|
15
|
+
if (required.includes(p)) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
} else if (typeof y[p] !== typeof x[p]) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (p in x) {
|
|
24
|
+
if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) {
|
|
25
|
+
if (required.includes(p)) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
continue;
|
|
29
|
+
} else if (typeof y[p] !== typeof x[p]) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
switch (typeof x[p]) {
|
|
34
|
+
case "object":
|
|
35
|
+
case "function":
|
|
36
|
+
leftChain.push(x);
|
|
37
|
+
rightChain.push(y);
|
|
38
|
+
|
|
39
|
+
if (!Objects(x[p], y[p], x[p].required || required)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
leftChain.pop();
|
|
44
|
+
rightChain.pop();
|
|
45
|
+
break;
|
|
46
|
+
|
|
47
|
+
default:
|
|
48
|
+
if (x[p] !== y[p]) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (i = 1, l = arguments.length; i < l; i++) {
|
|
59
|
+
leftChain = []; //Todo: this can be cached
|
|
60
|
+
rightChain = [];
|
|
61
|
+
|
|
62
|
+
if (!Objects(arguments[0], arguments[i], arguments[0].required || [])) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {Array<string>} given
|
|
72
|
+
* @param {object} options
|
|
73
|
+
* @param {Array<string>} options.except
|
|
74
|
+
* @param {object} options.schema
|
|
75
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
76
|
+
*/
|
|
77
|
+
module.exports = (given, options, context) => {
|
|
78
|
+
const result = [];
|
|
79
|
+
const paths = given || [];
|
|
80
|
+
if (paths.length === 0) return result;
|
|
81
|
+
if (options.except.includes(String(context.path[1]))) return result;
|
|
82
|
+
|
|
83
|
+
if (DeepCompare(options.schema, given)) return result;
|
|
84
|
+
|
|
85
|
+
return [{
|
|
86
|
+
message: context.rule.message,
|
|
87
|
+
}];
|
|
88
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {object} given
|
|
3
|
+
* @param {object} options
|
|
4
|
+
* @param {string} options.type
|
|
5
|
+
* @param {string} options.match
|
|
6
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
7
|
+
*/
|
|
8
|
+
module.exports = (given, options, context) => {
|
|
9
|
+
const errors = [];
|
|
10
|
+
if (!given) return errors;
|
|
11
|
+
|
|
12
|
+
if (!given.type || given.type.toString() !== options.type.toString()) {
|
|
13
|
+
errors.push({
|
|
14
|
+
message: context.rule.message,
|
|
15
|
+
});
|
|
16
|
+
} else if (!given.default || given.default.toString() !== options.match.toString()) {
|
|
17
|
+
errors.push({
|
|
18
|
+
message: context.rule.message,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return errors;
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const { pattern } = require("@stoplight/spectral-functions");
|
|
2
|
+
|
|
3
|
+
const patterns = {
|
|
4
|
+
kebabCase: /^(\/|[a-z0-9-.]+|{[a-zA-Z0-9_]+})+$/,
|
|
5
|
+
camelCase: /^[a-z]+([A-Z][a-z0-9]+)*$/,
|
|
6
|
+
snakeCase: /^[a-z]+(_[a-z0-9]+)*$/,
|
|
7
|
+
pascalCase: /^[A-Z]+([A-Z][a-z0-9]+)*$/
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} given
|
|
12
|
+
* @param {object} options
|
|
13
|
+
* @param {string} options.pattern
|
|
14
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
15
|
+
*/
|
|
16
|
+
module.exports = (given, options, context) => {
|
|
17
|
+
const errors = [];
|
|
18
|
+
if (!given) return errors;
|
|
19
|
+
|
|
20
|
+
return pattern(given, { match: patterns[options.pattern] }, context);
|
|
21
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const NAMING_REGEX = {
|
|
2
|
+
"snake_case": /^[a-z0-9_$]*$/,
|
|
3
|
+
"kebab-case": /^[a-z0-9-]*$/,
|
|
4
|
+
"camelCase": /^[a-z]+([A-Z][a-z]+)*([A-Z])?$/,
|
|
5
|
+
"UpperCamelCase": /^[A-Z][a-z]+(?:[A-Z][a-z]+)*$/
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const NAME_EXCEPTIONS = new Set([
|
|
9
|
+
"$init", "$start", "$limit", "$total", "$expand", "$orderby", "$select", "$exclude", "$filter"
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const PARAM_REGEX = /\{([^}{]*)\}/g;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} given
|
|
16
|
+
* @param {object} options
|
|
17
|
+
* @param {string} options.namingConvention
|
|
18
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
19
|
+
*/
|
|
20
|
+
module.exports = (given, options, context) => {
|
|
21
|
+
if (!given) return [];
|
|
22
|
+
|
|
23
|
+
const convention = options.namingConvention || "snake_case";
|
|
24
|
+
const regex = NAMING_REGEX[convention];
|
|
25
|
+
|
|
26
|
+
if (!regex) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`OAR012: Unknown naming convention "${convention}". ` +
|
|
29
|
+
`Allowed values: ${Object.keys(NAMING_REGEX).join(", ")}`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (NAME_EXCEPTIONS.has(given)) {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let name = given;
|
|
38
|
+
|
|
39
|
+
// Strip parameter names
|
|
40
|
+
name = name.replace(PARAM_REGEX, (_, match) => match); // Keep only the parameter name without braces
|
|
41
|
+
|
|
42
|
+
switch (convention) {
|
|
43
|
+
case "camelCase":
|
|
44
|
+
case "UpperCamelCase":
|
|
45
|
+
name = name.replace(/\//g, "");
|
|
46
|
+
if (name.includes("_") || name.includes("-")) {
|
|
47
|
+
return [
|
|
48
|
+
{
|
|
49
|
+
message: `OAR012: "${given}" must follow ${convention}`,
|
|
50
|
+
path: context.path
|
|
51
|
+
}
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
break;
|
|
55
|
+
|
|
56
|
+
case "kebab-case":
|
|
57
|
+
name = name.replace(/\//g, "-");
|
|
58
|
+
break;
|
|
59
|
+
|
|
60
|
+
case "snake_case":
|
|
61
|
+
default:
|
|
62
|
+
name = name.replace(/\//g, "_");
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!regex.test(name)) {
|
|
67
|
+
return [
|
|
68
|
+
{
|
|
69
|
+
message: `OAR012: "${given}" must follow ${convention}`,
|
|
70
|
+
path: context.path
|
|
71
|
+
}
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return [];
|
|
76
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Check that format is valid for a schema type.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {object} given
|
|
5
|
+
* @param {object} options
|
|
6
|
+
* @param {Array<string>} options.formats
|
|
7
|
+
* @param {Array<string>} options.properties
|
|
8
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
9
|
+
*/
|
|
10
|
+
module.exports = function checkTypeAndFormat(given, options, context) {
|
|
11
|
+
if (given === null || typeof given !== "object") {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const errors = [];
|
|
16
|
+
const path = context.path || [];
|
|
17
|
+
const name = path.slice(-1)[0].toString();
|
|
18
|
+
|
|
19
|
+
if (options.properties.includes(name)) {
|
|
20
|
+
if (!given.format || !options.formats.includes(given.format)) {
|
|
21
|
+
errors.push({
|
|
22
|
+
message: context.rule.message,
|
|
23
|
+
path: [...path, name]
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return errors;
|
|
29
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const DEFAULT_ALLOWED_PATTERNS = `
|
|
2
|
+
;get:^/[^/{}]+$
|
|
3
|
+
;get:^/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+$
|
|
4
|
+
;get:^/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+$
|
|
5
|
+
;get:^/[^/{}]+/(\\{[^/{}]+\\}|me)$
|
|
6
|
+
;get:^/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)$
|
|
7
|
+
;get:^/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)$
|
|
8
|
+
|
|
9
|
+
;post:^/[^/{}]+$
|
|
10
|
+
;post:^/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+$
|
|
11
|
+
;post:^/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+$
|
|
12
|
+
;post:^/[^/{}]+/get$
|
|
13
|
+
;post:^/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/get$
|
|
14
|
+
;post:^/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/get$
|
|
15
|
+
;post:^/[^/{}]+/delete$
|
|
16
|
+
;post:^/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/delete$
|
|
17
|
+
;post:^/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/delete$
|
|
18
|
+
|
|
19
|
+
;put:^/[^/{}]+/(\\{[^/{}]+\\}|me)$
|
|
20
|
+
;put:^/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)$
|
|
21
|
+
;put:^/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)$
|
|
22
|
+
|
|
23
|
+
;patch:^/[^/{}]+/(\\{[^/{}]+\\}|me)$
|
|
24
|
+
;patch:^/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)$
|
|
25
|
+
;patch:^/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)$
|
|
26
|
+
|
|
27
|
+
;delete:^/[^/{}]+/(\\{[^/{}]+\\}|me)$
|
|
28
|
+
;delete:^/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)$
|
|
29
|
+
;delete:^/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)$
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
const SUPPORTED_VERBS = ['get', 'post', 'put', 'patch', 'delete'];
|
|
33
|
+
const CONFIG_ERROR_PREFIX = 'OAR018:';
|
|
34
|
+
|
|
35
|
+
const parseAllowedPatterns = (patterns) => {
|
|
36
|
+
if (typeof patterns !== 'string') {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`${CONFIG_ERROR_PREFIX} allowed-resources-paths must be a string`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return patterns
|
|
43
|
+
.split(';')
|
|
44
|
+
.map(p => p.trim())
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.map(entry => {
|
|
47
|
+
const idx = entry.indexOf(':');
|
|
48
|
+
|
|
49
|
+
if (idx === -1) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`${CONFIG_ERROR_PREFIX} Invalid entry "${entry}". Expected format "<verb>:<regex>"`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const verb = entry.slice(0, idx).toLowerCase();
|
|
56
|
+
const regexSource = entry.slice(idx + 1);
|
|
57
|
+
|
|
58
|
+
if (!SUPPORTED_VERBS.includes(verb)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`${CONFIG_ERROR_PREFIX} Unsupported HTTP verb "${verb}" in "${entry}"`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
return {
|
|
66
|
+
verb,
|
|
67
|
+
regex: new RegExp(regexSource),
|
|
68
|
+
};
|
|
69
|
+
} catch (err) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`${CONFIG_ERROR_PREFIX} Invalid regex for verb "${verb}": ${regexSource}. ${err.message}`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
module.exports = (given, functionOptions = {}, context) => {
|
|
78
|
+
const results = [];
|
|
79
|
+
if (!given || typeof given !== 'object') return results;
|
|
80
|
+
|
|
81
|
+
let allowedPatterns;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const patterns = functionOptions['allowed-resources-paths']?.trim()
|
|
85
|
+
? functionOptions['allowed-resources-paths']
|
|
86
|
+
: DEFAULT_ALLOWED_PATTERNS;
|
|
87
|
+
|
|
88
|
+
allowedPatterns = parseAllowedPatterns(patterns);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return [{
|
|
91
|
+
message: error.message,
|
|
92
|
+
path: context.path,
|
|
93
|
+
}];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
Object.entries(given).forEach(([path, operations]) => {
|
|
97
|
+
Object.keys(operations || {}).forEach(verb => {
|
|
98
|
+
const verbLower = verb.toLowerCase();
|
|
99
|
+
if (!SUPPORTED_VERBS.includes(verbLower)) return;
|
|
100
|
+
|
|
101
|
+
const isAllowed = allowedPatterns.some(p =>
|
|
102
|
+
p.verb === verbLower && p.regex.test(path)
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (!isAllowed) {
|
|
106
|
+
results.push({
|
|
107
|
+
message: context.rule.message
|
|
108
|
+
.replace('{{path}}', path)
|
|
109
|
+
.replace('{{verb}}', verbLower.toUpperCase()),
|
|
110
|
+
path: [...context.path, path, verb],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return results;
|
|
117
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
|
|
2
|
+
module.exports = (given, options, context) => {
|
|
3
|
+
const result = [];
|
|
4
|
+
if (!given) return result;
|
|
5
|
+
|
|
6
|
+
if (options.verbs !== '*' && !Object.keys(given).some(key => options.verbs.includes(key))) {
|
|
7
|
+
return result;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
Object.keys(given).forEach(element => {
|
|
11
|
+
const verb = given[element];
|
|
12
|
+
if (!verb.responses) return;
|
|
13
|
+
if (options.parameters && !verb.parameters) return;
|
|
14
|
+
if (options.parameters && !verb.parameters.some(p => options.parameters.includes(p.in))) return;
|
|
15
|
+
|
|
16
|
+
if (Object.keys(verb.responses).some(response => options.responses.includes(response))) return;
|
|
17
|
+
|
|
18
|
+
const path = context.path || [];
|
|
19
|
+
result.push({
|
|
20
|
+
message: context.rule.message,
|
|
21
|
+
path: [...path, element]
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return result;
|
|
26
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Check that format is valid for a schema type.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {object} given
|
|
5
|
+
* @param {object} options
|
|
6
|
+
* @param {object} options.formats
|
|
7
|
+
* @param {Array<string>} options.formats.string
|
|
8
|
+
* @param {Array<string>} options.formats.number
|
|
9
|
+
* @param {Array<string>} options.formats.integer
|
|
10
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
11
|
+
*/
|
|
12
|
+
module.exports = function checkTypeAndFormat(given, options, context) {
|
|
13
|
+
if (given === null || typeof given !== "object") {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const errors = [];
|
|
18
|
+
const path = context.path || [];
|
|
19
|
+
|
|
20
|
+
if (given.type === "string" && options.formats.string) {
|
|
21
|
+
if (given.format) {
|
|
22
|
+
if (!options.formats.string.includes(given.format)) {
|
|
23
|
+
errors.push({
|
|
24
|
+
message: `Schema with type string has unrecognized format: ${given.format}`,
|
|
25
|
+
path: [...path, "format"]
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
} else if (given.type === "integer" && options.formats.integer) {
|
|
30
|
+
if (given.format) {
|
|
31
|
+
if (!options.formats.integer.includes(given.format)) {
|
|
32
|
+
errors.push({
|
|
33
|
+
message: `Schema with type integer has unrecognized format: ${given.format}`,
|
|
34
|
+
path: [...path, "format"]
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
errors.push({
|
|
39
|
+
message: "Schema with type integer should specify format",
|
|
40
|
+
path
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
} else if (given.type === "number" && options.formats.number) {
|
|
44
|
+
if (given.format) {
|
|
45
|
+
if (!options.formats.number.includes(given.format)) {
|
|
46
|
+
errors.push({
|
|
47
|
+
message: `Schema with type number has unrecognized format: ${given.format}`,
|
|
48
|
+
path: [...path, "format"]
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
errors.push({
|
|
53
|
+
message: "Schema with type number should specify format",
|
|
54
|
+
path
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
} else if (given.type === "boolean") {
|
|
58
|
+
if (given.format) {
|
|
59
|
+
errors.push({
|
|
60
|
+
message: "Schema with type boolean should not specify format",
|
|
61
|
+
path: [...path, "format"]
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
} else if (given.properties && typeof given.properties === "object") {
|
|
65
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
66
|
+
for (const [key, value] of Object.entries(given.properties)) {
|
|
67
|
+
errors.push(...checkTypeAndFormat(value, options, {
|
|
68
|
+
path: [...path, "properties", key],
|
|
69
|
+
document: context.document,
|
|
70
|
+
documentInventory: context.documentInventory,
|
|
71
|
+
rule: context.rule
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (given.type === "array") {
|
|
77
|
+
errors.push(...checkTypeAndFormat(given.items, options, {
|
|
78
|
+
path: [...path, "items"],
|
|
79
|
+
document: context.document,
|
|
80
|
+
documentInventory: context.documentInventory,
|
|
81
|
+
rule: context.rule
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (given.allOf && Array.isArray(given.allOf)) {
|
|
86
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
87
|
+
for (const [index, value] of given.allOf.entries()) {
|
|
88
|
+
errors.push(...checkTypeAndFormat(value, options, {
|
|
89
|
+
path: [...path, "allOf", index],
|
|
90
|
+
document: context.document,
|
|
91
|
+
documentInventory: context.documentInventory,
|
|
92
|
+
rule: context.rule
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return errors;
|
|
98
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module.exports = function apqStandardResponseCodes(targetVal, options, context) {
|
|
2
|
+
const results = [];
|
|
3
|
+
|
|
4
|
+
if (!targetVal || !context?.path) return results;
|
|
5
|
+
|
|
6
|
+
const responses = targetVal.responses;
|
|
7
|
+
if (!responses || typeof responses !== 'object') return results;
|
|
8
|
+
|
|
9
|
+
const definedCodes = Object.keys(responses);
|
|
10
|
+
const pathIndex = context.path.indexOf('paths');
|
|
11
|
+
if (pathIndex === -1) return results;
|
|
12
|
+
|
|
13
|
+
const resourcePath = context.path[pathIndex + 1];
|
|
14
|
+
const verb = context.path[pathIndex + 2];
|
|
15
|
+
|
|
16
|
+
if (typeof resourcePath !== 'string' || typeof verb !== 'string') return results;
|
|
17
|
+
|
|
18
|
+
const exclusions = options?.['resources-exclusions'] || [];
|
|
19
|
+
if (exclusions.some(ex => {
|
|
20
|
+
const [exVerb, exPath] = ex.split(':');
|
|
21
|
+
return exVerb.toLowerCase() === verb.toLowerCase() && new RegExp(`^${exPath}$`).test(resourcePath);
|
|
22
|
+
})) return results;
|
|
23
|
+
|
|
24
|
+
const rulesConfig = options?.['required-codes-by-resources-paths'];
|
|
25
|
+
if (!rulesConfig) return results;
|
|
26
|
+
|
|
27
|
+
const rules = rulesConfig
|
|
28
|
+
.split(/[\n;]/)
|
|
29
|
+
.map(r => r.trim())
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.map(rule => {
|
|
32
|
+
const parts = rule.split(':');
|
|
33
|
+
if (parts.length < 3) return null;
|
|
34
|
+
|
|
35
|
+
const verbPart = parts[0].toLowerCase();
|
|
36
|
+
const codesPart = parts[parts.length - 1];
|
|
37
|
+
const regexPart = parts.slice(1, -1).join(':');
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
verb: verbPart,
|
|
41
|
+
pathRegex: new RegExp(regexPart),
|
|
42
|
+
requiredCodes: codesPart.split(',').map(c => c.trim()).filter(Boolean)
|
|
43
|
+
};
|
|
44
|
+
}).filter(Boolean);
|
|
45
|
+
|
|
46
|
+
const matchedRule = rules.find(
|
|
47
|
+
r => r.verb === verb.toLowerCase() && r.pathRegex.test(resourcePath)
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (!matchedRule) return results;
|
|
51
|
+
|
|
52
|
+
matchedRule.requiredCodes.forEach(ruleCode => {
|
|
53
|
+
let missing = false;
|
|
54
|
+
let msg = "";
|
|
55
|
+
|
|
56
|
+
if (ruleCode.includes('|')) {
|
|
57
|
+
const alternatives = ruleCode.split('|');
|
|
58
|
+
if (!alternatives.some(c => definedCodes.includes(c))) {
|
|
59
|
+
missing = true;
|
|
60
|
+
msg = `OAR039: Response code ${alternatives.join(' or ')} must be defined.`;
|
|
61
|
+
}
|
|
62
|
+
} else if (!definedCodes.includes(ruleCode)) {
|
|
63
|
+
missing = true;
|
|
64
|
+
msg = `OAR039: Response code ${ruleCode} must be defined.`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (missing) {
|
|
68
|
+
results.push({
|
|
69
|
+
message: msg,
|
|
70
|
+
path: context.path
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return results;
|
|
76
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* @param {Object} given
|
|
4
|
+
* @param {object} options
|
|
5
|
+
* @param {string} options.property
|
|
6
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
7
|
+
*/
|
|
8
|
+
module.exports = (given, options, context) => {
|
|
9
|
+
const errors = [];
|
|
10
|
+
|
|
11
|
+
if (!given || Object.keys(given).some(key => key.toUpperCase() !== options.property.toUpperCase())) {
|
|
12
|
+
errors.push({
|
|
13
|
+
message: context.rule.message
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return errors;
|
|
18
|
+
};
|