api-quality-spectral-ruleset 1.5.0-beta.2 → 1.5.0-beta.3

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
@@ -34,6 +34,12 @@ functions:
34
34
  - apq-query-params-optional
35
35
  - apq-wso2-scopes-valid
36
36
  - apq-numeric-path-param
37
+ - apq-numeric-invalid-format
38
+ - apq-numeric-missing-format
39
+ - apq-numeric-well-defined-format
40
+ - apq-string-parameter-integrity
41
+ - apq-example-schema-types
42
+ - apq-standard-response-schema
37
43
  extends:
38
44
  - spectral:asyncapi
39
45
  - spectral:oas
@@ -196,29 +202,10 @@ rules:
196
202
  message: "OAR016: Numeric types must use a valid format for their type."
197
203
  documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR016.md"
198
204
  severity: error
199
- given: "$..[?(@ && (@.type=='number' || @.type=='integer' || (@.type && @.type.indexOf && (@.type.indexOf('number') > -1 || @.type.indexOf('integer') > -1))))]"
205
+ resolved: false
206
+ given: "$..[?(@ && @.type)]"
200
207
  then:
201
- function: schema
202
- functionOptions:
203
- schema:
204
- required: [format]
205
- allOf:
206
- - if:
207
- properties:
208
- type:
209
- const: integer
210
- then:
211
- properties:
212
- format:
213
- enum: [int32, int64, uint32, uint64]
214
- - if:
215
- properties:
216
- type:
217
- const: number
218
- then:
219
- properties:
220
- format:
221
- enum: [float, double, decimal]
208
+ function: apq-numeric-invalid-format
222
209
  apiq:OAR017:
223
210
  description: "Resource path should alternate static and parametrized parts."
224
211
  message: "OAR017: Resource path should alternate static and parametrized parts"
@@ -376,38 +363,14 @@ rules:
376
363
  function: apq-has-filter-query-param
377
364
  apiq:OAR029:
378
365
  description: "A response not compliant with the standard may cause application issues."
379
- message: "OAR029: A response not compliant with the standard may cause application issues."
366
+ message: "{{error}}"
380
367
  severity: error
381
- given: "$.components.schemas.errorResponse"
368
+ given: "$.paths"
382
369
  then:
383
- function: schema
370
+ function: apq-standard-response-schema
384
371
  functionOptions:
385
- schema:
386
- type: object
387
- properties:
388
- properties:
389
- type: object
390
- properties:
391
- error:
392
- type: object
393
- properties:
394
- properties:
395
- type: object
396
- properties:
397
- code:
398
- type: string
399
- message:
400
- type: string
401
- details:
402
- type: array
403
- httpStatus:
404
- type: integer
405
- required:
406
- - code
407
- - message
408
- - httpStatus
409
- required:
410
- - properties
372
+ response-schema: '{"type":"object","properties":{"status":{"type":"object","properties":{"code":{"type":"integer"},"description":{"type":"string"},"internal_code":{"type":"string"},"errors":{"type":"array","nullable":true,"items":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"string"}}}}},"required":["code"]},"payload":{"type":"any"}},"required":["status","payload"]}'
373
+ path-exclusions: "/status"
411
374
  apiq:OAR030:
412
375
  description: "The configured status endpoint must be declared with the configured HTTP method."
413
376
  message: "OAR030: The required status endpoint is not declared or does not have the required method."
@@ -688,10 +651,10 @@ rules:
688
651
  message: "OAR052: Numeric types requires a format"
689
652
  documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR052.md"
690
653
  severity: warn
691
- given: "$..[?(@ && (@.type == 'integer' || @.type == 'number' || (@.type && @.type.indexOf && (@.type.indexOf('integer') > -1 || @.type.indexOf('number') > -1))))]"
654
+ resolved: false
655
+ given: "$..[?(@ && @.type)]"
692
656
  then:
693
- field: format
694
- function: truthy
657
+ function: apq-numeric-missing-format
695
658
  apiq:OAR053:
696
659
  description: "Response headers for API observability and tracing must be defined (excluding 204 responses and health endpoints)."
697
660
  message: "OAR053: Response must include mandatory headers and exclude forbidden headers."
@@ -889,47 +852,25 @@ rules:
889
852
  description: "String parameters should have minLength, maxLength, pattern (regular expression), or enum restriction."
890
853
  message: "OAR075: String parameters should have minLength, maxLength, pattern, or enum restriction."
891
854
  severity: error
892
- given: "$.paths[*][get,post,put,patch,delete].parameters[?(@ && @.schema && (@.schema.type == 'string' || (@.schema.type && @.schema.type.indexOf && @.schema.type.indexOf('string') > -1)) && @.in == 'path')]"
855
+ resolved: false
856
+ given:
857
+ - "$.paths[*][*].parameters[*]"
858
+ - "$.paths[*].parameters[*]"
859
+ - "$.components.parameters[*]"
860
+ - "$.parameters[*]"
893
861
  then:
894
- function: schema
862
+ function: apq-string-parameter-integrity
895
863
  functionOptions:
896
- schema:
897
- type: object
898
- properties:
899
- schema:
900
- anyOf:
901
- - required: ["minLength"]
902
- - required: ["maxLength"]
903
- - required: ["pattern"]
904
- - required: ["enum"]
864
+ parameter_integrity: "minLength,maxLength,pattern,enum"
905
865
  apiq:OAR076:
906
866
  description: "Schema should use well-defined type and format."
907
867
  message: "OAR076: Schema should use well-defined type and format."
908
868
  documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR076.md"
909
869
  severity: error
910
- given: "$..[?(@ && (@.type=='number' || @.type=='integer' || (@.type && @.type.indexOf && (@.type.indexOf('number') > -1 || @.type.indexOf('integer') > -1))))]"
870
+ resolved: false
871
+ given: "$..[?(@ && @.type)]"
911
872
  then:
912
- function: schema
913
- functionOptions:
914
- schema:
915
- required: [format]
916
- allOf:
917
- - if:
918
- properties:
919
- type:
920
- const: integer
921
- then:
922
- properties:
923
- format:
924
- enum: [int32, int64]
925
- - if:
926
- properties:
927
- type:
928
- const: number
929
- then:
930
- properties:
931
- format:
932
- enum: [float, double]
873
+ function: apq-numeric-well-defined-format
933
874
  apiq:OAR077:
934
875
  description: "All parameters in query must be snake_case."
935
876
  message: "OAR077: All parameters in query must be snake_case."
@@ -1199,19 +1140,10 @@ rules:
1199
1140
  description: "The schemas should match the provided examples."
1200
1141
  message: "OAR108: Schema does not match the provided example."
1201
1142
  severity: "error"
1202
- given:
1203
- - "$.paths['/item'].get.responses['200'].content['application/json'].example"
1204
- - "$.paths['/item'].get.responses['200'].examples['application/json']"
1143
+ resolved: false
1144
+ given: "$.paths[*][*].responses[*]"
1205
1145
  then:
1206
- - function: "schema"
1207
- functionOptions:
1208
- schema:
1209
- type: "object"
1210
- properties:
1211
- id:
1212
- type: "integer"
1213
- nombre:
1214
- type: "string"
1146
+ function: apq-example-schema-types
1215
1147
  apiq:OAR109:
1216
1148
  description: "Use default response instead of directly specifying 5XX codes."
1217
1149
  message: "OAR109: Use default response instead of specifying 5XX codes directly."
@@ -1265,9 +1197,8 @@ rules:
1265
1197
  message: "OAR115: All fields in the required array must be defined in schema properties."
1266
1198
  documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR115.md"
1267
1199
  severity: warn
1268
- given:
1269
- - "$.components.schemas[*]"
1270
- - "$.definitions[*]"
1200
+ resolved: false
1201
+ given: "$..[?(@ && @.required)]"
1271
1202
  then:
1272
1203
  function: apq-required-fields-exist
1273
1204
  apiq:OAR116:
@@ -0,0 +1,116 @@
1
+ function getPrimaryType(type) {
2
+ if (type === undefined || type === null) return null;
3
+ if (Array.isArray(type)) {
4
+ for (const element of type) {
5
+ if (element === null || element === undefined) continue;
6
+ const value = String(element);
7
+ if (value !== 'null') return value;
8
+ }
9
+ return null;
10
+ }
11
+ if (typeof type === 'object') return null;
12
+ return String(type);
13
+ }
14
+
15
+ function determineExampleType(value) {
16
+ if (value === null || value === undefined) return 'null';
17
+ if (Array.isArray(value)) return 'array';
18
+ if (typeof value === 'object') return 'object';
19
+ if (typeof value === 'boolean') return 'boolean';
20
+ if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number';
21
+ return 'string';
22
+ }
23
+
24
+ function schemaTypes(schema) {
25
+ const types = new Map();
26
+ if (!schema || typeof schema !== 'object') return types;
27
+ const { properties } = schema;
28
+ if (!properties || typeof properties !== 'object') return types;
29
+ Object.keys(properties).forEach((name) => {
30
+ const property = properties[name];
31
+ const type = property && typeof property === 'object' ? property.type : undefined;
32
+ types.set(name, getPrimaryType(type));
33
+ });
34
+ return types;
35
+ }
36
+
37
+ function exampleTypes(example) {
38
+ const types = new Map();
39
+ if (!example || typeof example !== 'object' || Array.isArray(example)) return types;
40
+ Object.keys(example).forEach((name) => {
41
+ types.set(name, determineExampleType(example[name]));
42
+ });
43
+ return types;
44
+ }
45
+
46
+ function swaggerExampleTypes(examples) {
47
+ const types = new Map();
48
+ if (!examples || typeof examples !== 'object' || Array.isArray(examples)) return types;
49
+ Object.keys(examples).forEach((mediaType) => {
50
+ const payload = examples[mediaType];
51
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return;
52
+ Object.keys(payload).forEach((name) => {
53
+ types.set(name, determineExampleType(payload[name]));
54
+ });
55
+ });
56
+ return types;
57
+ }
58
+
59
+ function isCompatible(expectedType, actualType) {
60
+ if (expectedType === null) return true;
61
+ if (actualType === 'null') return true;
62
+ if (expectedType === actualType) return true;
63
+ return expectedType === 'number' && actualType === 'integer';
64
+ }
65
+
66
+ function mismatches(schema, example, isSwagger) {
67
+ const expected = schemaTypes(schema);
68
+ if (expected.size === 0) return false;
69
+ const actual = isSwagger ? swaggerExampleTypes(example) : exampleTypes(example);
70
+
71
+ for (const [name, expectedType] of expected) {
72
+ const actualType = actual.has(name) ? actual.get(name) : 'unknown';
73
+ if (!isCompatible(expectedType, actualType)) return true;
74
+ }
75
+ return false;
76
+ }
77
+
78
+ function isPresent(value) {
79
+ return value !== undefined && value !== null;
80
+ }
81
+
82
+ /**
83
+ * @param {object} targetVal a Response Object
84
+ * @param {object} options
85
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
86
+ */
87
+ module.exports = (targetVal, options, context) => {
88
+ if (!targetVal || typeof targetVal !== 'object') return [];
89
+
90
+ const results = [];
91
+ const { content } = targetVal;
92
+
93
+ if (isPresent(content) && typeof content === 'object' && !Array.isArray(content)) {
94
+ Object.keys(content).forEach((mediaType) => {
95
+ const mediaTypeNode = content[mediaType];
96
+ if (!mediaTypeNode || typeof mediaTypeNode !== 'object') return;
97
+ const { schema, example } = mediaTypeNode;
98
+ if (!isPresent(schema) || !isPresent(example)) return;
99
+ if (mismatches(schema, example, false)) {
100
+ results.push({
101
+ message: context.rule.message,
102
+ path: [...context.path, 'content', mediaType, 'example'],
103
+ });
104
+ }
105
+ });
106
+ return results;
107
+ }
108
+
109
+ const { schema, examples } = targetVal;
110
+ if (!isPresent(schema) || !isPresent(examples)) return results;
111
+ if (mismatches(schema, examples, true)) {
112
+ results.push({ message: context.rule.message, path: [...context.path, 'examples'] });
113
+ }
114
+
115
+ return results;
116
+ };
@@ -0,0 +1,64 @@
1
+ const VALID_INTEGER_FORMATS = new Set(['int32', 'int64']);
2
+ const VALID_NUMBER_FORMATS = new Set(['float', 'double']);
3
+
4
+ function getPrimaryType(type) {
5
+ if (type === undefined || type === null) return null;
6
+ if (Array.isArray(type)) {
7
+ for (const element of type) {
8
+ if (element === null || element === undefined) continue;
9
+ const value = String(element);
10
+ if (value !== 'null') return value;
11
+ }
12
+ return null;
13
+ }
14
+ if (typeof type === 'object') return null;
15
+ return String(type);
16
+ }
17
+
18
+ function readFormat(node) {
19
+ if (!Object.prototype.hasOwnProperty.call(node, 'format')) return { present: false, blank: false };
20
+ const raw = node.format;
21
+ if (raw === null || raw === undefined) return { present: false, blank: false };
22
+ if (typeof raw === 'object') return { present: true, blank: true };
23
+ const value = String(raw).trim();
24
+ if (value === '') return { present: true, blank: true };
25
+ return { present: true, blank: false, value };
26
+ }
27
+
28
+ const NON_SCHEMA_CONTAINERS = new Set(['example', 'examples', 'default', 'enum']);
29
+
30
+ function isInsideNonSchemaValue(path) {
31
+ for (let i = 0; i < path.length; i += 1) {
32
+ const segment = path[i];
33
+ if (typeof segment !== 'string' || !NON_SCHEMA_CONTAINERS.has(segment)) continue;
34
+ if (i > 0 && path[i - 1] === 'properties') continue;
35
+ return true;
36
+ }
37
+ return false;
38
+ }
39
+
40
+ function isSwaggerResponseHeader(path) {
41
+ const i = path.length - 2;
42
+ return i >= 0 && path[i] === 'headers' && (i === 0 || path[i - 1] !== 'properties');
43
+ }
44
+
45
+ /**
46
+ * @param {object} targetVal
47
+ * @param {object} options
48
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
49
+ */
50
+ module.exports = (targetVal, options, context) => {
51
+ if (!targetVal || typeof targetVal !== 'object') return [];
52
+ if (isInsideNonSchemaValue(context.path) || isSwaggerResponseHeader(context.path)) return [];
53
+
54
+ const type = getPrimaryType(targetVal.type);
55
+ if (type !== 'integer' && type !== 'number') return [];
56
+
57
+ const format = readFormat(targetVal);
58
+ if (!format.present || format.blank) return [];
59
+
60
+ const valid = type === 'integer' ? VALID_INTEGER_FORMATS : VALID_NUMBER_FORMATS;
61
+ if (valid.has(format.value)) return [];
62
+
63
+ return [{ message: context.rule.message, path: [...context.path, 'type'] }];
64
+ };
@@ -0,0 +1,58 @@
1
+ function getPrimaryType(type) {
2
+ if (type === undefined || type === null) return null;
3
+ if (Array.isArray(type)) {
4
+ for (const element of type) {
5
+ if (element === null || element === undefined) continue;
6
+ const value = String(element);
7
+ if (value !== 'null') return value;
8
+ }
9
+ return null;
10
+ }
11
+ if (typeof type === 'object') return null;
12
+ return String(type);
13
+ }
14
+
15
+ function readFormat(node) {
16
+ if (!Object.prototype.hasOwnProperty.call(node, 'format')) return { present: false, blank: false };
17
+ const raw = node.format;
18
+ if (raw === null || raw === undefined) return { present: false, blank: false };
19
+ if (typeof raw === 'object') return { present: true, blank: true };
20
+ const value = String(raw).trim();
21
+ if (value === '') return { present: true, blank: true };
22
+ return { present: true, blank: false, value };
23
+ }
24
+
25
+ const NON_SCHEMA_CONTAINERS = new Set(['example', 'examples', 'default', 'enum']);
26
+
27
+ function isInsideNonSchemaValue(path) {
28
+ for (let i = 0; i < path.length; i += 1) {
29
+ const segment = path[i];
30
+ if (typeof segment !== 'string' || !NON_SCHEMA_CONTAINERS.has(segment)) continue;
31
+ if (i > 0 && path[i - 1] === 'properties') continue;
32
+ return true;
33
+ }
34
+ return false;
35
+ }
36
+
37
+ function isSwaggerResponseHeader(path) {
38
+ const i = path.length - 2;
39
+ return i >= 0 && path[i] === 'headers' && (i === 0 || path[i - 1] !== 'properties');
40
+ }
41
+
42
+ /**
43
+ * @param {object} targetVal
44
+ * @param {object} options
45
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
46
+ */
47
+ module.exports = (targetVal, options, context) => {
48
+ if (!targetVal || typeof targetVal !== 'object') return [];
49
+ if (isInsideNonSchemaValue(context.path) || isSwaggerResponseHeader(context.path)) return [];
50
+
51
+ const type = getPrimaryType(targetVal.type);
52
+ if (type !== 'integer' && type !== 'number') return [];
53
+
54
+ const format = readFormat(targetVal);
55
+ if (format.present) return [];
56
+
57
+ return [{ message: context.rule.message, path: [...context.path, 'type'] }];
58
+ };
@@ -0,0 +1,64 @@
1
+ const VALID_INTEGER_FORMATS = new Set(['int32', 'int64']);
2
+ const VALID_NUMBER_FORMATS = new Set(['float', 'double']);
3
+
4
+ function getPrimaryType(type) {
5
+ if (type === undefined || type === null) return null;
6
+ if (Array.isArray(type)) {
7
+ for (const element of type) {
8
+ if (element === null || element === undefined) continue;
9
+ const value = String(element);
10
+ if (value !== 'null') return value;
11
+ }
12
+ return null;
13
+ }
14
+ if (typeof type === 'object') return null;
15
+ return String(type);
16
+ }
17
+
18
+ function readFormat(node) {
19
+ if (!Object.prototype.hasOwnProperty.call(node, 'format')) return { present: false, blank: false };
20
+ const raw = node.format;
21
+ if (raw === null || raw === undefined) return { present: false, blank: false };
22
+ if (typeof raw === 'object') return { present: true, blank: true };
23
+ const value = String(raw).trim();
24
+ if (value === '') return { present: true, blank: true };
25
+ return { present: true, blank: false, value };
26
+ }
27
+
28
+ const NON_SCHEMA_CONTAINERS = new Set(['example', 'examples', 'default', 'enum']);
29
+
30
+ function isInsideNonSchemaValue(path) {
31
+ for (let i = 0; i < path.length; i += 1) {
32
+ const segment = path[i];
33
+ if (typeof segment !== 'string' || !NON_SCHEMA_CONTAINERS.has(segment)) continue;
34
+ if (i > 0 && path[i - 1] === 'properties') continue;
35
+ return true;
36
+ }
37
+ return false;
38
+ }
39
+
40
+ function isSwaggerResponseHeader(path) {
41
+ const i = path.length - 2;
42
+ return i >= 0 && path[i] === 'headers' && (i === 0 || path[i - 1] !== 'properties');
43
+ }
44
+
45
+ /**
46
+ * @param {object} targetVal
47
+ * @param {object} options
48
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
49
+ */
50
+ module.exports = (targetVal, options, context) => {
51
+ if (!targetVal || typeof targetVal !== 'object') return [];
52
+ if (isInsideNonSchemaValue(context.path) || isSwaggerResponseHeader(context.path)) return [];
53
+
54
+ const type = getPrimaryType(targetVal.type);
55
+ if (type !== 'integer' && type !== 'number') return [];
56
+
57
+ const format = readFormat(targetVal);
58
+ if (format.blank) return [];
59
+
60
+ const valid = type === 'integer' ? VALID_INTEGER_FORMATS : VALID_NUMBER_FORMATS;
61
+ if (format.present && valid.has(format.value)) return [];
62
+
63
+ return [{ message: context.rule.message, path: [...context.path, 'type'] }];
64
+ };
@@ -1,3 +1,28 @@
1
+ const NON_SCHEMA_CONTAINERS = new Set(['example', 'examples', 'default', 'enum']);
2
+
3
+ function isInsideNonSchemaValue(path) {
4
+ for (let i = 0; i < path.length; i += 1) {
5
+ const segment = path[i];
6
+ if (typeof segment !== 'string' || !NON_SCHEMA_CONTAINERS.has(segment)) continue;
7
+ if (i > 0 && path[i - 1] === 'properties') continue;
8
+ return true;
9
+ }
10
+ return false;
11
+ }
12
+
13
+ function isResponseJsonSchema(path) {
14
+ const n = path.length;
15
+ if (path[n - 1] !== 'schema') return false;
16
+ if (n >= 5 && path[n - 2] === 'application/json' && path[n - 3] === 'content' && path[n - 5] === 'responses') {
17
+ return true;
18
+ }
19
+ return n >= 3 && path[n - 3] === 'responses';
20
+ }
21
+
22
+ function isObjectType(type) {
23
+ return type === 'object' || (Array.isArray(type) && type.indexOf('object') > -1);
24
+ }
25
+
1
26
  /**
2
27
  * @param {object} given
3
28
  * @param {object} options
@@ -6,10 +31,10 @@
6
31
  module.exports = (given, options, context) => {
7
32
  const results = [];
8
33
 
9
- if (!given) return results;
10
- const t = given.type;
11
- const isObjectType = t === 'object' || (Array.isArray(t) && t.indexOf('object') > -1);
12
- if (!isObjectType) return results;
34
+ if (!given || typeof given !== 'object') return results;
35
+ if (isInsideNonSchemaValue(context.path)) return results;
36
+
37
+ if (!isObjectType(given.type) && !isResponseJsonSchema(context.path)) return results;
13
38
 
14
39
  const { required, properties } = given;
15
40
  if (!Array.isArray(required)) return results;
@@ -21,7 +46,7 @@ module.exports = (given, options, context) => {
21
46
  if (!propertyNames.has(field)) {
22
47
  results.push({
23
48
  message: `${ruleCode}: This value does not exist, '${field}' must be defined in the schema properties.`,
24
- path: [...context.path, 'required', index]
49
+ path: [...context.path, 'required', index],
25
50
  });
26
51
  }
27
52
  });
@@ -13,7 +13,24 @@ function isValidPattern(pattern) {
13
13
  }
14
14
  }
15
15
 
16
+ const NON_SCHEMA_CONTAINERS = new Set(['example', 'examples', 'default', 'enum']);
17
+
18
+ function isNotASchemaNode(path) {
19
+ for (let i = 0; i < path.length; i += 1) {
20
+ const segment = path[i];
21
+ if (typeof segment !== 'string' || !NON_SCHEMA_CONTAINERS.has(segment)) continue;
22
+ if (i > 0 && path[i - 1] === 'properties') continue;
23
+ return true;
24
+ }
25
+ const last = path.length - 2;
26
+ return last >= 0 && path[last] === 'headers' && (last === 0 || path[last - 1] !== 'properties');
27
+ }
28
+
16
29
  module.exports = (targetVal, _options, context) => {
30
+ if (isNotASchemaNode(context.path)) {
31
+ return [];
32
+ }
33
+
17
34
  const typePath = [...context.path, 'type'];
18
35
 
19
36
  const type = targetVal.type;
@@ -0,0 +1,318 @@
1
+ const DEFAULT_RESPONSE_SCHEMA = '{"type":"object","properties":{"status":{"type":"object","properties":{"code":{"type":"integer"},"description":{"type":"string"},"internal_code":{"type":"string"},"errors":{"type":"array","nullable":true,"items":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"string"}}}}},"required":["code"]},"payload":{"type":"any"}},"required":["status","payload"]}';
2
+ const DEFAULT_PATH_EXCLUSIONS = '/status';
3
+
4
+ const TYPE_ANY = '*';
5
+ const OPERATIONS = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']);
6
+
7
+ const MESSAGES = {
8
+ propertyMissing: (name) => `'${name}' property is missing`,
9
+ propertyWrongType: (name, type) => `'${name}' must be of type ${type}`,
10
+ propertyItemsMissing: (name) => `'${name}' items are missing`,
11
+ propertyItemsWrongType: (name, type) => `'${name}' items must be of type ${type}`,
12
+ requiredProperties: (fields) => `The following fields must be required: ${fields}`,
13
+ requiredOneProperty: () => 'At least one property must be defined',
14
+ };
15
+
16
+ function isType(type, name) {
17
+ if (name === TYPE_ANY) return true;
18
+ if (type === undefined || type === null) return false;
19
+ if (Array.isArray(type)) return type.some((element) => String(element) === name);
20
+ return String(type) === name;
21
+ }
22
+
23
+ function typeOf(node) {
24
+ return node && typeof node === 'object' ? node.type : undefined;
25
+ }
26
+
27
+ function getAllProperties(schema, basePath) {
28
+ const properties = new Map();
29
+ if (!schema || typeof schema !== 'object') return properties;
30
+
31
+ if (schema.properties && typeof schema.properties === 'object') {
32
+ Object.keys(schema.properties).forEach((name) => {
33
+ properties.set(name, {
34
+ value: schema.properties[name],
35
+ path: [...basePath, 'properties', name],
36
+ });
37
+ });
38
+ }
39
+
40
+ if (Array.isArray(schema.allOf)) {
41
+ schema.allOf.forEach((member, index) => {
42
+ getAllProperties(member, [...basePath, 'allOf', index]).forEach((entry, name) => {
43
+ properties.set(name, entry);
44
+ });
45
+ });
46
+ }
47
+
48
+ return properties;
49
+ }
50
+
51
+ function matchesTypeViaAllOf(node, expectedType) {
52
+ if (!node || !Array.isArray(node.allOf)) return false;
53
+ return node.allOf.some((member) => member && typeof member === 'object' && isType(member.type, expectedType));
54
+ }
55
+
56
+ function declaredTypeOf(declared) {
57
+ let type = declared && typeof declared === 'object' && typeof declared.type === 'string'
58
+ ? declared.type.trim()
59
+ : '';
60
+ if (type === '' || type === 'any') type = TYPE_ANY;
61
+ if (type === TYPE_ANY && declared && typeof declared === 'object' && declared.properties) type = 'object';
62
+ return type;
63
+ }
64
+
65
+ function createValidator(config, ruleCode) {
66
+ const results = [];
67
+
68
+ const push = (path, text) => results.push({ message: `${ruleCode}: ${text}`, path });
69
+
70
+ function validateProperty(properties, propertyName, propertyType, parentPath) {
71
+ const entry = properties.get(propertyName);
72
+ if (!entry) {
73
+ push(parentPath, MESSAGES.propertyMissing(propertyName));
74
+ return null;
75
+ }
76
+ const { value, path } = entry;
77
+ if (isType(typeOf(value), propertyType)) return entry;
78
+ if (matchesTypeViaAllOf(value, propertyType)) return entry;
79
+
80
+ const hasType = typeOf(value) !== undefined;
81
+ push(hasType ? [...path, 'type'] : path, MESSAGES.propertyWrongType(propertyName, propertyType));
82
+ return null;
83
+ }
84
+
85
+ function validateRequiredProperties(schema, schemaPath, requiredValues) {
86
+ const declared = schema && typeof schema === 'object' ? schema.required : undefined;
87
+ const present = new Set(Array.isArray(declared) ? declared.map(String) : []);
88
+ if (requiredValues.every((name) => present.has(name))) return;
89
+ push(
90
+ Array.isArray(declared) ? [...schemaPath, 'required'] : schemaPath,
91
+ MESSAGES.requiredProperties(requiredValues.join(', ')),
92
+ );
93
+ }
94
+
95
+ function validateItems(entry, propertyName, itemsType) {
96
+ const { value, path } = entry;
97
+ if (!isType(typeOf(value), 'array')) return null;
98
+
99
+ const items = value.items;
100
+ if (items === undefined || items === null) {
101
+ push(path, MESSAGES.propertyItemsMissing(propertyName));
102
+ return null;
103
+ }
104
+ const itemsPath = [...path, 'items'];
105
+ if (!isType(typeOf(items), itemsType)) {
106
+ push(
107
+ typeOf(items) === undefined ? itemsPath : [...itemsPath, 'type'],
108
+ MESSAGES.propertyItemsWrongType(propertyName, itemsType),
109
+ );
110
+ return null;
111
+ }
112
+ return { value: items, path: itemsPath };
113
+ }
114
+
115
+ function validateObject(declaredSchema, entry) {
116
+ const declaredProperties = declaredSchema && typeof declaredSchema === 'object'
117
+ ? declaredSchema.properties
118
+ : null;
119
+ if (declaredProperties && typeof declaredProperties === 'object') {
120
+ Object.keys(declaredProperties).sort().forEach((child) => {
121
+ validateProperties(child, declaredProperties[child], entry);
122
+ });
123
+ }
124
+
125
+ const declaredRequired = declaredSchema && typeof declaredSchema === 'object'
126
+ ? declaredSchema.required
127
+ : null;
128
+ if (Array.isArray(declaredRequired) && declaredRequired.length > 0) {
129
+ const required = [...new Set(declaredRequired.map(String))].sort();
130
+ validateRequiredProperties(entry.value, entry.path, required);
131
+ }
132
+ }
133
+
134
+ function validateArray(declaredSchema, entry, propertyName) {
135
+ const declaredItems = declaredSchema && typeof declaredSchema === 'object' ? declaredSchema.items : null;
136
+ const itemsType = declaredTypeOf(declaredItems);
137
+
138
+ if (itemsType === 'object') {
139
+ const items = validateItems(entry, propertyName, itemsType);
140
+ if (items) validateObject(declaredItems, items);
141
+ return;
142
+ }
143
+ if (itemsType === 'array') {
144
+ const items = validateItems(entry, propertyName, itemsType);
145
+ if (items) validateArray(declaredItems, items, propertyName);
146
+ return;
147
+ }
148
+ validateItems(entry, propertyName, itemsType);
149
+ }
150
+
151
+ function validateProperties(propertyName, declaredSchema, containerEntry) {
152
+ const propertyMap = getAllProperties(containerEntry.value, containerEntry.path);
153
+ const schemaType = declaredTypeOf(declaredSchema);
154
+
155
+ if (schemaType === 'object') {
156
+ const entry = validateProperty(propertyMap, propertyName, 'object', containerEntry.path);
157
+ if (entry) validateObject(declaredSchema, entry);
158
+ return;
159
+ }
160
+ if (schemaType === 'array') {
161
+ const entry = validateProperty(propertyMap, propertyName, 'array', containerEntry.path);
162
+ if (entry) validateArray(declaredSchema, entry, propertyName);
163
+ return;
164
+ }
165
+ validateProperty(propertyMap, propertyName, schemaType, containerEntry.path);
166
+ }
167
+
168
+ function validateDataProperty(name, declaredSchema, properties, parentEntry) {
169
+ let type = declaredSchema && typeof declaredSchema === 'object' && typeof declaredSchema.type === 'string'
170
+ ? declaredSchema.type
171
+ : TYPE_ANY;
172
+ if (type === 'any') type = TYPE_ANY;
173
+
174
+ const entry = validateProperty(properties, name, type, parentEntry.path);
175
+ if (!entry) return;
176
+
177
+ const parentIsArray = isType(typeOf(parentEntry.value), 'array');
178
+ if (getAllProperties(entry.value, entry.path).size === 0 && !parentIsArray) {
179
+ push(entry.path, MESSAGES.requiredOneProperty());
180
+ }
181
+ }
182
+
183
+ function validateRootProperties(requiredNames, properties, parentEntry) {
184
+ if (!Array.isArray(requiredNames) || requiredNames.length === 0) return;
185
+ requiredNames.map(String).forEach((name) => {
186
+ const declaredSchema = config.properties && Object.prototype.hasOwnProperty.call(config.properties, name)
187
+ ? config.properties[name]
188
+ : null;
189
+ if (name === config.dataProperty) {
190
+ validateDataProperty(name, declaredSchema, properties, parentEntry);
191
+ } else {
192
+ validateProperties(name, declaredSchema, parentEntry);
193
+ }
194
+ });
195
+ }
196
+
197
+ function resolveRootNode(properties, schemaEntry) {
198
+ let rootProperty = config.rootProperty;
199
+ if (rootProperty === TYPE_ANY) {
200
+ const first = properties.keys().next();
201
+ if (first.done) return null;
202
+ rootProperty = first.value;
203
+ }
204
+
205
+ const rootEntry = validateProperty(properties, rootProperty, 'object', schemaEntry.path);
206
+ if (rootEntry && getAllProperties(rootEntry.value, rootEntry.path).size === 0) {
207
+ push(rootEntry.path, MESSAGES.requiredOneProperty());
208
+ }
209
+ return rootEntry;
210
+ }
211
+
212
+ function visitSchemaNode(schemaEntry, statusCode) {
213
+ let successCode = false;
214
+ let code = 0;
215
+ if (String(statusCode).toLowerCase() !== 'default') {
216
+ code = Number.parseInt(String(statusCode), 10);
217
+ if (Number.isNaN(code)) return;
218
+ successCode = code >= 200 && code < 300 && code !== 204;
219
+ }
220
+ if (code === 204) return;
221
+
222
+ let entry = schemaEntry;
223
+ let properties = getAllProperties(entry.value, entry.path);
224
+
225
+ if (config.rootProperty !== null) {
226
+ const rootEntry = resolveRootNode(properties, entry);
227
+ if (!rootEntry) return;
228
+ entry = rootEntry;
229
+ properties = getAllProperties(rootEntry.value, rootEntry.path);
230
+ }
231
+
232
+ validateRootProperties(successCode ? config.requiredOnSuccess : config.requiredOnError, properties, entry);
233
+ validateRootProperties(config.requiredAlways, properties, entry);
234
+ }
235
+
236
+ return { results, visitSchemaNode };
237
+ }
238
+
239
+ function parseConfig(options) {
240
+ const raw = options && options['response-schema'] !== undefined && options['response-schema'] !== null
241
+ ? options['response-schema']
242
+ : DEFAULT_RESPONSE_SCHEMA;
243
+ const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
244
+ return {
245
+ requiredOnSuccess: Array.isArray(parsed.requiredOnSuccess) ? parsed.requiredOnSuccess : null,
246
+ requiredOnError: Array.isArray(parsed.requiredOnError) ? parsed.requiredOnError : null,
247
+ requiredAlways: Array.isArray(parsed.requiredAlways) ? parsed.requiredAlways : null,
248
+ properties: parsed.properties && typeof parsed.properties === 'object' ? parsed.properties : null,
249
+ dataProperty: typeof parsed.dataProperty === 'string' ? parsed.dataProperty : null,
250
+ rootProperty: typeof parsed.rootProperty === 'string' ? parsed.rootProperty : null,
251
+ };
252
+ }
253
+
254
+ /**
255
+ * @param {object} targetVal the Paths Object
256
+ * @param {object} options
257
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
258
+ */
259
+ module.exports = (targetVal, options, context) => {
260
+ if (!targetVal || typeof targetVal !== 'object') return [];
261
+
262
+ const ruleCode = context.rule.name.split(':').pop();
263
+
264
+ let config;
265
+ try {
266
+ config = parseConfig(options);
267
+ } catch (err) {
268
+ return [{ message: `${ruleCode}: Error parsing Standard Response Schemas`, path: context.path }];
269
+ }
270
+
271
+ const rawExclusions = options && options['path-exclusions'] !== undefined && options['path-exclusions'] !== null
272
+ ? String(options['path-exclusions'])
273
+ : DEFAULT_PATH_EXCLUSIONS;
274
+ const exclusions = new Set(rawExclusions.split(',').map((item) => item.trim()));
275
+
276
+ const validator = createValidator(config, ruleCode);
277
+
278
+ Object.keys(targetVal).forEach((pathName) => {
279
+ if (exclusions.has(pathName)) return;
280
+ const pathItem = targetVal[pathName];
281
+ if (!pathItem || typeof pathItem !== 'object') return;
282
+
283
+ Object.keys(pathItem).forEach((operationName) => {
284
+ if (!OPERATIONS.has(operationName.toLowerCase())) return;
285
+ const operation = pathItem[operationName];
286
+ if (!operation || typeof operation !== 'object') return;
287
+ const { responses } = operation;
288
+ if (!responses || typeof responses !== 'object') return;
289
+
290
+ Object.keys(responses).forEach((statusCode) => {
291
+ const response = responses[statusCode];
292
+ if (!response || typeof response !== 'object') return;
293
+ const responsePath = [...context.path, pathName, operationName, 'responses', statusCode];
294
+
295
+ if (response.content && typeof response.content === 'object') {
296
+ Object.keys(response.content).forEach((mediaType) => {
297
+ if (!mediaType.toLowerCase().includes('json')) return;
298
+ const mediaTypeNode = response.content[mediaType];
299
+ if (!mediaTypeNode || typeof mediaTypeNode !== 'object') return;
300
+ const { schema } = mediaTypeNode;
301
+ if (schema === undefined || schema === null) return;
302
+ validator.visitSchemaNode(
303
+ { value: schema, path: [...responsePath, 'content', mediaType, 'schema'] },
304
+ statusCode,
305
+ );
306
+ });
307
+ return;
308
+ }
309
+
310
+ const { schema } = response;
311
+ if (schema === undefined || schema === null) return;
312
+ validator.visitSchemaNode({ value: schema, path: [...responsePath, 'schema'] }, statusCode);
313
+ });
314
+ });
315
+ });
316
+
317
+ return validator.results;
318
+ };
@@ -0,0 +1,35 @@
1
+ const DEFAULT_INTEGRITY = 'minLength,maxLength,pattern,enum';
2
+
3
+ function isStringType(type) {
4
+ return type === 'string' || (Array.isArray(type) && type.indexOf('string') > -1);
5
+ }
6
+
7
+ /**
8
+ * @param {object} targetVal a Parameter Object
9
+ * @param {object} options
10
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
11
+ */
12
+ module.exports = (targetVal, options, context) => {
13
+ if (!targetVal || typeof targetVal !== 'object') return [];
14
+
15
+ const hasOwnType = Object.prototype.hasOwnProperty.call(targetVal, 'type');
16
+ const isSwaggerBody = targetVal.in === 'body';
17
+ const container = hasOwnType || isSwaggerBody ? targetVal : targetVal.schema;
18
+ const containerPath = hasOwnType || isSwaggerBody ? context.path : [...context.path, 'schema'];
19
+ if (!container || typeof container !== 'object') return [];
20
+
21
+ if (!isStringType(container.type)) return [];
22
+
23
+ const raw = options && options.parameter_integrity !== undefined && options.parameter_integrity !== null
24
+ ? String(options.parameter_integrity)
25
+ : DEFAULT_INTEGRITY;
26
+ const checks = raw.split(',').map((key) => key.trim());
27
+
28
+ const satisfied = checks.some((key) => {
29
+ if (!Object.prototype.hasOwnProperty.call(container, key)) return false;
30
+ return container[key] !== undefined && container[key] !== null;
31
+ });
32
+ if (satisfied) return [];
33
+
34
+ return [{ message: context.rule.message, path: [...containerPath, 'type'] }];
35
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "api-quality-spectral-ruleset",
3
- "version": "1.5.0-beta.2",
3
+ "version": "1.5.0-beta.3",
4
4
  "description": "Spectral ruleset by API Quality",
5
5
  "main": "apq-spectral.yaml",
6
6
  "files": [