api-quality-spectral-ruleset 1.6.0 → 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 +43 -37
- package/functions/apq-paged-response-check.js +292 -97
- 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,14 +439,16 @@ rules:
|
|
|
439
439
|
functionOptions:
|
|
440
440
|
notMatch: "^(Accept|Content-Type|Authorization)$"
|
|
441
441
|
apiq:OAR034:
|
|
442
|
-
description: "
|
|
443
|
-
message: "
|
|
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
|
+
message: "{{error}}"
|
|
444
444
|
severity: error
|
|
445
|
-
given: "$.paths[*]
|
|
445
|
+
given: "$.paths[*].get.responses[*]"
|
|
446
446
|
then:
|
|
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: "(?!)"
|
|
@@ -1,124 +1,319 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
const DEFAULT_PAGING_SCHEMA = {
|
|
2
|
+
type: 'object',
|
|
3
|
+
properties: {
|
|
4
|
+
numPages: { type: 'integer' },
|
|
5
|
+
total: { type: 'integer' },
|
|
6
|
+
start: { type: 'integer' },
|
|
7
|
+
limit: { type: 'integer' },
|
|
8
|
+
links: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
next: { type: 'object', properties: { href: { type: 'string' } } },
|
|
12
|
+
previous: { type: 'object', properties: { href: { type: 'string' } } },
|
|
13
|
+
last: { type: 'object', properties: { href: { type: 'string' } } },
|
|
14
|
+
self: { type: 'object', properties: { href: { type: 'string' } } },
|
|
15
|
+
first: { type: 'object', properties: { href: { type: 'string' } } },
|
|
16
|
+
},
|
|
17
|
+
required: ['self', 'previous', 'next'],
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
required: ['start', 'limit', 'links'],
|
|
21
|
+
pagingPropertyName: 'paging',
|
|
22
|
+
};
|
|
3
23
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
24
|
+
const DEFAULT_PAGING_PROPERTY = 'paging';
|
|
25
|
+
const DEFAULT_PATHS = '/status,/health,/ping,**/me';
|
|
26
|
+
|
|
27
|
+
const SUCCESS_CODE_REGEX = /^2(\d\d|XX)$/i;
|
|
28
|
+
const NO_CONTENT_CODE = '204';
|
|
29
|
+
const MEDIA_TYPE_TOKEN = /^[a-z0-9!#$&^_.+-]+$/;
|
|
7
30
|
|
|
8
|
-
|
|
9
|
-
|
|
31
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
32
|
+
|
|
33
|
+
const parsePagingSchema = (raw) => {
|
|
34
|
+
if (isObject(raw)) return raw;
|
|
35
|
+
if (typeof raw !== 'string' || raw.trim() === '') return DEFAULT_PAGING_SCHEMA;
|
|
10
36
|
try {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
? JSON.parse(pagingSchemaStr)
|
|
14
|
-
: pagingSchemaStr;
|
|
37
|
+
const parsed = JSON.parse(raw);
|
|
38
|
+
return isObject(parsed) ? parsed : DEFAULT_PAGING_SCHEMA;
|
|
15
39
|
} catch (err) {
|
|
16
|
-
return
|
|
17
|
-
message: `${context.rule.name.split(':').pop()}: Invalid paging-schema configuration: ${err.message}`,
|
|
18
|
-
path: context.path,
|
|
19
|
-
}];
|
|
40
|
+
return DEFAULT_PAGING_SCHEMA;
|
|
20
41
|
}
|
|
42
|
+
};
|
|
21
43
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
44
|
+
const declaredTypes = (schema) => {
|
|
45
|
+
if (!isObject(schema)) return [];
|
|
46
|
+
if (typeof schema.type === 'string') return [schema.type];
|
|
47
|
+
if (Array.isArray(schema.type)) return schema.type.filter((t) => typeof t === 'string');
|
|
48
|
+
return [];
|
|
49
|
+
};
|
|
25
50
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
51
|
+
const isNullSchema = (schema) => {
|
|
52
|
+
const types = declaredTypes(schema);
|
|
53
|
+
return types.length > 0 && types.every((t) => t === 'null');
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const unwrapNullable = (schema, schemaPath) => {
|
|
57
|
+
let current = schema;
|
|
58
|
+
let currentPath = schemaPath;
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
while (isObject(current) && !seen.has(current)) {
|
|
61
|
+
seen.add(current);
|
|
62
|
+
const key = ['oneOf', 'anyOf'].find((k) => Array.isArray(current[k]));
|
|
63
|
+
if (!key || current.properties || current.items || current.allOf) break;
|
|
64
|
+
const branches = current[key].map((branch, index) => ({ branch, index })).filter(({ branch }) => !isNullSchema(branch));
|
|
65
|
+
if (branches.length !== 1) break;
|
|
66
|
+
currentPath = [...currentPath, key, branches[0].index];
|
|
67
|
+
current = branches[0].branch;
|
|
29
68
|
}
|
|
69
|
+
return { schema: current, path: currentPath };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const buildView = (schema, schemaPath) => {
|
|
73
|
+
const root = unwrapNullable(schema, schemaPath);
|
|
74
|
+
const view = {
|
|
75
|
+
types: new Set(),
|
|
76
|
+
properties: new Map(),
|
|
77
|
+
required: new Set(),
|
|
78
|
+
items: null,
|
|
79
|
+
path: root.path,
|
|
80
|
+
rootSchema: root.schema,
|
|
81
|
+
propertiesPath: null,
|
|
82
|
+
requiredPath: null,
|
|
83
|
+
};
|
|
84
|
+
const visited = new Set();
|
|
30
85
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
86
|
+
const collect = (node, nodePath) => {
|
|
87
|
+
const unwrapped = unwrapNullable(node, nodePath);
|
|
88
|
+
const current = unwrapped.schema;
|
|
89
|
+
const currentPath = unwrapped.path;
|
|
90
|
+
if (!isObject(current) || visited.has(current)) return;
|
|
91
|
+
visited.add(current);
|
|
92
|
+
|
|
93
|
+
declaredTypes(current).filter((t) => t !== 'null').forEach((t) => view.types.add(t));
|
|
94
|
+
|
|
95
|
+
if (isObject(current.properties)) {
|
|
96
|
+
if (!view.propertiesPath) view.propertiesPath = [...currentPath, 'properties'];
|
|
97
|
+
Object.entries(current.properties).forEach(([name, propertySchema]) => {
|
|
98
|
+
if (!view.properties.has(name)) {
|
|
99
|
+
view.properties.set(name, { schema: propertySchema, path: [...currentPath, 'properties', name] });
|
|
100
|
+
}
|
|
42
101
|
});
|
|
43
102
|
}
|
|
44
|
-
return results;
|
|
45
|
-
}
|
|
46
103
|
|
|
47
|
-
|
|
104
|
+
if (Array.isArray(current.required)) {
|
|
105
|
+
if (!view.requiredPath) view.requiredPath = [...currentPath, 'required'];
|
|
106
|
+
current.required.filter((name) => typeof name === 'string').forEach((name) => view.required.add(name));
|
|
107
|
+
}
|
|
48
108
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
109
|
+
if (isObject(current.items) && !view.items) {
|
|
110
|
+
view.items = { schema: current.items, path: [...currentPath, 'items'] };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (Array.isArray(current.allOf)) {
|
|
114
|
+
current.allOf.forEach((member, index) => collect(member, [...currentPath, 'allOf', index]));
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
collect(root.schema, root.path);
|
|
119
|
+
|
|
120
|
+
if (view.types.size === 0) {
|
|
121
|
+
if (view.properties.size > 0) view.types.add('object');
|
|
122
|
+
else if (view.items) view.types.add('array');
|
|
56
123
|
}
|
|
124
|
+
return view;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const expectedType = (template) => {
|
|
128
|
+
const types = declaredTypes(template).filter((t) => t !== 'null' && t !== 'any');
|
|
129
|
+
if (types.length > 0) return types[0];
|
|
130
|
+
if (isObject(template.properties) || Array.isArray(template.required)) return 'object';
|
|
131
|
+
if (isObject(template.items)) return 'array';
|
|
132
|
+
return null;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const typeMatches = (expected, actualTypes) => {
|
|
136
|
+
if (!expected || actualTypes.size === 0) return true;
|
|
137
|
+
if (actualTypes.has(expected)) return true;
|
|
138
|
+
return expected === 'number' && actualTypes.has('integer');
|
|
139
|
+
};
|
|
57
140
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
141
|
+
const isArrayView = (view) => view.types.has('array');
|
|
142
|
+
|
|
143
|
+
const typePath = (view) => (isObject(view.rootSchema) && view.rootSchema.type !== undefined ? [...view.path, 'type'] : view.path);
|
|
144
|
+
|
|
145
|
+
const validateAgainstTemplate = (template, view, label, report) => {
|
|
146
|
+
if (!isObject(template)) return;
|
|
147
|
+
const type = expectedType(template);
|
|
148
|
+
|
|
149
|
+
if (type === 'array') {
|
|
150
|
+
if (isObject(template.items) && view.items) {
|
|
151
|
+
validateAgainstTemplate(template.items, buildView(view.items.schema, view.items.path), `${label}[]`, report);
|
|
63
152
|
}
|
|
153
|
+
return;
|
|
64
154
|
}
|
|
155
|
+
if (type !== 'object') return;
|
|
65
156
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
157
|
+
const requiredNames = Array.isArray(template.required) ? template.required.filter((name) => typeof name === 'string') : [];
|
|
158
|
+
const missing = requiredNames.filter((name) => !view.properties.has(name));
|
|
159
|
+
const notMarked = requiredNames.filter((name) => view.properties.has(name) && !view.required.has(name));
|
|
160
|
+
|
|
161
|
+
if (missing.length > 0) {
|
|
162
|
+
report(`'${label}' must include required properties: ${missing.join(', ')}`, view.propertiesPath || view.path);
|
|
163
|
+
}
|
|
164
|
+
if (notMarked.length > 0) {
|
|
165
|
+
report(`'${label}' must list as required: ${notMarked.join(', ')}`, view.requiredPath || view.path);
|
|
71
166
|
}
|
|
72
167
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
168
|
+
if (!isObject(template.properties)) return;
|
|
169
|
+
Object.entries(template.properties).forEach(([name, childTemplate]) => {
|
|
170
|
+
const property = view.properties.get(name);
|
|
171
|
+
if (!property || !isObject(childTemplate)) return;
|
|
172
|
+
const childView = buildView(property.schema, property.path);
|
|
173
|
+
const childType = expectedType(childTemplate);
|
|
174
|
+
if (!typeMatches(childType, childView.types)) {
|
|
175
|
+
report(`'${label}.${name}' must be of type ${childType}`, typePath(childView));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
validateAgainstTemplate(childTemplate, childView, `${label}.${name}`, report);
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
|
|
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';
|
|
88
205
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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;
|
|
95
212
|
}
|
|
213
|
+
return false;
|
|
96
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 = [];
|
|
235
|
+
if (isOas2) {
|
|
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 };
|
|
97
246
|
}
|
|
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 };
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @param {object} response - a resolved response of a GET operation
|
|
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
|
|
264
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
265
|
+
*/
|
|
266
|
+
module.exports = (response, options = {}, context) => {
|
|
267
|
+
if (!isObject(response) || isUnresolvedRef(response)) return [];
|
|
268
|
+
const opts = options || {};
|
|
269
|
+
|
|
270
|
+
const responsePath = context.path || [];
|
|
271
|
+
const statusCode = String(responsePath[responsePath.length - 1]);
|
|
272
|
+
if (!SUCCESS_CODE_REGEX.test(statusCode) || statusCode === NO_CONTENT_CODE) return [];
|
|
273
|
+
|
|
274
|
+
const pathKey = typeof responsePath[1] === 'string' ? responsePath[1] : '';
|
|
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 [];
|
|
279
|
+
|
|
280
|
+
const pagingSchema = parsePagingSchema(opts['paging-schema']);
|
|
281
|
+
const pagingPropertyName = typeof pagingSchema.pagingPropertyName === 'string' && pagingSchema.pagingPropertyName.trim() !== ''
|
|
282
|
+
? pagingSchema.pagingPropertyName.trim()
|
|
283
|
+
: DEFAULT_PAGING_PROPERTY;
|
|
284
|
+
|
|
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';
|
|
289
|
+
const ruleCode = context.rule && context.rule.name ? context.rule.name.split(':').pop() : 'OAR034';
|
|
290
|
+
|
|
291
|
+
const results = [];
|
|
292
|
+
const report = (message, path) => results.push({ message: `${ruleCode}: ${message}`, path });
|
|
293
|
+
|
|
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 }) => {
|
|
298
|
+
const view = buildView(schema, path);
|
|
299
|
+
const paging = view.properties.get(pagingPropertyName);
|
|
300
|
+
|
|
301
|
+
if (!paging) {
|
|
302
|
+
if (isArrayView(view)) {
|
|
303
|
+
report(`Collection response must be an object with a '${pagingPropertyName}' property, not a top-level array`, view.path);
|
|
304
|
+
} else {
|
|
305
|
+
report(`Collection response must include '${pagingPropertyName}' property for pagination`, view.path);
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const pagingView = buildView(paging.schema, paging.path);
|
|
311
|
+
if (!typeMatches('object', pagingView.types)) {
|
|
312
|
+
report(`'${pagingPropertyName}' must be of type object`, typePath(pagingView));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
validateAgainstTemplate(pagingSchema, pagingView, pagingPropertyName, report);
|
|
316
|
+
});
|
|
98
317
|
|
|
99
318
|
return results;
|
|
100
319
|
};
|
|
101
|
-
|
|
102
|
-
// Default paging schema matching Java implementation
|
|
103
|
-
const defaultPagingSchema = JSON.stringify({
|
|
104
|
-
type: 'object',
|
|
105
|
-
properties: {
|
|
106
|
-
numPages: { type: 'integer' },
|
|
107
|
-
total: { type: 'integer' },
|
|
108
|
-
start: { type: 'integer' },
|
|
109
|
-
limit: { type: 'integer' },
|
|
110
|
-
links: {
|
|
111
|
-
type: 'object',
|
|
112
|
-
properties: {
|
|
113
|
-
next: { type: 'object', properties: { href: { type: 'string' } } },
|
|
114
|
-
previous: { type: 'object', properties: { href: { type: 'string' } } },
|
|
115
|
-
last: { type: 'object', properties: { href: { type: 'string' } } },
|
|
116
|
-
self: { type: 'object', properties: { href: { type: 'string' } } },
|
|
117
|
-
first: { type: 'object', properties: { href: { type: 'string' } } },
|
|
118
|
-
},
|
|
119
|
-
required: ['self', 'previous', 'next'],
|
|
120
|
-
},
|
|
121
|
-
},
|
|
122
|
-
required: ['start', 'limit', 'links'],
|
|
123
|
-
pagingPropertyName: 'paging',
|
|
124
|
-
});
|
|
@@ -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",
|