api-quality-spectral-ruleset 1.6.0-beta.2 → 1.6.1
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
|
@@ -53,6 +53,7 @@ functions:
|
|
|
53
53
|
- apq-wso2-auth-type-required
|
|
54
54
|
- apq-response-content-required
|
|
55
55
|
- apq-tags-consistency
|
|
56
|
+
- apq-forbidden-title-pattern
|
|
56
57
|
rules:
|
|
57
58
|
apiq:OAR001:
|
|
58
59
|
description: "For security reasons and as a REST best practice, the HTTPS protocol is mandatory."
|
|
@@ -438,10 +439,10 @@ rules:
|
|
|
438
439
|
functionOptions:
|
|
439
440
|
notMatch: "^(Accept|Content-Type|Authorization)$"
|
|
440
441
|
apiq:OAR034:
|
|
441
|
-
description: "
|
|
442
|
-
message: "
|
|
442
|
+
description: "GET collection responses must include a paging block that complies with the standard paging schema (required start, limit and links with self/previous/next; optional numPages and total as integers)."
|
|
443
|
+
message: "{{error}}"
|
|
443
444
|
severity: error
|
|
444
|
-
given: "$.paths[*]
|
|
445
|
+
given: "$.paths[*].get.responses[*]"
|
|
445
446
|
then:
|
|
446
447
|
function: apq-paged-response-check
|
|
447
448
|
functionOptions:
|
|
@@ -1214,4 +1215,14 @@ rules:
|
|
|
1214
1215
|
then:
|
|
1215
1216
|
function: apq-path-pattern
|
|
1216
1217
|
functionOptions:
|
|
1217
|
-
pattern: "^/"
|
|
1218
|
+
pattern: "^/"
|
|
1219
|
+
apiq:OAR117:
|
|
1220
|
+
description: "The API title must not match the configured forbidden regular expression."
|
|
1221
|
+
message: "{{error}}"
|
|
1222
|
+
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR117.md"
|
|
1223
|
+
severity: error
|
|
1224
|
+
given: "$.info.title"
|
|
1225
|
+
then:
|
|
1226
|
+
function: apq-forbidden-title-pattern
|
|
1227
|
+
functionOptions:
|
|
1228
|
+
forbidden-pattern: "(?!)"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const INLINE_FLAGS_REGEX = /^\(\?([a-zA-Z]+)\)/;
|
|
2
|
+
const SUPPORTED_INLINE_FLAGS = 'imsu';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {string} pattern configured regular expression
|
|
6
|
+
* @returns {RegExp|null} the matcher, or null when the pattern is empty or not valid
|
|
7
|
+
*/
|
|
8
|
+
function buildRegex(pattern) {
|
|
9
|
+
let source = pattern;
|
|
10
|
+
let flags = '';
|
|
11
|
+
|
|
12
|
+
const inline = INLINE_FLAGS_REGEX.exec(source);
|
|
13
|
+
if (inline) {
|
|
14
|
+
const requested = inline[1].toLowerCase();
|
|
15
|
+
if ([...requested].every((flag) => SUPPORTED_INLINE_FLAGS.includes(flag))) {
|
|
16
|
+
flags = [...new Set(requested)].join('');
|
|
17
|
+
source = source.slice(inline[0].length);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (source === '') {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
return new RegExp(source, flags);
|
|
27
|
+
} catch (e) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {unknown} targetVal value of `info.title`
|
|
34
|
+
* @param {object} options
|
|
35
|
+
* @param {string} options['forbidden-pattern'] regular expression that raises an issue when it
|
|
36
|
+
* matches the title
|
|
37
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
38
|
+
* @returns {Array<{message: string, path: Array}>}
|
|
39
|
+
*/
|
|
40
|
+
module.exports = (targetVal, options, context) => {
|
|
41
|
+
if (typeof targetVal !== 'string') {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const patternStr = options && options['forbidden-pattern'];
|
|
46
|
+
if (typeof patternStr !== 'string' || patternStr === '') {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const regex = buildRegex(patternStr);
|
|
51
|
+
if (regex === null || !regex.test(targetVal)) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
message: `OAR117: Title '${targetVal}' matches the forbidden pattern: ${patternStr}`,
|
|
58
|
+
path: (context && context.path) || []
|
|
59
|
+
}
|
|
60
|
+
];
|
|
61
|
+
};
|
|
@@ -1,124 +1,255 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
const DEFAULT_PAGING_SCHEMA = {
|
|
2
|
+
type: 'object',
|
|
3
|
+
properties: {
|
|
4
|
+
numPages: { type: 'integer' },
|
|
5
|
+
total: { type: 'integer' },
|
|
6
|
+
start: { type: 'integer' },
|
|
7
|
+
limit: { type: 'integer' },
|
|
8
|
+
links: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
next: { type: 'object', properties: { href: { type: 'string' } } },
|
|
12
|
+
previous: { type: 'object', properties: { href: { type: 'string' } } },
|
|
13
|
+
last: { type: 'object', properties: { href: { type: 'string' } } },
|
|
14
|
+
self: { type: 'object', properties: { href: { type: 'string' } } },
|
|
15
|
+
first: { type: 'object', properties: { href: { type: 'string' } } },
|
|
16
|
+
},
|
|
17
|
+
required: ['self', 'previous', 'next'],
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
required: ['start', 'limit', 'links'],
|
|
21
|
+
pagingPropertyName: 'paging',
|
|
22
|
+
};
|
|
3
23
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
24
|
+
const DEFAULT_PAGING_PROPERTY = 'paging';
|
|
25
|
+
|
|
26
|
+
const COLLECTION_PROPERTY_NAMES = ['data', 'items', 'results', 'content', 'elements', 'records', 'entries', 'values', 'list', 'rows', 'payload'];
|
|
7
27
|
|
|
8
|
-
|
|
9
|
-
|
|
28
|
+
const SUCCESS_CODE_REGEX = /^2(\d\d|XX)$/i;
|
|
29
|
+
const NO_CONTENT_CODE = '204';
|
|
30
|
+
const DETAIL_PATH_REGEX = /\/\{[^}]+\}$/;
|
|
31
|
+
|
|
32
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
33
|
+
|
|
34
|
+
const parsePagingSchema = (raw) => {
|
|
35
|
+
if (isObject(raw)) return raw;
|
|
36
|
+
if (typeof raw !== 'string' || raw.trim() === '') return DEFAULT_PAGING_SCHEMA;
|
|
10
37
|
try {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
? JSON.parse(pagingSchemaStr)
|
|
14
|
-
: pagingSchemaStr;
|
|
38
|
+
const parsed = JSON.parse(raw);
|
|
39
|
+
return isObject(parsed) ? parsed : DEFAULT_PAGING_SCHEMA;
|
|
15
40
|
} catch (err) {
|
|
16
|
-
return
|
|
17
|
-
message: `${context.rule.name.split(':').pop()}: Invalid paging-schema configuration: ${err.message}`,
|
|
18
|
-
path: context.path,
|
|
19
|
-
}];
|
|
41
|
+
return DEFAULT_PAGING_SCHEMA;
|
|
20
42
|
}
|
|
43
|
+
};
|
|
21
44
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
45
|
+
const declaredTypes = (schema) => {
|
|
46
|
+
if (!isObject(schema)) return [];
|
|
47
|
+
if (typeof schema.type === 'string') return [schema.type];
|
|
48
|
+
if (Array.isArray(schema.type)) return schema.type.filter((t) => typeof t === 'string');
|
|
49
|
+
return [];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const isNullSchema = (schema) => {
|
|
53
|
+
const types = declaredTypes(schema);
|
|
54
|
+
return types.length > 0 && types.every((t) => t === 'null');
|
|
55
|
+
};
|
|
25
56
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
57
|
+
const unwrapNullable = (schema, schemaPath) => {
|
|
58
|
+
let current = schema;
|
|
59
|
+
let currentPath = schemaPath;
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
while (isObject(current) && !seen.has(current)) {
|
|
62
|
+
seen.add(current);
|
|
63
|
+
const key = ['oneOf', 'anyOf'].find((k) => Array.isArray(current[k]));
|
|
64
|
+
if (!key || current.properties || current.items || current.allOf) break;
|
|
65
|
+
const branches = current[key].map((branch, index) => ({ branch, index })).filter(({ branch }) => !isNullSchema(branch));
|
|
66
|
+
if (branches.length !== 1) break;
|
|
67
|
+
currentPath = [...currentPath, key, branches[0].index];
|
|
68
|
+
current = branches[0].branch;
|
|
29
69
|
}
|
|
70
|
+
return { schema: current, path: currentPath };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const buildView = (schema, schemaPath) => {
|
|
74
|
+
const root = unwrapNullable(schema, schemaPath);
|
|
75
|
+
const view = {
|
|
76
|
+
types: new Set(),
|
|
77
|
+
properties: new Map(),
|
|
78
|
+
required: new Set(),
|
|
79
|
+
items: null,
|
|
80
|
+
path: root.path,
|
|
81
|
+
rootSchema: root.schema,
|
|
82
|
+
propertiesPath: null,
|
|
83
|
+
requiredPath: null,
|
|
84
|
+
};
|
|
85
|
+
const visited = new Set();
|
|
86
|
+
|
|
87
|
+
const collect = (node, nodePath) => {
|
|
88
|
+
const unwrapped = unwrapNullable(node, nodePath);
|
|
89
|
+
const current = unwrapped.schema;
|
|
90
|
+
const currentPath = unwrapped.path;
|
|
91
|
+
if (!isObject(current) || visited.has(current)) return;
|
|
92
|
+
visited.add(current);
|
|
30
93
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
results.push({
|
|
40
|
-
message: `${context.rule.name.split(':').pop()}: Response must include '${pagingPropertyName}' property for pagination`,
|
|
41
|
-
path: context.path,
|
|
94
|
+
declaredTypes(current).filter((t) => t !== 'null').forEach((t) => view.types.add(t));
|
|
95
|
+
|
|
96
|
+
if (isObject(current.properties)) {
|
|
97
|
+
if (!view.propertiesPath) view.propertiesPath = [...currentPath, 'properties'];
|
|
98
|
+
Object.entries(current.properties).forEach(([name, propertySchema]) => {
|
|
99
|
+
if (!view.properties.has(name)) {
|
|
100
|
+
view.properties.set(name, { schema: propertySchema, path: [...currentPath, 'properties', name] });
|
|
101
|
+
}
|
|
42
102
|
});
|
|
43
103
|
}
|
|
44
|
-
|
|
104
|
+
|
|
105
|
+
if (Array.isArray(current.required)) {
|
|
106
|
+
if (!view.requiredPath) view.requiredPath = [...currentPath, 'required'];
|
|
107
|
+
current.required.filter((name) => typeof name === 'string').forEach((name) => view.required.add(name));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (isObject(current.items) && !view.items) {
|
|
111
|
+
view.items = { schema: current.items, path: [...currentPath, 'items'] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (Array.isArray(current.allOf)) {
|
|
115
|
+
current.allOf.forEach((member, index) => collect(member, [...currentPath, 'allOf', index]));
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
collect(root.schema, root.path);
|
|
120
|
+
|
|
121
|
+
if (view.types.size === 0) {
|
|
122
|
+
if (view.properties.size > 0) view.types.add('object');
|
|
123
|
+
else if (view.items) view.types.add('array');
|
|
45
124
|
}
|
|
125
|
+
return view;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const expectedType = (template) => {
|
|
129
|
+
const types = declaredTypes(template).filter((t) => t !== 'null' && t !== 'any');
|
|
130
|
+
if (types.length > 0) return types[0];
|
|
131
|
+
if (isObject(template.properties) || Array.isArray(template.required)) return 'object';
|
|
132
|
+
if (isObject(template.items)) return 'array';
|
|
133
|
+
return null;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const typeMatches = (expected, actualTypes) => {
|
|
137
|
+
if (!expected || actualTypes.size === 0) return true;
|
|
138
|
+
if (actualTypes.has(expected)) return true;
|
|
139
|
+
return expected === 'number' && actualTypes.has('integer');
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const isArrayView = (view) => view.types.has('array');
|
|
46
143
|
|
|
47
|
-
|
|
144
|
+
const typePath = (view) => (isObject(view.rootSchema) && view.rootSchema.type !== undefined ? [...view.path, 'type'] : view.path);
|
|
48
145
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
});
|
|
55
|
-
return results;
|
|
146
|
+
const findCollectionProperty = (view) => {
|
|
147
|
+
for (const [name, property] of view.properties) {
|
|
148
|
+
if (COLLECTION_PROPERTY_NAMES.includes(name.toLowerCase()) && isArrayView(buildView(property.schema, property.path))) {
|
|
149
|
+
return name;
|
|
150
|
+
}
|
|
56
151
|
}
|
|
152
|
+
return null;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const validateAgainstTemplate = (template, view, label, report) => {
|
|
156
|
+
if (!isObject(template)) return;
|
|
157
|
+
const type = expectedType(template);
|
|
57
158
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (!pagingObj.properties[requiredField]) {
|
|
62
|
-
missingFields.push(requiredField);
|
|
159
|
+
if (type === 'array') {
|
|
160
|
+
if (isObject(template.items) && view.items) {
|
|
161
|
+
validateAgainstTemplate(template.items, buildView(view.items.schema, view.items.path), `${label}[]`, report);
|
|
63
162
|
}
|
|
163
|
+
return;
|
|
64
164
|
}
|
|
165
|
+
if (type !== 'object') return;
|
|
166
|
+
|
|
167
|
+
const requiredNames = Array.isArray(template.required) ? template.required.filter((name) => typeof name === 'string') : [];
|
|
168
|
+
const missing = requiredNames.filter((name) => !view.properties.has(name));
|
|
169
|
+
const notMarked = requiredNames.filter((name) => view.properties.has(name) && !view.required.has(name));
|
|
65
170
|
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
});
|
|
171
|
+
if (missing.length > 0) {
|
|
172
|
+
report(`'${label}' must include required properties: ${missing.join(', ')}`, view.propertiesPath || view.path);
|
|
173
|
+
}
|
|
174
|
+
if (notMarked.length > 0) {
|
|
175
|
+
report(`'${label}' must list as required: ${notMarked.join(', ')}`, view.requiredPath || view.path);
|
|
71
176
|
}
|
|
72
177
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
178
|
+
if (!isObject(template.properties)) return;
|
|
179
|
+
Object.entries(template.properties).forEach(([name, childTemplate]) => {
|
|
180
|
+
const property = view.properties.get(name);
|
|
181
|
+
if (!property || !isObject(childTemplate)) return;
|
|
182
|
+
const childView = buildView(property.schema, property.path);
|
|
183
|
+
const childType = expectedType(childTemplate);
|
|
184
|
+
if (!typeMatches(childType, childView.types)) {
|
|
185
|
+
report(`'${label}.${name}' must be of type ${childType}`, typePath(childView));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
validateAgainstTemplate(childTemplate, childView, `${label}.${name}`, report);
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const responseSchemas = (response, responsePath, isOas2) => {
|
|
193
|
+
if (isOas2) {
|
|
194
|
+
return isObject(response.schema) ? [{ schema: response.schema, path: [...responsePath, 'schema'] }] : [];
|
|
195
|
+
}
|
|
196
|
+
if (!isObject(response.content)) return [];
|
|
197
|
+
return Object.entries(response.content)
|
|
198
|
+
.filter(([, mediaType]) => isObject(mediaType) && isObject(mediaType.schema))
|
|
199
|
+
.map(([mediaTypeName, mediaType]) => ({ schema: mediaType.schema, path: [...responsePath, 'content', mediaTypeName, 'schema'] }));
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @param {object} response - a resolved response of a GET operation
|
|
204
|
+
* @param {object} options - { 'paging-schema': JSON string or object }
|
|
205
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
206
|
+
*/
|
|
207
|
+
module.exports = (response, options = {}, context) => {
|
|
208
|
+
if (!isObject(response)) return [];
|
|
209
|
+
|
|
210
|
+
const responsePath = context.path || [];
|
|
211
|
+
const statusCode = String(responsePath[responsePath.length - 1]);
|
|
212
|
+
if (!SUCCESS_CODE_REGEX.test(statusCode) || statusCode === NO_CONTENT_CODE) return [];
|
|
213
|
+
|
|
214
|
+
const pathKey = typeof responsePath[1] === 'string' ? responsePath[1] : '';
|
|
215
|
+
const isDetailPath = DETAIL_PATH_REGEX.test(pathKey);
|
|
216
|
+
|
|
217
|
+
const pagingSchema = parsePagingSchema((options || {})['paging-schema']);
|
|
218
|
+
const pagingPropertyName = typeof pagingSchema.pagingPropertyName === 'string' && pagingSchema.pagingPropertyName.trim() !== ''
|
|
219
|
+
? pagingSchema.pagingPropertyName.trim()
|
|
220
|
+
: DEFAULT_PAGING_PROPERTY;
|
|
88
221
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
222
|
+
const document = context.document && context.document.data;
|
|
223
|
+
const isOas2 = isObject(document) && typeof document.swagger === 'string';
|
|
224
|
+
const ruleCode = context.rule && context.rule.name ? context.rule.name.split(':').pop() : 'OAR034';
|
|
225
|
+
|
|
226
|
+
const results = [];
|
|
227
|
+
const report = (message, path) => results.push({ message: `${ruleCode}: ${message}`, path });
|
|
228
|
+
|
|
229
|
+
responseSchemas(response, responsePath, isOas2).forEach(({ schema, path }) => {
|
|
230
|
+
const view = buildView(schema, path);
|
|
231
|
+
const paging = view.properties.get(pagingPropertyName);
|
|
232
|
+
|
|
233
|
+
if (!paging) {
|
|
234
|
+
if (isDetailPath) return;
|
|
235
|
+
if (isArrayView(view)) {
|
|
236
|
+
report(`Collection response must be an object with a '${pagingPropertyName}' property, not a top-level array`, view.path);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const collectionProperty = findCollectionProperty(view);
|
|
240
|
+
if (collectionProperty) {
|
|
241
|
+
report(`Response with collection property '${collectionProperty}' must include '${pagingPropertyName}' property for pagination`, view.path);
|
|
95
242
|
}
|
|
243
|
+
return;
|
|
96
244
|
}
|
|
97
|
-
|
|
245
|
+
|
|
246
|
+
const pagingView = buildView(paging.schema, paging.path);
|
|
247
|
+
if (!typeMatches('object', pagingView.types)) {
|
|
248
|
+
report(`'${pagingPropertyName}' must be of type object`, typePath(pagingView));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
validateAgainstTemplate(pagingSchema, pagingView, pagingPropertyName, report);
|
|
252
|
+
});
|
|
98
253
|
|
|
99
254
|
return results;
|
|
100
255
|
};
|
|
101
|
-
|
|
102
|
-
// Default paging schema matching Java implementation
|
|
103
|
-
const defaultPagingSchema = JSON.stringify({
|
|
104
|
-
type: 'object',
|
|
105
|
-
properties: {
|
|
106
|
-
numPages: { type: 'integer' },
|
|
107
|
-
total: { type: 'integer' },
|
|
108
|
-
start: { type: 'integer' },
|
|
109
|
-
limit: { type: 'integer' },
|
|
110
|
-
links: {
|
|
111
|
-
type: 'object',
|
|
112
|
-
properties: {
|
|
113
|
-
next: { type: 'object', properties: { href: { type: 'string' } } },
|
|
114
|
-
previous: { type: 'object', properties: { href: { type: 'string' } } },
|
|
115
|
-
last: { type: 'object', properties: { href: { type: 'string' } } },
|
|
116
|
-
self: { type: 'object', properties: { href: { type: 'string' } } },
|
|
117
|
-
first: { type: 'object', properties: { href: { type: 'string' } } },
|
|
118
|
-
},
|
|
119
|
-
required: ['self', 'previous', 'next'],
|
|
120
|
-
},
|
|
121
|
-
},
|
|
122
|
-
required: ['start', 'limit', 'links'],
|
|
123
|
-
pagingPropertyName: 'paging',
|
|
124
|
-
});
|