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 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 all operations."
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[*].get.parameters"
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 all operations."
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[*].get.parameters"
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 all operations."
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[*].get.parameters"
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[*].get.parameters"
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[*].get.parameters"
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
- required: [name, in]
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[*].get.parameters"
391
+ given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get"
365
392
  then:
366
- field: "$[?(@.name == '$filter' && @.in == 'query')]"
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 path '/status' must be declared."
404
- message: "OAR030: The path '/status' must be declared."
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
- field: "/status"
409
- function: truthy
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
- field: "name"
427
- function: pattern
454
+ function: apq-check-ambiguous-path
428
455
  functionOptions:
429
- notMatch: "^.*elements|instances|resources|values|terms|objects|items|elements|instances|resources|values.*$"
456
+ ambiguous-words: "elements,instances,resources,values,terms,objects,items"
430
457
  apiq:OAR033:
431
- description: "Headers 'Accept', 'Content-Type', and 'Authorization' are not allowed."
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 not compliant with the standard may cause application issues."
442
- message: "OAR034: Paged response schema must be compliant with the standard."
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[*][*].responses[?(@property != '204' && @property != '304' && @property != 'default')]"
471
+ given: "$.paths[*][get].responses[?(@property != '204' && @property != '304' && @property != 'default')]..schema"
445
472
  then:
446
- field: content
447
- function: truthy
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[*][*].responses"
480
+ given: "$.paths[*][*]"
453
481
  then:
454
- function: truthy
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 ipv6)."
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['/pets'].get"
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: "Provide a different summary and description for each operation."
642
- message: "OAR051: Provide a different summary and description for each operation."
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: "There are mandatory response headers and others that are not allowed."
662
- message: "{{error}}"
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,x-request-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,/prueba"
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[?(!(@property === '/status' || @property === '/another'))].get.responses"
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[?(!( @property == '/status' || @property == '/another' ))].post.responses"
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[?(!( @property == '/status' || @property == '/another' ))].put.responses"
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[?(!( @property == '/status' || @property == '/another' ))].patch.responses"
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[?(!( @property == '/status' || @property == '/another' ))].delete.responses"
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: "$.paths.*.*[responses,requestBody]..content..schema..properties.*~"
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: "Any param in PATH or QUERY should have a Bad Request (400) response."
792
- message: "OAR069: Any param in PATH or QUERY should have a Bad Request (400) response."
793
- severity: "warn"
794
- given: "$.paths[*][*].responses"
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: truthy
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 the /status path."
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[?(@ !== '/status')][*].responses"
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: "{{error}}"
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[?(@.type == 'string' && @.format == 'number')]"
971
+ given: "$..properties"
943
972
  then:
944
- field: "format"
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: The string properties must define a 'byte' or 'binary' format."
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
- field: format
955
- function: pattern
980
+ function: apq-binary-format-check
956
981
  functionOptions:
957
- match: "^(byte|binary)$"
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['/sample'].get.responses['200'].$ref"
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: "{{error}}"
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: "There are mandatory response headers and others that are not allowed."
1221
- message: "{{error}}"
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,x-trace-id"
1229
- allowed-headers: "x-api-key,x-trace-id,traceId,dateTime"
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: "{{error}}"
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
- * @param {object} given
3
- * @param {object} options
4
- * @param {string} options.property
5
- * @param {string} options.equalTo
6
- * @param {string} options.result
7
- * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
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
- typeof propA === 'string' &&
21
- typeof propB === 'string' &&
22
- options.result === 'falsy' &&
23
- propA.trim().toUpperCase() === propB.trim().toUpperCase()
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.includes(path)) {
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}: Header not allowed`,
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 [exVerb, exPath] = ex.split(':');
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "api-quality-spectral-ruleset",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "Spectral ruleset by API Quality",
5
5
  "main": "apq-spectral.yaml",
6
6
  "files": [