api-quality-spectral-ruleset 1.5.0-beta.1 → 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 +55 -106
- package/functions/apq-example-schema-types.js +116 -0
- package/functions/apq-numeric-invalid-format.js +64 -0
- package/functions/apq-numeric-missing-format.js +58 -0
- package/functions/apq-numeric-well-defined-format.js +64 -0
- package/functions/apq-path-pattern.js +14 -0
- package/functions/apq-query-params-optional.js +182 -0
- package/functions/apq-required-fields-exist.js +30 -5
- package/functions/apq-schema-format.js +17 -0
- package/functions/apq-standard-response-schema.js +318 -0
- package/functions/apq-string-parameter-integrity.js +35 -0
- package/package.json +1 -1
package/apq-spectral.yaml
CHANGED
|
@@ -30,8 +30,16 @@ functions:
|
|
|
30
30
|
- apq-status-endpoint-check
|
|
31
31
|
- apq-validate-structure
|
|
32
32
|
- apq-numeric-parameter-integrity
|
|
33
|
+
- apq-path-pattern
|
|
34
|
+
- apq-query-params-optional
|
|
33
35
|
- apq-wso2-scopes-valid
|
|
34
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
|
|
35
43
|
extends:
|
|
36
44
|
- spectral:asyncapi
|
|
37
45
|
- spectral:oas
|
|
@@ -194,29 +202,10 @@ rules:
|
|
|
194
202
|
message: "OAR016: Numeric types must use a valid format for their type."
|
|
195
203
|
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR016.md"
|
|
196
204
|
severity: error
|
|
197
|
-
|
|
205
|
+
resolved: false
|
|
206
|
+
given: "$..[?(@ && @.type)]"
|
|
198
207
|
then:
|
|
199
|
-
function:
|
|
200
|
-
functionOptions:
|
|
201
|
-
schema:
|
|
202
|
-
required: [format]
|
|
203
|
-
allOf:
|
|
204
|
-
- if:
|
|
205
|
-
properties:
|
|
206
|
-
type:
|
|
207
|
-
const: integer
|
|
208
|
-
then:
|
|
209
|
-
properties:
|
|
210
|
-
format:
|
|
211
|
-
enum: [int32, int64, uint32, uint64]
|
|
212
|
-
- if:
|
|
213
|
-
properties:
|
|
214
|
-
type:
|
|
215
|
-
const: number
|
|
216
|
-
then:
|
|
217
|
-
properties:
|
|
218
|
-
format:
|
|
219
|
-
enum: [float, double, decimal]
|
|
208
|
+
function: apq-numeric-invalid-format
|
|
220
209
|
apiq:OAR017:
|
|
221
210
|
description: "Resource path should alternate static and parametrized parts."
|
|
222
211
|
message: "OAR017: Resource path should alternate static and parametrized parts"
|
|
@@ -374,38 +363,14 @@ rules:
|
|
|
374
363
|
function: apq-has-filter-query-param
|
|
375
364
|
apiq:OAR029:
|
|
376
365
|
description: "A response not compliant with the standard may cause application issues."
|
|
377
|
-
message: "
|
|
366
|
+
message: "{{error}}"
|
|
378
367
|
severity: error
|
|
379
|
-
given: "$.
|
|
368
|
+
given: "$.paths"
|
|
380
369
|
then:
|
|
381
|
-
function: schema
|
|
370
|
+
function: apq-standard-response-schema
|
|
382
371
|
functionOptions:
|
|
383
|
-
schema:
|
|
384
|
-
|
|
385
|
-
properties:
|
|
386
|
-
properties:
|
|
387
|
-
type: object
|
|
388
|
-
properties:
|
|
389
|
-
error:
|
|
390
|
-
type: object
|
|
391
|
-
properties:
|
|
392
|
-
properties:
|
|
393
|
-
type: object
|
|
394
|
-
properties:
|
|
395
|
-
code:
|
|
396
|
-
type: string
|
|
397
|
-
message:
|
|
398
|
-
type: string
|
|
399
|
-
details:
|
|
400
|
-
type: array
|
|
401
|
-
httpStatus:
|
|
402
|
-
type: integer
|
|
403
|
-
required:
|
|
404
|
-
- code
|
|
405
|
-
- message
|
|
406
|
-
- httpStatus
|
|
407
|
-
required:
|
|
408
|
-
- 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"
|
|
409
374
|
apiq:OAR030:
|
|
410
375
|
description: "The configured status endpoint must be declared with the configured HTTP method."
|
|
411
376
|
message: "OAR030: The required status endpoint is not declared or does not have the required method."
|
|
@@ -686,10 +651,10 @@ rules:
|
|
|
686
651
|
message: "OAR052: Numeric types requires a format"
|
|
687
652
|
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR052.md"
|
|
688
653
|
severity: warn
|
|
689
|
-
|
|
654
|
+
resolved: false
|
|
655
|
+
given: "$..[?(@ && @.type)]"
|
|
690
656
|
then:
|
|
691
|
-
|
|
692
|
-
function: truthy
|
|
657
|
+
function: apq-numeric-missing-format
|
|
693
658
|
apiq:OAR053:
|
|
694
659
|
description: "Response headers for API observability and tracing must be defined (excluding 204 responses and health endpoints)."
|
|
695
660
|
message: "OAR053: Response must include mandatory headers and exclude forbidden headers."
|
|
@@ -716,12 +681,18 @@ rules:
|
|
|
716
681
|
match: ^(http(s)?:\/\/.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.apiquality.io\b([-a-zA-Z0-9@:%_\+.~#?&\/=]*)$
|
|
717
682
|
apiq:OAR060:
|
|
718
683
|
description: "All query parameters must be defined as optional."
|
|
719
|
-
message: "
|
|
684
|
+
message: "{{error}}"
|
|
720
685
|
severity: error
|
|
721
|
-
given:
|
|
686
|
+
given:
|
|
687
|
+
- "$.paths[*][get,put,post,delete,options,head,patch,trace].parameters[?(@.in == 'query')]"
|
|
688
|
+
- "$.paths[*].parameters[?(@.in == 'query')]"
|
|
689
|
+
- "$.components.parameters[?(@.in == 'query')]"
|
|
690
|
+
- "$.parameters[?(@.in == 'query')]"
|
|
722
691
|
then:
|
|
723
692
|
field: "required"
|
|
724
|
-
function:
|
|
693
|
+
function: apq-query-params-optional
|
|
694
|
+
functionOptions:
|
|
695
|
+
path-exclusions: "/status"
|
|
725
696
|
apiq:OAR061:
|
|
726
697
|
description: "Ensure get have mandatory response codes"
|
|
727
698
|
message: "OAR061: Ensure get have the mandatory response codes"
|
|
@@ -881,47 +852,25 @@ rules:
|
|
|
881
852
|
description: "String parameters should have minLength, maxLength, pattern (regular expression), or enum restriction."
|
|
882
853
|
message: "OAR075: String parameters should have minLength, maxLength, pattern, or enum restriction."
|
|
883
854
|
severity: error
|
|
884
|
-
|
|
855
|
+
resolved: false
|
|
856
|
+
given:
|
|
857
|
+
- "$.paths[*][*].parameters[*]"
|
|
858
|
+
- "$.paths[*].parameters[*]"
|
|
859
|
+
- "$.components.parameters[*]"
|
|
860
|
+
- "$.parameters[*]"
|
|
885
861
|
then:
|
|
886
|
-
function:
|
|
862
|
+
function: apq-string-parameter-integrity
|
|
887
863
|
functionOptions:
|
|
888
|
-
|
|
889
|
-
type: object
|
|
890
|
-
properties:
|
|
891
|
-
schema:
|
|
892
|
-
anyOf:
|
|
893
|
-
- required: ["minLength"]
|
|
894
|
-
- required: ["maxLength"]
|
|
895
|
-
- required: ["pattern"]
|
|
896
|
-
- required: ["enum"]
|
|
864
|
+
parameter_integrity: "minLength,maxLength,pattern,enum"
|
|
897
865
|
apiq:OAR076:
|
|
898
866
|
description: "Schema should use well-defined type and format."
|
|
899
867
|
message: "OAR076: Schema should use well-defined type and format."
|
|
900
868
|
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR076.md"
|
|
901
869
|
severity: error
|
|
902
|
-
|
|
870
|
+
resolved: false
|
|
871
|
+
given: "$..[?(@ && @.type)]"
|
|
903
872
|
then:
|
|
904
|
-
function:
|
|
905
|
-
functionOptions:
|
|
906
|
-
schema:
|
|
907
|
-
required: [format]
|
|
908
|
-
allOf:
|
|
909
|
-
- if:
|
|
910
|
-
properties:
|
|
911
|
-
type:
|
|
912
|
-
const: integer
|
|
913
|
-
then:
|
|
914
|
-
properties:
|
|
915
|
-
format:
|
|
916
|
-
enum: [int32, int64]
|
|
917
|
-
- if:
|
|
918
|
-
properties:
|
|
919
|
-
type:
|
|
920
|
-
const: number
|
|
921
|
-
then:
|
|
922
|
-
properties:
|
|
923
|
-
format:
|
|
924
|
-
enum: [float, double]
|
|
873
|
+
function: apq-numeric-well-defined-format
|
|
925
874
|
apiq:OAR077:
|
|
926
875
|
description: "All parameters in query must be snake_case."
|
|
927
876
|
message: "OAR077: All parameters in query must be snake_case."
|
|
@@ -1191,19 +1140,10 @@ rules:
|
|
|
1191
1140
|
description: "The schemas should match the provided examples."
|
|
1192
1141
|
message: "OAR108: Schema does not match the provided example."
|
|
1193
1142
|
severity: "error"
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
- "$.paths['/item'].get.responses['200'].examples['application/json']"
|
|
1143
|
+
resolved: false
|
|
1144
|
+
given: "$.paths[*][*].responses[*]"
|
|
1197
1145
|
then:
|
|
1198
|
-
|
|
1199
|
-
functionOptions:
|
|
1200
|
-
schema:
|
|
1201
|
-
type: "object"
|
|
1202
|
-
properties:
|
|
1203
|
-
id:
|
|
1204
|
-
type: "integer"
|
|
1205
|
-
nombre:
|
|
1206
|
-
type: "string"
|
|
1146
|
+
function: apq-example-schema-types
|
|
1207
1147
|
apiq:OAR109:
|
|
1208
1148
|
description: "Use default response instead of directly specifying 5XX codes."
|
|
1209
1149
|
message: "OAR109: Use default response instead of specifying 5XX codes directly."
|
|
@@ -1257,8 +1197,17 @@ rules:
|
|
|
1257
1197
|
message: "OAR115: All fields in the required array must be defined in schema properties."
|
|
1258
1198
|
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR115.md"
|
|
1259
1199
|
severity: warn
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
- "$.definitions[*]"
|
|
1200
|
+
resolved: false
|
|
1201
|
+
given: "$..[?(@ && @.required)]"
|
|
1263
1202
|
then:
|
|
1264
|
-
function: apq-required-fields-exist
|
|
1203
|
+
function: apq-required-fields-exist
|
|
1204
|
+
apiq:OAR116:
|
|
1205
|
+
description: "Every API path must match the configured regular expression."
|
|
1206
|
+
message: "{{error}}"
|
|
1207
|
+
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR116.md"
|
|
1208
|
+
severity: error
|
|
1209
|
+
given: "$.paths.*~"
|
|
1210
|
+
then:
|
|
1211
|
+
function: apq-path-pattern
|
|
1212
|
+
functionOptions:
|
|
1213
|
+
pattern: "^/"
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module.exports = (targetVal, options = {}) => {
|
|
2
|
+
const patternStr = (options && options.pattern) || '^/';
|
|
3
|
+
|
|
4
|
+
if (typeof targetVal !== 'string') {
|
|
5
|
+
return [];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const regex = new RegExp(patternStr);
|
|
9
|
+
if (!regex.test(targetVal)) {
|
|
10
|
+
return [{ message: `OAR116: Path does not match the required pattern: ${patternStr}` }];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return [];
|
|
14
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
const HTTP_VERBS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
|
|
2
|
+
|
|
3
|
+
// Guards against a cycle in a `$ref` chain (A -> B -> A).
|
|
4
|
+
const MAX_REF_DEPTH = 10;
|
|
5
|
+
|
|
6
|
+
// The `$ref` usage map depends only on the source document, so cache it per document instead of
|
|
7
|
+
// rebuilding it on every matched parameter.
|
|
8
|
+
const usageCache = new WeakMap();
|
|
9
|
+
|
|
10
|
+
const issue = () => [{ message: 'OAR060: All query parameter must be optional (required: false).' }];
|
|
11
|
+
|
|
12
|
+
function parseExclusions(value) {
|
|
13
|
+
return new Set(
|
|
14
|
+
String(value === undefined || value === null ? '' : value)
|
|
15
|
+
.split(',')
|
|
16
|
+
.map((p) => p.trim())
|
|
17
|
+
.filter(Boolean),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Split a local JSON pointer (`#/components/parameters/Foo`) into its decoded segments.
|
|
23
|
+
* Returns null for external refs and for anything that is not a document-local pointer.
|
|
24
|
+
*/
|
|
25
|
+
function refToSegments(ref) {
|
|
26
|
+
if (typeof ref !== 'string' || !ref.startsWith('#/')) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return ref.slice(2).split('/').map((raw) => {
|
|
30
|
+
let segment = raw;
|
|
31
|
+
try {
|
|
32
|
+
segment = decodeURIComponent(raw);
|
|
33
|
+
} catch (e) {
|
|
34
|
+
segment = raw;
|
|
35
|
+
}
|
|
36
|
+
return segment.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getAt(doc, segments) {
|
|
41
|
+
return segments.reduce(
|
|
42
|
+
(node, segment) => (node !== null && typeof node === 'object' ? node[segment] : undefined),
|
|
43
|
+
doc,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const keyOf = (segments) => JSON.stringify(segments);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The two containers the rule's `given` scans for shared parameter definitions:
|
|
51
|
+
* `components.parameters.<name>` (OpenAPI 3) and `parameters.<name>` (OpenAPI 2).
|
|
52
|
+
*/
|
|
53
|
+
function definitionSegments(path) {
|
|
54
|
+
if (path[0] === 'components' && path[1] === 'parameters' && path.length >= 3) {
|
|
55
|
+
return path.slice(0, 3);
|
|
56
|
+
}
|
|
57
|
+
if (path[0] === 'parameters' && path.length >= 2) {
|
|
58
|
+
return path.slice(0, 2);
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const isSharedDefinitionRef = (segments) => segments !== null
|
|
64
|
+
&& ((segments.length === 3 && segments[0] === 'components' && segments[1] === 'parameters')
|
|
65
|
+
|| (segments.length === 2 && segments[0] === 'parameters'));
|
|
66
|
+
|
|
67
|
+
/** Map of shared-definition pointer -> set of API paths that reference it through a `$ref`. */
|
|
68
|
+
function buildRefUsages(doc) {
|
|
69
|
+
const usages = new Map();
|
|
70
|
+
const paths = doc && typeof doc === 'object' ? doc.paths : undefined;
|
|
71
|
+
if (!paths || typeof paths !== 'object') {
|
|
72
|
+
return usages;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
Object.keys(paths).forEach((apiPath) => {
|
|
76
|
+
const pathItem = paths[apiPath];
|
|
77
|
+
if (!pathItem || typeof pathItem !== 'object') {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const parameterLists = [pathItem.parameters];
|
|
82
|
+
HTTP_VERBS.forEach((verb) => {
|
|
83
|
+
const operation = pathItem[verb];
|
|
84
|
+
if (operation && typeof operation === 'object') {
|
|
85
|
+
parameterLists.push(operation.parameters);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
parameterLists.forEach((list) => {
|
|
90
|
+
if (!Array.isArray(list)) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
list.forEach((parameter) => {
|
|
94
|
+
let current = parameter;
|
|
95
|
+
for (let depth = 0; depth < MAX_REF_DEPTH; depth += 1) {
|
|
96
|
+
if (!current || typeof current !== 'object' || typeof current.$ref !== 'string') {
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
const segments = refToSegments(current.$ref);
|
|
100
|
+
if (segments === null) {
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
const key = keyOf(segments);
|
|
104
|
+
if (!usages.has(key)) {
|
|
105
|
+
usages.set(key, new Set());
|
|
106
|
+
}
|
|
107
|
+
usages.get(key).add(apiPath);
|
|
108
|
+
const next = getAt(doc, segments);
|
|
109
|
+
if (!next || next === current) {
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
current = next;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return usages;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function refUsagesFor(doc) {
|
|
122
|
+
if (!doc || typeof doc !== 'object') {
|
|
123
|
+
return new Map();
|
|
124
|
+
}
|
|
125
|
+
let cached = usageCache.get(doc);
|
|
126
|
+
if (cached === undefined) {
|
|
127
|
+
cached = buildRefUsages(doc);
|
|
128
|
+
usageCache.set(doc, cached);
|
|
129
|
+
}
|
|
130
|
+
return cached;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* True when the match landed on a `$ref` to a shared definition the rule also scans on its own —
|
|
135
|
+
* either a use site under `paths`, or one shared definition aliasing another.
|
|
136
|
+
*
|
|
137
|
+
* Spectral runs the rule over the resolved document, so it matches such a parameter once here and
|
|
138
|
+
* once at the definition it points to. Reporting is left to the definition-site match, which is
|
|
139
|
+
* also the only place Sonar reports it (its AST visits the definition, never the use sites).
|
|
140
|
+
*/
|
|
141
|
+
function isRefToSharedDefinition(source, path) {
|
|
142
|
+
const parameterPath = path[path.length - 1] === 'required' ? path.slice(0, -1) : path;
|
|
143
|
+
const node = getAt(source, parameterPath);
|
|
144
|
+
if (!node || typeof node !== 'object' || typeof node.$ref !== 'string') {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
return isSharedDefinitionRef(refToSegments(node.$ref));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = (targetVal, options = {}, context = {}) => {
|
|
151
|
+
const exclusions = parseExclusions((options || {})['path-exclusions']);
|
|
152
|
+
const path = (context && context.path) || [];
|
|
153
|
+
const source = context && context.document ? context.document.data : undefined;
|
|
154
|
+
|
|
155
|
+
if (isRefToSharedDefinition(source, path)) {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (path[0] === 'paths') {
|
|
160
|
+
const apiPath = path.find((p) => typeof p === 'string' && p.startsWith('/'));
|
|
161
|
+
if (apiPath && exclusions.has(apiPath)) {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
} else if (exclusions.size > 0) {
|
|
165
|
+
// A shared definition has no path of its own: the AST/JSONPath match lands on
|
|
166
|
+
// `components.parameters.<name>` (or `parameters.<name>` in OpenAPI 2). Exclude it only when
|
|
167
|
+
// every path that references it is excluded — an unreferenced definition stays in scope.
|
|
168
|
+
const definition = definitionSegments(path);
|
|
169
|
+
const usages = definition === null
|
|
170
|
+
? undefined
|
|
171
|
+
: refUsagesFor(source).get(keyOf(definition));
|
|
172
|
+
if (usages !== undefined && usages.size > 0 && [...usages].every((u) => exclusions.has(u))) {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (targetVal === true || targetVal === 'true') {
|
|
178
|
+
return issue();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return [];
|
|
182
|
+
};
|
|
@@ -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
|
-
|
|
11
|
-
|
|
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
|
+
};
|