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 CHANGED
@@ -439,10 +439,10 @@ rules:
439
439
  functionOptions:
440
440
  notMatch: "^(Accept|Content-Type|Authorization)$"
441
441
  apiq:OAR034:
442
- description: "A paged response must comply with the standard paging schema (start, limit, links with self/previous/next, numPages, total)."
443
- message: "OAR034: Paged response schema does not match the required standard."
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[*][get].responses[?(@property != '204' && @property != '304' && @property != 'default')]..schema"
445
+ given: "$.paths[*].get.responses[*]"
446
446
  then:
447
447
  function: apq-paged-response-check
448
448
  functionOptions:
@@ -1,124 +1,255 @@
1
- module.exports = (schema, options = {}, context) => {
2
- const results = [];
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
- if (!schema || typeof schema !== 'object') {
5
- return results;
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
- // Parse paging schema from options
9
- let pagingSchema = {};
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 pagingSchemaStr = options['paging-schema'] || defaultPagingSchema;
12
- pagingSchema = typeof pagingSchemaStr === 'string'
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
- const pagingPropertyName = pagingSchema.pagingPropertyName || 'paging';
23
- const requiredFields = pagingSchema.required || [];
24
- const properties = pagingSchema.properties || {};
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
- // Check if schema doesn't have properties at all - skip validation
27
- if (!schema.properties) {
28
- return results;
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
- // Check if schema has the paging property
32
- if (!schema.properties[pagingPropertyName]) {
33
- // Only flag if schema looks like it should have paging (has data/items/results arrays)
34
- const hasArrayProperty = Object.values(schema.properties || {}).some(
35
- prop => prop && prop.type === 'array'
36
- );
37
-
38
- if (hasArrayProperty) {
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
- return results;
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
- const pagingObj = schema.properties[pagingPropertyName];
144
+ const typePath = (view) => (isObject(view.rootSchema) && view.rootSchema.type !== undefined ? [...view.path, 'type'] : view.path);
48
145
 
49
- // Validate paging object structure
50
- if (!pagingObj.properties) {
51
- results.push({
52
- message: `${context.rule.name.split(':').pop()}: '${pagingPropertyName}' must be an object with required properties`,
53
- path: [...context.path, 'properties', pagingPropertyName],
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
- // Check required fields
59
- const missingFields = [];
60
- for (const requiredField of requiredFields) {
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 (missingFields.length > 0) {
67
- results.push({
68
- message: `${context.rule.name.split(':').pop()}: Paging object must include required fields: ${missingFields.join(', ')}`,
69
- path: [...context.path, 'properties', pagingPropertyName, 'properties'],
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
- // Special validation for links object if it's required
74
- if (requiredFields.includes('links') && pagingObj.properties.links) {
75
- const linksObj = pagingObj.properties.links;
76
- if (linksObj.properties) {
77
- const requiredLinks = ['self', 'previous', 'next']; // From default schema
78
- const linksRequired = linksObj.required || [];
79
-
80
- for (const requiredLink of requiredLinks) {
81
- if (!linksObj.properties[requiredLink]) {
82
- results.push({
83
- message: `${context.rule.name.split(':').pop()}: Links object must include '${requiredLink}' property`,
84
- path: [...context.path, 'properties', pagingPropertyName, 'properties', 'links', 'properties'],
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
- // Check if links has required array
90
- if (!linksRequired.includes('self') || !linksRequired.includes('previous') || !linksRequired.includes('next')) {
91
- results.push({
92
- message: `${context.rule.name.split(':').pop()}: Links object must require 'self', 'previous', and 'next'`,
93
- path: [...context.path, 'properties', pagingPropertyName, 'properties', 'links'],
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
- });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "api-quality-spectral-ruleset",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "Spectral ruleset by API Quality",
5
5
  "main": "apq-spectral.yaml",
6
6
  "files": [