api-quality-spectral-ruleset 1.6.1 → 1.7.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/README.md +4 -4
- package/apq-spectral.yaml +41 -35
- package/functions/apq-paged-response-check.js +95 -31
- package/functions/apq-valid-response-schema.js +58 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!-- README.md -->
|
|
2
2
|
|
|
3
|
-
[](https://github.com/apiaddicts/apiaddicts-style-guide-spectral/actions/workflows/node.js.yml) 
|
|
4
4
|
|
|
5
5
|
# APIAddicts Style Guide Spectral
|
|
6
6
|
|
|
@@ -26,19 +26,19 @@ The Spectral Ruleset requires Node version 14 or later.
|
|
|
26
26
|
|
|
27
27
|
You can specify the ruleset directly on the command line:
|
|
28
28
|
|
|
29
|
-
`spectral lint -r https://raw.githubusercontent.com/apiaddicts/
|
|
29
|
+
`spectral lint -r https://raw.githubusercontent.com/apiaddicts/apiaddicts-style-guide-spectral/refs/heads/main/apq-spectral.yaml <api definition file>`
|
|
30
30
|
|
|
31
31
|
Or you can create a Spectral configuration file (`.spectral.yaml`) that references the ruleset:
|
|
32
32
|
|
|
33
33
|
```yaml
|
|
34
34
|
extends:
|
|
35
|
-
- https://raw.githubusercontent.com/apiaddicts/
|
|
35
|
+
- https://raw.githubusercontent.com/apiaddicts/apiaddicts-style-guide-spectral/refs/heads/main/apq-spectral.yaml
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
### Example
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
|
-
spectral lint -r https://raw.githubusercontent.com/apiaddicts/
|
|
41
|
+
spectral lint -r https://raw.githubusercontent.com/apiaddicts/apiaddicts-style-guide-spectral/refs/heads/main/apq-spectral.yaml petstore.yaml
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
### Using the Spectral VSCode extension
|
package/apq-spectral.yaml
CHANGED
|
@@ -58,7 +58,7 @@ rules:
|
|
|
58
58
|
apiq:OAR001:
|
|
59
59
|
description: "For security reasons and as a REST best practice, the HTTPS protocol is mandatory."
|
|
60
60
|
message: "OAR001: HTTPS protocol is mandatory."
|
|
61
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
61
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR001.md"
|
|
62
62
|
severity: "error"
|
|
63
63
|
given: "$.servers[*].url"
|
|
64
64
|
then:
|
|
@@ -68,7 +68,7 @@ rules:
|
|
|
68
68
|
apiq:OAR002:
|
|
69
69
|
description: "A wrong scope definition may cause problems to import the API definition into WSO2."
|
|
70
70
|
message: "{{error}}"
|
|
71
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
71
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR002.md"
|
|
72
72
|
severity: "error"
|
|
73
73
|
given: "$.x-wso2-security.apim.x-wso2-scopes"
|
|
74
74
|
then:
|
|
@@ -76,7 +76,7 @@ rules:
|
|
|
76
76
|
apiq:OAR003:
|
|
77
77
|
description: "A description can help other developers to understand the correct use of the scope."
|
|
78
78
|
message: "OAR003: Scope must define a 'description' attribute."
|
|
79
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
79
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR003.md"
|
|
80
80
|
severity: "error"
|
|
81
81
|
given: "$.x-wso2-security.apim.x-wso2-scopes[*]"
|
|
82
82
|
then:
|
|
@@ -86,7 +86,7 @@ rules:
|
|
|
86
86
|
apiq:OAR004:
|
|
87
87
|
description: "A role with forbidden characters may cause problems in some applications."
|
|
88
88
|
message: "{{error}}"
|
|
89
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
89
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR004.md"
|
|
90
90
|
severity: "error"
|
|
91
91
|
given: "$.x-wso2-security.apim.x-wso2-scopes[*].roles"
|
|
92
92
|
then:
|
|
@@ -96,7 +96,7 @@ rules:
|
|
|
96
96
|
apiq:OAR005:
|
|
97
97
|
description: "A wrong scope may cause problems to import the API definition into WSO2 or allow all users to call the endpoint."
|
|
98
98
|
message: "{{error}}"
|
|
99
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
99
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR005.md"
|
|
100
100
|
severity: "error"
|
|
101
101
|
resolved: false
|
|
102
102
|
given: "$"
|
|
@@ -106,7 +106,7 @@ rules:
|
|
|
106
106
|
description: "Routes must define request media types supported by the API."
|
|
107
107
|
message: "OAR006: Specify at least one Media Type in the content of the request body."
|
|
108
108
|
severity: "error"
|
|
109
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
109
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR006.md"
|
|
110
110
|
given: "$.paths[*][post,put,patch]"
|
|
111
111
|
then:
|
|
112
112
|
- field: "requestBody"
|
|
@@ -116,7 +116,7 @@ rules:
|
|
|
116
116
|
apiq:OAR007:
|
|
117
117
|
description: "Routes must define response media types supported by the API"
|
|
118
118
|
message: "OAR007: Specify at least one Media Type in the content of the response body."
|
|
119
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
119
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR007.md"
|
|
120
120
|
severity: "error"
|
|
121
121
|
given:
|
|
122
122
|
- "$.paths[*][post,put,patch]"
|
|
@@ -128,7 +128,7 @@ rules:
|
|
|
128
128
|
apiq:OAR008:
|
|
129
129
|
description: "HTTP verbs not encouraged."
|
|
130
130
|
message: "{{error}}"
|
|
131
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
131
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR008.md"
|
|
132
132
|
severity: error
|
|
133
133
|
given: "$.paths[*]"
|
|
134
134
|
then:
|
|
@@ -162,7 +162,7 @@ rules:
|
|
|
162
162
|
apiq:OAR011:
|
|
163
163
|
description: "URLs should follow the configured naming convention (default kebab-case): all literal path segments must comply."
|
|
164
164
|
message: "{{error}}"
|
|
165
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
165
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR011.md"
|
|
166
166
|
severity: error
|
|
167
167
|
given:
|
|
168
168
|
- "$.paths[*]~"
|
|
@@ -174,7 +174,7 @@ rules:
|
|
|
174
174
|
apiq:OAR012:
|
|
175
175
|
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"
|
|
176
176
|
message: "OAR012: Path params, query params, object names and property names must follow the configured naming convention."
|
|
177
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
177
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR012.md"
|
|
178
178
|
severity: warn
|
|
179
179
|
given:
|
|
180
180
|
- "$.paths[*][*].parameters[?(@.in == 'path' || @.in == 'query')].name"
|
|
@@ -197,7 +197,7 @@ rules:
|
|
|
197
197
|
apiq:OAR014:
|
|
198
198
|
description: "Resources depth level should be below the non-suggested range."
|
|
199
199
|
message: "{{error}}"
|
|
200
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
200
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR014.md"
|
|
201
201
|
severity: warn
|
|
202
202
|
given: "$.paths.*~"
|
|
203
203
|
then:
|
|
@@ -221,7 +221,7 @@ rules:
|
|
|
221
221
|
apiq:OAR016:
|
|
222
222
|
description: "Numeric types requires a valid format."
|
|
223
223
|
message: "OAR016: Numeric types must use a valid format for their type."
|
|
224
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
224
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR016.md"
|
|
225
225
|
severity: error
|
|
226
226
|
resolved: false
|
|
227
227
|
given: "$..[?(@ && @.type)]"
|
|
@@ -230,7 +230,7 @@ rules:
|
|
|
230
230
|
apiq:OAR017:
|
|
231
231
|
description: "Resource path should alternate static and parametrized parts."
|
|
232
232
|
message: "OAR017: Resource path should alternate static and parametrized parts"
|
|
233
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
233
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR017.md"
|
|
234
234
|
severity: error
|
|
235
235
|
given: "$.paths.*~"
|
|
236
236
|
then:
|
|
@@ -240,7 +240,7 @@ rules:
|
|
|
240
240
|
apiq:OAR018:
|
|
241
241
|
description: Operation not recommended for resource path depending on HTTP verb
|
|
242
242
|
message: "OAR018: Operation not recommended for resource path: {{path}} ({{verb}})"
|
|
243
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
243
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR018.md"
|
|
244
244
|
severity: warn
|
|
245
245
|
given: "$.paths"
|
|
246
246
|
then:
|
|
@@ -322,7 +322,7 @@ rules:
|
|
|
322
322
|
apiq:OAR022:
|
|
323
323
|
description: "$orderby must be defined as a query parameter in the collection GET operations selected by the configured paths."
|
|
324
324
|
message: "{{error}}"
|
|
325
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
325
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR022.md"
|
|
326
326
|
severity: warn
|
|
327
327
|
given: "$.paths"
|
|
328
328
|
then:
|
|
@@ -350,7 +350,7 @@ rules:
|
|
|
350
350
|
apiq:OAR025:
|
|
351
351
|
description: "$limit must be defined as an integer query parameter in the collection GET operations selected by the configured paths."
|
|
352
352
|
message: "{{error}}"
|
|
353
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
353
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR025.md"
|
|
354
354
|
severity: error
|
|
355
355
|
given: "$.paths"
|
|
356
356
|
then:
|
|
@@ -439,7 +439,7 @@ rules:
|
|
|
439
439
|
functionOptions:
|
|
440
440
|
notMatch: "^(Accept|Content-Type|Authorization)$"
|
|
441
441
|
apiq:OAR034:
|
|
442
|
-
description: "GET
|
|
442
|
+
description: "GET success JSON responses on collection paths must define an object schema with a paging block that complies with paging-schema (required start, limit and links with self/previous/next; optional numPages and total as integers)."
|
|
443
443
|
message: "{{error}}"
|
|
444
444
|
severity: error
|
|
445
445
|
given: "$.paths[*].get.responses[*]"
|
|
@@ -447,6 +447,8 @@ rules:
|
|
|
447
447
|
function: apq-paged-response-check
|
|
448
448
|
functionOptions:
|
|
449
449
|
paging-schema: '{"type":"object","properties":{"numPages":{"type":"integer"},"total":{"type":"integer"},"start":{"type":"integer"},"limit":{"type":"integer"},"links":{"type":"object","properties":{"next":{"type":"object","properties":{"href":{"type":"string"}}},"previous":{"type":"object","properties":{"href":{"type":"string"}}},"last":{"type":"object","properties":{"href":{"type":"string"}}},"self":{"type":"object","properties":{"href":{"type":"string"}}},"first":{"type":"object","properties":{"href":{"type":"string"}}}},"required":["self","previous","next"]}},"required":["start","limit","links"],"pagingPropertyName":"paging"}'
|
|
450
|
+
paths: "/status,/health,/ping,**/me"
|
|
451
|
+
pathValidationStrategy: "/exclude"
|
|
450
452
|
apiq:OAR035:
|
|
451
453
|
description: "Response code 401 must be defined for operations with security schemes defined."
|
|
452
454
|
message: "{{error}}"
|
|
@@ -477,7 +479,7 @@ rules:
|
|
|
477
479
|
then:
|
|
478
480
|
function: apq-schema-format
|
|
479
481
|
apiq:OAR038:
|
|
480
|
-
description: "The 201 response schema of a POST operation must have properties
|
|
482
|
+
description: "The 201 response schema of a POST operation must only have the allowed top-level properties ('data' or 'error' by default), each with at least one sub-property, and every required one."
|
|
481
483
|
message: "{{error}}"
|
|
482
484
|
severity: error
|
|
483
485
|
given: "$.paths.*.post.responses['201']"
|
|
@@ -485,10 +487,14 @@ rules:
|
|
|
485
487
|
function: apq-valid-response-schema
|
|
486
488
|
functionOptions:
|
|
487
489
|
data-property: data
|
|
490
|
+
allowed-properties: ""
|
|
491
|
+
required-properties: ""
|
|
492
|
+
paths: "/me,/health,/ping,/status"
|
|
493
|
+
pathValidationStrategy: "/exclude"
|
|
488
494
|
apiq:OAR039:
|
|
489
495
|
description: "Response codes must be defined according to the standard depending on the HTTP verb and resource path."
|
|
490
496
|
message: "{{error}}"
|
|
491
|
-
documentationUrl: https://github.com/apiaddicts/
|
|
497
|
+
documentationUrl: https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR039.md
|
|
492
498
|
severity: error
|
|
493
499
|
given: "$.paths[*][*]"
|
|
494
500
|
then:
|
|
@@ -533,7 +539,7 @@ rules:
|
|
|
533
539
|
apiq:OAR041:
|
|
534
540
|
description: "A WSO2 x-scope on an operation always requires an x-auth-type definition."
|
|
535
541
|
message: "{{error}}"
|
|
536
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
542
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR041.md"
|
|
537
543
|
severity: "error"
|
|
538
544
|
resolved: false
|
|
539
545
|
given: "$"
|
|
@@ -553,7 +559,7 @@ rules:
|
|
|
553
559
|
apiq:OAR043:
|
|
554
560
|
description: "OpenAPI definition contains structural errors that would be detected by a strict parser or validator."
|
|
555
561
|
message: "{{error}}"
|
|
556
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
562
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR043.md"
|
|
557
563
|
severity: error
|
|
558
564
|
given:
|
|
559
565
|
- $.paths.*.*.parameters.*.in
|
|
@@ -602,7 +608,7 @@ rules:
|
|
|
602
608
|
- "$.paths[*].patch"
|
|
603
609
|
- "$.paths[*].delete"
|
|
604
610
|
then:
|
|
605
|
-
function: schema
|
|
611
|
+
function: schema
|
|
606
612
|
functionOptions:
|
|
607
613
|
schema:
|
|
608
614
|
type: object
|
|
@@ -621,7 +627,7 @@ rules:
|
|
|
621
627
|
apiq:OAR048:
|
|
622
628
|
description: APIs must define at most one body parameter.
|
|
623
629
|
message: "OAR048: An operation can have at most one body parameter"
|
|
624
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
630
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR048.md"
|
|
625
631
|
severity: error
|
|
626
632
|
given: "$.paths[*][*]"
|
|
627
633
|
then:
|
|
@@ -657,7 +663,7 @@ rules:
|
|
|
657
663
|
apiq:OAR052:
|
|
658
664
|
description: "Numeric schema types must define a format."
|
|
659
665
|
message: "OAR052: Numeric types requires a format"
|
|
660
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
666
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR052.md"
|
|
661
667
|
severity: warn
|
|
662
668
|
resolved: false
|
|
663
669
|
given: "$..[?(@ && @.type)]"
|
|
@@ -666,7 +672,7 @@ rules:
|
|
|
666
672
|
apiq:OAR053:
|
|
667
673
|
description: "Response headers for API observability and tracing must be defined (excluding 204 responses and health endpoints)."
|
|
668
674
|
message: "OAR053: Response must include mandatory headers and exclude forbidden headers."
|
|
669
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
675
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR053.md"
|
|
670
676
|
severity: error
|
|
671
677
|
given: "$.paths[*][*].responses[*]"
|
|
672
678
|
then:
|
|
@@ -863,7 +869,7 @@ rules:
|
|
|
863
869
|
apiq:OAR076:
|
|
864
870
|
description: "Schema should use well-defined type and format."
|
|
865
871
|
message: "OAR076: Schema should use well-defined type and format."
|
|
866
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
872
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR076.md"
|
|
867
873
|
severity: error
|
|
868
874
|
resolved: false
|
|
869
875
|
given: "$..[?(@ && @.type)]"
|
|
@@ -884,7 +890,7 @@ rules:
|
|
|
884
890
|
apiq:OAR078:
|
|
885
891
|
description: "All API methods must have security defined."
|
|
886
892
|
message: "OAR078: Operation must have security defined."
|
|
887
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
893
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR078.md"
|
|
888
894
|
severity: error
|
|
889
895
|
given: "$"
|
|
890
896
|
then:
|
|
@@ -892,7 +898,7 @@ rules:
|
|
|
892
898
|
apiq:OAR079:
|
|
893
899
|
description: "Operations with path parameters should include a 404 Not Found response."
|
|
894
900
|
message: "OAR079: Path parameter present, but missing 404 Not Found response."
|
|
895
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
901
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR079.md"
|
|
896
902
|
severity: "warn"
|
|
897
903
|
given: "$.paths[*][get,post,put,patch,delete]"
|
|
898
904
|
then:
|
|
@@ -1010,7 +1016,7 @@ rules:
|
|
|
1010
1016
|
apiq:OAR091:
|
|
1011
1017
|
description: "Parameters must contain only $ref references."
|
|
1012
1018
|
message: "OAR091: Parameters must contain only $ref references."
|
|
1013
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
1019
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR091.md"
|
|
1014
1020
|
severity: error
|
|
1015
1021
|
resolved: false
|
|
1016
1022
|
given: "$.paths[*][get,post,put,patch,delete].parameters[*]"
|
|
@@ -1176,7 +1182,7 @@ rules:
|
|
|
1176
1182
|
apiq:OAR113:
|
|
1177
1183
|
description: "Field or extension must be at the assigned location"
|
|
1178
1184
|
message: "OAR113: Field or extension x-custom-example must be at the assigned location"
|
|
1179
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
1185
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR113.md"
|
|
1180
1186
|
severity: warn
|
|
1181
1187
|
given: "$"
|
|
1182
1188
|
then:
|
|
@@ -1187,7 +1193,7 @@ rules:
|
|
|
1187
1193
|
apiq:OAR114:
|
|
1188
1194
|
description: "Response headers for API security and key management must be defined (excluding 204 responses)."
|
|
1189
1195
|
message: "OAR114: Response must include mandatory headers and exclude forbidden headers."
|
|
1190
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
1196
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR114.md"
|
|
1191
1197
|
severity: "error"
|
|
1192
1198
|
given: "$.paths[*][*].responses[*]"
|
|
1193
1199
|
then:
|
|
@@ -1200,7 +1206,7 @@ rules:
|
|
|
1200
1206
|
apiq:OAR115:
|
|
1201
1207
|
description: "All fields listed in the required array must be defined in the schema properties."
|
|
1202
1208
|
message: "OAR115: All fields in the required array must be defined in schema properties."
|
|
1203
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
1209
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR115.md"
|
|
1204
1210
|
severity: warn
|
|
1205
1211
|
resolved: false
|
|
1206
1212
|
given: "$..[?(@ && @.required)]"
|
|
@@ -1209,7 +1215,7 @@ rules:
|
|
|
1209
1215
|
apiq:OAR116:
|
|
1210
1216
|
description: "Every API path must match the configured regular expression."
|
|
1211
1217
|
message: "{{error}}"
|
|
1212
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
1218
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR116.md"
|
|
1213
1219
|
severity: error
|
|
1214
1220
|
given: "$.paths.*~"
|
|
1215
1221
|
then:
|
|
@@ -1219,10 +1225,10 @@ rules:
|
|
|
1219
1225
|
apiq:OAR117:
|
|
1220
1226
|
description: "The API title must not match the configured forbidden regular expression."
|
|
1221
1227
|
message: "{{error}}"
|
|
1222
|
-
documentationUrl: "https://github.com/apiaddicts/
|
|
1228
|
+
documentationUrl: "https://github.com/apiaddicts/apiaddicts-style-guide-spectral/blob/main/docs/resources/OAR117.md"
|
|
1223
1229
|
severity: error
|
|
1224
1230
|
given: "$.info.title"
|
|
1225
1231
|
then:
|
|
1226
1232
|
function: apq-forbidden-title-pattern
|
|
1227
1233
|
functionOptions:
|
|
1228
|
-
forbidden-pattern: "(?!)"
|
|
1234
|
+
forbidden-pattern: "(?!)"
|
|
@@ -22,12 +22,11 @@ const DEFAULT_PAGING_SCHEMA = {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
const DEFAULT_PAGING_PROPERTY = 'paging';
|
|
25
|
-
|
|
26
|
-
const COLLECTION_PROPERTY_NAMES = ['data', 'items', 'results', 'content', 'elements', 'records', 'entries', 'values', 'list', 'rows', 'payload'];
|
|
25
|
+
const DEFAULT_PATHS = '/status,/health,/ping,**/me';
|
|
27
26
|
|
|
28
27
|
const SUCCESS_CODE_REGEX = /^2(\d\d|XX)$/i;
|
|
29
28
|
const NO_CONTENT_CODE = '204';
|
|
30
|
-
const
|
|
29
|
+
const MEDIA_TYPE_TOKEN = /^[a-z0-9!#$&^_.+-]+$/;
|
|
31
30
|
|
|
32
31
|
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
33
32
|
|
|
@@ -143,15 +142,6 @@ const isArrayView = (view) => view.types.has('array');
|
|
|
143
142
|
|
|
144
143
|
const typePath = (view) => (isObject(view.rootSchema) && view.rootSchema.type !== undefined ? [...view.path, 'type'] : view.path);
|
|
145
144
|
|
|
146
|
-
const findCollectionProperty = (view) => {
|
|
147
|
-
for (const [name, property] of view.properties) {
|
|
148
|
-
if (COLLECTION_PROPERTY_NAMES.includes(name.toLowerCase()) && isArrayView(buildView(property.schema, property.path))) {
|
|
149
|
-
return name;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
return null;
|
|
153
|
-
};
|
|
154
|
-
|
|
155
145
|
const validateAgainstTemplate = (template, view, label, report) => {
|
|
156
146
|
if (!isObject(template)) return;
|
|
157
147
|
const type = expectedType(template);
|
|
@@ -189,56 +179,130 @@ const validateAgainstTemplate = (template, view, label, report) => {
|
|
|
189
179
|
});
|
|
190
180
|
};
|
|
191
181
|
|
|
192
|
-
const
|
|
182
|
+
const isJsonMediaType = (mediaType) => {
|
|
183
|
+
const essence = String(mediaType).split(';')[0].trim().toLowerCase();
|
|
184
|
+
const parts = essence.split('/');
|
|
185
|
+
if (parts.length !== 2 || parts[0] === '*') return true;
|
|
186
|
+
const [type, subtype] = parts;
|
|
187
|
+
if (!MEDIA_TYPE_TOKEN.test(type) || (subtype !== '*' && !MEDIA_TYPE_TOKEN.test(subtype))) return true;
|
|
188
|
+
if (type === 'application' && (subtype === 'json' || subtype === '*')) return true;
|
|
189
|
+
return subtype.endsWith('+json');
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const pathPatterns = (raw) => {
|
|
193
|
+
let items = [];
|
|
194
|
+
if (raw === undefined) items = DEFAULT_PATHS.split(',');
|
|
195
|
+
else if (typeof raw === 'string') items = raw.split(',');
|
|
196
|
+
else if (Array.isArray(raw)) items = raw;
|
|
197
|
+
return items
|
|
198
|
+
.filter((item) => typeof item === 'string')
|
|
199
|
+
.map((item) => item.trim())
|
|
200
|
+
.filter(Boolean)
|
|
201
|
+
.map((pattern) => pattern.split('/'));
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const isIncludeStrategy = (value) => typeof value === 'string' && value.trim().replace(/^\//, '').toLowerCase() === 'include';
|
|
205
|
+
|
|
206
|
+
const matchesPattern = (pattern, segments) => {
|
|
207
|
+
const match = (patternIndex, segmentIndex) => {
|
|
208
|
+
if (patternIndex === pattern.length) return segmentIndex === segments.length;
|
|
209
|
+
if (pattern[patternIndex] === '**') {
|
|
210
|
+
for (let next = segmentIndex; next <= segments.length; next += 1) {
|
|
211
|
+
if (match(patternIndex + 1, next)) return true;
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
if (segmentIndex === segments.length) return false;
|
|
216
|
+
return (pattern[patternIndex] === '*' || pattern[patternIndex] === segments[segmentIndex]) && match(patternIndex + 1, segmentIndex + 1);
|
|
217
|
+
};
|
|
218
|
+
return match(0, 0);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const isDetailPath = (pathKey) => pathKey.slice(pathKey.lastIndexOf('/') + 1).startsWith('{');
|
|
222
|
+
|
|
223
|
+
const isUnresolvedRef = (value) => isObject(value) && typeof value.$ref === 'string';
|
|
224
|
+
|
|
225
|
+
const producedMediaTypes = (document, pathKey) => {
|
|
226
|
+
const pathItem = isObject(document) && isObject(document.paths) ? document.paths[pathKey] : null;
|
|
227
|
+
const operation = isObject(pathItem) ? pathItem.get : null;
|
|
228
|
+
const listed = (value) => (Array.isArray(value) && value.length > 0 ? value : null);
|
|
229
|
+
return listed(isObject(operation) ? operation.produces : null) || listed(isObject(document) ? document.produces : null);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const evaluatedSchemas = (response, responsePath, document, pathKey, isOas2) => {
|
|
233
|
+
const schemas = [];
|
|
234
|
+
const missing = [];
|
|
193
235
|
if (isOas2) {
|
|
194
|
-
|
|
236
|
+
const produces = producedMediaTypes(document, pathKey);
|
|
237
|
+
if (produces && !produces.some(isJsonMediaType)) return { schemas, missing };
|
|
238
|
+
if (isUnresolvedRef(response.schema)) return { schemas, missing };
|
|
239
|
+
if (isObject(response.schema)) schemas.push({ schema: response.schema, path: [...responsePath, 'schema'] });
|
|
240
|
+
else missing.push(responsePath);
|
|
241
|
+
return { schemas, missing };
|
|
242
|
+
}
|
|
243
|
+
if (!isObject(response.content) || Object.keys(response.content).length === 0) {
|
|
244
|
+
missing.push(isObject(response.content) ? [...responsePath, 'content'] : responsePath);
|
|
245
|
+
return { schemas, missing };
|
|
195
246
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
.
|
|
199
|
-
|
|
247
|
+
Object.entries(response.content)
|
|
248
|
+
.filter(([mediaTypeName]) => isJsonMediaType(mediaTypeName))
|
|
249
|
+
.forEach(([mediaTypeName, mediaType]) => {
|
|
250
|
+
const mediaTypePath = [...responsePath, 'content', mediaTypeName];
|
|
251
|
+
if (isUnresolvedRef(mediaType) || (isObject(mediaType) && isUnresolvedRef(mediaType.schema))) return;
|
|
252
|
+
if (isObject(mediaType) && isObject(mediaType.schema)) schemas.push({ schema: mediaType.schema, path: [...mediaTypePath, 'schema'] });
|
|
253
|
+
else missing.push(mediaTypePath);
|
|
254
|
+
});
|
|
255
|
+
return { schemas, missing };
|
|
200
256
|
};
|
|
201
257
|
|
|
202
258
|
/**
|
|
203
259
|
* @param {object} response - a resolved response of a GET operation
|
|
204
|
-
* @param {object} options
|
|
260
|
+
* @param {object} options
|
|
261
|
+
* @param {string|object} options['paging-schema'] - JSON string or object with the paging structure and its pagingPropertyName (default "paging")
|
|
262
|
+
* @param {string|string[]} options.paths - comma-separated path patterns matched segment by segment; "*" matches one segment and "**" zero or more (default "/status,/health,/ping,**\/me")
|
|
263
|
+
* @param {string} options.pathValidationStrategy - "/exclude" (default) skips the matching paths, "/include" only evaluates them
|
|
205
264
|
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
206
265
|
*/
|
|
207
266
|
module.exports = (response, options = {}, context) => {
|
|
208
|
-
if (!isObject(response)) return [];
|
|
267
|
+
if (!isObject(response) || isUnresolvedRef(response)) return [];
|
|
268
|
+
const opts = options || {};
|
|
209
269
|
|
|
210
270
|
const responsePath = context.path || [];
|
|
211
271
|
const statusCode = String(responsePath[responsePath.length - 1]);
|
|
212
272
|
if (!SUCCESS_CODE_REGEX.test(statusCode) || statusCode === NO_CONTENT_CODE) return [];
|
|
213
273
|
|
|
214
274
|
const pathKey = typeof responsePath[1] === 'string' ? responsePath[1] : '';
|
|
215
|
-
|
|
275
|
+
if (isDetailPath(pathKey)) return [];
|
|
276
|
+
const segments = pathKey.split('/');
|
|
277
|
+
const isListed = pathPatterns(opts.paths).some((pattern) => matchesPattern(pattern, segments));
|
|
278
|
+
if (isIncludeStrategy(opts.pathValidationStrategy) ? !isListed : isListed) return [];
|
|
216
279
|
|
|
217
|
-
const pagingSchema = parsePagingSchema(
|
|
280
|
+
const pagingSchema = parsePagingSchema(opts['paging-schema']);
|
|
218
281
|
const pagingPropertyName = typeof pagingSchema.pagingPropertyName === 'string' && pagingSchema.pagingPropertyName.trim() !== ''
|
|
219
282
|
? pagingSchema.pagingPropertyName.trim()
|
|
220
283
|
: DEFAULT_PAGING_PROPERTY;
|
|
221
284
|
|
|
222
|
-
const
|
|
223
|
-
const
|
|
285
|
+
const rawDocument = context.document && context.document.data;
|
|
286
|
+
const resolvedDocument = context.documentInventory && context.documentInventory.resolved;
|
|
287
|
+
const document = isObject(resolvedDocument) ? resolvedDocument : rawDocument;
|
|
288
|
+
const isOas2 = isObject(rawDocument) && typeof rawDocument.swagger === 'string';
|
|
224
289
|
const ruleCode = context.rule && context.rule.name ? context.rule.name.split(':').pop() : 'OAR034';
|
|
225
290
|
|
|
226
291
|
const results = [];
|
|
227
292
|
const report = (message, path) => results.push({ message: `${ruleCode}: ${message}`, path });
|
|
228
293
|
|
|
229
|
-
|
|
294
|
+
const { schemas, missing } = evaluatedSchemas(response, responsePath, document, pathKey, isOas2);
|
|
295
|
+
missing.forEach((path) => report(`Collection response must define a schema with a '${pagingPropertyName}' property`, path));
|
|
296
|
+
|
|
297
|
+
schemas.forEach(({ schema, path }) => {
|
|
230
298
|
const view = buildView(schema, path);
|
|
231
299
|
const paging = view.properties.get(pagingPropertyName);
|
|
232
300
|
|
|
233
301
|
if (!paging) {
|
|
234
|
-
if (isDetailPath) return;
|
|
235
302
|
if (isArrayView(view)) {
|
|
236
303
|
report(`Collection response must be an object with a '${pagingPropertyName}' property, not a top-level array`, view.path);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const collectionProperty = findCollectionProperty(view);
|
|
240
|
-
if (collectionProperty) {
|
|
241
|
-
report(`Response with collection property '${collectionProperty}' must include '${pagingPropertyName}' property for pagination`, view.path);
|
|
304
|
+
} else {
|
|
305
|
+
report(`Collection response must include '${pagingPropertyName}' property for pagination`, view.path);
|
|
242
306
|
}
|
|
243
307
|
return;
|
|
244
308
|
}
|
|
@@ -18,9 +18,40 @@ function mergedProperties(schema, visited) {
|
|
|
18
18
|
return properties;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function parseList(value) {
|
|
22
|
+
let items = [];
|
|
23
|
+
if (Array.isArray(value)) {
|
|
24
|
+
items = value;
|
|
25
|
+
} else if (typeof value === 'string') {
|
|
26
|
+
items = value.split(',');
|
|
27
|
+
}
|
|
28
|
+
const names = items
|
|
29
|
+
.filter((item) => typeof item === 'string' || typeof item === 'number')
|
|
30
|
+
.map((item) => String(item).trim())
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
return [...new Set(names)];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatNames(names) {
|
|
36
|
+
const quoted = names.map((name) => `'${name}'`);
|
|
37
|
+
if (quoted.length <= 1) {
|
|
38
|
+
return quoted.join('');
|
|
39
|
+
}
|
|
40
|
+
return `${quoted.slice(0, -1).join(', ')} or ${quoted[quoted.length - 1]}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isIncludeStrategy(value) {
|
|
44
|
+
return typeof value === 'string' && value.trim().replace(/^\//, '').toLowerCase() === 'include';
|
|
45
|
+
}
|
|
46
|
+
|
|
21
47
|
/**
|
|
22
48
|
* @param {object} given - the 201 response object of a POST operation
|
|
23
49
|
* @param {object} options
|
|
50
|
+
* @param {string} options['data-property'] - name accepted next to 'error' when allowed-properties is not set (default "data")
|
|
51
|
+
* @param {string|string[]} options['allowed-properties'] - top-level property names accepted in the schema, array or comma-separated; blank falls back to [data-property, 'error']
|
|
52
|
+
* @param {string|string[]} options['required-properties'] - top-level property names that must be present, array or comma-separated
|
|
53
|
+
* @param {string|string[]} options.paths - paths to include/exclude (exact match), array or comma-separated
|
|
54
|
+
* @param {string} options.pathValidationStrategy - "/include" or "/exclude" (default "/exclude")
|
|
24
55
|
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
25
56
|
*/
|
|
26
57
|
module.exports = (given, options, context) => {
|
|
@@ -28,17 +59,28 @@ module.exports = (given, options, context) => {
|
|
|
28
59
|
return [];
|
|
29
60
|
}
|
|
30
61
|
|
|
31
|
-
const
|
|
32
|
-
const
|
|
62
|
+
const opts = options || {};
|
|
63
|
+
const basePath = context.path || [];
|
|
64
|
+
|
|
65
|
+
const paths = parseList(opts.paths);
|
|
66
|
+
const isListed = paths.includes(basePath[1]);
|
|
67
|
+
if (isIncludeStrategy(opts.pathValidationStrategy) ? !isListed : isListed) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const dataProperty = opts['data-property'] || 'data';
|
|
72
|
+
const configuredNames = parseList(opts['allowed-properties']);
|
|
73
|
+
const allowedNames = configuredNames.length > 0 ? configuredNames : [...new Set([dataProperty, 'error'])];
|
|
74
|
+
const requiredNames = parseList(opts['required-properties']);
|
|
75
|
+
const allowedText = formatNames(allowedNames);
|
|
33
76
|
const doc = context.document?.data ?? {};
|
|
34
77
|
const isOAP2 = typeof doc.swagger === 'string';
|
|
35
|
-
const basePath = context.path || [];
|
|
36
78
|
const errors = [];
|
|
37
79
|
|
|
38
80
|
const checkSchema = (schema, schemaPath) => {
|
|
39
81
|
if (!schema || typeof schema !== 'object') {
|
|
40
82
|
errors.push({
|
|
41
|
-
message: `OAR038: 201 response must define a schema with a
|
|
83
|
+
message: `OAR038: 201 response must define a schema with a ${allowedText} property.`,
|
|
42
84
|
path: schemaPath,
|
|
43
85
|
});
|
|
44
86
|
return;
|
|
@@ -49,16 +91,16 @@ module.exports = (given, options, context) => {
|
|
|
49
91
|
|
|
50
92
|
if (propNames.length === 0) {
|
|
51
93
|
errors.push({
|
|
52
|
-
message: `OAR038: Response schema must have properties named
|
|
94
|
+
message: `OAR038: Response schema must have properties named ${allowedText}.`,
|
|
53
95
|
path: schemaPath,
|
|
54
96
|
});
|
|
55
97
|
return;
|
|
56
98
|
}
|
|
57
99
|
|
|
58
100
|
for (const propName of propNames) {
|
|
59
|
-
if (!
|
|
101
|
+
if (!allowedNames.includes(propName)) {
|
|
60
102
|
errors.push({
|
|
61
|
-
message: `OAR038: Response property must be named
|
|
103
|
+
message: `OAR038: Response property must be named ${allowedText}. Got '${propName}'.`,
|
|
62
104
|
path: [...schemaPath, 'properties', propName],
|
|
63
105
|
});
|
|
64
106
|
continue;
|
|
@@ -71,6 +113,15 @@ module.exports = (given, options, context) => {
|
|
|
71
113
|
});
|
|
72
114
|
}
|
|
73
115
|
}
|
|
116
|
+
|
|
117
|
+
for (const requiredName of requiredNames) {
|
|
118
|
+
if (!Object.prototype.hasOwnProperty.call(properties, requiredName)) {
|
|
119
|
+
errors.push({
|
|
120
|
+
message: `OAR038: Response schema must define the required property '${requiredName}'.`,
|
|
121
|
+
path: schemaPath,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
74
125
|
};
|
|
75
126
|
|
|
76
127
|
if (isOAP2) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "api-quality-spectral-ruleset",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Spectral ruleset by API Quality",
|
|
5
5
|
"main": "apq-spectral.yaml",
|
|
6
6
|
"files": [
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"license": "GNU AGPLv3",
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|
|
25
|
-
"url": "https://github.com/apiaddicts/
|
|
25
|
+
"url": "https://github.com/apiaddicts/apiaddicts-style-guide-spectral"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@stoplight/spectral-core": "1.23.0",
|