api-quality-spectral-ruleset 1.3.0-beta.3 → 1.3.0-beta.5
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/apq-spectral.yaml +52 -29
- package/functions/apq-alternate-paths.js +8 -5
- package/functions/apq-forbidden-characters.js +30 -0
- package/functions/apq-path-depth.js +3 -2
- package/functions/apq-schema-format.js +12 -97
- package/functions/apq-valid-response-schema.js +39 -0
- package/package.json +1 -1
package/apq-spectral.yaml
CHANGED
|
@@ -16,7 +16,10 @@ functions:
|
|
|
16
16
|
- apq-custom-field
|
|
17
17
|
- apq-check-examples-coverage
|
|
18
18
|
- apq-path-depth
|
|
19
|
+
- apq-forbidden-characters
|
|
20
|
+
- apq-valid-response-schema
|
|
19
21
|
- apq-path-param-query-conflict
|
|
22
|
+
- apq-schema-format
|
|
20
23
|
- apq-security-required-response
|
|
21
24
|
- apq-check-ambiguous-path
|
|
22
25
|
- apq-password-format
|
|
@@ -65,9 +68,7 @@ rules:
|
|
|
65
68
|
severity: "error"
|
|
66
69
|
given: "$.x-wso2-security.apim.x-wso2-scopes[*].roles"
|
|
67
70
|
then:
|
|
68
|
-
function:
|
|
69
|
-
functionOptions:
|
|
70
|
-
match: "^[a-zA-Z0-9_\\-., ]+$"
|
|
71
|
+
function: apq-forbidden-characters
|
|
71
72
|
apiq:OAR005:
|
|
72
73
|
description: "A wrong scope may cause problems to import the API definition into WSO2 or allow all users to call the endpoint."
|
|
73
74
|
message: "OAR005: Scope in the operation must be defined correctly and match an existing scope."
|
|
@@ -177,9 +178,11 @@ rules:
|
|
|
177
178
|
severity: error
|
|
178
179
|
given: "$.paths.*~"
|
|
179
180
|
then:
|
|
180
|
-
function:
|
|
181
|
+
function: apq-path-depth
|
|
181
182
|
functionOptions:
|
|
182
|
-
|
|
183
|
+
maxDepth: 5
|
|
184
|
+
ignoreSegments:
|
|
185
|
+
- me
|
|
183
186
|
apiq:OAR016:
|
|
184
187
|
description: "Numeric types requires a valid format."
|
|
185
188
|
message: "OAR016: Numeric types must use a valid format for their type."
|
|
@@ -217,7 +220,7 @@ rules:
|
|
|
217
220
|
then:
|
|
218
221
|
function: apq-alternate-paths
|
|
219
222
|
functionOptions:
|
|
220
|
-
except: ["me"]
|
|
223
|
+
except: ["me", "get", "search"]
|
|
221
224
|
apiq:OAR018:
|
|
222
225
|
description: Operation not recommended for resource path depending on HTTP verb
|
|
223
226
|
message: "OAR018: Operation not recommended for resource path: {{path}} ({{verb}})"
|
|
@@ -281,18 +284,44 @@ rules:
|
|
|
281
284
|
description: "$expand must be defined as a query parameter in collection operations (excluding detail endpoints and health checks)."
|
|
282
285
|
message: "OAR020: $expand must be defined as a query parameter in this operation."
|
|
283
286
|
severity: warn
|
|
284
|
-
given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get
|
|
287
|
+
given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get"
|
|
285
288
|
then:
|
|
286
|
-
|
|
287
|
-
|
|
289
|
+
function: schema
|
|
290
|
+
functionOptions:
|
|
291
|
+
schema:
|
|
292
|
+
required: ["parameters"]
|
|
293
|
+
properties:
|
|
294
|
+
parameters:
|
|
295
|
+
type: array
|
|
296
|
+
contains:
|
|
297
|
+
type: object
|
|
298
|
+
properties:
|
|
299
|
+
name:
|
|
300
|
+
const: "$expand"
|
|
301
|
+
in:
|
|
302
|
+
const: query
|
|
303
|
+
required: [name, in]
|
|
288
304
|
apiq:OAR021:
|
|
289
305
|
description: "$exclude must be defined as a query parameter in collection operations (excluding detail endpoints and health checks)."
|
|
290
306
|
message: "OAR021: $exclude must be defined as a query parameter in this operation."
|
|
291
307
|
severity: warn
|
|
292
|
-
given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get
|
|
308
|
+
given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get"
|
|
293
309
|
then:
|
|
294
|
-
|
|
295
|
-
|
|
310
|
+
function: schema
|
|
311
|
+
functionOptions:
|
|
312
|
+
schema:
|
|
313
|
+
required: ["parameters"]
|
|
314
|
+
properties:
|
|
315
|
+
parameters:
|
|
316
|
+
type: array
|
|
317
|
+
contains:
|
|
318
|
+
type: object
|
|
319
|
+
properties:
|
|
320
|
+
name:
|
|
321
|
+
const: "$exclude"
|
|
322
|
+
in:
|
|
323
|
+
const: query
|
|
324
|
+
required: [name, in]
|
|
296
325
|
apiq:OAR022:
|
|
297
326
|
description: "$orderby must be defined as a query parameter in all operations."
|
|
298
327
|
message: "OAR022: $orderby must be defined as a query parameter in this operation."
|
|
@@ -495,24 +524,18 @@ rules:
|
|
|
495
524
|
notMatch: "^(Cookie|Set-Cookie)$"
|
|
496
525
|
apiq:OAR037:
|
|
497
526
|
description: "Schema should use well-defined type and format."
|
|
498
|
-
message: "OAR037: String schemas must specify a valid format (date, date-time, password, byte, binary, email, uuid, uri, hostname, ipv4, ipv6, HEX, json, xml, or base64)."
|
|
527
|
+
message: "OAR037: String schemas must specify a valid format (date, date-time, password, byte, binary, email, uuid, uri, hostname, ipv4, ipv6, HEX, HEX(16), json, xml, or base64)."
|
|
499
528
|
severity: error
|
|
500
|
-
given: "$..
|
|
529
|
+
given: "$..[?(@.type=='string')]"
|
|
501
530
|
then:
|
|
502
|
-
|
|
503
|
-
function: pattern
|
|
504
|
-
functionOptions:
|
|
505
|
-
match: "^(date|date-time|password|byte|binary|email|uuid|uri|hostname|ipv4|ipv6|HEX|json|xml|base64)$"
|
|
531
|
+
function: apq-schema-format
|
|
506
532
|
apiq:OAR038:
|
|
507
|
-
description: "
|
|
508
|
-
message: "OAR038: Response property
|
|
533
|
+
description: "The 201 response schema of a POST operation must have properties named 'data' or 'error' with at least one sub-property."
|
|
534
|
+
message: "OAR038: Response property must be named 'data' or 'error'. Got '{{property}}'."
|
|
509
535
|
severity: error
|
|
510
|
-
given: "$.paths
|
|
536
|
+
given: "$.paths.*.post.responses.*.content..schema.properties"
|
|
511
537
|
then:
|
|
512
|
-
|
|
513
|
-
function: pattern
|
|
514
|
-
functionOptions:
|
|
515
|
-
match: "^data|error$"
|
|
538
|
+
function: apq-valid-response-schema
|
|
516
539
|
apiq:OAR039:
|
|
517
540
|
description: "Response codes must be defined according to the standard depending on the HTTP verb and resource path."
|
|
518
541
|
message: "{{error}}"
|
|
@@ -809,9 +832,9 @@ rules:
|
|
|
809
832
|
- "$.paths.*.*.parameters[*].schema..properties.*~"
|
|
810
833
|
- "$.paths.*.*.responses[*].schema..properties.*~"
|
|
811
834
|
then:
|
|
812
|
-
function:
|
|
835
|
+
function: pattern
|
|
813
836
|
functionOptions:
|
|
814
|
-
|
|
837
|
+
match: "^([a-z$][a-z0-9_$]*|_[a-z0-9_]+|@[a-zA-Z][a-zA-Z0-9_]*|x-[a-zA-Z][a-zA-Z0-9_-]*)$"
|
|
815
838
|
apiq:OAR067:
|
|
816
839
|
description: "RequestBody and Responses schema property names must be compliant with the camelCase naming convention."
|
|
817
840
|
message: "OAR067: RequestBody and Responses schema property names must be compliant with the camelCase naming convention."
|
|
@@ -870,10 +893,10 @@ rules:
|
|
|
870
893
|
functionOptions:
|
|
871
894
|
notMatch: "stacktrace"
|
|
872
895
|
apiq:OAR073:
|
|
873
|
-
description: "API should include a 429 response to indicate rate limiting, except for health check paths like /status, /health, /ping."
|
|
896
|
+
description: "API should include a 429 response to indicate rate limiting, except for health check paths like /status, /health, /health-check, /ping, /liveness, /readiness."
|
|
874
897
|
message: "OAR073: API should include a 429 response to indicate rate limiting."
|
|
875
898
|
severity: error
|
|
876
|
-
given: "$.paths[?(
|
|
899
|
+
given: "$.paths[?(@property !== '/status' && @property !== '/health' && @property !== '/health-check' && @property !== '/ping' && @property !== '/liveness' && @property !== '/readiness')][*].responses"
|
|
877
900
|
then:
|
|
878
901
|
field: "429"
|
|
879
902
|
function: truthy
|
|
@@ -10,11 +10,14 @@ module.exports = (given, { except }, context) => {
|
|
|
10
10
|
|
|
11
11
|
const parts = paths.substr(1).split('/').filter(p => p.length > 0);
|
|
12
12
|
if (parts.length === 0) return result;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
const firstPart = parts.shift();
|
|
14
|
+
let previousIsVar;
|
|
15
|
+
if (except && except.includes(firstPart)) {
|
|
16
|
+
previousIsVar = true;
|
|
17
|
+
} else if (isVariable(firstPart)) {
|
|
18
|
+
return [{ message: context.rule.message }];
|
|
19
|
+
} else {
|
|
20
|
+
previousIsVar = false;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
for (const part of parts) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {string|string[]} targetVal
|
|
3
|
+
* @param {object} options
|
|
4
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
5
|
+
*/
|
|
6
|
+
module.exports = (targetVal, _options, context) => {
|
|
7
|
+
const pattern = /^[a-zA-Z0-9_\-., ]+$/;
|
|
8
|
+
const errors = [];
|
|
9
|
+
const basePath = context.path || [];
|
|
10
|
+
|
|
11
|
+
if (typeof targetVal === 'string') {
|
|
12
|
+
if (!pattern.test(targetVal)) {
|
|
13
|
+
errors.push({
|
|
14
|
+
message: context.rule.message,
|
|
15
|
+
path: basePath
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
} else if (Array.isArray(targetVal)) {
|
|
19
|
+
targetVal.forEach((role, index) => {
|
|
20
|
+
if (typeof role === 'string' && !pattern.test(role)) {
|
|
21
|
+
errors.push({
|
|
22
|
+
message: context.rule.message,
|
|
23
|
+
path: [...basePath, index]
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return errors;
|
|
30
|
+
};
|
|
@@ -6,14 +6,15 @@
|
|
|
6
6
|
module.exports = (targetVal, options, context) => {
|
|
7
7
|
const maxDepth = options.maxDepth ?? 3
|
|
8
8
|
const ignore = options.ignoreSegments ?? []
|
|
9
|
-
|
|
10
9
|
const segments = targetVal
|
|
11
10
|
.split('/')
|
|
12
11
|
.filter(Boolean)
|
|
13
12
|
.filter(segment => !segment.startsWith('{') && !segment.endsWith('}'))
|
|
14
13
|
.filter(segment => !ignore.includes(segment))
|
|
15
14
|
|
|
16
|
-
|
|
15
|
+
const depth = segments.length;
|
|
16
|
+
|
|
17
|
+
if (depth > maxDepth) {
|
|
17
18
|
return [
|
|
18
19
|
{ message: context.rule.message }
|
|
19
20
|
]
|
|
@@ -1,98 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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;
|
|
1
|
+
const VALID_FORMATS = new Set([
|
|
2
|
+
'date', 'date-time', 'password', 'byte', 'binary', 'email',
|
|
3
|
+
'uuid', 'uri', 'hostname', 'ipv4', 'ipv6', 'hex', 'hex(16)',
|
|
4
|
+
'json', 'xml', 'base64'
|
|
5
|
+
]);
|
|
6
|
+
|
|
7
|
+
module.exports = (targetVal, _options, context) => {
|
|
8
|
+
const format = targetVal.format;
|
|
9
|
+
if (format === undefined || format === null || !VALID_FORMATS.has(String(format).toLowerCase())) {
|
|
10
|
+
return [{ message: context.rule.message }];
|
|
11
|
+
}
|
|
12
|
+
return [];
|
|
98
13
|
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {object} targetVal
|
|
3
|
+
* @param {object} _options
|
|
4
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
5
|
+
*/
|
|
6
|
+
module.exports = (targetVal, _options, context) => {
|
|
7
|
+
const errors = [];
|
|
8
|
+
const VALID_NAMES = ['data', 'error'];
|
|
9
|
+
|
|
10
|
+
if (!targetVal || typeof targetVal !== 'object') {
|
|
11
|
+
return errors;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const path = context.path || [];
|
|
15
|
+
const responsesIdx = path.lastIndexOf('responses');
|
|
16
|
+
if (responsesIdx >= 0 && path[responsesIdx + 1] !== '201') {
|
|
17
|
+
return errors;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
for (const [propName, propValue] of Object.entries(targetVal)) {
|
|
21
|
+
if (!VALID_NAMES.includes(propName)) {
|
|
22
|
+
errors.push({
|
|
23
|
+
message: context.rule.message,
|
|
24
|
+
path: [propName]
|
|
25
|
+
});
|
|
26
|
+
} else {
|
|
27
|
+
const subProps = propValue && propValue.properties;
|
|
28
|
+
const hasSubProps = subProps && Object.keys(subProps).length > 0;
|
|
29
|
+
if (!hasSubProps) {
|
|
30
|
+
errors.push({
|
|
31
|
+
message: `OAR038: Property '${propName}' must have at least one sub-property.`,
|
|
32
|
+
path: [propName]
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return errors;
|
|
39
|
+
};
|