api-quality-spectral-ruleset 1.5.0-beta.3 → 1.5.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/apq-spectral.yaml +71 -69
- package/functions/apq-allowed-http-verbs.js +31 -0
- package/functions/apq-alternate-paths.js +8 -4
- package/functions/apq-check-ambiguous-path.js +24 -38
- package/functions/apq-forbidden-query-format.js +29 -0
- package/functions/apq-mandatory-response-codes.js +36 -0
- package/functions/apq-rate-limit-response.js +21 -0
- package/functions/apq-require-response-on-path-params.js +12 -23
- package/functions/apq-response-media-type.js +28 -3
- package/functions/apq-url-naming-convention.js +59 -0
- package/package.json +1 -1
package/apq-spectral.yaml
CHANGED
|
@@ -40,6 +40,11 @@ functions:
|
|
|
40
40
|
- apq-string-parameter-integrity
|
|
41
41
|
- apq-example-schema-types
|
|
42
42
|
- apq-standard-response-schema
|
|
43
|
+
- apq-allowed-http-verbs
|
|
44
|
+
- apq-url-naming-convention
|
|
45
|
+
- apq-mandatory-response-codes
|
|
46
|
+
- apq-rate-limit-response
|
|
47
|
+
- apq-forbidden-query-format
|
|
43
48
|
extends:
|
|
44
49
|
- spectral:asyncapi
|
|
45
50
|
- spectral:oas
|
|
@@ -117,12 +122,14 @@ rules:
|
|
|
117
122
|
function: truthy
|
|
118
123
|
apiq:OAR008:
|
|
119
124
|
description: "HTTP verbs not encouraged."
|
|
120
|
-
message: "
|
|
125
|
+
message: "{{error}}"
|
|
121
126
|
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR008.md"
|
|
122
127
|
severity: error
|
|
123
|
-
given: "$.paths[*]
|
|
128
|
+
given: "$.paths[*]"
|
|
124
129
|
then:
|
|
125
|
-
function:
|
|
130
|
+
function: apq-allowed-http-verbs
|
|
131
|
+
functionOptions:
|
|
132
|
+
allowed-verbs: "get,post,put,delete,patch"
|
|
126
133
|
apiq:OAR009:
|
|
127
134
|
description: "Default request media type should be defined for operations."
|
|
128
135
|
message: "OAR009: Default request media type is mandatory."
|
|
@@ -138,18 +145,21 @@ rules:
|
|
|
138
145
|
given: "$.paths[*][get,post,put,patch,delete]"
|
|
139
146
|
then:
|
|
140
147
|
function: apq-response-media-type
|
|
148
|
+
functionOptions:
|
|
149
|
+
default-media-type: "application/json"
|
|
150
|
+
media-type-exceptions: "-"
|
|
141
151
|
apiq:OAR011:
|
|
142
|
-
description: "URLs should follow the
|
|
143
|
-
message: "
|
|
152
|
+
description: "URLs should follow the configured naming convention (default kebab-case): all literal path segments must comply."
|
|
153
|
+
message: "{{error}}"
|
|
144
154
|
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR011.md"
|
|
145
155
|
severity: error
|
|
146
156
|
given:
|
|
147
157
|
- "$.paths[*]~"
|
|
148
158
|
- "$.servers[*].url"
|
|
149
159
|
then:
|
|
150
|
-
function:
|
|
160
|
+
function: apq-url-naming-convention
|
|
151
161
|
functionOptions:
|
|
152
|
-
|
|
162
|
+
naming-convention: "kebab-case"
|
|
153
163
|
apiq:OAR012:
|
|
154
164
|
description: "Path params, query params, object names and property names should follow the configured naming convention. You can configure snake_case (default), kebab-case, camelCase or UpperCamelCase"
|
|
155
165
|
message: "OAR012: Path params, query params, object names and property names must follow the configured naming convention."
|
|
@@ -215,7 +225,7 @@ rules:
|
|
|
215
225
|
then:
|
|
216
226
|
function: apq-alternate-paths
|
|
217
227
|
functionOptions:
|
|
218
|
-
|
|
228
|
+
exclude_patterns: "get,me,search,delete"
|
|
219
229
|
apiq:OAR018:
|
|
220
230
|
description: Operation not recommended for resource path depending on HTTP verb
|
|
221
231
|
message: "OAR018: Operation not recommended for resource path: {{path}} ({{verb}})"
|
|
@@ -355,12 +365,16 @@ rules:
|
|
|
355
365
|
field: "headers.Location.schema.type"
|
|
356
366
|
function: truthy
|
|
357
367
|
apiq:OAR028:
|
|
358
|
-
description: "$filter must be defined as a query parameter in
|
|
359
|
-
message: "
|
|
368
|
+
description: "$filter must be defined as a query parameter in the collection GET operations selected by the configured paths."
|
|
369
|
+
message: "{{error}}"
|
|
360
370
|
severity: warn
|
|
361
|
-
given: "$.paths
|
|
371
|
+
given: "$.paths"
|
|
362
372
|
then:
|
|
363
|
-
function: apq-
|
|
373
|
+
function: apq-collection-query-param-required
|
|
374
|
+
functionOptions:
|
|
375
|
+
parameterName: "$filter"
|
|
376
|
+
paths: "/examples"
|
|
377
|
+
pathValidationStrategy: "/include"
|
|
364
378
|
apiq:OAR029:
|
|
365
379
|
description: "A response not compliant with the standard may cause application issues."
|
|
366
380
|
message: "{{error}}"
|
|
@@ -398,13 +412,13 @@ rules:
|
|
|
398
412
|
validateProperty: true
|
|
399
413
|
apiq:OAR032:
|
|
400
414
|
description: "Ambiguous path parts not encouraged."
|
|
401
|
-
message: "
|
|
415
|
+
message: "{{error}}"
|
|
402
416
|
severity: error
|
|
403
417
|
given: "$.paths.*~"
|
|
404
418
|
then:
|
|
405
419
|
function: apq-check-ambiguous-path
|
|
406
420
|
functionOptions:
|
|
407
|
-
ambiguous-
|
|
421
|
+
ambiguous-names: "elementos,instancias,recursos,valores,terminos,objetos,articulos,elements,instances,resources,values,terms,objects,items"
|
|
408
422
|
apiq:OAR033:
|
|
409
423
|
description: "Request operation parameters must not include forbidden headers (Accept, Content-Type, Authorization). Note: This validates REQUEST headers, not response headers (see OAR053/114 for response header validation)."
|
|
410
424
|
message: "OAR033: The request header parameter '{{value}}' is not allowed in operations."
|
|
@@ -695,75 +709,59 @@ rules:
|
|
|
695
709
|
path-exclusions: "/status"
|
|
696
710
|
apiq:OAR061:
|
|
697
711
|
description: "Ensure get have mandatory response codes"
|
|
698
|
-
message: "
|
|
712
|
+
message: "{{error}}"
|
|
699
713
|
severity: error
|
|
700
|
-
given: "$.paths[
|
|
714
|
+
given: "$.paths[*][get]"
|
|
701
715
|
then:
|
|
702
|
-
function:
|
|
716
|
+
function: apq-mandatory-response-codes
|
|
703
717
|
functionOptions:
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
- required: ['202']
|
|
708
|
-
- required: ['206']
|
|
709
|
-
not:
|
|
710
|
-
required: ['201', '204']
|
|
718
|
+
mandatory-response-codes: "200, 202, 206"
|
|
719
|
+
paths: "/status, /another"
|
|
720
|
+
pathValidationStrategy: "/exclude"
|
|
711
721
|
apiq:OAR062:
|
|
712
722
|
description: "Ensure post have mandatory response codes"
|
|
713
|
-
message: "
|
|
723
|
+
message: "{{error}}"
|
|
714
724
|
severity: error
|
|
715
|
-
given: "$.paths[
|
|
725
|
+
given: "$.paths[*][post]"
|
|
716
726
|
then:
|
|
717
|
-
function:
|
|
727
|
+
function: apq-mandatory-response-codes
|
|
718
728
|
functionOptions:
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
- required: ['201']
|
|
723
|
-
- required: ['202']
|
|
724
|
-
- required: ['204']
|
|
725
|
-
- required: ['206']
|
|
729
|
+
mandatory-response-codes: "200, 201, 202, 204, 206"
|
|
730
|
+
paths: "/status, /another"
|
|
731
|
+
pathValidationStrategy: "/exclude"
|
|
726
732
|
apiq:OAR063:
|
|
727
733
|
description: "Ensure put have mandatory response codes"
|
|
728
|
-
message: "
|
|
734
|
+
message: "{{error}}"
|
|
729
735
|
severity: error
|
|
730
|
-
given: "$.paths[
|
|
736
|
+
given: "$.paths[*][put]"
|
|
731
737
|
then:
|
|
732
|
-
function:
|
|
738
|
+
function: apq-mandatory-response-codes
|
|
733
739
|
functionOptions:
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
- required: ['202']
|
|
738
|
-
- required: ['204']
|
|
739
|
-
- required: ['206']
|
|
740
|
+
mandatory-response-codes: "200, 202, 204, 206"
|
|
741
|
+
paths: "/status, /another"
|
|
742
|
+
pathValidationStrategy: "/exclude"
|
|
740
743
|
apiq:OAR064:
|
|
741
744
|
description: "Ensure patch have mandatory response codes"
|
|
742
|
-
message: "
|
|
745
|
+
message: "{{error}}"
|
|
743
746
|
severity: error
|
|
744
|
-
given: "$.paths[
|
|
747
|
+
given: "$.paths[*][patch]"
|
|
745
748
|
then:
|
|
746
|
-
function:
|
|
749
|
+
function: apq-mandatory-response-codes
|
|
747
750
|
functionOptions:
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
- required: ['202']
|
|
752
|
-
- required: ['204']
|
|
753
|
-
- required: ['206']
|
|
751
|
+
mandatory-response-codes: "200, 202, 204, 206"
|
|
752
|
+
paths: "/status, /another"
|
|
753
|
+
pathValidationStrategy: "/exclude"
|
|
754
754
|
apiq:OAR065:
|
|
755
755
|
description: "Ensure delete have mandatory response codes"
|
|
756
|
-
message: "
|
|
756
|
+
message: "{{error}}"
|
|
757
757
|
severity: error
|
|
758
|
-
given: "$.paths[
|
|
758
|
+
given: "$.paths[*][delete]"
|
|
759
759
|
then:
|
|
760
|
-
function:
|
|
760
|
+
function: apq-mandatory-response-codes
|
|
761
761
|
functionOptions:
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
- required: ['202']
|
|
766
|
-
- required: ['204']
|
|
762
|
+
mandatory-response-codes: "200, 202, 204"
|
|
763
|
+
paths: "/status, /another"
|
|
764
|
+
pathValidationStrategy: "/exclude"
|
|
767
765
|
apiq:OAR066:
|
|
768
766
|
description: "RequestBody and Responses schema property names must be compliant with the snake_case naming convention."
|
|
769
767
|
message: "OAR066: RequestBody and Responses schema property names must be compliant with the snake_case naming convention."
|
|
@@ -834,10 +832,12 @@ rules:
|
|
|
834
832
|
description: "API should include a 429 response to indicate rate limiting, except for health check paths like /status, /health, /health-check, /ping, /liveness, /readiness."
|
|
835
833
|
message: "OAR073: API should include a 429 response to indicate rate limiting."
|
|
836
834
|
severity: error
|
|
837
|
-
given: "$.paths[
|
|
835
|
+
given: "$.paths[*][get,post,put,patch,delete]"
|
|
838
836
|
then:
|
|
839
|
-
|
|
840
|
-
|
|
837
|
+
function: apq-rate-limit-response
|
|
838
|
+
functionOptions:
|
|
839
|
+
paths: "/status, /health, /health-check, /ping, /liveness, /readiness"
|
|
840
|
+
pathValidationStrategy: "/exclude"
|
|
841
841
|
apiq:OAR074:
|
|
842
842
|
description: "Numeric parameters should define minimum and maximum, or a format restriction."
|
|
843
843
|
message: "OAR074: Numeric parameter should define both 'minimum' and 'maximum', or a 'format' restriction."
|
|
@@ -898,7 +898,8 @@ rules:
|
|
|
898
898
|
then:
|
|
899
899
|
function: apq-require-response-on-path-params
|
|
900
900
|
functionOptions:
|
|
901
|
-
|
|
901
|
+
paths: "/status"
|
|
902
|
+
pathValidationStrategy: "/exclude"
|
|
902
903
|
apiq:OAR080:
|
|
903
904
|
description: "The security scheme must be among those allowed by the organization and must be complete."
|
|
904
905
|
message: "OAR080: The security scheme '{{property}}' must be among those allowed by the organization and must be complete."
|
|
@@ -936,15 +937,16 @@ rules:
|
|
|
936
937
|
functionOptions:
|
|
937
938
|
notMatch: "^(email|password)$"
|
|
938
939
|
apiq:OAR084:
|
|
939
|
-
description: "
|
|
940
|
-
message: "
|
|
940
|
+
description: "Some formats should not pass through this querystring."
|
|
941
|
+
message: "{{error}}"
|
|
941
942
|
severity: error
|
|
942
943
|
given: "$.paths[*][get,post,put,patch,delete].parameters[?(@.in == 'query')]"
|
|
943
944
|
then:
|
|
944
|
-
|
|
945
|
-
function: pattern
|
|
945
|
+
function: apq-forbidden-query-format
|
|
946
946
|
functionOptions:
|
|
947
|
-
|
|
947
|
+
forbidden-query-formats: "password"
|
|
948
|
+
paths: "/examples"
|
|
949
|
+
pathValidationStrategy: "/include"
|
|
948
950
|
apiq:OAR085:
|
|
949
951
|
description: "The OpenAPI version must be one of: 2.0, 3.0.0, 3.0.1, 3.0.2, 3.0.3, 3.0.4, 3.1.0, 3.1.1, 3.1.2, 3.2.0."
|
|
950
952
|
message: "{{error}}"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const HTTP_VERBS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {object} given
|
|
5
|
+
* @param {object} options
|
|
6
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
7
|
+
*/
|
|
8
|
+
module.exports = (given, options, context) => {
|
|
9
|
+
if (!given || typeof given !== 'object') {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const allowedVerbs = ((options && options['allowed-verbs']) || '')
|
|
14
|
+
.split(',')
|
|
15
|
+
.map((verb) => verb.trim().toLowerCase())
|
|
16
|
+
.filter(Boolean);
|
|
17
|
+
|
|
18
|
+
const basePath = context.path || [];
|
|
19
|
+
const errors = [];
|
|
20
|
+
|
|
21
|
+
HTTP_VERBS.forEach((verb) => {
|
|
22
|
+
if (Object.prototype.hasOwnProperty.call(given, verb) && !allowedVerbs.includes(verb)) {
|
|
23
|
+
errors.push({
|
|
24
|
+
message: `HTTP verb '${verb}' is not encouraged. Only the following verbs are allowed: ${allowedVerbs.join(', ')}.`,
|
|
25
|
+
path: [...basePath, verb]
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return errors;
|
|
31
|
+
};
|
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
|
|
2
1
|
const isVariable = (part) => {
|
|
3
2
|
return (part.startsWith('{') && part.endsWith('}'));
|
|
4
3
|
}
|
|
5
4
|
|
|
6
|
-
module.exports = (given,
|
|
5
|
+
module.exports = (given, options, context) => {
|
|
7
6
|
const result = [];
|
|
8
7
|
const paths = given || [];
|
|
9
8
|
if (paths.length === 0) return result;
|
|
10
9
|
|
|
10
|
+
const excludePatterns = ((options && options['exclude_patterns']) || '')
|
|
11
|
+
.split(',')
|
|
12
|
+
.map((pattern) => pattern.trim())
|
|
13
|
+
.filter(Boolean);
|
|
14
|
+
|
|
11
15
|
const parts = paths.substr(1).split('/').filter(p => p.length > 0);
|
|
12
16
|
if (parts.length === 0) return result;
|
|
13
17
|
const firstPart = parts.shift();
|
|
14
18
|
let previousIsVar;
|
|
15
|
-
if (
|
|
19
|
+
if (excludePatterns.includes(firstPart)) {
|
|
16
20
|
previousIsVar = true;
|
|
17
21
|
} else if (isVariable(firstPart)) {
|
|
18
22
|
return [{ message: context.rule.message }];
|
|
@@ -21,7 +25,7 @@ module.exports = (given, { except }, context) => {
|
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
for (const part of parts) {
|
|
24
|
-
if (
|
|
28
|
+
if (excludePatterns.includes(part)) {
|
|
25
29
|
previousIsVar = true;
|
|
26
30
|
continue;
|
|
27
31
|
}
|
|
@@ -1,52 +1,38 @@
|
|
|
1
|
+
const PARAM_REGEX = /\{[^}{]*}/g;
|
|
2
|
+
|
|
3
|
+
const DEFAULT_AMBIGUOUS_NAMES =
|
|
4
|
+
'elementos,instancias,recursos,valores,terminos,objetos,articulos,elements,instances,resources,values,terms,objects,items';
|
|
5
|
+
|
|
1
6
|
/**
|
|
2
|
-
* Validates that URL path segments use clear, unambiguous names
|
|
3
7
|
* @param {string} given - The path key (e.g. "/users/{id}/items")
|
|
4
|
-
* @param {object} options
|
|
5
|
-
* @param {string} options
|
|
8
|
+
* @param {object} options
|
|
9
|
+
* @param {string} options['ambiguous-names']
|
|
6
10
|
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
7
11
|
*/
|
|
8
12
|
module.exports = (given, options, context) => {
|
|
9
|
-
const results = [];
|
|
10
|
-
|
|
11
13
|
if (typeof given !== 'string') {
|
|
12
|
-
return
|
|
14
|
+
return [];
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
'elements', 'instances', 'resources', 'values', 'terms', 'objects', 'items'
|
|
18
|
-
];
|
|
19
|
-
|
|
20
|
-
// Parse custom ambiguous words from options if provided
|
|
21
|
-
let ambiguousWords = defaultAmbiguousWords;
|
|
22
|
-
if (options && options['ambiguous-words']) {
|
|
23
|
-
ambiguousWords = options['ambiguous-words']
|
|
17
|
+
const forbiddenValues = new Set(
|
|
18
|
+
((options && options['ambiguous-names']) || DEFAULT_AMBIGUOUS_NAMES)
|
|
24
19
|
.split(',')
|
|
25
|
-
.map(
|
|
26
|
-
.filter(Boolean)
|
|
27
|
-
|
|
20
|
+
.map((value) => value.trim())
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
);
|
|
28
23
|
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
.filter(segment => {
|
|
33
|
-
// Keep only static segments (not empty, not {param})
|
|
34
|
-
return segment.length > 0 && !segment.startsWith('{');
|
|
35
|
-
});
|
|
24
|
+
const pathWithoutParams = given.replace(PARAM_REGEX, '');
|
|
25
|
+
const pathParts = pathWithoutParams.split('/').filter(Boolean);
|
|
26
|
+
const forbidden = pathParts.filter((part) => forbiddenValues.has(part));
|
|
36
27
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const lowerSegment = segment.toLowerCase();
|
|
40
|
-
for (const word of ambiguousWords) {
|
|
41
|
-
if (lowerSegment.includes(word)) {
|
|
42
|
-
results.push({
|
|
43
|
-
message: `${context.rule.message || 'OAR032'}: Path segment '${segment}' is ambiguous. Avoid using words like '${word}' in resource names.`,
|
|
44
|
-
path: context.path
|
|
45
|
-
});
|
|
46
|
-
break; // Report only once per segment
|
|
47
|
-
}
|
|
48
|
-
}
|
|
28
|
+
if (forbidden.length === 0) {
|
|
29
|
+
return [];
|
|
49
30
|
}
|
|
50
31
|
|
|
51
|
-
return
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
message: `Ambiguous name(s) found in path: ${forbidden.join(', ')}.`,
|
|
35
|
+
path: context.path
|
|
36
|
+
}
|
|
37
|
+
];
|
|
52
38
|
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module.exports = (parameter, options, context) => {
|
|
2
|
+
if (!parameter || typeof parameter !== 'object') return [];
|
|
3
|
+
|
|
4
|
+
const forbiddenFormats = ((options && options['forbidden-query-formats']) || '')
|
|
5
|
+
.split(',')
|
|
6
|
+
.map((value) => value.trim())
|
|
7
|
+
.filter(Boolean);
|
|
8
|
+
if (forbiddenFormats.length === 0) return [];
|
|
9
|
+
|
|
10
|
+
const paths = ((options && options.paths) || '')
|
|
11
|
+
.split(',')
|
|
12
|
+
.map((path) => path.trim())
|
|
13
|
+
.filter(Boolean);
|
|
14
|
+
const strategy = (options && options.pathValidationStrategy) || '/include';
|
|
15
|
+
|
|
16
|
+
const currentPath = context.path[1];
|
|
17
|
+
const isListed = paths.includes(currentPath);
|
|
18
|
+
const shouldExclude = strategy === '/exclude' ? isListed : !isListed;
|
|
19
|
+
if (shouldExclude) return [];
|
|
20
|
+
|
|
21
|
+
const root = context.document.parserResult.data;
|
|
22
|
+
const format = root.swagger ? parameter.format : parameter.schema && parameter.schema.format;
|
|
23
|
+
|
|
24
|
+
if (typeof format === 'string' && forbiddenFormats.includes(format)) {
|
|
25
|
+
return [{ message: context.rule.message }];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return [];
|
|
29
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module.exports = (operation, options, context) => {
|
|
2
|
+
if (!operation || typeof operation !== 'object') return [];
|
|
3
|
+
|
|
4
|
+
const mandatoryCodes = ((options && options['mandatory-response-codes']) || '')
|
|
5
|
+
.split(',')
|
|
6
|
+
.map((code) => code.trim())
|
|
7
|
+
.filter(Boolean);
|
|
8
|
+
if (mandatoryCodes.length === 0) return [];
|
|
9
|
+
|
|
10
|
+
const paths = ((options && options.paths) || '')
|
|
11
|
+
.split(',')
|
|
12
|
+
.map((path) => path.trim())
|
|
13
|
+
.filter(Boolean);
|
|
14
|
+
const strategy = (options && options.pathValidationStrategy) || '/exclude';
|
|
15
|
+
|
|
16
|
+
const currentPath = context.path[context.path.length - 2];
|
|
17
|
+
const isListed = paths.includes(currentPath);
|
|
18
|
+
const shouldExclude = strategy === '/exclude' ? isListed : !isListed;
|
|
19
|
+
if (shouldExclude) return [];
|
|
20
|
+
|
|
21
|
+
const message = `Mandatory response code(s) required: ${mandatoryCodes.join(', ')}.`;
|
|
22
|
+
|
|
23
|
+
const responses = operation.responses;
|
|
24
|
+
if (!responses || typeof responses !== 'object') {
|
|
25
|
+
return [{ message }];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const responseCodes = Object.keys(responses).map((code) => code.trim());
|
|
29
|
+
const hasMandatoryCode = mandatoryCodes.some((code) => responseCodes.includes(code));
|
|
30
|
+
|
|
31
|
+
if (!hasMandatoryCode) {
|
|
32
|
+
return [{ message }];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return [];
|
|
36
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module.exports = (operation, options, context) => {
|
|
2
|
+
if (!operation || typeof operation !== 'object' || Array.isArray(operation)) return [];
|
|
3
|
+
|
|
4
|
+
const paths = ((options && options.paths) || '')
|
|
5
|
+
.split(',')
|
|
6
|
+
.map((path) => path.trim())
|
|
7
|
+
.filter(Boolean);
|
|
8
|
+
const strategy = (options && options.pathValidationStrategy) || '/exclude';
|
|
9
|
+
|
|
10
|
+
const currentPath = context.path[context.path.length - 2];
|
|
11
|
+
const isListed = paths.includes(currentPath);
|
|
12
|
+
const shouldExclude = strategy === '/exclude' ? isListed : !isListed;
|
|
13
|
+
if (shouldExclude) return [];
|
|
14
|
+
|
|
15
|
+
const responses = operation.responses;
|
|
16
|
+
if (!responses || typeof responses !== 'object' || !('429' in responses)) {
|
|
17
|
+
return [{ message: context.rule.message }];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return [];
|
|
21
|
+
};
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Requires one or more HTTP response codes when path parameters are present.
|
|
3
|
-
*
|
|
4
2
|
* @param {object} given
|
|
5
3
|
* @param {object} options
|
|
6
|
-
* @param {string} options.
|
|
4
|
+
* @param {string} options.paths Comma-separated list of paths to include/exclude (exact match)
|
|
5
|
+
* @param {string} options.pathValidationStrategy "/include" or "/exclude" (default "/exclude")
|
|
7
6
|
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
8
7
|
*/
|
|
9
8
|
module.exports = (given, options, context) => {
|
|
@@ -11,21 +10,15 @@ module.exports = (given, options, context) => {
|
|
|
11
10
|
return [];
|
|
12
11
|
}
|
|
13
12
|
|
|
14
|
-
const
|
|
15
|
-
const responseOpt = options?.response;
|
|
16
|
-
|
|
17
|
-
if (typeof responseOpt !== 'string') {
|
|
18
|
-
throw new TypeError(`${ruleCode}: "response" option must be a comma-separated string of HTTP status codes`);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const requiredResponses = responseOpt
|
|
13
|
+
const excludedPaths = ((options && options.paths) || '')
|
|
22
14
|
.split(',')
|
|
23
|
-
.map(
|
|
24
|
-
.filter(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
15
|
+
.map((path) => path.trim())
|
|
16
|
+
.filter(Boolean);
|
|
17
|
+
const strategy = (options && options.pathValidationStrategy) || '/exclude';
|
|
18
|
+
const currentPath = context.path[context.path.length - 2];
|
|
19
|
+
const isListed = excludedPaths.includes(currentPath);
|
|
20
|
+
const shouldExclude = strategy === '/exclude' ? isListed : !isListed;
|
|
21
|
+
if (shouldExclude) return [];
|
|
29
22
|
|
|
30
23
|
const pathItemKeyIndex = context.path?.length - 2;
|
|
31
24
|
const pathItem = typeof pathItemKeyIndex === 'number'
|
|
@@ -46,11 +39,7 @@ module.exports = (given, options, context) => {
|
|
|
46
39
|
|
|
47
40
|
const responses = given?.responses || {};
|
|
48
41
|
|
|
49
|
-
|
|
50
|
-
code => responses[code]
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
if (!hasRequiredResponse) {
|
|
42
|
+
if (!responses['404']) {
|
|
54
43
|
return [
|
|
55
44
|
{
|
|
56
45
|
message: context.rule.message,
|
|
@@ -59,4 +48,4 @@ module.exports = (given, options, context) => {
|
|
|
59
48
|
}
|
|
60
49
|
|
|
61
50
|
return [];
|
|
62
|
-
};
|
|
51
|
+
};
|
|
@@ -1,21 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @param {object} given
|
|
3
3
|
* @param {object} options
|
|
4
|
+
* @param {string} options['default-media-type']
|
|
5
|
+
* @param {string} options['media-type-exceptions']
|
|
4
6
|
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
const NO_BODY_CODES = new Set(['204']);
|
|
8
10
|
|
|
11
|
+
function parseExceptions(value) {
|
|
12
|
+
return new Set(
|
|
13
|
+
String(value ?? '-')
|
|
14
|
+
.split(',')
|
|
15
|
+
.map((mediaType) => mediaType.trim().toLowerCase())
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isSupported(mediaType, defaultMediaType, exceptions) {
|
|
21
|
+
return mediaType === defaultMediaType || exceptions.has(mediaType);
|
|
22
|
+
}
|
|
23
|
+
|
|
9
24
|
module.exports = (given, options, context) => {
|
|
10
25
|
const errors = [];
|
|
11
26
|
if (!given) return errors;
|
|
12
27
|
|
|
28
|
+
const defaultMediaType = ((options && options['default-media-type']) || 'application/json').toLowerCase();
|
|
29
|
+
const exceptions = parseExceptions(options && options['media-type-exceptions']);
|
|
30
|
+
|
|
13
31
|
const root = context.document.parserResult.data;
|
|
14
32
|
|
|
15
33
|
if (root.swagger) {
|
|
16
34
|
const produces = given.produces ?? root.produces;
|
|
17
35
|
|
|
18
|
-
if (
|
|
36
|
+
if (
|
|
37
|
+
!produces ||
|
|
38
|
+
!Array.isArray(produces) ||
|
|
39
|
+
!produces.some((mediaType) => isSupported(String(mediaType).toLowerCase(), defaultMediaType, exceptions))
|
|
40
|
+
) {
|
|
19
41
|
errors.push({
|
|
20
42
|
message: context.rule.message,
|
|
21
43
|
path: [...context.path]
|
|
@@ -31,7 +53,10 @@ module.exports = (given, options, context) => {
|
|
|
31
53
|
for (const [statusCode, response] of Object.entries(responses)) {
|
|
32
54
|
if (NO_BODY_CODES.has(statusCode)) continue;
|
|
33
55
|
|
|
34
|
-
|
|
56
|
+
const contentTypes = response?.content ? Object.keys(response.content) : [];
|
|
57
|
+
const supported = contentTypes.some((mediaType) => isSupported(mediaType.toLowerCase(), defaultMediaType, exceptions));
|
|
58
|
+
|
|
59
|
+
if (!supported) {
|
|
35
60
|
errors.push({
|
|
36
61
|
message: context.rule.message,
|
|
37
62
|
path: [...context.path, 'responses', statusCode]
|
|
@@ -40,4 +65,4 @@ module.exports = (given, options, context) => {
|
|
|
40
65
|
}
|
|
41
66
|
|
|
42
67
|
return errors;
|
|
43
|
-
};
|
|
68
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
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])?$/
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const PARAM_REGEX = /\{[^}{]*}/g;
|
|
9
|
+
const URL_PREFIX_REGEX = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]*/;
|
|
10
|
+
|
|
11
|
+
function stripParams(name) {
|
|
12
|
+
return name.indexOf('/') >= 0 ? name.replace(PARAM_REGEX, '') : name;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function extractUrlPath(value) {
|
|
16
|
+
const match = URL_PREFIX_REGEX.exec(value);
|
|
17
|
+
return match ? value.slice(match[0].length) : value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isValid(name, convention) {
|
|
21
|
+
const regex = NAMING_REGEX[convention];
|
|
22
|
+
if (convention === 'camelCase' || convention === 'UpperCamelCase') {
|
|
23
|
+
const joined = name.replace(/\//g, '');
|
|
24
|
+
if (joined.includes('_') || joined.includes('-')) return false;
|
|
25
|
+
return regex.test(joined);
|
|
26
|
+
}
|
|
27
|
+
const separator = convention === 'kebab-case' ? '-' : '_';
|
|
28
|
+
return regex.test(name.replace(/\//g, separator));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} given
|
|
33
|
+
* @param {object} options
|
|
34
|
+
* @param {string} options['naming-convention']
|
|
35
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
36
|
+
*/
|
|
37
|
+
module.exports = (given, options, context) => {
|
|
38
|
+
if (typeof given !== 'string') {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const convention = (options && options['naming-convention']) || 'kebab-case';
|
|
43
|
+
if (!NAMING_REGEX[convention]) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const name = stripParams(extractUrlPath(given));
|
|
48
|
+
|
|
49
|
+
if (!isValid(name, convention)) {
|
|
50
|
+
return [
|
|
51
|
+
{
|
|
52
|
+
message: `Value '${given}' does not follow the '${convention}' naming convention.`,
|
|
53
|
+
path: context.path
|
|
54
|
+
}
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return [];
|
|
59
|
+
};
|