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 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,14 +439,16 @@ rules:
439
439
  functionOptions:
440
440
  notMatch: "^(Accept|Content-Type|Authorization)$"
441
441
  apiq:OAR034:
442
- description: "A paged response must comply with the standard paging schema (start, limit, links with self/previous/next, numPages, total)."
443
- message: "OAR034: Paged response schema does not match the required standard."
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[*][get].responses[?(@property != '204' && @property != '304' && @property != 'default')]..schema"
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 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: "(?!)"
@@ -1,124 +1,319 @@
1
- module.exports = (schema, options = {}, context) => {
2
- const results = [];
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
- if (!schema || typeof schema !== 'object') {
5
- return results;
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
- // Parse paging schema from options
9
- let pagingSchema = {};
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 pagingSchemaStr = options['paging-schema'] || defaultPagingSchema;
12
- pagingSchema = typeof pagingSchemaStr === 'string'
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
- const pagingPropertyName = pagingSchema.pagingPropertyName || 'paging';
23
- const requiredFields = pagingSchema.required || [];
24
- const properties = pagingSchema.properties || {};
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
- // Check if schema doesn't have properties at all - skip validation
27
- if (!schema.properties) {
28
- return results;
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
- // Check if schema has the paging property
32
- if (!schema.properties[pagingPropertyName]) {
33
- // Only flag if schema looks like it should have paging (has data/items/results arrays)
34
- const hasArrayProperty = Object.values(schema.properties || {}).some(
35
- prop => prop && prop.type === 'array'
36
- );
37
-
38
- if (hasArrayProperty) {
39
- results.push({
40
- message: `${context.rule.name.split(':').pop()}: Response must include '${pagingPropertyName}' property for pagination`,
41
- path: context.path,
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
- const pagingObj = schema.properties[pagingPropertyName];
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
- // Validate paging object structure
50
- if (!pagingObj.properties) {
51
- results.push({
52
- message: `${context.rule.name.split(':').pop()}: '${pagingPropertyName}' must be an object with required properties`,
53
- path: [...context.path, 'properties', pagingPropertyName],
54
- });
55
- return results;
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
- // Check required fields
59
- const missingFields = [];
60
- for (const requiredField of requiredFields) {
61
- if (!pagingObj.properties[requiredField]) {
62
- missingFields.push(requiredField);
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
- if (missingFields.length > 0) {
67
- results.push({
68
- message: `${context.rule.name.split(':').pop()}: Paging object must include required fields: ${missingFields.join(', ')}`,
69
- path: [...context.path, 'properties', pagingPropertyName, 'properties'],
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
- // Special validation for links object if it's required
74
- if (requiredFields.includes('links') && pagingObj.properties.links) {
75
- const linksObj = pagingObj.properties.links;
76
- if (linksObj.properties) {
77
- const requiredLinks = ['self', 'previous', 'next']; // From default schema
78
- const linksRequired = linksObj.required || [];
79
-
80
- for (const requiredLink of requiredLinks) {
81
- if (!linksObj.properties[requiredLink]) {
82
- results.push({
83
- message: `${context.rule.name.split(':').pop()}: Links object must include '${requiredLink}' property`,
84
- path: [...context.path, 'properties', pagingPropertyName, 'properties', 'links', 'properties'],
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
- // Check if links has required array
90
- if (!linksRequired.includes('self') || !linksRequired.includes('previous') || !linksRequired.includes('next')) {
91
- results.push({
92
- message: `${context.rule.name.split(':').pop()}: Links object must require 'self', 'previous', and 'next'`,
93
- path: [...context.path, 'properties', pagingPropertyName, 'properties', 'links'],
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 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.0",
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",