api-quality-spectral-ruleset 1.4.0-beta.5 → 1.4.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
@@ -18,6 +18,8 @@ functions:
18
18
  - apq-path-depth
19
19
  - apq-forbidden-characters
20
20
  - apq-valid-response-schema
21
+ - apq-valid-openapi-version
22
+ - apq-collection-query-param-required
21
23
  - apq-path-param-query-conflict
22
24
  - apq-schema-format
23
25
  - apq-security-required-response
@@ -69,6 +71,8 @@ rules:
69
71
  given: "$.x-wso2-security.apim.x-wso2-scopes[*].roles"
70
72
  then:
71
73
  function: apq-forbidden-characters
74
+ functionOptions:
75
+ pattern: "^[a-zA-Z0-9_\\-., ]+$"
72
76
  apiq:OAR005:
73
77
  description: "A wrong scope may cause problems to import the API definition into WSO2 or allow all users to call the endpoint."
74
78
  message: "OAR005: Scope in the operation must be defined correctly and match an existing scope."
@@ -169,7 +173,8 @@ rules:
169
173
  then:
170
174
  function: apq-path-depth
171
175
  functionOptions:
172
- maxDepth: 3
176
+ min-level: 4
177
+ max-level: 5
173
178
  ignoreSegments:
174
179
  - me
175
180
  apiq:OAR015:
@@ -274,54 +279,31 @@ rules:
274
279
  ;options:.*
275
280
  apiq:OAR019:
276
281
  description: "$select must be defined as a query parameter in collection operations (excluding detail endpoints and health checks)."
277
- message: "OAR019: $select must be defined as a query parameter in this operation."
282
+ message: "{{error}}"
278
283
  severity: warn
279
- given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get.parameters"
284
+ given: "$.paths"
280
285
  then:
281
- field: "$[?(@.name == '$select' && @.in == 'query')]"
282
- function: truthy
286
+ function: apq-collection-query-param-required
287
+ functionOptions:
288
+ parameter-name: "$select"
283
289
  apiq:OAR020:
284
290
  description: "$expand must be defined as a query parameter in collection operations (excluding detail endpoints and health checks)."
285
- message: "OAR020: $expand must be defined as a query parameter in this operation."
291
+ message: "{{error}}"
286
292
  severity: warn
287
- given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get"
293
+ given: "$.paths"
288
294
  then:
289
- function: schema
295
+ function: apq-collection-query-param-required
290
296
  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]
297
+ parameter-name: "$expand"
304
298
  apiq:OAR021:
305
299
  description: "$exclude must be defined as a query parameter in collection operations (excluding detail endpoints and health checks)."
306
- message: "OAR021: $exclude must be defined as a query parameter in this operation."
300
+ message: "{{error}}"
307
301
  severity: warn
308
- given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get"
302
+ given: "$.paths"
309
303
  then:
310
- function: schema
304
+ function: apq-collection-query-param-required
311
305
  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]
306
+ parameter-name: "$exclude"
325
307
  apiq:OAR022:
326
308
  description: "$orderby must be defined as a query parameter in all operations."
327
309
  message: "OAR022: $orderby must be defined as a query parameter in this operation."
@@ -537,11 +519,15 @@ rules:
537
519
  function: apq-schema-format
538
520
  apiq:OAR038:
539
521
  description: "The 201 response schema of a POST operation must have properties named 'data' or 'error' with at least one sub-property."
540
- message: "OAR038: Response property must be named 'data' or 'error'. Got '{{property}}'."
522
+ message: "{{error}}"
541
523
  severity: error
542
- given: "$.paths.*.post.responses.*.content..schema.properties"
524
+ given:
525
+ - "$.paths.*.post.responses.*.content..schema.properties"
526
+ - "$.paths.*.post.responses.*.schema.properties"
543
527
  then:
544
528
  function: apq-valid-response-schema
529
+ functionOptions:
530
+ data-property: data
545
531
  apiq:OAR039:
546
532
  description: "Response codes must be defined according to the standard depending on the HTTP verb and resource path."
547
533
  message: "{{error}}"
@@ -580,13 +566,13 @@ rules:
580
566
  ;patch:^/[^/{}]+/(\{[^/{}]+\}|\bme\b)/[^/{}]+/(\{[^/{}]+\}|\bme\b)/[^/{}]+/(\{[^/{}]+\}|\bme\b)$:200,404,400,415,500,503
581
567
  apiq:OAR040:
582
568
  description: "A scope name non-compliant with the standard may cause problems at application level."
583
- message: "OAR040: Scope name must follow the 'apfc_sc_' prefix standard."
569
+ message: "OAR040: Scope name value is non compliant with the standard."
584
570
  severity: "error"
585
571
  given: "$.x-wso2-security.apim.x-wso2-scopes[*].name"
586
572
  then:
587
573
  function: pattern
588
574
  functionOptions:
589
- match: "^apfc_sc_.*"
575
+ match: "^[a-zA-Z]{4,}_(SC|sc)_[a-zA-Z0-9]{1,}$"
590
576
  apiq:OAR041:
591
577
  description: Available scopes must be defined at the end of the document, at the root level, inside `x-wso2-security`.
592
578
  message: "OAR041:Scopes must be defined in `x-wso2-security` at the end of the document, before the paths."
@@ -1021,7 +1007,7 @@ rules:
1021
1007
  function: apq-password-format
1022
1008
  apiq:OAR082:
1023
1009
  description: "The string properties 'product', 'line', and 'price' must define a byte or binary format."
1024
- message: "OAR082: Property '{{property}}' must define a 'byte' or 'binary' format (currently: {{format}})."
1010
+ message: "{{error}}"
1025
1011
  severity: error
1026
1012
  given: "$..[?(@.properties)]"
1027
1013
  then:
@@ -1050,13 +1036,15 @@ rules:
1050
1036
  notMatch: "^password$"
1051
1037
  apiq:OAR085:
1052
1038
  description: "The OpenAPI version must be one of: 2.0, 3.0.0, 3.0.1, 3.0.2, 3.0.3, 3.1.0."
1053
- message: "OAR085: The OpenAPI version '{{value}}' is not allowed. Allowed versions: 2.0, 3.0.0, 3.0.1, 3.0.2, 3.0.3, 3.1.0."
1039
+ message: "{{error}}"
1054
1040
  severity: warn
1055
- given: "$.openapi"
1041
+ given:
1042
+ - "$.openapi"
1043
+ - "$.swagger"
1056
1044
  then:
1057
- function: pattern
1045
+ function: apq-valid-openapi-version
1058
1046
  functionOptions:
1059
- match: "^(2\\.0|3\\.0\\.0|3\\.0\\.1|3\\.0\\.2|3\\.0\\.3|3\\.1\\.0)$"
1047
+ valid-versions: "2.0,3.0.0,3.0.1,3.0.2,3.0.3,3.1.0"
1060
1048
  apiq:OAR086:
1061
1049
  description: "Descriptions must begin with a capital letter, end with a period, and not be empty."
1062
1050
  message: "OAR086: Descriptions must begin with a capital letter, end with a period, and not be empty."
@@ -23,14 +23,11 @@ module.exports = (schema, options = {}, context) => {
23
23
  return;
24
24
  }
25
25
 
26
- // Check if format is byte or binary
27
26
  const format = propSchema.format || '';
28
- if (format && format !== 'byte' && format !== 'binary') {
27
+ if (format !== 'byte' && format !== 'binary') {
29
28
  results.push({
30
- message: context.rule.message
31
- .replace('{{property}}', propName)
32
- .replace('{{format}}', format || 'undefined'),
33
- path: [...context.path, 'properties', propName],
29
+ message: `OAR082: Property '${propName}' must define a 'byte' or 'binary' format (currently: ${format || 'missing'}).`,
30
+ path: [...context.path, 'properties', propName, 'type'],
34
31
  });
35
32
  }
36
33
  });
@@ -0,0 +1,69 @@
1
+
2
+ const DEFAULT_PATHS = '/me;/health;/ping;/status';
3
+ const PATH_PARAM_SUFFIX_REGEX = /\/\{[^}]+\}$/;
4
+ const STRATEGY_INCLUDE = 'Include';
5
+ const STRATEGY_EXCLUDE = 'Exclude';
6
+
7
+ const escapeRegExp = (segment) => segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8
+
9
+
10
+ const parsePathPatterns = (paths) => paths
11
+ .split(/[\n;]/)
12
+ .map((p) => p.trim())
13
+ .filter(Boolean)
14
+ .map((segment) => new RegExp(`${escapeRegExp(segment)}(/|$)`));
15
+
16
+ const shouldIncludePath = (path, patterns, strategy) => {
17
+ const matchesList = patterns.some((regex) => regex.test(path));
18
+ return strategy === STRATEGY_INCLUDE ? matchesList : !matchesList;
19
+ };
20
+
21
+ const hasQueryParameter = (parameters, parameterName) => Array.isArray(parameters)
22
+ && parameters.some((p) => p && !p.$ref && p.name === parameterName && p.in === 'query');
23
+
24
+ /**
25
+ * @param {object} given
26
+ * @param {object} options
27
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
28
+ */
29
+ module.exports = (given, options = {}, context) => {
30
+ if (!given || typeof given !== 'object') return [];
31
+
32
+ const ruleCode = context && context.rule && context.rule.name ? context.rule.name.split(':').pop() : 'apq-collection-query-param-required';
33
+
34
+ const parameterName = options['parameter-name'];
35
+ if (!parameterName) {
36
+ return [{
37
+ message: `${ruleCode}: "parameter-name" functionOption is required.`,
38
+ path: context.path,
39
+ }];
40
+ }
41
+
42
+ const strategy = options.pathValidationStrategy === STRATEGY_INCLUDE
43
+ ? STRATEGY_INCLUDE
44
+ : STRATEGY_EXCLUDE;
45
+
46
+ const rawPaths = typeof options.paths === 'string' ? options.paths : DEFAULT_PATHS;
47
+ const patterns = parsePathPatterns(rawPaths);
48
+
49
+ const results = [];
50
+
51
+ Object.entries(given).forEach(([path, pathItem]) => {
52
+ if (!pathItem || typeof pathItem !== 'object') return;
53
+
54
+ const getOperation = pathItem.get;
55
+ if (!getOperation || typeof getOperation !== 'object') return;
56
+
57
+ if (PATH_PARAM_SUFFIX_REGEX.test(path)) return;
58
+ if (!shouldIncludePath(path, patterns, strategy)) return;
59
+
60
+ if (!hasQueryParameter(getOperation.parameters, parameterName)) {
61
+ results.push({
62
+ message: `${ruleCode}: ${parameterName} must be defined as a query parameter in this operation.`,
63
+ path: [...context.path, path, 'get'],
64
+ });
65
+ }
66
+ });
67
+
68
+ return results;
69
+ };
@@ -3,8 +3,8 @@
3
3
  * @param {object} options
4
4
  * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
5
5
  */
6
- module.exports = (targetVal, _options, context) => {
7
- const pattern = /^[a-zA-Z0-9_\-., ]+$/;
6
+ module.exports = (targetVal, options, context) => {
7
+ const pattern = new RegExp((options && options.pattern) || '^[a-zA-Z0-9_\\-., ]+$');
8
8
  const errors = [];
9
9
  const basePath = context.path || [];
10
10
 
@@ -4,7 +4,8 @@
4
4
  * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
5
5
  */
6
6
  module.exports = (targetVal, options, context) => {
7
- const maxDepth = options.maxDepth ?? 3
7
+ const minDepth = options['min-level'] ?? options.minDepth ?? null
8
+ const maxDepth = options['max-level'] ?? options.maxDepth ?? 3
8
9
  const ignore = options.ignoreSegments ?? []
9
10
  const segments = targetVal
10
11
  .split('/')
@@ -14,7 +15,11 @@ module.exports = (targetVal, options, context) => {
14
15
 
15
16
  const depth = segments.length;
16
17
 
17
- if (depth > maxDepth) {
18
+ const isOutOfRange = minDepth != null
19
+ ? (depth >= minDepth && depth <= maxDepth)
20
+ : depth > maxDepth
21
+
22
+ if (isOutOfRange) {
18
23
  return [
19
24
  { message: context.rule.message }
20
25
  ]
@@ -0,0 +1,20 @@
1
+ const DEFAULT_VALID_VERSIONS = '2.0,3.0.0,3.0.1,3.0.2,3.0.3,3.1.0';
2
+
3
+ /**
4
+ * @param {string} targetVal
5
+ * @param {object} options
6
+ */
7
+ module.exports = (targetVal, options) => {
8
+ const raw = (options && options['valid-versions']) || DEFAULT_VALID_VERSIONS;
9
+ const validVersions = (Array.isArray(raw) ? raw : String(raw).split(','))
10
+ .map(version => String(version).trim())
11
+ .filter(Boolean);
12
+
13
+ if (!validVersions.includes(String(targetVal))) {
14
+ return [
15
+ {
16
+ message: `OAR085: The OpenAPI version '${targetVal}' is not allowed. Allowed versions: ${validVersions.join(', ')}.`
17
+ }
18
+ ];
19
+ }
20
+ };
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * @param {object} targetVal
3
- * @param {object} _options
3
+ * @param {object} options
4
4
  * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
5
5
  */
6
- module.exports = (targetVal, _options, context) => {
6
+ module.exports = (targetVal, options, context) => {
7
7
  const errors = [];
8
- const VALID_NAMES = ['data', 'error'];
8
+ const dataProperty = (options && options['data-property']) || 'data';
9
+ const VALID_NAMES = [dataProperty, 'error'];
9
10
 
10
11
  if (!targetVal || typeof targetVal !== 'object') {
11
12
  return errors;
@@ -20,8 +21,8 @@ module.exports = (targetVal, _options, context) => {
20
21
  for (const [propName, propValue] of Object.entries(targetVal)) {
21
22
  if (!VALID_NAMES.includes(propName)) {
22
23
  errors.push({
23
- message: context.rule.message,
24
- path: [propName]
24
+ message: `OAR038: Response property must be named '${dataProperty}' or 'error'. Got '${propName}'.`,
25
+ path: [...path, propName]
25
26
  });
26
27
  } else {
27
28
  const subProps = propValue && propValue.properties;
@@ -29,7 +30,7 @@ module.exports = (targetVal, _options, context) => {
29
30
  if (!hasSubProps) {
30
31
  errors.push({
31
32
  message: `OAR038: Property '${propName}' must have at least one sub-property.`,
32
- path: [propName]
33
+ path: [...path, propName]
33
34
  });
34
35
  }
35
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "api-quality-spectral-ruleset",
3
- "version": "1.4.0-beta.5",
3
+ "version": "1.4.0",
4
4
  "description": "Spectral ruleset by API Quality",
5
5
  "main": "apq-spectral.yaml",
6
6
  "files": [