api-quality-spectral-ruleset 1.1.2 → 1.2.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/apq-spectral.yaml +110 -83
- package/functions/apq-alternate-paths.js +2 -1
- package/functions/apq-binary-format-check.js +39 -0
- package/functions/apq-check-ambiguous-path.js +52 -0
- package/functions/apq-check-examples-coverage.js +12 -0
- package/functions/apq-compare-insensitive.js +110 -13
- package/functions/apq-has-filter-query-param.js +26 -0
- package/functions/apq-paged-response-check.js +124 -0
- package/functions/apq-password-format.js +35 -0
- package/functions/apq-path-param-query-conflict.js +54 -0
- package/functions/apq-resources-by-verb.js +1 -1
- package/functions/apq-response-headers.js +3 -2
- package/functions/apq-security-required-response.js +46 -0
- package/functions/apq-standard-response-codes.js +4 -1
- package/functions/apq-status-endpoint-check.js +32 -0
- package/package.json +1 -1
package/apq-spectral.yaml
CHANGED
|
@@ -3,6 +3,7 @@ functions:
|
|
|
3
3
|
- apq-alternate-paths
|
|
4
4
|
- apq-custom-schema
|
|
5
5
|
- apq-compare-insensitive
|
|
6
|
+
- apq-has-filter-query-param
|
|
6
7
|
- apq-parameter-naming-convention
|
|
7
8
|
- apq-resources-by-verb
|
|
8
9
|
- apq-at-most-one-body-parameter
|
|
@@ -15,6 +16,13 @@ functions:
|
|
|
15
16
|
- apq-custom-field
|
|
16
17
|
- apq-check-examples-coverage
|
|
17
18
|
- apq-path-depth
|
|
19
|
+
- apq-path-param-query-conflict
|
|
20
|
+
- apq-security-required-response
|
|
21
|
+
- apq-check-ambiguous-path
|
|
22
|
+
- apq-password-format
|
|
23
|
+
- apq-binary-format-check
|
|
24
|
+
- apq-paged-response-check
|
|
25
|
+
- apq-status-endpoint-check
|
|
18
26
|
extends:
|
|
19
27
|
- spectral:asyncapi
|
|
20
28
|
- spectral:oas
|
|
@@ -190,7 +198,7 @@ rules:
|
|
|
190
198
|
then:
|
|
191
199
|
properties:
|
|
192
200
|
format:
|
|
193
|
-
enum: [int32, int64]
|
|
201
|
+
enum: [int32, int64, uint32, uint64]
|
|
194
202
|
- if:
|
|
195
203
|
properties:
|
|
196
204
|
type:
|
|
@@ -198,7 +206,7 @@ rules:
|
|
|
198
206
|
then:
|
|
199
207
|
properties:
|
|
200
208
|
format:
|
|
201
|
-
enum: [float, double]
|
|
209
|
+
enum: [float, double, decimal]
|
|
202
210
|
apiq:OAR017:
|
|
203
211
|
description: "Resource path should alternate static and parametrized parts."
|
|
204
212
|
message: "OAR017: Resource path should alternate static and parametrized parts."
|
|
@@ -247,27 +255,40 @@ rules:
|
|
|
247
255
|
;delete:^/[^/{}]+/(\{[^/{}]+\}|me)$
|
|
248
256
|
;delete:^/[^/{}]+/(\{[^/{}]+\}|me)/[^/{}]+/(\{[^/{}]+\}|me)$
|
|
249
257
|
;delete:^/[^/{}]+/(\{[^/{}]+\}|me)/[^/{}]+/(\{[^/{}]+\}|me)/[^/{}]+/(\{[^/{}]+\}|me)$
|
|
258
|
+
|
|
259
|
+
;post:^/[^/{}]+/archive$
|
|
260
|
+
;post:^/[^/{}]+/(\{[^/{}]+\}|me)/[^/{}]+/archive$
|
|
261
|
+
;post:^/[^/{}]+/(\{[^/{}]+\}|me)/[^/{}]+/(\{[^/{}]+\}|me)/[^/{}]+/archive$
|
|
262
|
+
;post:^/[^/{}]+/clone$
|
|
263
|
+
;post:^/[^/{}]+/(\{[^/{}]+\}|me)/[^/{}]+/clone$
|
|
264
|
+
;post:^/[^/{}]+/(\{[^/{}]+\}|me)/[^/{}]+/(\{[^/{}]+\}|me)/[^/{}]+/clone$
|
|
265
|
+
;post:^/[^/{}]+/restore$
|
|
266
|
+
;post:^/[^/{}]+/(\{[^/{}]+\}|me)/[^/{}]+/restore$
|
|
267
|
+
;post:^/[^/{}]+/(\{[^/{}]+\}|me)/[^/{}]+/(\{[^/{}]+\}|me)/[^/{}]+/restore$
|
|
268
|
+
|
|
269
|
+
;head:.*
|
|
270
|
+
;options:.*
|
|
250
271
|
apiq:OAR019:
|
|
251
|
-
description: "$select must be defined as a query parameter in
|
|
272
|
+
description: "$select must be defined as a query parameter in collection operations (excluding detail endpoints and health checks)."
|
|
252
273
|
message: "OAR019: $select must be defined as a query parameter in this operation."
|
|
253
274
|
severity: warn
|
|
254
|
-
given: "$.paths[
|
|
275
|
+
given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get.parameters"
|
|
255
276
|
then:
|
|
256
277
|
field: "$[?(@.name == '$select' && @.in == 'query')]"
|
|
257
278
|
function: truthy
|
|
258
279
|
apiq:OAR020:
|
|
259
|
-
description: "$expand must be defined as a query parameter in
|
|
280
|
+
description: "$expand must be defined as a query parameter in collection operations (excluding detail endpoints and health checks)."
|
|
260
281
|
message: "OAR020: $expand must be defined as a query parameter in this operation."
|
|
261
282
|
severity: warn
|
|
262
|
-
given: "$.paths[
|
|
283
|
+
given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get.parameters"
|
|
263
284
|
then:
|
|
264
285
|
field: "$[?(@.name == '$expand' && @.in == 'query')]"
|
|
265
286
|
function: truthy
|
|
266
287
|
apiq:OAR021:
|
|
267
|
-
description: "$exclude must be defined as a query parameter in
|
|
288
|
+
description: "$exclude must be defined as a query parameter in collection operations (excluding detail endpoints and health checks)."
|
|
268
289
|
message: "OAR021: $exclude must be defined as a query parameter in this operation."
|
|
269
290
|
severity: warn
|
|
270
|
-
given: "$.paths[
|
|
291
|
+
given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get.parameters"
|
|
271
292
|
then:
|
|
272
293
|
field: "$[?(@.name == '$exclude' && @.in == 'query')]"
|
|
273
294
|
function: truthy
|
|
@@ -299,27 +320,27 @@ rules:
|
|
|
299
320
|
const: query
|
|
300
321
|
required: [name, in]
|
|
301
322
|
apiq:OAR023:
|
|
302
|
-
description: "$total must be defined as a query parameter in all operations."
|
|
323
|
+
description: "$total must be defined as a query parameter in all collection operations (excluding detail endpoints and health checks)."
|
|
303
324
|
message: "OAR023: $total must be defined as a query parameter in this operation."
|
|
304
325
|
severity: error
|
|
305
|
-
given: "$.paths[
|
|
326
|
+
given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get.parameters"
|
|
306
327
|
then:
|
|
307
328
|
field: "$[?(@.name == '$total' && @.in == 'query')]"
|
|
308
329
|
function: truthy
|
|
309
330
|
apiq:OAR024:
|
|
310
|
-
description: "$start must be defined as a query parameter in all operations."
|
|
331
|
+
description: "$start must be defined as a query parameter in all collection operations (excluding detail endpoints and health checks)."
|
|
311
332
|
message: "OAR024: $start must be defined as a query parameter in this operation."
|
|
312
333
|
severity: error
|
|
313
|
-
given: "$.paths[
|
|
334
|
+
given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get.parameters"
|
|
314
335
|
then:
|
|
315
336
|
field: "$[?(@.name == '$start' && @.in == 'query')]"
|
|
316
337
|
function: truthy
|
|
317
338
|
apiq:OAR025:
|
|
318
|
-
description: "$limit must be defined as a query parameter in all operations."
|
|
339
|
+
description: "$limit must be defined as a query parameter in all collection operations (excluding detail endpoints and health checks)."
|
|
319
340
|
message: "OAR025: $limit must be defined as a query parameter in this operation."
|
|
320
341
|
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR025.md"
|
|
321
342
|
severity: error
|
|
322
|
-
given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/))].get"
|
|
343
|
+
given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get"
|
|
323
344
|
then:
|
|
324
345
|
function: schema
|
|
325
346
|
functionOptions:
|
|
@@ -340,7 +361,13 @@ rules:
|
|
|
340
361
|
const: "$limit"
|
|
341
362
|
in:
|
|
342
363
|
const: query
|
|
343
|
-
|
|
364
|
+
schema:
|
|
365
|
+
type: object
|
|
366
|
+
properties:
|
|
367
|
+
type:
|
|
368
|
+
const: integer
|
|
369
|
+
required: [type]
|
|
370
|
+
required: [name, in, schema]
|
|
344
371
|
apiq:OAR026:
|
|
345
372
|
description: "The $total parameter default value should be false."
|
|
346
373
|
message: "OAR026: The $total parameter default value should be false."
|
|
@@ -358,13 +385,12 @@ rules:
|
|
|
358
385
|
field: "headers.Location.schema.type"
|
|
359
386
|
function: truthy
|
|
360
387
|
apiq:OAR028:
|
|
361
|
-
description: "$filter must be defined as a query parameter in all operations."
|
|
388
|
+
description: "$filter must be defined as a query parameter in all collection operations (excluding detail endpoints and health checks)."
|
|
362
389
|
message: "OAR028: $filter must be defined as a query parameter in this operation."
|
|
363
390
|
severity: warn
|
|
364
|
-
given: "$.paths[
|
|
391
|
+
given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get"
|
|
365
392
|
then:
|
|
366
|
-
|
|
367
|
-
function: truthy
|
|
393
|
+
function: apq-has-filter-query-param
|
|
368
394
|
apiq:OAR029:
|
|
369
395
|
description: "A response not compliant with the standard may cause application issues."
|
|
370
396
|
message: "OAR029: A response not compliant with the standard may cause application issues."
|
|
@@ -400,13 +426,15 @@ rules:
|
|
|
400
426
|
required:
|
|
401
427
|
- properties
|
|
402
428
|
apiq:OAR030:
|
|
403
|
-
description: "The
|
|
404
|
-
message: "OAR030: The
|
|
429
|
+
description: "The configured status endpoint must be declared with the configured HTTP method."
|
|
430
|
+
message: "OAR030: The required status endpoint is not declared or does not have the required method."
|
|
405
431
|
severity: error
|
|
406
432
|
given: "$.paths"
|
|
407
433
|
then:
|
|
408
|
-
|
|
409
|
-
|
|
434
|
+
function: apq-status-endpoint-check
|
|
435
|
+
functionOptions:
|
|
436
|
+
status-endpoint: "/status"
|
|
437
|
+
method: "get"
|
|
410
438
|
apiq:OAR031:
|
|
411
439
|
description: The examples can help developers to understand the response data structure and representation."
|
|
412
440
|
message: "OAR031: Examples - Parameters, Request Body and Responses must have an examples defined."
|
|
@@ -423,13 +451,12 @@ rules:
|
|
|
423
451
|
severity: error
|
|
424
452
|
given: "$.paths.*~"
|
|
425
453
|
then:
|
|
426
|
-
|
|
427
|
-
function: pattern
|
|
454
|
+
function: apq-check-ambiguous-path
|
|
428
455
|
functionOptions:
|
|
429
|
-
|
|
456
|
+
ambiguous-words: "elements,instances,resources,values,terms,objects,items"
|
|
430
457
|
apiq:OAR033:
|
|
431
|
-
description: "
|
|
432
|
-
message: "OAR033: The header '{{value}}' is not allowed."
|
|
458
|
+
description: "Request operation parameters must not include forbidden headers (Accept, Content-Type, Authorization). Note: This validates REQUEST headers, not response headers (see OAR053/114 for response header validation)."
|
|
459
|
+
message: "OAR033: The request header parameter '{{value}}' is not allowed in operations."
|
|
433
460
|
severity: error
|
|
434
461
|
given: "$.paths[*][get,post,put,patch,delete].parameters[?(@.in == 'header')]"
|
|
435
462
|
then:
|
|
@@ -438,22 +465,21 @@ rules:
|
|
|
438
465
|
functionOptions:
|
|
439
466
|
notMatch: "^(Accept|Content-Type|Authorization)$"
|
|
440
467
|
apiq:OAR034:
|
|
441
|
-
description: "A paged response
|
|
442
|
-
message: "OAR034: Paged response schema
|
|
468
|
+
description: "A paged response must comply with the standard paging schema (start, limit, links with self/previous/next, numPages, total)."
|
|
469
|
+
message: "OAR034: Paged response schema does not match the required standard."
|
|
443
470
|
severity: error
|
|
444
|
-
given: "$.paths[*][
|
|
471
|
+
given: "$.paths[*][get].responses[?(@property != '204' && @property != '304' && @property != 'default')]..schema"
|
|
445
472
|
then:
|
|
446
|
-
|
|
447
|
-
|
|
473
|
+
function: apq-paged-response-check
|
|
474
|
+
functionOptions:
|
|
475
|
+
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"}'
|
|
448
476
|
apiq:OAR035:
|
|
449
477
|
description: "Response code 401 must be defined for operations with security schemes defined."
|
|
450
478
|
message: "OAR035: Response code 401 must be defined for operations with security schemes defined."
|
|
451
479
|
severity: "error"
|
|
452
|
-
given: "$.paths[*][*]
|
|
480
|
+
given: "$.paths[*][*]"
|
|
453
481
|
then:
|
|
454
|
-
function:
|
|
455
|
-
field: "401"
|
|
456
|
-
message: "Operation must define a 401 Unauthorized response for security schemes."
|
|
482
|
+
function: apq-security-required-response
|
|
457
483
|
apiq:OAR036:
|
|
458
484
|
description: "Cookie use is forbidden as a session mechanism."
|
|
459
485
|
message: "OAR036: Cookie use is forbidden as a session mechanism."
|
|
@@ -468,14 +494,14 @@ rules:
|
|
|
468
494
|
notMatch: "^(Cookie|Set-Cookie)$"
|
|
469
495
|
apiq:OAR037:
|
|
470
496
|
description: "Schema should use well-defined type and format."
|
|
471
|
-
message: "OAR037: String schemas must specify a valid format (date, date-time, password, byte, binary, email, uuid, uri, hostname, ipv4, or
|
|
497
|
+
message: "OAR037: String schemas must specify a valid format (date, date-time, password, byte, binary, email, uuid, uri, hostname, ipv4, ipv6, HEX, json, xml, or base64)."
|
|
472
498
|
severity: error
|
|
473
|
-
given: "$..schema..[?(@.type=='string')]"
|
|
499
|
+
given: "$..schema..[?(@.type=='string' && !@.enum)]"
|
|
474
500
|
then:
|
|
475
501
|
field: format
|
|
476
502
|
function: pattern
|
|
477
503
|
functionOptions:
|
|
478
|
-
match: "^(date|date-time|password|byte|binary|email|uuid|uri|hostname|ipv4|ipv6)$"
|
|
504
|
+
match: "^(date|date-time|password|byte|binary|email|uuid|uri|hostname|ipv4|ipv6|HEX|json|xml|base64)$"
|
|
479
505
|
apiq:OAR038:
|
|
480
506
|
description: "Response schema must be compliant with the standard."
|
|
481
507
|
message: "OAR038: Response property is required for status code '{{property}}'."
|
|
@@ -511,6 +537,8 @@ rules:
|
|
|
511
537
|
;post:^/[^/{}]+/(\{[^/{}]+\}|\bme\b)/[^/{}]+/get$:200,404,400,415,500,503
|
|
512
538
|
;post:^/[^/{}]+/delete$:200,404,400,415,500,503
|
|
513
539
|
;post:^/[^/{}]+/(\{[^/{}]+\}|\bme\b)/[^/{}]+/delete$:200,404,400,415,500,503
|
|
540
|
+
;post:^/[^/{}]+/(\{[^/{}]+\}|\bme\b)/[^/{}]+/(\{[^/{}]+\}|\bme\b)/[^/{}]+/get$:200,404,400,415,500,503
|
|
541
|
+
;post:^/[^/{}]+/(\{[^/{}]+\}|\bme\b)/[^/{}]+/(\{[^/{}]+\}|\bme\b)/[^/{}]+/delete$:200,404,400,415,500,503
|
|
514
542
|
;put:^/[^/{}]+/(\{[^/{}]+\}|\bme\b)$:200,404,400,415,500,503
|
|
515
543
|
;put:^/[^/{}]+/(\{[^/{}]+\}|\bme\b)/[^/{}]+/(\{[^/{}]+\}|\bme\b)$:200,404,400,415,500,503
|
|
516
544
|
;put:^/[^/{}]+/(\{[^/{}]+\}|\bme\b)/[^/{}]+/(\{[^/{}]+\}|\bme\b)/[^/{}]+/(\{[^/{}]+\}|\bme\b)$:200,404,400,415,500,503
|
|
@@ -533,7 +561,7 @@ rules:
|
|
|
533
561
|
description: Available scopes must be defined at the end of the document, at the root level, inside `x-wso2-security`.
|
|
534
562
|
message: "OAR041:Scopes must be defined in `x-wso2-security` at the end of the document, before the paths."
|
|
535
563
|
severity: "error"
|
|
536
|
-
given: "$.paths[
|
|
564
|
+
given: "$.paths[*].get"
|
|
537
565
|
then:
|
|
538
566
|
field: "x-aut-type"
|
|
539
567
|
function: truthy
|
|
@@ -554,7 +582,6 @@ rules:
|
|
|
554
582
|
severity: "error"
|
|
555
583
|
given: "$.paths.*.*.responses.*.content.*~"
|
|
556
584
|
then:
|
|
557
|
-
field: name
|
|
558
585
|
function: pattern
|
|
559
586
|
functionOptions:
|
|
560
587
|
match: "^application\/[a-zA-Z0-9-_]+$"
|
|
@@ -638,16 +665,17 @@ rules:
|
|
|
638
665
|
field: "summary"
|
|
639
666
|
function: truthy
|
|
640
667
|
apiq:OAR051:
|
|
641
|
-
description: "
|
|
642
|
-
message: "OAR051:
|
|
668
|
+
description: "Summary and description must be different - not just case variations or semantic duplicates."
|
|
669
|
+
message: "OAR051: Summary and description must be meaningfully different from each other."
|
|
643
670
|
severity: "error"
|
|
644
|
-
given: "$.paths[*]
|
|
671
|
+
given: "$.paths[*][get,post,put,patch,delete]"
|
|
645
672
|
then:
|
|
646
673
|
function: apq-compare-insensitive
|
|
647
674
|
functionOptions:
|
|
648
675
|
property: summary
|
|
649
676
|
equalTo: description
|
|
650
677
|
result: falsy
|
|
678
|
+
threshold: 0.55
|
|
651
679
|
apiq:OAR052:
|
|
652
680
|
description: "Numeric schema types must define a format."
|
|
653
681
|
message: "OAR052: Numeric types requires a format"
|
|
@@ -658,19 +686,19 @@ rules:
|
|
|
658
686
|
field: format
|
|
659
687
|
function: truthy
|
|
660
688
|
apiq:OAR053:
|
|
661
|
-
description: "
|
|
662
|
-
message: "
|
|
689
|
+
description: "Response headers for API observability and tracing must be defined (excluding 204 responses and health endpoints)."
|
|
690
|
+
message: "OAR053: Response must include mandatory headers and exclude forbidden headers."
|
|
663
691
|
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR053.md"
|
|
664
692
|
severity: error
|
|
665
693
|
given: "$.paths[*][*].responses[*]"
|
|
666
694
|
then:
|
|
667
695
|
- function: apq-response-headers
|
|
668
696
|
functionOptions:
|
|
669
|
-
mandatory-headers: "x-trace-id
|
|
697
|
+
mandatory-headers: "x-trace-id"
|
|
670
698
|
allowed-headers: "idcorrelacion,x-correlacionid,x-global-trasaction-id,x-power-by,x-trace-id,x-request-id"
|
|
671
699
|
included-response-codes: "*"
|
|
672
700
|
excluded-response-codes: "204"
|
|
673
|
-
path-exclusions: "/status
|
|
701
|
+
path-exclusions: "/status"
|
|
674
702
|
apiq:OAR054:
|
|
675
703
|
description: "Ensure the host matches the specified format"
|
|
676
704
|
message: "OAR054: Hostname must be a subdomain of the organization's domain name."
|
|
@@ -693,7 +721,7 @@ rules:
|
|
|
693
721
|
description: "Ensure get have mandatory response codes"
|
|
694
722
|
message: "OAR061: Ensure get have the mandatory response codes"
|
|
695
723
|
severity: error
|
|
696
|
-
given: "$.paths[?(
|
|
724
|
+
given: "$.paths[?(@property !== '/status')].get.responses"
|
|
697
725
|
then:
|
|
698
726
|
function: schema
|
|
699
727
|
functionOptions:
|
|
@@ -708,7 +736,7 @@ rules:
|
|
|
708
736
|
description: "Ensure post have mandatory response codes"
|
|
709
737
|
message: "OAR062: Ensure post have the mandatory response codes"
|
|
710
738
|
severity: error
|
|
711
|
-
given: "$.paths[?(
|
|
739
|
+
given: "$.paths[?(@property !== '/status')].post.responses"
|
|
712
740
|
then:
|
|
713
741
|
function: schema
|
|
714
742
|
functionOptions:
|
|
@@ -723,7 +751,7 @@ rules:
|
|
|
723
751
|
description: "Ensure put have mandatory response codes"
|
|
724
752
|
message: "OAR063: Ensure put have the mandatory response codes"
|
|
725
753
|
severity: error
|
|
726
|
-
given: "$.paths[?(
|
|
754
|
+
given: "$.paths[?(@property !== '/status')].put.responses"
|
|
727
755
|
then:
|
|
728
756
|
function: schema
|
|
729
757
|
functionOptions:
|
|
@@ -737,7 +765,7 @@ rules:
|
|
|
737
765
|
description: "Ensure patch have mandatory response codes"
|
|
738
766
|
message: "OAR064: Ensure patch have the mandatory response codes"
|
|
739
767
|
severity: error
|
|
740
|
-
given: "$.paths[?(
|
|
768
|
+
given: "$.paths[?(@property !== '/status')].patch.responses"
|
|
741
769
|
then:
|
|
742
770
|
function: schema
|
|
743
771
|
functionOptions:
|
|
@@ -751,7 +779,7 @@ rules:
|
|
|
751
779
|
description: "Ensure delete have mandatory response codes"
|
|
752
780
|
message: "OAR065: Ensure delete have the mandatory response codes"
|
|
753
781
|
severity: error
|
|
754
|
-
given: "$.paths[?(
|
|
782
|
+
given: "$.paths[?(@property !== '/status')].delete.responses"
|
|
755
783
|
then:
|
|
756
784
|
function: schema
|
|
757
785
|
functionOptions:
|
|
@@ -764,7 +792,10 @@ rules:
|
|
|
764
792
|
description: "RequestBody and Responses schema property names must be compliant with the snake_case naming convention."
|
|
765
793
|
message: "OAR066: RequestBody and Responses schema property names must be compliant with the snake_case naming convention."
|
|
766
794
|
severity: "warn"
|
|
767
|
-
given:
|
|
795
|
+
given:
|
|
796
|
+
- "$.paths.*.*[responses,requestBody]..content..schema..properties.*~"
|
|
797
|
+
- "$.paths.*.*.parameters[*].schema..properties.*~"
|
|
798
|
+
- "$.paths.*.*.responses[*].schema..properties.*~"
|
|
768
799
|
then:
|
|
769
800
|
function: casing
|
|
770
801
|
functionOptions:
|
|
@@ -788,14 +819,12 @@ rules:
|
|
|
788
819
|
functionOptions:
|
|
789
820
|
type: pascal
|
|
790
821
|
apiq:OAR069:
|
|
791
|
-
description: "
|
|
792
|
-
message: "OAR069:
|
|
793
|
-
severity: "
|
|
794
|
-
given: "$.paths
|
|
822
|
+
description: "Path parameters should not appear as query parameters."
|
|
823
|
+
message: "OAR069: Path parameter should not appear as both path and query parameter."
|
|
824
|
+
severity: "error"
|
|
825
|
+
given: "$.paths"
|
|
795
826
|
then:
|
|
796
|
-
function:
|
|
797
|
-
field: "400"
|
|
798
|
-
message: "Operation must define a 400 Bad Request response for parameters in PATH or QUERY."
|
|
827
|
+
function: apq-path-param-query-conflict
|
|
799
828
|
apiq:OAR070:
|
|
800
829
|
description: "Parameters in path should not be numeric."
|
|
801
830
|
message: "OAR070: Parameters in path should not be numeric."
|
|
@@ -829,10 +858,10 @@ rules:
|
|
|
829
858
|
functionOptions:
|
|
830
859
|
notMatch: "stacktrace"
|
|
831
860
|
apiq:OAR073:
|
|
832
|
-
description: "API should include a 429 response to indicate rate limiting, except for
|
|
861
|
+
description: "API should include a 429 response to indicate rate limiting, except for health check paths like /status, /health, /ping."
|
|
833
862
|
message: "OAR073: API should include a 429 response to indicate rate limiting."
|
|
834
863
|
severity: error
|
|
835
|
-
given: "$.paths[?(@
|
|
864
|
+
given: "$.paths[?(!(@property.match(/\\/status|health|ping|liveness|readiness/)))][*].responses"
|
|
836
865
|
then:
|
|
837
866
|
field: "429"
|
|
838
867
|
function: truthy
|
|
@@ -909,7 +938,7 @@ rules:
|
|
|
909
938
|
match: ^\$?_?[a-z]+(_[a-z]+)*$
|
|
910
939
|
apiq:OAR078:
|
|
911
940
|
description: "All API methods must have security defined."
|
|
912
|
-
message: "
|
|
941
|
+
message: "OAR078: Operation must have security defined."
|
|
913
942
|
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR078.md"
|
|
914
943
|
severity: error
|
|
915
944
|
given: "$"
|
|
@@ -939,22 +968,18 @@ rules:
|
|
|
939
968
|
description: "Fields of type password should be string with format password."
|
|
940
969
|
message: "OAR081: Fields of type password should be string with format password."
|
|
941
970
|
severity: error
|
|
942
|
-
given: "$..properties
|
|
971
|
+
given: "$..properties"
|
|
943
972
|
then:
|
|
944
|
-
|
|
945
|
-
function: pattern
|
|
946
|
-
functionOptions:
|
|
947
|
-
match: "^password$"
|
|
973
|
+
function: apq-password-format
|
|
948
974
|
apiq:OAR082:
|
|
949
|
-
description: "The string properties must define a byte or binary format."
|
|
950
|
-
message: "OAR082:
|
|
975
|
+
description: "The string properties 'product', 'line', and 'price' must define a byte or binary format."
|
|
976
|
+
message: "OAR082: Property '{{property}}' must define a 'byte' or 'binary' format (currently: {{format}})."
|
|
951
977
|
severity: error
|
|
952
|
-
given: "$..properties
|
|
978
|
+
given: "$..[?(@.properties)]"
|
|
953
979
|
then:
|
|
954
|
-
|
|
955
|
-
function: pattern
|
|
980
|
+
function: apq-binary-format-check
|
|
956
981
|
functionOptions:
|
|
957
|
-
|
|
982
|
+
fields-to-apply: "product,line,price"
|
|
958
983
|
apiq:OAR083:
|
|
959
984
|
description: "Certain parameters (e.g., email, password) should not pass through the querystring."
|
|
960
985
|
message: "OAR083: The parameter '{{value}}' should not pass through the querystring."
|
|
@@ -1024,7 +1049,7 @@ rules:
|
|
|
1024
1049
|
description: "The $ref of a response must end with the suffix Response."
|
|
1025
1050
|
message: "OAR090: The $ref of a response must end with the suffix Response"
|
|
1026
1051
|
severity: error
|
|
1027
|
-
given: "$.paths[
|
|
1052
|
+
given: "$.paths[*][*].responses[*].$ref"
|
|
1028
1053
|
then:
|
|
1029
1054
|
function: pattern
|
|
1030
1055
|
functionOptions:
|
|
@@ -1207,7 +1232,7 @@ rules:
|
|
|
1207
1232
|
function: truthy
|
|
1208
1233
|
apiq:OAR113:
|
|
1209
1234
|
description: "Field or extension must be at the assigned location"
|
|
1210
|
-
message: "
|
|
1235
|
+
message: "OAR113: Field or extension x-custom-example must be at the assigned location"
|
|
1211
1236
|
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR113.md"
|
|
1212
1237
|
severity: warn
|
|
1213
1238
|
given: "$"
|
|
@@ -1217,19 +1242,21 @@ rules:
|
|
|
1217
1242
|
fieldName: "x-custom-example"
|
|
1218
1243
|
fieldLocation: "path,operation_get,response_200"
|
|
1219
1244
|
apiq:OAR114:
|
|
1220
|
-
description: "
|
|
1221
|
-
message: "
|
|
1245
|
+
description: "Response headers for API security and key management must be defined (excluding 204 responses)."
|
|
1246
|
+
message: "OAR114: Response must include mandatory headers and exclude forbidden headers."
|
|
1222
1247
|
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR114.md"
|
|
1223
1248
|
severity: "error"
|
|
1224
1249
|
given: "$.paths[*][*].responses[*]"
|
|
1225
1250
|
then:
|
|
1226
1251
|
- function: apq-response-headers
|
|
1227
1252
|
functionOptions:
|
|
1228
|
-
mandatory-headers: "x-api-key
|
|
1229
|
-
allowed-headers: "x-api-key,
|
|
1253
|
+
mandatory-headers: "x-api-key"
|
|
1254
|
+
allowed-headers: "x-api-key,traceId,dateTime"
|
|
1255
|
+
excluded-response-codes: "204"
|
|
1256
|
+
path-exclusions: "/status"
|
|
1230
1257
|
apiq:OAR115:
|
|
1231
1258
|
description: "All fields listed in the required array must be defined in the schema properties."
|
|
1232
|
-
message: "
|
|
1259
|
+
message: "OAR115: All fields in the required array must be defined in schema properties."
|
|
1233
1260
|
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR115.md"
|
|
1234
1261
|
severity: warn
|
|
1235
1262
|
given:
|
|
@@ -8,7 +8,8 @@ module.exports = (given, { except }, context) => {
|
|
|
8
8
|
const paths = given || [];
|
|
9
9
|
if (paths.length === 0) return result;
|
|
10
10
|
|
|
11
|
-
const parts = paths.substr(1).split('/');
|
|
11
|
+
const parts = paths.substr(1).split('/').filter(p => p.length > 0);
|
|
12
|
+
if (parts.length === 0) return result;
|
|
12
13
|
let previousIsVar = isVariable(parts.shift());
|
|
13
14
|
if (previousIsVar) {
|
|
14
15
|
return [{
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module.exports = (schema, options = {}, context) => {
|
|
2
|
+
const results = [];
|
|
3
|
+
|
|
4
|
+
if (!schema || typeof schema !== 'object') {
|
|
5
|
+
return results;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Fields that must have byte or binary format
|
|
9
|
+
const fieldsToCheck = (options['fields-to-apply'] || 'product,line,price')
|
|
10
|
+
.split(',')
|
|
11
|
+
.map(f => f.trim().toLowerCase());
|
|
12
|
+
|
|
13
|
+
const properties = schema.properties || {};
|
|
14
|
+
|
|
15
|
+
Object.entries(properties).forEach(([propName, propSchema]) => {
|
|
16
|
+
// Only check if this property is in our list
|
|
17
|
+
if (!fieldsToCheck.includes(propName.toLowerCase())) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Check if it's a string type
|
|
22
|
+
if (propSchema.type !== 'string') {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Check if format is byte or binary
|
|
27
|
+
const format = propSchema.format || '';
|
|
28
|
+
if (format && format !== 'byte' && format !== 'binary') {
|
|
29
|
+
results.push({
|
|
30
|
+
message: context.rule.message
|
|
31
|
+
.replace('{{property}}', propName)
|
|
32
|
+
.replace('{{format}}', format || 'undefined'),
|
|
33
|
+
path: [...context.path, 'properties', propName],
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return results;
|
|
39
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates that URL path segments use clear, unambiguous names
|
|
3
|
+
* @param {string} given - The path key (e.g. "/users/{id}/items")
|
|
4
|
+
* @param {object} options - Function options
|
|
5
|
+
* @param {string} options.ambiguous-words - Comma-separated list of ambiguous words
|
|
6
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
7
|
+
*/
|
|
8
|
+
module.exports = (given, options, context) => {
|
|
9
|
+
const results = [];
|
|
10
|
+
|
|
11
|
+
if (typeof given !== 'string') {
|
|
12
|
+
return results;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Default ambiguous words (from the original regex)
|
|
16
|
+
const defaultAmbiguousWords = [
|
|
17
|
+
'elements', 'instances', 'resources', 'values', 'terms', 'objects', 'items'
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// Parse custom ambiguous words from options if provided
|
|
21
|
+
let ambiguousWords = defaultAmbiguousWords;
|
|
22
|
+
if (options && options['ambiguous-words']) {
|
|
23
|
+
ambiguousWords = options['ambiguous-words']
|
|
24
|
+
.split(',')
|
|
25
|
+
.map(w => w.trim().toLowerCase())
|
|
26
|
+
.filter(Boolean);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Split path into segments and filter out parameters
|
|
30
|
+
const segments = given
|
|
31
|
+
.split('/')
|
|
32
|
+
.filter(segment => {
|
|
33
|
+
// Keep only static segments (not empty, not {param})
|
|
34
|
+
return segment.length > 0 && !segment.startsWith('{');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Check each segment for ambiguous words
|
|
38
|
+
for (const segment of segments) {
|
|
39
|
+
const lowerSegment = segment.toLowerCase();
|
|
40
|
+
for (const word of ambiguousWords) {
|
|
41
|
+
if (lowerSegment.includes(word)) {
|
|
42
|
+
results.push({
|
|
43
|
+
message: `${context.rule.message || 'OAR032'}: Path segment '${segment}' is ambiguous. Avoid using words like '${word}' in resource names.`,
|
|
44
|
+
path: context.path
|
|
45
|
+
});
|
|
46
|
+
break; // Report only once per segment
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return results;
|
|
52
|
+
};
|
|
@@ -39,6 +39,18 @@ module.exports = (given, options, context) => {
|
|
|
39
39
|
return checkSchema(schema.items);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
if (schema.allOf && Array.isArray(schema.allOf)) {
|
|
43
|
+
return schema.allOf.some(subSchema => checkSchema(subSchema));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (schema.oneOf && Array.isArray(schema.oneOf)) {
|
|
47
|
+
return schema.oneOf.some(subSchema => checkSchema(subSchema));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (schema.anyOf && Array.isArray(schema.anyOf)) {
|
|
51
|
+
return schema.anyOf.some(subSchema => checkSchema(subSchema));
|
|
52
|
+
}
|
|
53
|
+
|
|
42
54
|
return false;
|
|
43
55
|
};
|
|
44
56
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* @param {
|
|
5
|
-
* @param {
|
|
6
|
-
* @param {string} options.
|
|
7
|
-
* @param {
|
|
8
|
-
*
|
|
2
|
+
* Enhanced semantic text comparison function
|
|
3
|
+
* Detects both exact matches and semantic similarity
|
|
4
|
+
* @param {object} given
|
|
5
|
+
* @param {object} options
|
|
6
|
+
* @param {string} options.property
|
|
7
|
+
* @param {string} options.equalTo
|
|
8
|
+
* @param {string} options.result
|
|
9
|
+
* @param {number} options.threshold - Similarity threshold (0.0-1.0), default 0.6
|
|
10
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
9
11
|
*/
|
|
10
12
|
module.exports = (given, options, context) => {
|
|
11
13
|
const errors = [];
|
|
@@ -16,18 +18,113 @@ module.exports = (given, options, context) => {
|
|
|
16
18
|
const propA = given[options.property];
|
|
17
19
|
const propB = given[options.equalTo];
|
|
18
20
|
|
|
19
|
-
if (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
if (typeof propA !== 'string' || typeof propB !== 'string') {
|
|
22
|
+
return errors;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (options.result !== 'falsy') {
|
|
26
|
+
return errors;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const threshold = options.threshold || 0.6;
|
|
30
|
+
|
|
31
|
+
// Exact match (case-insensitive)
|
|
32
|
+
if (propA.trim().toUpperCase() === propB.trim().toUpperCase()) {
|
|
25
33
|
errors.push({
|
|
26
34
|
message: context.rule.message,
|
|
27
35
|
path: [...context.path, options.property]
|
|
28
36
|
});
|
|
37
|
+
return errors;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Semantic similarity check
|
|
41
|
+
const similarity = calculateSimilarity(propA, propB);
|
|
42
|
+
if (similarity >= threshold) {
|
|
43
|
+
errors.push({
|
|
44
|
+
message: `${context.rule.message} (${Math.round(similarity * 100)}% similar)`,
|
|
45
|
+
path: [...context.path, options.property]
|
|
46
|
+
});
|
|
29
47
|
}
|
|
30
48
|
|
|
31
49
|
return errors;
|
|
32
50
|
};
|
|
33
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Calculate semantic similarity between two strings
|
|
54
|
+
* Uses token overlap and cosine similarity
|
|
55
|
+
*/
|
|
56
|
+
function calculateSimilarity(str1, str2) {
|
|
57
|
+
const tokens1 = tokenize(str1);
|
|
58
|
+
const tokens2 = tokenize(str2);
|
|
59
|
+
|
|
60
|
+
if (tokens1.length === 0 || tokens2.length === 0) {
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Calculate Jaccard similarity (token overlap)
|
|
65
|
+
const intersection = new Set([...tokens1].filter(x => tokens2.includes(x)));
|
|
66
|
+
const union = new Set([...tokens1, ...tokens2]);
|
|
67
|
+
const jaccardSimilarity = intersection.size / union.size;
|
|
68
|
+
|
|
69
|
+
// Calculate word order similarity
|
|
70
|
+
const order1 = tokens1.join(' ');
|
|
71
|
+
const order2 = tokens2.join(' ');
|
|
72
|
+
const levenshteinDist = levenshteinDistance(order1, order2);
|
|
73
|
+
const maxLen = Math.max(order1.length, order2.length);
|
|
74
|
+
const orderSimilarity = maxLen === 0 ? 0 : 1 - levenshteinDist / maxLen;
|
|
75
|
+
|
|
76
|
+
// Weighted average
|
|
77
|
+
return jaccardSimilarity * 0.6 + orderSimilarity * 0.4;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Tokenize string into meaningful words with stemming
|
|
82
|
+
*/
|
|
83
|
+
function tokenize(str) {
|
|
84
|
+
return str
|
|
85
|
+
.toLowerCase()
|
|
86
|
+
.replace(/[^\w\s]/g, '')
|
|
87
|
+
.split(/\s+/)
|
|
88
|
+
.filter(token => token.length > 2) // Ignore very short words
|
|
89
|
+
.map(token => stem(token)) // Apply stemming
|
|
90
|
+
.sort();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Simple word stemming (removes common suffixes)
|
|
95
|
+
*/
|
|
96
|
+
function stem(word) {
|
|
97
|
+
// Remove common verb/noun suffixes
|
|
98
|
+
return word
|
|
99
|
+
.replace(/s$/, '') // plural
|
|
100
|
+
.replace(/ed$/, '') // past tense
|
|
101
|
+
.replace(/ing$/, '') // continuous
|
|
102
|
+
.replace(/er$/, '') // agent
|
|
103
|
+
.replace(/est$/, ''); // superlative
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Calculate Levenshtein distance between two strings
|
|
108
|
+
*/
|
|
109
|
+
function levenshteinDistance(str1, str2) {
|
|
110
|
+
const len1 = str1.length;
|
|
111
|
+
const len2 = str2.length;
|
|
112
|
+
const matrix = Array(len2 + 1).fill(null).map(() => Array(len1 + 1).fill(0));
|
|
113
|
+
|
|
114
|
+
for (let i = 0; i <= len1; i++) matrix[0][i] = i;
|
|
115
|
+
for (let j = 0; j <= len2; j++) matrix[j][0] = j;
|
|
116
|
+
|
|
117
|
+
for (let j = 1; j <= len2; j++) {
|
|
118
|
+
for (let i = 1; i <= len1; i++) {
|
|
119
|
+
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
|
120
|
+
matrix[j][i] = Math.min(
|
|
121
|
+
matrix[j][i - 1] + 1,
|
|
122
|
+
matrix[j - 1][i] + 1,
|
|
123
|
+
matrix[j - 1][i - 1] + cost
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return matrix[len2][len1];
|
|
129
|
+
}
|
|
130
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {object} given
|
|
3
|
+
* @param {object} options
|
|
4
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
5
|
+
*/
|
|
6
|
+
module.exports = (given, options, context) => {
|
|
7
|
+
if (!given || typeof given !== 'object') {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const params = given.parameters;
|
|
12
|
+
|
|
13
|
+
if (!params || !Array.isArray(params)) {
|
|
14
|
+
return [{ message: context.rule.message }];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const hasFilter = params.some(
|
|
18
|
+
p => p && !p.$ref && p.name === '$filter' && p.in === 'query'
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
if (!hasFilter) {
|
|
22
|
+
return [{ message: context.rule.message }];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return [];
|
|
26
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
module.exports = (schema, options = {}, context) => {
|
|
2
|
+
const results = [];
|
|
3
|
+
|
|
4
|
+
if (!schema || typeof schema !== 'object') {
|
|
5
|
+
return results;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Parse paging schema from options
|
|
9
|
+
let pagingSchema = {};
|
|
10
|
+
try {
|
|
11
|
+
const pagingSchemaStr = options['paging-schema'] || defaultPagingSchema;
|
|
12
|
+
pagingSchema = typeof pagingSchemaStr === 'string'
|
|
13
|
+
? JSON.parse(pagingSchemaStr)
|
|
14
|
+
: pagingSchemaStr;
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return [{
|
|
17
|
+
message: `${context.rule.name.split(':').pop()}: Invalid paging-schema configuration: ${err.message}`,
|
|
18
|
+
path: context.path,
|
|
19
|
+
}];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const pagingPropertyName = pagingSchema.pagingPropertyName || 'paging';
|
|
23
|
+
const requiredFields = pagingSchema.required || [];
|
|
24
|
+
const properties = pagingSchema.properties || {};
|
|
25
|
+
|
|
26
|
+
// Check if schema doesn't have properties at all - skip validation
|
|
27
|
+
if (!schema.properties) {
|
|
28
|
+
return results;
|
|
29
|
+
}
|
|
30
|
+
|
|
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,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return results;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const pagingObj = schema.properties[pagingPropertyName];
|
|
48
|
+
|
|
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;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check required fields
|
|
59
|
+
const missingFields = [];
|
|
60
|
+
for (const requiredField of requiredFields) {
|
|
61
|
+
if (!pagingObj.properties[requiredField]) {
|
|
62
|
+
missingFields.push(requiredField);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
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
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
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
|
+
}
|
|
88
|
+
|
|
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
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return results;
|
|
100
|
+
};
|
|
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
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates that properties intended for password storage use format: password
|
|
3
|
+
* @param {object} given - The schema properties object
|
|
4
|
+
* @param {object} options - Function options
|
|
5
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
6
|
+
*/
|
|
7
|
+
module.exports = (given, options, context) => {
|
|
8
|
+
const results = [];
|
|
9
|
+
|
|
10
|
+
if (!given || typeof given !== 'object') {
|
|
11
|
+
return results;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Check each property in the schema
|
|
15
|
+
Object.entries(given).forEach(([propName, propSchema]) => {
|
|
16
|
+
if (!propSchema || typeof propSchema !== 'object') {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Check if this is a password-related field (name contains "password")
|
|
21
|
+
const isPasswordField = propName.toLowerCase().includes('password');
|
|
22
|
+
|
|
23
|
+
if (isPasswordField && propSchema.type === 'string') {
|
|
24
|
+
// Password fields must have format: password
|
|
25
|
+
if (propSchema.format !== 'password') {
|
|
26
|
+
results.push({
|
|
27
|
+
message: context.rule.message || 'OAR081: Password fields should use format: password',
|
|
28
|
+
path: [...context.path, propName, 'format']
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return results;
|
|
35
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates that path parameters don't appear as query parameters
|
|
3
|
+
* This prevents ambiguity and design issues
|
|
4
|
+
*
|
|
5
|
+
* @param {object} given - The paths object
|
|
6
|
+
* @param {object} options - Function options
|
|
7
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
8
|
+
*/
|
|
9
|
+
module.exports = (given, options, context) => {
|
|
10
|
+
const errors = [];
|
|
11
|
+
|
|
12
|
+
if (!given || typeof given !== 'object') {
|
|
13
|
+
return errors;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const httpMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'];
|
|
17
|
+
|
|
18
|
+
for (const [pathKey, pathItem] of Object.entries(given)) {
|
|
19
|
+
if (!pathItem || typeof pathItem !== 'object') {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const [operationKey, operation] of Object.entries(pathItem)) {
|
|
24
|
+
if (!operation || typeof operation !== 'object' ||
|
|
25
|
+
!httpMethods.includes(operationKey)) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const parameters = operation.parameters || [];
|
|
30
|
+
|
|
31
|
+
if (!Array.isArray(parameters)) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const hasPathOrQueryParams = parameters.some(
|
|
36
|
+
param => param && (param.in === 'path' || param.in === 'query')
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
if (!hasPathOrQueryParams) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const responses = operation.responses || {};
|
|
44
|
+
if (!responses['400']) {
|
|
45
|
+
errors.push({
|
|
46
|
+
message: `OAR069: Any param in PATH or QUERY, should have bad request (400) response.`,
|
|
47
|
+
path: [...context.path, pathKey, operationKey, 'responses']
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return errors;
|
|
54
|
+
};
|
|
@@ -29,7 +29,7 @@ const DEFAULT_ALLOWED_PATTERNS = `
|
|
|
29
29
|
;delete:^/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)$
|
|
30
30
|
`;
|
|
31
31
|
|
|
32
|
-
const SUPPORTED_VERBS = ['get', 'post', 'put', 'patch', 'delete'];
|
|
32
|
+
const SUPPORTED_VERBS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
33
33
|
const CONFIG_ERROR_PREFIX = 'OAR018:';
|
|
34
34
|
|
|
35
35
|
const parseAllowedPatterns = (patterns) => {
|
|
@@ -41,7 +41,7 @@ module.exports = (responseNode, options = {}, context) => {
|
|
|
41
41
|
const path = context.path?.find(p => typeof p === 'string' && p.startsWith('/'));
|
|
42
42
|
const responseCode = context.path?.[context.path.length - 1];
|
|
43
43
|
|
|
44
|
-
if (path && pathExclusions.
|
|
44
|
+
if (path && pathExclusions.some(ex => path === ex || path.startsWith(ex + '/'))) {
|
|
45
45
|
return [];
|
|
46
46
|
}
|
|
47
47
|
|
|
@@ -85,7 +85,8 @@ module.exports = (responseNode, options = {}, context) => {
|
|
|
85
85
|
const forbidden = headerNames.filter(h => !allowedHeaders.includes(h));
|
|
86
86
|
if (forbidden.length > 0) {
|
|
87
87
|
results.push({
|
|
88
|
-
message: `${ruleCode}:
|
|
88
|
+
message: `${ruleCode}: Headers [${forbidden.join(', ')}] are not allowed`,
|
|
89
|
+
path: [...context.path, 'headers', forbidden[0]],
|
|
89
90
|
});
|
|
90
91
|
}
|
|
91
92
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates that operations with security defined include a 401 Unauthorized response
|
|
3
|
+
* @param {object} given - The operation node
|
|
4
|
+
* @param {object} options - Function options
|
|
5
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
6
|
+
*/
|
|
7
|
+
module.exports = (given, options, context) => {
|
|
8
|
+
const results = [];
|
|
9
|
+
|
|
10
|
+
if (!given || typeof given !== 'object') {
|
|
11
|
+
return results;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const responses = given.responses;
|
|
15
|
+
if (!responses || typeof responses !== 'object') {
|
|
16
|
+
return results;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Check if this operation has security defined
|
|
20
|
+
const operationHasSecurity = given.security &&
|
|
21
|
+
Array.isArray(given.security) &&
|
|
22
|
+
given.security.length > 0;
|
|
23
|
+
|
|
24
|
+
// Check if there's global security in the document
|
|
25
|
+
let globalHasSecurity = false;
|
|
26
|
+
try {
|
|
27
|
+
const rootSecurity = context.document?.parserResult?.data?.security;
|
|
28
|
+
globalHasSecurity = rootSecurity &&
|
|
29
|
+
Array.isArray(rootSecurity) &&
|
|
30
|
+
rootSecurity.length > 0;
|
|
31
|
+
} catch (e) {
|
|
32
|
+
// Ignore errors accessing root security
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Only require 401 if security is defined (operation-level or global)
|
|
36
|
+
if (operationHasSecurity || globalHasSecurity) {
|
|
37
|
+
if (!responses['401']) {
|
|
38
|
+
results.push({
|
|
39
|
+
message: context.rule.message || 'OAR035: Response code 401 must be defined for operations with security schemes defined.',
|
|
40
|
+
path: [...context.path, 'responses']
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return results;
|
|
46
|
+
};
|
|
@@ -22,7 +22,10 @@ module.exports = function apqStandardResponseCodes(given, options, context) {
|
|
|
22
22
|
|
|
23
23
|
const exclusions = options?.['resources-exclusions'] || [];
|
|
24
24
|
if (exclusions.some(ex => {
|
|
25
|
-
const
|
|
25
|
+
const colonIdx = ex.indexOf(':');
|
|
26
|
+
if (colonIdx === -1) return false;
|
|
27
|
+
const exVerb = ex.slice(0, colonIdx);
|
|
28
|
+
const exPath = ex.slice(colonIdx + 1);
|
|
26
29
|
return exVerb.toLowerCase() === verb.toLowerCase() && new RegExp(`^${exPath}$`).test(resourcePath);
|
|
27
30
|
})) return results;
|
|
28
31
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module.exports = (paths, options = {}, context) => {
|
|
2
|
+
const results = [];
|
|
3
|
+
|
|
4
|
+
if (!paths || typeof paths !== 'object') {
|
|
5
|
+
return results;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Get configuration options
|
|
9
|
+
const statusEndpoint = (options['status-endpoint'] || '/status').trim();
|
|
10
|
+
const method = (options['method'] || 'get').toLowerCase().trim();
|
|
11
|
+
|
|
12
|
+
// Check if the endpoint exists
|
|
13
|
+
if (!paths[statusEndpoint]) {
|
|
14
|
+
results.push({
|
|
15
|
+
message: `${context.rule.name.split(':').pop()}: The status endpoint '${statusEndpoint}' must be declared`,
|
|
16
|
+
path: context.path,
|
|
17
|
+
});
|
|
18
|
+
return results;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const endpointNode = paths[statusEndpoint];
|
|
22
|
+
|
|
23
|
+
// Check if the configured method exists
|
|
24
|
+
if (!endpointNode[method]) {
|
|
25
|
+
results.push({
|
|
26
|
+
message: `${context.rule.name.split(':').pop()}: The status endpoint '${statusEndpoint}' must support the '${method.toUpperCase()}' method`,
|
|
27
|
+
path: [...context.path, statusEndpoint],
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return results;
|
|
32
|
+
};
|