api-quality-spectral-ruleset 1.5.0 → 1.6.0-beta.1
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 +57 -55
- package/functions/apq-check-examples-coverage.js +11 -18
- package/functions/apq-compare-insensitive.js +4 -3
- package/functions/apq-password-format.js +5 -2
- package/functions/apq-path-param-query-conflict.js +20 -3
- package/functions/apq-post-201-location-header.js +36 -0
- package/functions/apq-require-response-on-path-params.js +6 -4
- package/functions/apq-response-content-required.js +24 -0
- package/functions/apq-response-media-type.js +22 -19
- package/functions/apq-response-no-content.js +27 -0
- package/functions/apq-tags-consistency.js +71 -0
- package/functions/apq-total-param-default-value.js +24 -0
- package/functions/apq-undefined-response-media-type.js +46 -0
- package/functions/apq-valid-response-schema.js +71 -20
- package/functions/apq-validate-structure.js +34 -5
- package/functions/apq-wso2-auth-type-required.js +134 -0
- package/functions/apq-wso2-scope-defined.js +271 -0
- package/package.json +1 -1
package/apq-spectral.yaml
CHANGED
|
@@ -18,8 +18,11 @@ functions:
|
|
|
18
18
|
- apq-path-depth
|
|
19
19
|
- apq-forbidden-characters
|
|
20
20
|
- apq-valid-response-schema
|
|
21
|
+
- apq-post-201-location-header
|
|
22
|
+
- apq-response-no-content
|
|
21
23
|
- apq-valid-openapi-version
|
|
22
24
|
- apq-collection-query-param-required
|
|
25
|
+
- apq-total-param-default-value
|
|
23
26
|
- apq-path-param-query-conflict
|
|
24
27
|
- apq-schema-format
|
|
25
28
|
- apq-security-required-response
|
|
@@ -45,6 +48,11 @@ functions:
|
|
|
45
48
|
- apq-mandatory-response-codes
|
|
46
49
|
- apq-rate-limit-response
|
|
47
50
|
- apq-forbidden-query-format
|
|
51
|
+
- apq-wso2-scope-defined
|
|
52
|
+
- apq-undefined-response-media-type
|
|
53
|
+
- apq-wso2-auth-type-required
|
|
54
|
+
- apq-response-content-required
|
|
55
|
+
- apq-tags-consistency
|
|
48
56
|
extends:
|
|
49
57
|
- spectral:asyncapi
|
|
50
58
|
- spectral:oas
|
|
@@ -89,17 +97,13 @@ rules:
|
|
|
89
97
|
pattern: "^[a-zA-Z0-9_\\-., ]+$"
|
|
90
98
|
apiq:OAR005:
|
|
91
99
|
description: "A wrong scope may cause problems to import the API definition into WSO2 or allow all users to call the endpoint."
|
|
92
|
-
message: "
|
|
100
|
+
message: "{{error}}"
|
|
101
|
+
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR005.md"
|
|
93
102
|
severity: "error"
|
|
94
|
-
|
|
103
|
+
resolved: false
|
|
104
|
+
given: "$"
|
|
95
105
|
then:
|
|
96
|
-
|
|
97
|
-
message: "Scope must be defined in the operation."
|
|
98
|
-
- function: enumeration
|
|
99
|
-
functionOptions:
|
|
100
|
-
values:
|
|
101
|
-
- read
|
|
102
|
-
- write
|
|
106
|
+
function: apq-wso2-scope-defined
|
|
103
107
|
apiq:OAR006:
|
|
104
108
|
description: "Routes must define request media types supported by the API."
|
|
105
109
|
message: "OAR006: Specify at least one Media Type in the content of the request body."
|
|
@@ -116,10 +120,13 @@ rules:
|
|
|
116
120
|
message: "OAR007: Specify at least one Media Type in the content of the response body."
|
|
117
121
|
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR007.md"
|
|
118
122
|
severity: "error"
|
|
119
|
-
given:
|
|
123
|
+
given:
|
|
124
|
+
- "$.paths[*][post,put,patch]"
|
|
125
|
+
- "$.webhooks[*][post,put,patch]"
|
|
126
|
+
- "$.paths[*][get,post,put,patch,delete].responses[?(@property != '204')]"
|
|
127
|
+
- "$.webhooks[*][get,post,put,patch,delete].responses[?(@property != '204')]"
|
|
120
128
|
then:
|
|
121
|
-
|
|
122
|
-
function: truthy
|
|
129
|
+
function: apq-undefined-response-media-type
|
|
123
130
|
apiq:OAR008:
|
|
124
131
|
description: "HTTP verbs not encouraged."
|
|
125
132
|
message: "{{error}}"
|
|
@@ -142,7 +149,13 @@ rules:
|
|
|
142
149
|
description: "Default response media type should be defined for responses."
|
|
143
150
|
message: "OAR010: Default response media type is mandatory."
|
|
144
151
|
severity: warn
|
|
145
|
-
given:
|
|
152
|
+
given:
|
|
153
|
+
- "$.paths[*][post,put,patch]"
|
|
154
|
+
- "$.webhooks[*][post,put,patch]"
|
|
155
|
+
- "$.paths[*][get,post,put,patch,delete].responses[?(@property != '204')]"
|
|
156
|
+
- "$.webhooks[*][get,post,put,patch,delete].responses[?(@property != '204')]"
|
|
157
|
+
- "$.paths[*][get,post,put,patch,delete].responses[?(@property != '204')].content"
|
|
158
|
+
- "$.webhooks[*][get,post,put,patch,delete].responses[?(@property != '204')].content"
|
|
146
159
|
then:
|
|
147
160
|
function: apq-response-media-type
|
|
148
161
|
functionOptions:
|
|
@@ -350,20 +363,18 @@ rules:
|
|
|
350
363
|
pathValidationStrategy: "/include"
|
|
351
364
|
apiq:OAR026:
|
|
352
365
|
description: "The $total parameter default value should be false."
|
|
353
|
-
message: "
|
|
366
|
+
message: "{{error}}"
|
|
354
367
|
severity: error
|
|
355
368
|
given: "$.paths[*].get.parameters[?(@.name == '$total' && @.in == 'query')]"
|
|
356
369
|
then:
|
|
357
|
-
|
|
358
|
-
function: falsy
|
|
370
|
+
function: apq-total-param-default-value
|
|
359
371
|
apiq:OAR027:
|
|
360
372
|
description: "Location header is required in responses with code 201 from POST operations."
|
|
361
|
-
message: "
|
|
373
|
+
message: "{{error}}"
|
|
362
374
|
severity: error
|
|
363
375
|
given: "$.paths.*.post.responses['201']"
|
|
364
376
|
then:
|
|
365
|
-
|
|
366
|
-
function: truthy
|
|
377
|
+
function: apq-post-201-location-header
|
|
367
378
|
apiq:OAR028:
|
|
368
379
|
description: "$filter must be defined as a query parameter in the collection GET operations selected by the configured paths."
|
|
369
380
|
message: "{{error}}"
|
|
@@ -471,9 +482,7 @@ rules:
|
|
|
471
482
|
description: "The 201 response schema of a POST operation must have properties named 'data' or 'error' with at least one sub-property."
|
|
472
483
|
message: "{{error}}"
|
|
473
484
|
severity: error
|
|
474
|
-
given:
|
|
475
|
-
- "$.paths.*.post.responses.*.content..schema.properties"
|
|
476
|
-
- "$.paths.*.post.responses.*.schema.properties"
|
|
485
|
+
given: "$.paths.*.post.responses['201']"
|
|
477
486
|
then:
|
|
478
487
|
function: apq-valid-response-schema
|
|
479
488
|
functionOptions:
|
|
@@ -524,13 +533,14 @@ rules:
|
|
|
524
533
|
functionOptions:
|
|
525
534
|
pattern: "^[a-zA-Z]{4,}_(SC|sc)_[a-zA-Z0-9]{1,}$"
|
|
526
535
|
apiq:OAR041:
|
|
527
|
-
description:
|
|
528
|
-
message: "
|
|
536
|
+
description: "A WSO2 x-scope on an operation always requires an x-auth-type definition."
|
|
537
|
+
message: "{{error}}"
|
|
538
|
+
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR041.md"
|
|
529
539
|
severity: "error"
|
|
530
|
-
|
|
540
|
+
resolved: false
|
|
541
|
+
given: "$"
|
|
531
542
|
then:
|
|
532
|
-
|
|
533
|
-
function: truthy
|
|
543
|
+
function: apq-wso2-auth-type-required
|
|
534
544
|
apiq:OAR042:
|
|
535
545
|
description: "Base path must be compliant with the standard."
|
|
536
546
|
message: "OAR042: Base path must follow the '/api-<name>/v<version>' standard."
|
|
@@ -545,12 +555,19 @@ rules:
|
|
|
545
555
|
apiq:OAR043:
|
|
546
556
|
description: "OpenAPI definition contains structural errors that would be detected by a strict parser or validator."
|
|
547
557
|
message: "{{error}}"
|
|
558
|
+
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR043.md"
|
|
548
559
|
severity: error
|
|
549
560
|
given:
|
|
550
561
|
- $.paths.*.*.parameters.*.in
|
|
551
562
|
- $.components.parameters.*.in
|
|
563
|
+
- $.paths.*.*.parameters.*.type
|
|
564
|
+
- $.components.parameters.*.type
|
|
552
565
|
- $.paths.*.*.parameters.*.schema.type
|
|
553
566
|
- $.components.parameters.*.schema.type
|
|
567
|
+
- $.webhooks.*.*.parameters.*.in
|
|
568
|
+
- $.webhooks.*.*.parameters.*.schema.type
|
|
569
|
+
- $.components.pathItems.*.*.parameters.*.in
|
|
570
|
+
- $.components.pathItems.*.*.parameters.*.schema.type
|
|
554
571
|
then:
|
|
555
572
|
function: apq-validate-structure
|
|
556
573
|
apiq:OAR044:
|
|
@@ -575,8 +592,7 @@ rules:
|
|
|
575
592
|
severity: error
|
|
576
593
|
given: "$.paths[*][get,post,put,patch,delete].responses[?(@property !== '204')]"
|
|
577
594
|
then:
|
|
578
|
-
|
|
579
|
-
function: truthy
|
|
595
|
+
function: apq-response-content-required
|
|
580
596
|
apiq:OAR046:
|
|
581
597
|
description: "Each operation SHOULD have a tag."
|
|
582
598
|
message: "OAR046: You should categorize the operations of your contract with tags."
|
|
@@ -598,29 +614,12 @@ rules:
|
|
|
598
614
|
type: array
|
|
599
615
|
minItems: 1
|
|
600
616
|
apiq:OAR047:
|
|
601
|
-
description: "Tags required and each tag must have a short description."
|
|
602
|
-
message: "
|
|
617
|
+
description: "Tags required and each tag must have a short description, must not be duplicated, and every tag used by an operation must be declared at the top level."
|
|
618
|
+
message: "{{error}}"
|
|
603
619
|
severity: error
|
|
604
620
|
given: "$"
|
|
605
621
|
then:
|
|
606
|
-
|
|
607
|
-
function: schema
|
|
608
|
-
functionOptions:
|
|
609
|
-
schema:
|
|
610
|
-
type: array
|
|
611
|
-
minLength: 1
|
|
612
|
-
items:
|
|
613
|
-
type: object
|
|
614
|
-
required:
|
|
615
|
-
- name
|
|
616
|
-
- description
|
|
617
|
-
properties:
|
|
618
|
-
name:
|
|
619
|
-
type: string
|
|
620
|
-
minLength: 1
|
|
621
|
-
description:
|
|
622
|
-
type: string
|
|
623
|
-
minLength: 1
|
|
622
|
+
function: apq-tags-consistency
|
|
624
623
|
apiq:OAR048:
|
|
625
624
|
description: APIs must define at most one body parameter.
|
|
626
625
|
message: "OAR048: An operation can have at most one body parameter"
|
|
@@ -634,12 +633,11 @@ rules:
|
|
|
634
633
|
function: apq-at-most-one-body-parameter
|
|
635
634
|
apiq:OAR049:
|
|
636
635
|
description: "204 No Content MUST NOT return any content."
|
|
637
|
-
message: "
|
|
636
|
+
message: "{{error}}"
|
|
638
637
|
severity: error
|
|
639
638
|
given: "$.paths[*][get,post,put,patch,delete].responses['204']"
|
|
640
639
|
then:
|
|
641
|
-
|
|
642
|
-
function: falsy
|
|
640
|
+
function: apq-response-no-content
|
|
643
641
|
apiq:OAR050:
|
|
644
642
|
description: "Provide a summary for each operation."
|
|
645
643
|
message: "OAR050: Provide a summary for each operation."
|
|
@@ -650,7 +648,7 @@ rules:
|
|
|
650
648
|
function: truthy
|
|
651
649
|
apiq:OAR051:
|
|
652
650
|
description: "Summary and description must be different - not just case variations or semantic duplicates."
|
|
653
|
-
message: "
|
|
651
|
+
message: "{{error}}"
|
|
654
652
|
severity: "error"
|
|
655
653
|
given: "$.paths[*][get,post,put,patch,delete]"
|
|
656
654
|
then:
|
|
@@ -875,7 +873,9 @@ rules:
|
|
|
875
873
|
description: "All parameters in query must be snake_case."
|
|
876
874
|
message: "OAR077: All parameters in query must be snake_case."
|
|
877
875
|
severity: warn
|
|
878
|
-
given:
|
|
876
|
+
given:
|
|
877
|
+
- "$.paths[*][*].parameters[?(@.in == 'query')]"
|
|
878
|
+
- "$.paths[*].parameters[?(@.in == 'query')]"
|
|
879
879
|
then:
|
|
880
880
|
field: "name"
|
|
881
881
|
function: pattern
|
|
@@ -940,7 +940,9 @@ rules:
|
|
|
940
940
|
description: "Some formats should not pass through this querystring."
|
|
941
941
|
message: "{{error}}"
|
|
942
942
|
severity: error
|
|
943
|
-
given:
|
|
943
|
+
given:
|
|
944
|
+
- "$.paths[*][get,post,put,patch,delete].parameters[?(@.in == 'query')]"
|
|
945
|
+
- "$.paths[*].parameters[?(@.in == 'query')]"
|
|
944
946
|
then:
|
|
945
947
|
function: apq-forbidden-query-format
|
|
946
948
|
functionOptions:
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
const COMBINERS = ['allOf', 'oneOf', 'anyOf'];
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const typeIncludes = (type, name) => (Array.isArray(type) ? type.includes(name) : type === name);
|
|
4
|
+
|
|
4
5
|
const isOn = (value) => value !== false;
|
|
5
6
|
|
|
6
|
-
// Whole-body (root) example declared directly on a schema node.
|
|
7
7
|
const schemaHasRootExample = (schema) =>
|
|
8
8
|
!!schema && typeof schema === 'object'
|
|
9
9
|
&& (schema.example !== undefined || schema.examples !== undefined);
|
|
10
10
|
|
|
11
|
-
// Parameter-level example: param.example/examples, the param schema's root example,
|
|
12
|
-
// or a media-type example under param.content (OAS3). OAS2 params use param.example.
|
|
13
11
|
const hasParameterExample = (param) => {
|
|
14
12
|
if (!param || typeof param !== 'object') return false;
|
|
15
13
|
if (param.example !== undefined || param.examples !== undefined) return true;
|
|
@@ -23,9 +21,6 @@ const hasParameterExample = (param) => {
|
|
|
23
21
|
return false;
|
|
24
22
|
};
|
|
25
23
|
|
|
26
|
-
// Body-level (whole response / requestBody) example: a media-type example/examples or a
|
|
27
|
-
// root schema example. OAS3 -> content.<mt>.{example|examples|schema.example}.
|
|
28
|
-
// OAS2 -> node.examples map or node.schema root example.
|
|
29
24
|
const hasBodyLevelExample = (node) => {
|
|
30
25
|
if (!node || typeof node !== 'object') return false;
|
|
31
26
|
if (node.content && typeof node.content === 'object') {
|
|
@@ -34,16 +29,15 @@ const hasBodyLevelExample = (node) => {
|
|
|
34
29
|
&& (mt.example !== undefined || mt.examples !== undefined || schemaHasRootExample(mt.schema)),
|
|
35
30
|
);
|
|
36
31
|
}
|
|
37
|
-
if (node.examples !== undefined) return true;
|
|
38
|
-
if (schemaHasRootExample(node.schema)) return true;
|
|
32
|
+
if (node.examples !== undefined) return true;
|
|
33
|
+
if (schemaHasRootExample(node.schema)) return true;
|
|
39
34
|
return false;
|
|
40
35
|
};
|
|
41
36
|
|
|
42
|
-
// Per-leaf-property example check (recurses objects, arrays and combiners).
|
|
43
37
|
const collectPropertyIssues = (schema, issues, basePath) => {
|
|
44
38
|
if (!schema || typeof schema !== 'object' || schema.$ref) return;
|
|
45
39
|
|
|
46
|
-
if (schema.type
|
|
40
|
+
if (typeIncludes(schema.type, 'array')) {
|
|
47
41
|
if (schema.items) collectPropertyIssues(schema.items, issues, [...basePath, 'items']);
|
|
48
42
|
return;
|
|
49
43
|
}
|
|
@@ -53,7 +47,7 @@ const collectPropertyIssues = (schema, issues, basePath) => {
|
|
|
53
47
|
if (!propSchema || typeof propSchema !== 'object' || propSchema.$ref) continue;
|
|
54
48
|
const propPath = [...basePath, 'properties', propName];
|
|
55
49
|
const propType = propSchema.type;
|
|
56
|
-
if (propType
|
|
50
|
+
if (typeIncludes(propType, 'object') || typeIncludes(propType, 'array') || (!propType && propSchema.properties)) {
|
|
57
51
|
collectPropertyIssues(propSchema, issues, propPath);
|
|
58
52
|
} else if (propType !== undefined && propSchema.example === undefined && propSchema.examples === undefined) {
|
|
59
53
|
issues.push({
|
|
@@ -71,7 +65,6 @@ const collectPropertyIssues = (schema, issues, basePath) => {
|
|
|
71
65
|
});
|
|
72
66
|
};
|
|
73
67
|
|
|
74
|
-
// Property-level coverage for a response / requestBody node (OAS3 content.* or OAS2 schema).
|
|
75
68
|
const collectBodyProperties = (node, issues, basePath) => {
|
|
76
69
|
if (node.content && typeof node.content === 'object') {
|
|
77
70
|
Object.entries(node.content).forEach(([mediaType, mt]) => {
|
|
@@ -83,10 +76,6 @@ const collectBodyProperties = (node, issues, basePath) => {
|
|
|
83
76
|
};
|
|
84
77
|
|
|
85
78
|
/**
|
|
86
|
-
* OAR031 — examples coverage, validated independently per level. Each level can be
|
|
87
|
-
* switched off via functionOptions (all default on):
|
|
88
|
-
* validateResponse, validateRequestBody, validateParameter, validateProperty
|
|
89
|
-
*
|
|
90
79
|
* @param {object} given
|
|
91
80
|
* @param {object} options
|
|
92
81
|
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
@@ -106,13 +95,17 @@ module.exports = function oar031ExamplesCoverage(given, options, context) {
|
|
|
106
95
|
const isRequestBody = nodePath.at(-1) === 'requestBody';
|
|
107
96
|
|
|
108
97
|
if (isParameter) {
|
|
98
|
+
const doc = context.document?.data ?? {};
|
|
99
|
+
const isOAP2 = typeof doc.swagger === 'string';
|
|
100
|
+
if (isOAP2 && given.in !== 'body') {
|
|
101
|
+
return issues;
|
|
102
|
+
}
|
|
109
103
|
if (validateParameter && !hasParameterExample(given)) {
|
|
110
104
|
issues.push({
|
|
111
105
|
message: `OAR031: Parameter '${given.name || ''}' must have an example defined`,
|
|
112
106
|
path: nodePath,
|
|
113
107
|
});
|
|
114
108
|
}
|
|
115
|
-
// OAS2 body parameters carry a schema, so property-level coverage applies to them too.
|
|
116
109
|
if (given.in === 'body' && validateProperty && given.schema) {
|
|
117
110
|
collectPropertyIssues(given.schema, issues, [...nodePath, 'schema']);
|
|
118
111
|
}
|
|
@@ -26,12 +26,13 @@ module.exports = (given, options, context) => {
|
|
|
26
26
|
return errors;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
const threshold = options.threshold
|
|
29
|
+
const threshold = typeof options.threshold === 'number' ? options.threshold : 0.6;
|
|
30
|
+
const baseMessage = 'OAR051: Summary and description must be meaningfully different from each other.';
|
|
30
31
|
|
|
31
32
|
// Exact match (case-insensitive)
|
|
32
33
|
if (propA.trim().toUpperCase() === propB.trim().toUpperCase()) {
|
|
33
34
|
errors.push({
|
|
34
|
-
message:
|
|
35
|
+
message: baseMessage,
|
|
35
36
|
path: [...context.path, options.property]
|
|
36
37
|
});
|
|
37
38
|
return errors;
|
|
@@ -41,7 +42,7 @@ module.exports = (given, options, context) => {
|
|
|
41
42
|
const similarity = calculateSimilarity(propA, propB);
|
|
42
43
|
if (similarity >= threshold) {
|
|
43
44
|
errors.push({
|
|
44
|
-
message: `${
|
|
45
|
+
message: `${baseMessage} (${Math.round(similarity * 100)}% similar)`,
|
|
45
46
|
path: [...context.path, options.property]
|
|
46
47
|
});
|
|
47
48
|
}
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
* @param {object} options - Function options
|
|
5
5
|
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
6
6
|
*/
|
|
7
|
+
function typeIncludes(type, name) {
|
|
8
|
+
return Array.isArray(type) ? type.includes(name) : type === name;
|
|
9
|
+
}
|
|
10
|
+
|
|
7
11
|
module.exports = (given, options, context) => {
|
|
8
12
|
const results = [];
|
|
9
13
|
|
|
@@ -17,10 +21,9 @@ module.exports = (given, options, context) => {
|
|
|
17
21
|
return;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
// Check if this is a password-related field (name contains "password")
|
|
21
24
|
const isPasswordField = propName.toLowerCase().includes('password');
|
|
22
25
|
|
|
23
|
-
if (isPasswordField && propSchema.type
|
|
26
|
+
if (isPasswordField && typeIncludes(propSchema.type, 'string')) {
|
|
24
27
|
// Password fields must have format: password
|
|
25
28
|
if (propSchema.format !== 'password') {
|
|
26
29
|
results.push({
|
|
@@ -18,14 +18,16 @@ module.exports = (given, options, context) => {
|
|
|
18
18
|
continue;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
const sharedParams = Array.isArray(pathItem.parameters) ? pathItem.parameters : [];
|
|
22
|
+
|
|
21
23
|
for (const [operationKey, operation] of Object.entries(pathItem)) {
|
|
22
24
|
if (!operation || typeof operation !== 'object' ||
|
|
23
25
|
!httpMethods.includes(operationKey)) {
|
|
24
26
|
continue;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
const
|
|
28
|
-
if (
|
|
29
|
+
const operationParams = Array.isArray(operation.parameters) ? operation.parameters : [];
|
|
30
|
+
if (sharedParams.length === 0 && operationParams.length === 0) {
|
|
29
31
|
continue;
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -35,7 +37,13 @@ module.exports = (given, options, context) => {
|
|
|
35
37
|
continue;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
|
|
40
|
+
const overridden = new Set(
|
|
41
|
+
operationParams
|
|
42
|
+
.filter((p) => p && p.name && p.in)
|
|
43
|
+
.map((p) => `${p.in}:${p.name}`)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
operationParams.forEach((param, index) => {
|
|
39
47
|
if (param && (param.in === 'path' || param.in === 'query')) {
|
|
40
48
|
errors.push({
|
|
41
49
|
message: context.rule.message,
|
|
@@ -43,6 +51,15 @@ module.exports = (given, options, context) => {
|
|
|
43
51
|
});
|
|
44
52
|
}
|
|
45
53
|
});
|
|
54
|
+
|
|
55
|
+
sharedParams.forEach((param, index) => {
|
|
56
|
+
if (!param || (param.in !== 'path' && param.in !== 'query')) return;
|
|
57
|
+
if (overridden.has(`${param.in}:${param.name}`)) return;
|
|
58
|
+
errors.push({
|
|
59
|
+
message: context.rule.message,
|
|
60
|
+
path: [...context.path, pathKey, 'parameters', index]
|
|
61
|
+
});
|
|
62
|
+
});
|
|
46
63
|
}
|
|
47
64
|
}
|
|
48
65
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {object} given - the 201 response object of a POST operation
|
|
3
|
+
* @param {object} options - unused
|
|
4
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
5
|
+
*/
|
|
6
|
+
module.exports = (given, options, context) => {
|
|
7
|
+
if (!given || typeof given !== 'object') {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const doc = context.document?.data ?? {};
|
|
12
|
+
const isOAP2 = typeof doc.swagger === 'string';
|
|
13
|
+
const path = context.path || [];
|
|
14
|
+
const message = 'OAR027: Location header is required in responses with code 201 from POST operations.';
|
|
15
|
+
|
|
16
|
+
const headers = given.headers;
|
|
17
|
+
const headerKey = headers && typeof headers === 'object'
|
|
18
|
+
? Object.keys(headers).find((key) => key.toLowerCase() === 'location')
|
|
19
|
+
: undefined;
|
|
20
|
+
|
|
21
|
+
if (!headerKey) {
|
|
22
|
+
return [{ message, path: [...path, 'headers'] }];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const header = headers[headerKey];
|
|
26
|
+
const type = isOAP2 ? header && header.type : header && header.schema && header.schema.type;
|
|
27
|
+
|
|
28
|
+
if (!type) {
|
|
29
|
+
const typePath = isOAP2
|
|
30
|
+
? [...path, 'headers', headerKey, 'type']
|
|
31
|
+
: [...path, 'headers', headerKey, 'schema', 'type'];
|
|
32
|
+
return [{ message, path: typePath }];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return [];
|
|
36
|
+
};
|
|
@@ -20,10 +20,12 @@ module.exports = (given, options, context) => {
|
|
|
20
20
|
const shouldExclude = strategy === '/exclude' ? isListed : !isListed;
|
|
21
21
|
if (shouldExclude) return [];
|
|
22
22
|
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
const pathKey = context.path[1];
|
|
24
|
+
const rawPaths = (context.document
|
|
25
|
+
&& context.document.parserResult
|
|
26
|
+
&& context.document.parserResult.data
|
|
27
|
+
&& context.document.parserResult.data.paths) || {};
|
|
28
|
+
const pathItem = rawPaths[pathKey] || null;
|
|
27
29
|
|
|
28
30
|
const pathParams = (pathItem?.parameters || []).filter(
|
|
29
31
|
p => p?.in === 'path'
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {object} given - the response object (e.g. responses['201'])
|
|
3
|
+
* @param {object} options - unused
|
|
4
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
5
|
+
*/
|
|
6
|
+
module.exports = (given, options, context) => {
|
|
7
|
+
if (!given || typeof given !== 'object') {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const doc = (context.document
|
|
12
|
+
&& context.document.parserResult
|
|
13
|
+
&& context.document.parserResult.data) || {};
|
|
14
|
+
const field = doc.swagger ? 'schema' : 'content';
|
|
15
|
+
const value = given[field];
|
|
16
|
+
|
|
17
|
+
const isEmpty = value === undefined || value === null
|
|
18
|
+
|| (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0);
|
|
19
|
+
|
|
20
|
+
if (isEmpty) {
|
|
21
|
+
return [{ message: context.rule.message }];
|
|
22
|
+
}
|
|
23
|
+
return [];
|
|
24
|
+
};
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
const BODY_ALLOWED_METHODS = new Set(['post', 'put', 'patch']);
|
|
10
10
|
|
|
11
11
|
function parseExceptions(value) {
|
|
12
12
|
return new Set(
|
|
@@ -29,39 +29,42 @@ module.exports = (given, options, context) => {
|
|
|
29
29
|
const exceptions = parseExceptions(options && options['media-type-exceptions']);
|
|
30
30
|
|
|
31
31
|
const root = context.document.parserResult.data;
|
|
32
|
+
const path = context.path;
|
|
33
|
+
const lastSegment = path[path.length - 1];
|
|
32
34
|
|
|
33
35
|
if (root.swagger) {
|
|
34
|
-
|
|
36
|
+
if (given.responses === undefined) return errors;
|
|
35
37
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
const method = path[path.length - 1];
|
|
39
|
+
if (!BODY_ALLOWED_METHODS.has(method)) return errors;
|
|
40
|
+
|
|
41
|
+
const hasOwnProduces = given.produces !== undefined && given.produces !== null;
|
|
42
|
+
const produces = hasOwnProduces ? given.produces : root.produces;
|
|
43
|
+
const supported = Array.isArray(produces) && produces.some((mediaType) => isSupported(String(mediaType).toLowerCase(), defaultMediaType, exceptions));
|
|
44
|
+
if (!supported) {
|
|
41
45
|
errors.push({
|
|
42
46
|
message: context.rule.message,
|
|
43
|
-
path: [...
|
|
47
|
+
path: hasOwnProduces ? [...path, 'produces'] : [...path]
|
|
44
48
|
});
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
return errors;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
|
-
|
|
51
|
-
if (!responses) return errors;
|
|
52
|
-
|
|
53
|
-
for (const [statusCode, response] of Object.entries(responses)) {
|
|
54
|
-
if (NO_BODY_CODES.has(statusCode)) continue;
|
|
54
|
+
if (given.responses !== undefined) return errors;
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
if (lastSegment === 'content') {
|
|
57
|
+
const contentTypes = Object.keys(given);
|
|
57
58
|
const supported = contentTypes.some((mediaType) => isSupported(mediaType.toLowerCase(), defaultMediaType, exceptions));
|
|
58
|
-
|
|
59
59
|
if (!supported) {
|
|
60
|
-
errors.push({
|
|
61
|
-
message: context.rule.message,
|
|
62
|
-
path: [...context.path, 'responses', statusCode]
|
|
63
|
-
});
|
|
60
|
+
errors.push({ message: context.rule.message, path: [...path] });
|
|
64
61
|
}
|
|
62
|
+
return errors;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const hasContentKey = given.content !== undefined && given.content !== null;
|
|
66
|
+
if (!hasContentKey) {
|
|
67
|
+
errors.push({ message: context.rule.message, path: [...path] });
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
return errors;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {object} given - the 204 response object
|
|
3
|
+
* @param {object} options - unused
|
|
4
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
5
|
+
*/
|
|
6
|
+
module.exports = (given, options, context) => {
|
|
7
|
+
if (!given || typeof given !== 'object') {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const doc = context.document?.data ?? {};
|
|
12
|
+
const isOAP2 = typeof doc.swagger === 'string';
|
|
13
|
+
const field = isOAP2 ? 'schema' : 'content';
|
|
14
|
+
const value = given[field];
|
|
15
|
+
|
|
16
|
+
const isEmpty = value === undefined || value === null
|
|
17
|
+
|| (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0);
|
|
18
|
+
|
|
19
|
+
if (isEmpty) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return [{
|
|
24
|
+
message: 'OAR049: 204 No Content MUST NOT return any content.',
|
|
25
|
+
path: [...(context.path || []), field],
|
|
26
|
+
}];
|
|
27
|
+
};
|