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 CHANGED
@@ -1,6 +1,6 @@
1
1
  <!-- README.md -->
2
2
 
3
- [![Node.js CI](https://github.com/apiaddicts/apquality-spectral/actions/workflows/node.js.yml/badge.svg)](https://github.com/apiaddicts/apquality-spectral/actions/workflows/node.js.yml) ![Coverage](./coverage/badges.svg)
3
+ [![Node.js CI](https://github.com/apiaddicts/apiaddicts-style-guide-spectral/actions/workflows/node.js.yml/badge.svg)](https://github.com/apiaddicts/apiaddicts-style-guide-spectral/actions/workflows/node.js.yml) ![Coverage](./coverage/badges.svg)
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/apquality-spectral/refs/heads/main/apq-spectral.yaml <api definition file>`
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/apquality-spectral/refs/heads/main/apq-spectral.yaml
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/apquality-spectral/refs/heads/main/apq-spectral.yaml petstore.yaml
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/apquality-spectral/blob/main/docs/resources/OAR001.md"
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/apquality-spectral/blob/main/docs/resources/OAR002.md"
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/apquality-spectral/blob/main/docs/resources/OAR003.md"
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/apquality-spectral/blob/main/docs/resources/OAR004.md"
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/apquality-spectral/blob/main/docs/resources/OAR005.md"
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/apquality-spectral/blob/main/docs/resources/OAR006.md"
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/apquality-spectral/blob/main/docs/resources/OAR007.md"
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/apquality-spectral/blob/main/docs/resources/OAR008.md"
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/apquality-spectral/blob/main/docs/resources/OAR011.md"
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/apquality-spectral/blob/main/docs/resources/OAR012.md"
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/apquality-spectral/blob/main/docs/resources/OAR014.md"
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/apquality-spectral/blob/main/docs/resources/OAR016.md"
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/apquality-spectral/blob/main/docs/resources/OAR017.md"
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/apquality-spectral/blob/main/docs/resources/OAR018.md"
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/apquality-spectral/blob/main/docs/resources/OAR022.md"
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/apquality-spectral/blob/main/docs/resources/OAR025.md"
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 collection responses must include a paging block that complies with the standard paging schema (required start, limit and links with self/previous/next; optional numPages and total as integers)."
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 named 'data' or 'error' with at least one sub-property."
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/apquality-spectral/blob/main/docs/resources/OAR039.md
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/apquality-spectral/blob/main/docs/resources/OAR041.md"
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/apquality-spectral/blob/main/docs/resources/OAR043.md"
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/apquality-spectral/blob/main/docs/resources/OAR048.md"
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/apquality-spectral/blob/main/docs/resources/OAR052.md"
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/apquality-spectral/blob/main/docs/resources/OAR053.md"
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/apquality-spectral/blob/main/docs/resources/OAR076.md"
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/apquality-spectral/blob/main/docs/resources/OAR078.md"
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/apquality-spectral/blob/main/docs/resources/OAR079.md"
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/apquality-spectral/blob/main/docs/resources/OAR091.md"
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/apquality-spectral/blob/main/docs/resources/OAR113.md"
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/apquality-spectral/blob/main/docs/resources/OAR114.md"
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/apquality-spectral/blob/main/docs/resources/OAR115.md"
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/apquality-spectral/blob/main/docs/resources/OAR116.md"
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/apquality-spectral/blob/main/docs/resources/OAR117.md"
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 DETAIL_PATH_REGEX = /\/\{[^}]+\}$/;
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 responseSchemas = (response, responsePath, isOas2) => {
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
- return isObject(response.schema) ? [{ schema: response.schema, path: [...responsePath, 'schema'] }] : [];
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
- if (!isObject(response.content)) return [];
197
- return Object.entries(response.content)
198
- .filter(([, mediaType]) => isObject(mediaType) && isObject(mediaType.schema))
199
- .map(([mediaTypeName, mediaType]) => ({ schema: mediaType.schema, path: [...responsePath, 'content', mediaTypeName, 'schema'] }));
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 - { 'paging-schema': JSON string or object }
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
- const isDetailPath = DETAIL_PATH_REGEX.test(pathKey);
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((options || {})['paging-schema']);
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 document = context.document && context.document.data;
223
- const isOas2 = isObject(document) && typeof document.swagger === 'string';
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
- responseSchemas(response, responsePath, isOas2).forEach(({ schema, path }) => {
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
- return;
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 dataProperty = (options && options['data-property']) || 'data';
32
- const VALID_NAMES = [dataProperty, 'error'];
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 '${dataProperty}' or 'error' property.`,
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 '${dataProperty}' or 'error'.`,
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 (!VALID_NAMES.includes(propName)) {
101
+ if (!allowedNames.includes(propName)) {
60
102
  errors.push({
61
- message: `OAR038: Response property must be named '${dataProperty}' or 'error'. Got '${propName}'.`,
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.6.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/apquality-spectral"
25
+ "url": "https://github.com/apiaddicts/apiaddicts-style-guide-spectral"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@stoplight/spectral-core": "1.23.0",