api-quality-spectral-ruleset 1.6.0 → 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 +3 -3
- package/functions/apq-paged-response-check.js +228 -97
- package/package.json +1 -1
package/apq-spectral.yaml
CHANGED
|
@@ -439,10 +439,10 @@ rules:
|
|
|
439
439
|
functionOptions:
|
|
440
440
|
notMatch: "^(Accept|Content-Type|Authorization)$"
|
|
441
441
|
apiq:OAR034:
|
|
442
|
-
description: "
|
|
443
|
-
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}}"
|
|
444
444
|
severity: error
|
|
445
|
-
given: "$.paths[*]
|
|
445
|
+
given: "$.paths[*].get.responses[*]"
|
|
446
446
|
then:
|
|
447
447
|
function: apq-paged-response-check
|
|
448
448
|
functionOptions:
|
|
@@ -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
|
-
});
|