api-quality-spectral-ruleset 1.3.0-beta.3 → 1.3.0-beta.5

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
@@ -16,7 +16,10 @@ functions:
16
16
  - apq-custom-field
17
17
  - apq-check-examples-coverage
18
18
  - apq-path-depth
19
+ - apq-forbidden-characters
20
+ - apq-valid-response-schema
19
21
  - apq-path-param-query-conflict
22
+ - apq-schema-format
20
23
  - apq-security-required-response
21
24
  - apq-check-ambiguous-path
22
25
  - apq-password-format
@@ -65,9 +68,7 @@ rules:
65
68
  severity: "error"
66
69
  given: "$.x-wso2-security.apim.x-wso2-scopes[*].roles"
67
70
  then:
68
- function: "pattern"
69
- functionOptions:
70
- match: "^[a-zA-Z0-9_\\-., ]+$"
71
+ function: apq-forbidden-characters
71
72
  apiq:OAR005:
72
73
  description: "A wrong scope may cause problems to import the API definition into WSO2 or allow all users to call the endpoint."
73
74
  message: "OAR005: Scope in the operation must be defined correctly and match an existing scope."
@@ -177,9 +178,11 @@ rules:
177
178
  severity: error
178
179
  given: "$.paths.*~"
179
180
  then:
180
- function: pattern
181
+ function: apq-path-depth
181
182
  functionOptions:
182
- match: "^\/[^\/]*((\/{[^}]*})*\/[^\/]*(\/{[^}]*})*){0,4}\/?$"
183
+ maxDepth: 5
184
+ ignoreSegments:
185
+ - me
183
186
  apiq:OAR016:
184
187
  description: "Numeric types requires a valid format."
185
188
  message: "OAR016: Numeric types must use a valid format for their type."
@@ -217,7 +220,7 @@ rules:
217
220
  then:
218
221
  function: apq-alternate-paths
219
222
  functionOptions:
220
- except: ["me"]
223
+ except: ["me", "get", "search"]
221
224
  apiq:OAR018:
222
225
  description: Operation not recommended for resource path depending on HTTP verb
223
226
  message: "OAR018: Operation not recommended for resource path: {{path}} ({{verb}})"
@@ -281,18 +284,44 @@ rules:
281
284
  description: "$expand must be defined as a query parameter in collection operations (excluding detail endpoints and health checks)."
282
285
  message: "OAR020: $expand must be defined as a query parameter in this operation."
283
286
  severity: warn
284
- given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get.parameters"
287
+ given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get"
285
288
  then:
286
- field: "$[?(@.name == '$expand' && @.in == 'query')]"
287
- function: truthy
289
+ function: schema
290
+ functionOptions:
291
+ schema:
292
+ required: ["parameters"]
293
+ properties:
294
+ parameters:
295
+ type: array
296
+ contains:
297
+ type: object
298
+ properties:
299
+ name:
300
+ const: "$expand"
301
+ in:
302
+ const: query
303
+ required: [name, in]
288
304
  apiq:OAR021:
289
305
  description: "$exclude must be defined as a query parameter in collection operations (excluding detail endpoints and health checks)."
290
306
  message: "OAR021: $exclude must be defined as a query parameter in this operation."
291
307
  severity: warn
292
- given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get.parameters"
308
+ given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get"
293
309
  then:
294
- field: "$[?(@.name == '$exclude' && @.in == 'query')]"
295
- function: truthy
310
+ function: schema
311
+ functionOptions:
312
+ schema:
313
+ required: ["parameters"]
314
+ properties:
315
+ parameters:
316
+ type: array
317
+ contains:
318
+ type: object
319
+ properties:
320
+ name:
321
+ const: "$exclude"
322
+ in:
323
+ const: query
324
+ required: [name, in]
296
325
  apiq:OAR022:
297
326
  description: "$orderby must be defined as a query parameter in all operations."
298
327
  message: "OAR022: $orderby must be defined as a query parameter in this operation."
@@ -495,24 +524,18 @@ rules:
495
524
  notMatch: "^(Cookie|Set-Cookie)$"
496
525
  apiq:OAR037:
497
526
  description: "Schema should use well-defined type and format."
498
- 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)."
527
+ message: "OAR037: String schemas must specify a valid format (date, date-time, password, byte, binary, email, uuid, uri, hostname, ipv4, ipv6, HEX, HEX(16), json, xml, or base64)."
499
528
  severity: error
500
- given: "$..schema..[?(@.type=='string' && !@.enum)]"
529
+ given: "$..[?(@.type=='string')]"
501
530
  then:
502
- field: format
503
- function: pattern
504
- functionOptions:
505
- match: "^(date|date-time|password|byte|binary|email|uuid|uri|hostname|ipv4|ipv6|HEX|json|xml|base64)$"
531
+ function: apq-schema-format
506
532
  apiq:OAR038:
507
- description: "Response schema must be compliant with the standard."
508
- message: "OAR038: Response property is required for status code '{{property}}'."
533
+ description: "The 201 response schema of a POST operation must have properties named 'data' or 'error' with at least one sub-property."
534
+ message: "OAR038: Response property must be named 'data' or 'error'. Got '{{property}}'."
509
535
  severity: error
510
- given: "$.paths.*.*.responses..content..schema.properties"
536
+ given: "$.paths.*.post.responses.*.content..schema.properties"
511
537
  then:
512
- field: "@key"
513
- function: pattern
514
- functionOptions:
515
- match: "^data|error$"
538
+ function: apq-valid-response-schema
516
539
  apiq:OAR039:
517
540
  description: "Response codes must be defined according to the standard depending on the HTTP verb and resource path."
518
541
  message: "{{error}}"
@@ -809,9 +832,9 @@ rules:
809
832
  - "$.paths.*.*.parameters[*].schema..properties.*~"
810
833
  - "$.paths.*.*.responses[*].schema..properties.*~"
811
834
  then:
812
- function: casing
835
+ function: pattern
813
836
  functionOptions:
814
- type: snake
837
+ match: "^([a-z$][a-z0-9_$]*|_[a-z0-9_]+|@[a-zA-Z][a-zA-Z0-9_]*|x-[a-zA-Z][a-zA-Z0-9_-]*)$"
815
838
  apiq:OAR067:
816
839
  description: "RequestBody and Responses schema property names must be compliant with the camelCase naming convention."
817
840
  message: "OAR067: RequestBody and Responses schema property names must be compliant with the camelCase naming convention."
@@ -870,10 +893,10 @@ rules:
870
893
  functionOptions:
871
894
  notMatch: "stacktrace"
872
895
  apiq:OAR073:
873
- description: "API should include a 429 response to indicate rate limiting, except for health check paths like /status, /health, /ping."
896
+ description: "API should include a 429 response to indicate rate limiting, except for health check paths like /status, /health, /health-check, /ping, /liveness, /readiness."
874
897
  message: "OAR073: API should include a 429 response to indicate rate limiting."
875
898
  severity: error
876
- given: "$.paths[?(!(@property.match(/\\/status|health|ping|liveness|readiness/)))][*].responses"
899
+ given: "$.paths[?(@property !== '/status' && @property !== '/health' && @property !== '/health-check' && @property !== '/ping' && @property !== '/liveness' && @property !== '/readiness')][*].responses"
877
900
  then:
878
901
  field: "429"
879
902
  function: truthy
@@ -10,11 +10,14 @@ module.exports = (given, { except }, context) => {
10
10
 
11
11
  const parts = paths.substr(1).split('/').filter(p => p.length > 0);
12
12
  if (parts.length === 0) return result;
13
- let previousIsVar = isVariable(parts.shift());
14
- if (previousIsVar) {
15
- return [{
16
- message: context.rule.message,
17
- }];
13
+ const firstPart = parts.shift();
14
+ let previousIsVar;
15
+ if (except && except.includes(firstPart)) {
16
+ previousIsVar = true;
17
+ } else if (isVariable(firstPart)) {
18
+ return [{ message: context.rule.message }];
19
+ } else {
20
+ previousIsVar = false;
18
21
  }
19
22
 
20
23
  for (const part of parts) {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @param {string|string[]} targetVal
3
+ * @param {object} options
4
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
5
+ */
6
+ module.exports = (targetVal, _options, context) => {
7
+ const pattern = /^[a-zA-Z0-9_\-., ]+$/;
8
+ const errors = [];
9
+ const basePath = context.path || [];
10
+
11
+ if (typeof targetVal === 'string') {
12
+ if (!pattern.test(targetVal)) {
13
+ errors.push({
14
+ message: context.rule.message,
15
+ path: basePath
16
+ });
17
+ }
18
+ } else if (Array.isArray(targetVal)) {
19
+ targetVal.forEach((role, index) => {
20
+ if (typeof role === 'string' && !pattern.test(role)) {
21
+ errors.push({
22
+ message: context.rule.message,
23
+ path: [...basePath, index]
24
+ });
25
+ }
26
+ });
27
+ }
28
+
29
+ return errors;
30
+ };
@@ -6,14 +6,15 @@
6
6
  module.exports = (targetVal, options, context) => {
7
7
  const maxDepth = options.maxDepth ?? 3
8
8
  const ignore = options.ignoreSegments ?? []
9
-
10
9
  const segments = targetVal
11
10
  .split('/')
12
11
  .filter(Boolean)
13
12
  .filter(segment => !segment.startsWith('{') && !segment.endsWith('}'))
14
13
  .filter(segment => !ignore.includes(segment))
15
14
 
16
- if (segments.length > maxDepth) {
15
+ const depth = segments.length;
16
+
17
+ if (depth > maxDepth) {
17
18
  return [
18
19
  { message: context.rule.message }
19
20
  ]
@@ -1,98 +1,13 @@
1
- // Check that format is valid for a schema type.
2
-
3
- /**
4
- * @param {object} given
5
- * @param {object} options
6
- * @param {object} options.formats
7
- * @param {Array<string>} options.formats.string
8
- * @param {Array<string>} options.formats.number
9
- * @param {Array<string>} options.formats.integer
10
- * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
11
- */
12
- module.exports = function checkTypeAndFormat(given, options, context) {
13
- if (given === null || typeof given !== "object") {
14
- return [];
15
- }
16
-
17
- const errors = [];
18
- const path = context.path || [];
19
-
20
- if (given.type === "string" && options.formats.string) {
21
- if (given.format) {
22
- if (!options.formats.string.includes(given.format)) {
23
- errors.push({
24
- message: `Schema with type string has unrecognized format: ${given.format}`,
25
- path: [...path, "format"]
26
- });
27
- }
28
- }
29
- } else if (given.type === "integer" && options.formats.integer) {
30
- if (given.format) {
31
- if (!options.formats.integer.includes(given.format)) {
32
- errors.push({
33
- message: `Schema with type integer has unrecognized format: ${given.format}`,
34
- path: [...path, "format"]
35
- });
36
- }
37
- } else {
38
- errors.push({
39
- message: "Schema with type integer should specify format",
40
- path
41
- });
42
- }
43
- } else if (given.type === "number" && options.formats.number) {
44
- if (given.format) {
45
- if (!options.formats.number.includes(given.format)) {
46
- errors.push({
47
- message: `Schema with type number has unrecognized format: ${given.format}`,
48
- path: [...path, "format"]
49
- });
50
- }
51
- } else {
52
- errors.push({
53
- message: "Schema with type number should specify format",
54
- path
55
- });
56
- }
57
- } else if (given.type === "boolean") {
58
- if (given.format) {
59
- errors.push({
60
- message: "Schema with type boolean should not specify format",
61
- path: [...path, "format"]
62
- });
63
- }
64
- } else if (given.properties && typeof given.properties === "object") {
65
- // eslint-disable-next-line no-restricted-syntax
66
- for (const [key, value] of Object.entries(given.properties)) {
67
- errors.push(...checkTypeAndFormat(value, options, {
68
- path: [...path, "properties", key],
69
- document: context.document,
70
- documentInventory: context.documentInventory,
71
- rule: context.rule
72
- }));
73
- }
74
- }
75
-
76
- if (given.type === "array") {
77
- errors.push(...checkTypeAndFormat(given.items, options, {
78
- path: [...path, "items"],
79
- document: context.document,
80
- documentInventory: context.documentInventory,
81
- rule: context.rule
82
- }));
83
- }
84
-
85
- if (given.allOf && Array.isArray(given.allOf)) {
86
- // eslint-disable-next-line no-restricted-syntax
87
- for (const [index, value] of given.allOf.entries()) {
88
- errors.push(...checkTypeAndFormat(value, options, {
89
- path: [...path, "allOf", index],
90
- document: context.document,
91
- documentInventory: context.documentInventory,
92
- rule: context.rule
93
- }));
94
- }
95
- }
96
-
97
- return errors;
1
+ const VALID_FORMATS = new Set([
2
+ 'date', 'date-time', 'password', 'byte', 'binary', 'email',
3
+ 'uuid', 'uri', 'hostname', 'ipv4', 'ipv6', 'hex', 'hex(16)',
4
+ 'json', 'xml', 'base64'
5
+ ]);
6
+
7
+ module.exports = (targetVal, _options, context) => {
8
+ const format = targetVal.format;
9
+ if (format === undefined || format === null || !VALID_FORMATS.has(String(format).toLowerCase())) {
10
+ return [{ message: context.rule.message }];
11
+ }
12
+ return [];
98
13
  };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @param {object} targetVal
3
+ * @param {object} _options
4
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
5
+ */
6
+ module.exports = (targetVal, _options, context) => {
7
+ const errors = [];
8
+ const VALID_NAMES = ['data', 'error'];
9
+
10
+ if (!targetVal || typeof targetVal !== 'object') {
11
+ return errors;
12
+ }
13
+
14
+ const path = context.path || [];
15
+ const responsesIdx = path.lastIndexOf('responses');
16
+ if (responsesIdx >= 0 && path[responsesIdx + 1] !== '201') {
17
+ return errors;
18
+ }
19
+
20
+ for (const [propName, propValue] of Object.entries(targetVal)) {
21
+ if (!VALID_NAMES.includes(propName)) {
22
+ errors.push({
23
+ message: context.rule.message,
24
+ path: [propName]
25
+ });
26
+ } else {
27
+ const subProps = propValue && propValue.properties;
28
+ const hasSubProps = subProps && Object.keys(subProps).length > 0;
29
+ if (!hasSubProps) {
30
+ errors.push({
31
+ message: `OAR038: Property '${propName}' must have at least one sub-property.`,
32
+ path: [propName]
33
+ });
34
+ }
35
+ }
36
+ }
37
+
38
+ return errors;
39
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "api-quality-spectral-ruleset",
3
- "version": "1.3.0-beta.3",
3
+ "version": "1.3.0-beta.5",
4
4
  "description": "Spectral ruleset by API Quality",
5
5
  "main": "apq-spectral.yaml",
6
6
  "files": [