api-quality-spectral-ruleset 1.1.1 → 1.2.0
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 +168 -97
- package/functions/apq-alternate-paths.js +2 -1
- package/functions/apq-binary-format-check.js +39 -0
- package/functions/apq-check-ambiguous-path.js +52 -0
- package/functions/apq-check-examples-coverage.js +12 -0
- package/functions/apq-compare-insensitive.js +110 -13
- package/functions/apq-has-filter-query-param.js +26 -0
- package/functions/apq-paged-response-check.js +124 -0
- package/functions/apq-password-format.js +35 -0
- package/functions/apq-path-depth.js +20 -0
- package/functions/apq-path-param-query-conflict.js +54 -0
- package/functions/apq-resources-by-verb.js +1 -1
- package/functions/apq-response-headers.js +3 -2
- package/functions/apq-response-media-type.js +43 -0
- package/functions/apq-security-required-response.js +46 -0
- package/functions/apq-standard-response-codes.js +4 -1
- package/functions/apq-status-endpoint-check.js +32 -0
- package/package.json +1 -1
- package/functions/apq-default-value.js +0 -23
- package/functions/apq-naming-convention.js +0 -21
- package/functions/apq-properties-schema-format.js +0 -29
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates that URL path segments use clear, unambiguous names
|
|
3
|
+
* @param {string} given - The path key (e.g. "/users/{id}/items")
|
|
4
|
+
* @param {object} options - Function options
|
|
5
|
+
* @param {string} options.ambiguous-words - Comma-separated list of ambiguous words
|
|
6
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
7
|
+
*/
|
|
8
|
+
module.exports = (given, options, context) => {
|
|
9
|
+
const results = [];
|
|
10
|
+
|
|
11
|
+
if (typeof given !== 'string') {
|
|
12
|
+
return results;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Default ambiguous words (from the original regex)
|
|
16
|
+
const defaultAmbiguousWords = [
|
|
17
|
+
'elements', 'instances', 'resources', 'values', 'terms', 'objects', 'items'
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// Parse custom ambiguous words from options if provided
|
|
21
|
+
let ambiguousWords = defaultAmbiguousWords;
|
|
22
|
+
if (options && options['ambiguous-words']) {
|
|
23
|
+
ambiguousWords = options['ambiguous-words']
|
|
24
|
+
.split(',')
|
|
25
|
+
.map(w => w.trim().toLowerCase())
|
|
26
|
+
.filter(Boolean);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Split path into segments and filter out parameters
|
|
30
|
+
const segments = given
|
|
31
|
+
.split('/')
|
|
32
|
+
.filter(segment => {
|
|
33
|
+
// Keep only static segments (not empty, not {param})
|
|
34
|
+
return segment.length > 0 && !segment.startsWith('{');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Check each segment for ambiguous words
|
|
38
|
+
for (const segment of segments) {
|
|
39
|
+
const lowerSegment = segment.toLowerCase();
|
|
40
|
+
for (const word of ambiguousWords) {
|
|
41
|
+
if (lowerSegment.includes(word)) {
|
|
42
|
+
results.push({
|
|
43
|
+
message: `${context.rule.message || 'OAR032'}: Path segment '${segment}' is ambiguous. Avoid using words like '${word}' in resource names.`,
|
|
44
|
+
path: context.path
|
|
45
|
+
});
|
|
46
|
+
break; // Report only once per segment
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return results;
|
|
52
|
+
};
|
|
@@ -39,6 +39,18 @@ module.exports = (given, options, context) => {
|
|
|
39
39
|
return checkSchema(schema.items);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
if (schema.allOf && Array.isArray(schema.allOf)) {
|
|
43
|
+
return schema.allOf.some(subSchema => checkSchema(subSchema));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (schema.oneOf && Array.isArray(schema.oneOf)) {
|
|
47
|
+
return schema.oneOf.some(subSchema => checkSchema(subSchema));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (schema.anyOf && Array.isArray(schema.anyOf)) {
|
|
51
|
+
return schema.anyOf.some(subSchema => checkSchema(subSchema));
|
|
52
|
+
}
|
|
53
|
+
|
|
42
54
|
return false;
|
|
43
55
|
};
|
|
44
56
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* @param {
|
|
5
|
-
* @param {
|
|
6
|
-
* @param {string} options.
|
|
7
|
-
* @param {
|
|
8
|
-
*
|
|
2
|
+
* Enhanced semantic text comparison function
|
|
3
|
+
* Detects both exact matches and semantic similarity
|
|
4
|
+
* @param {object} given
|
|
5
|
+
* @param {object} options
|
|
6
|
+
* @param {string} options.property
|
|
7
|
+
* @param {string} options.equalTo
|
|
8
|
+
* @param {string} options.result
|
|
9
|
+
* @param {number} options.threshold - Similarity threshold (0.0-1.0), default 0.6
|
|
10
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
9
11
|
*/
|
|
10
12
|
module.exports = (given, options, context) => {
|
|
11
13
|
const errors = [];
|
|
@@ -16,18 +18,113 @@ module.exports = (given, options, context) => {
|
|
|
16
18
|
const propA = given[options.property];
|
|
17
19
|
const propB = given[options.equalTo];
|
|
18
20
|
|
|
19
|
-
if (
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
if (typeof propA !== 'string' || typeof propB !== 'string') {
|
|
22
|
+
return errors;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (options.result !== 'falsy') {
|
|
26
|
+
return errors;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const threshold = options.threshold || 0.6;
|
|
30
|
+
|
|
31
|
+
// Exact match (case-insensitive)
|
|
32
|
+
if (propA.trim().toUpperCase() === propB.trim().toUpperCase()) {
|
|
25
33
|
errors.push({
|
|
26
34
|
message: context.rule.message,
|
|
27
35
|
path: [...context.path, options.property]
|
|
28
36
|
});
|
|
37
|
+
return errors;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Semantic similarity check
|
|
41
|
+
const similarity = calculateSimilarity(propA, propB);
|
|
42
|
+
if (similarity >= threshold) {
|
|
43
|
+
errors.push({
|
|
44
|
+
message: `${context.rule.message} (${Math.round(similarity * 100)}% similar)`,
|
|
45
|
+
path: [...context.path, options.property]
|
|
46
|
+
});
|
|
29
47
|
}
|
|
30
48
|
|
|
31
49
|
return errors;
|
|
32
50
|
};
|
|
33
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Calculate semantic similarity between two strings
|
|
54
|
+
* Uses token overlap and cosine similarity
|
|
55
|
+
*/
|
|
56
|
+
function calculateSimilarity(str1, str2) {
|
|
57
|
+
const tokens1 = tokenize(str1);
|
|
58
|
+
const tokens2 = tokenize(str2);
|
|
59
|
+
|
|
60
|
+
if (tokens1.length === 0 || tokens2.length === 0) {
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Calculate Jaccard similarity (token overlap)
|
|
65
|
+
const intersection = new Set([...tokens1].filter(x => tokens2.includes(x)));
|
|
66
|
+
const union = new Set([...tokens1, ...tokens2]);
|
|
67
|
+
const jaccardSimilarity = intersection.size / union.size;
|
|
68
|
+
|
|
69
|
+
// Calculate word order similarity
|
|
70
|
+
const order1 = tokens1.join(' ');
|
|
71
|
+
const order2 = tokens2.join(' ');
|
|
72
|
+
const levenshteinDist = levenshteinDistance(order1, order2);
|
|
73
|
+
const maxLen = Math.max(order1.length, order2.length);
|
|
74
|
+
const orderSimilarity = maxLen === 0 ? 0 : 1 - levenshteinDist / maxLen;
|
|
75
|
+
|
|
76
|
+
// Weighted average
|
|
77
|
+
return jaccardSimilarity * 0.6 + orderSimilarity * 0.4;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Tokenize string into meaningful words with stemming
|
|
82
|
+
*/
|
|
83
|
+
function tokenize(str) {
|
|
84
|
+
return str
|
|
85
|
+
.toLowerCase()
|
|
86
|
+
.replace(/[^\w\s]/g, '')
|
|
87
|
+
.split(/\s+/)
|
|
88
|
+
.filter(token => token.length > 2) // Ignore very short words
|
|
89
|
+
.map(token => stem(token)) // Apply stemming
|
|
90
|
+
.sort();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Simple word stemming (removes common suffixes)
|
|
95
|
+
*/
|
|
96
|
+
function stem(word) {
|
|
97
|
+
// Remove common verb/noun suffixes
|
|
98
|
+
return word
|
|
99
|
+
.replace(/s$/, '') // plural
|
|
100
|
+
.replace(/ed$/, '') // past tense
|
|
101
|
+
.replace(/ing$/, '') // continuous
|
|
102
|
+
.replace(/er$/, '') // agent
|
|
103
|
+
.replace(/est$/, ''); // superlative
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Calculate Levenshtein distance between two strings
|
|
108
|
+
*/
|
|
109
|
+
function levenshteinDistance(str1, str2) {
|
|
110
|
+
const len1 = str1.length;
|
|
111
|
+
const len2 = str2.length;
|
|
112
|
+
const matrix = Array(len2 + 1).fill(null).map(() => Array(len1 + 1).fill(0));
|
|
113
|
+
|
|
114
|
+
for (let i = 0; i <= len1; i++) matrix[0][i] = i;
|
|
115
|
+
for (let j = 0; j <= len2; j++) matrix[j][0] = j;
|
|
116
|
+
|
|
117
|
+
for (let j = 1; j <= len2; j++) {
|
|
118
|
+
for (let i = 1; i <= len1; i++) {
|
|
119
|
+
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
|
120
|
+
matrix[j][i] = Math.min(
|
|
121
|
+
matrix[j][i - 1] + 1,
|
|
122
|
+
matrix[j - 1][i] + 1,
|
|
123
|
+
matrix[j - 1][i - 1] + cost
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return matrix[len2][len1];
|
|
129
|
+
}
|
|
130
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {object} given
|
|
3
|
+
* @param {object} options
|
|
4
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
5
|
+
*/
|
|
6
|
+
module.exports = (given, options, context) => {
|
|
7
|
+
if (!given || typeof given !== 'object') {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const params = given.parameters;
|
|
12
|
+
|
|
13
|
+
if (!params || !Array.isArray(params)) {
|
|
14
|
+
return [{ message: context.rule.message }];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const hasFilter = params.some(
|
|
18
|
+
p => p && !p.$ref && p.name === '$filter' && p.in === 'query'
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
if (!hasFilter) {
|
|
22
|
+
return [{ message: context.rule.message }];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return [];
|
|
26
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
module.exports = (schema, options = {}, context) => {
|
|
2
|
+
const results = [];
|
|
3
|
+
|
|
4
|
+
if (!schema || typeof schema !== 'object') {
|
|
5
|
+
return results;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Parse paging schema from options
|
|
9
|
+
let pagingSchema = {};
|
|
10
|
+
try {
|
|
11
|
+
const pagingSchemaStr = options['paging-schema'] || defaultPagingSchema;
|
|
12
|
+
pagingSchema = typeof pagingSchemaStr === 'string'
|
|
13
|
+
? JSON.parse(pagingSchemaStr)
|
|
14
|
+
: pagingSchemaStr;
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return [{
|
|
17
|
+
message: `${context.rule.name.split(':').pop()}: Invalid paging-schema configuration: ${err.message}`,
|
|
18
|
+
path: context.path,
|
|
19
|
+
}];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const pagingPropertyName = pagingSchema.pagingPropertyName || 'paging';
|
|
23
|
+
const requiredFields = pagingSchema.required || [];
|
|
24
|
+
const properties = pagingSchema.properties || {};
|
|
25
|
+
|
|
26
|
+
// Check if schema doesn't have properties at all - skip validation
|
|
27
|
+
if (!schema.properties) {
|
|
28
|
+
return results;
|
|
29
|
+
}
|
|
30
|
+
|
|
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,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return results;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const pagingObj = schema.properties[pagingPropertyName];
|
|
48
|
+
|
|
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;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check required fields
|
|
59
|
+
const missingFields = [];
|
|
60
|
+
for (const requiredField of requiredFields) {
|
|
61
|
+
if (!pagingObj.properties[requiredField]) {
|
|
62
|
+
missingFields.push(requiredField);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
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
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
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
|
+
}
|
|
88
|
+
|
|
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
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return results;
|
|
100
|
+
};
|
|
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
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates that properties intended for password storage use format: password
|
|
3
|
+
* @param {object} given - The schema properties object
|
|
4
|
+
* @param {object} options - Function options
|
|
5
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
6
|
+
*/
|
|
7
|
+
module.exports = (given, options, context) => {
|
|
8
|
+
const results = [];
|
|
9
|
+
|
|
10
|
+
if (!given || typeof given !== 'object') {
|
|
11
|
+
return results;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Check each property in the schema
|
|
15
|
+
Object.entries(given).forEach(([propName, propSchema]) => {
|
|
16
|
+
if (!propSchema || typeof propSchema !== 'object') {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Check if this is a password-related field (name contains "password")
|
|
21
|
+
const isPasswordField = propName.toLowerCase().includes('password');
|
|
22
|
+
|
|
23
|
+
if (isPasswordField && propSchema.type === 'string') {
|
|
24
|
+
// Password fields must have format: password
|
|
25
|
+
if (propSchema.format !== 'password') {
|
|
26
|
+
results.push({
|
|
27
|
+
message: context.rule.message || 'OAR081: Password fields should use format: password',
|
|
28
|
+
path: [...context.path, propName, 'format']
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return results;
|
|
35
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {string} targetVal
|
|
3
|
+
* @param {object} options
|
|
4
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
5
|
+
*/
|
|
6
|
+
module.exports = (targetVal, options, context) => {
|
|
7
|
+
const maxDepth = options.maxDepth ?? 3
|
|
8
|
+
const ignore = options.ignoreSegments ?? []
|
|
9
|
+
|
|
10
|
+
const segments = targetVal
|
|
11
|
+
.split('/')
|
|
12
|
+
.filter(Boolean)
|
|
13
|
+
.filter(segment => !ignore.includes(segment))
|
|
14
|
+
|
|
15
|
+
if (segments.length > maxDepth) {
|
|
16
|
+
return [
|
|
17
|
+
{ message: context.rule.message }
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates that path parameters don't appear as query parameters
|
|
3
|
+
* This prevents ambiguity and design issues
|
|
4
|
+
*
|
|
5
|
+
* @param {object} given - The paths object
|
|
6
|
+
* @param {object} options - Function options
|
|
7
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
8
|
+
*/
|
|
9
|
+
module.exports = (given, options, context) => {
|
|
10
|
+
const errors = [];
|
|
11
|
+
|
|
12
|
+
if (!given || typeof given !== 'object') {
|
|
13
|
+
return errors;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const httpMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'];
|
|
17
|
+
|
|
18
|
+
for (const [pathKey, pathItem] of Object.entries(given)) {
|
|
19
|
+
if (!pathItem || typeof pathItem !== 'object') {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const [operationKey, operation] of Object.entries(pathItem)) {
|
|
24
|
+
if (!operation || typeof operation !== 'object' ||
|
|
25
|
+
!httpMethods.includes(operationKey)) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const parameters = operation.parameters || [];
|
|
30
|
+
|
|
31
|
+
if (!Array.isArray(parameters)) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const hasPathOrQueryParams = parameters.some(
|
|
36
|
+
param => param && (param.in === 'path' || param.in === 'query')
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
if (!hasPathOrQueryParams) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const responses = operation.responses || {};
|
|
44
|
+
if (!responses['400']) {
|
|
45
|
+
errors.push({
|
|
46
|
+
message: `OAR069: Any param in PATH or QUERY, should have bad request (400) response.`,
|
|
47
|
+
path: [...context.path, pathKey, operationKey, 'responses']
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return errors;
|
|
54
|
+
};
|
|
@@ -29,7 +29,7 @@ const DEFAULT_ALLOWED_PATTERNS = `
|
|
|
29
29
|
;delete:^/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)/[^/{}]+/(\\{[^/{}]+\\}|me)$
|
|
30
30
|
`;
|
|
31
31
|
|
|
32
|
-
const SUPPORTED_VERBS = ['get', 'post', 'put', 'patch', 'delete'];
|
|
32
|
+
const SUPPORTED_VERBS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
33
33
|
const CONFIG_ERROR_PREFIX = 'OAR018:';
|
|
34
34
|
|
|
35
35
|
const parseAllowedPatterns = (patterns) => {
|
|
@@ -41,7 +41,7 @@ module.exports = (responseNode, options = {}, context) => {
|
|
|
41
41
|
const path = context.path?.find(p => typeof p === 'string' && p.startsWith('/'));
|
|
42
42
|
const responseCode = context.path?.[context.path.length - 1];
|
|
43
43
|
|
|
44
|
-
if (path && pathExclusions.
|
|
44
|
+
if (path && pathExclusions.some(ex => path === ex || path.startsWith(ex + '/'))) {
|
|
45
45
|
return [];
|
|
46
46
|
}
|
|
47
47
|
|
|
@@ -85,7 +85,8 @@ module.exports = (responseNode, options = {}, context) => {
|
|
|
85
85
|
const forbidden = headerNames.filter(h => !allowedHeaders.includes(h));
|
|
86
86
|
if (forbidden.length > 0) {
|
|
87
87
|
results.push({
|
|
88
|
-
message: `${ruleCode}:
|
|
88
|
+
message: `${ruleCode}: Headers [${forbidden.join(', ')}] are not allowed`,
|
|
89
|
+
path: [...context.path, 'headers', forbidden[0]],
|
|
89
90
|
});
|
|
90
91
|
}
|
|
91
92
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {object} given
|
|
3
|
+
* @param {object} options
|
|
4
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const NO_BODY_CODES = new Set(['204']);
|
|
8
|
+
|
|
9
|
+
module.exports = (given, options, context) => {
|
|
10
|
+
const errors = [];
|
|
11
|
+
if (!given) return errors;
|
|
12
|
+
|
|
13
|
+
const root = context.document.parserResult.data;
|
|
14
|
+
|
|
15
|
+
if (root.swagger) {
|
|
16
|
+
const produces = given.produces ?? root.produces;
|
|
17
|
+
|
|
18
|
+
if (!produces || !Array.isArray(produces) || !produces.includes('application/json')) {
|
|
19
|
+
errors.push({
|
|
20
|
+
message: context.rule.message,
|
|
21
|
+
path: [...context.path]
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return errors;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const responses = given.responses;
|
|
29
|
+
if (!responses) return errors;
|
|
30
|
+
|
|
31
|
+
for (const [statusCode, response] of Object.entries(responses)) {
|
|
32
|
+
if (NO_BODY_CODES.has(statusCode)) continue;
|
|
33
|
+
|
|
34
|
+
if (!response?.content?.['application/json']) {
|
|
35
|
+
errors.push({
|
|
36
|
+
message: context.rule.message,
|
|
37
|
+
path: [...context.path, 'responses', statusCode]
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return errors;
|
|
43
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates that operations with security defined include a 401 Unauthorized response
|
|
3
|
+
* @param {object} given - The operation node
|
|
4
|
+
* @param {object} options - Function options
|
|
5
|
+
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
6
|
+
*/
|
|
7
|
+
module.exports = (given, options, context) => {
|
|
8
|
+
const results = [];
|
|
9
|
+
|
|
10
|
+
if (!given || typeof given !== 'object') {
|
|
11
|
+
return results;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const responses = given.responses;
|
|
15
|
+
if (!responses || typeof responses !== 'object') {
|
|
16
|
+
return results;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Check if this operation has security defined
|
|
20
|
+
const operationHasSecurity = given.security &&
|
|
21
|
+
Array.isArray(given.security) &&
|
|
22
|
+
given.security.length > 0;
|
|
23
|
+
|
|
24
|
+
// Check if there's global security in the document
|
|
25
|
+
let globalHasSecurity = false;
|
|
26
|
+
try {
|
|
27
|
+
const rootSecurity = context.document?.parserResult?.data?.security;
|
|
28
|
+
globalHasSecurity = rootSecurity &&
|
|
29
|
+
Array.isArray(rootSecurity) &&
|
|
30
|
+
rootSecurity.length > 0;
|
|
31
|
+
} catch (e) {
|
|
32
|
+
// Ignore errors accessing root security
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Only require 401 if security is defined (operation-level or global)
|
|
36
|
+
if (operationHasSecurity || globalHasSecurity) {
|
|
37
|
+
if (!responses['401']) {
|
|
38
|
+
results.push({
|
|
39
|
+
message: context.rule.message || 'OAR035: Response code 401 must be defined for operations with security schemes defined.',
|
|
40
|
+
path: [...context.path, 'responses']
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return results;
|
|
46
|
+
};
|
|
@@ -22,7 +22,10 @@ module.exports = function apqStandardResponseCodes(given, options, context) {
|
|
|
22
22
|
|
|
23
23
|
const exclusions = options?.['resources-exclusions'] || [];
|
|
24
24
|
if (exclusions.some(ex => {
|
|
25
|
-
const
|
|
25
|
+
const colonIdx = ex.indexOf(':');
|
|
26
|
+
if (colonIdx === -1) return false;
|
|
27
|
+
const exVerb = ex.slice(0, colonIdx);
|
|
28
|
+
const exPath = ex.slice(colonIdx + 1);
|
|
26
29
|
return exVerb.toLowerCase() === verb.toLowerCase() && new RegExp(`^${exPath}$`).test(resourcePath);
|
|
27
30
|
})) return results;
|
|
28
31
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module.exports = (paths, options = {}, context) => {
|
|
2
|
+
const results = [];
|
|
3
|
+
|
|
4
|
+
if (!paths || typeof paths !== 'object') {
|
|
5
|
+
return results;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Get configuration options
|
|
9
|
+
const statusEndpoint = (options['status-endpoint'] || '/status').trim();
|
|
10
|
+
const method = (options['method'] || 'get').toLowerCase().trim();
|
|
11
|
+
|
|
12
|
+
// Check if the endpoint exists
|
|
13
|
+
if (!paths[statusEndpoint]) {
|
|
14
|
+
results.push({
|
|
15
|
+
message: `${context.rule.name.split(':').pop()}: The status endpoint '${statusEndpoint}' must be declared`,
|
|
16
|
+
path: context.path,
|
|
17
|
+
});
|
|
18
|
+
return results;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const endpointNode = paths[statusEndpoint];
|
|
22
|
+
|
|
23
|
+
// Check if the configured method exists
|
|
24
|
+
if (!endpointNode[method]) {
|
|
25
|
+
results.push({
|
|
26
|
+
message: `${context.rule.name.split(':').pop()}: The status endpoint '${statusEndpoint}' must support the '${method.toUpperCase()}' method`,
|
|
27
|
+
path: [...context.path, statusEndpoint],
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return results;
|
|
32
|
+
};
|
package/package.json
CHANGED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @param {object} given
|
|
3
|
-
* @param {object} options
|
|
4
|
-
* @param {string} options.type
|
|
5
|
-
* @param {string} options.match
|
|
6
|
-
* @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
|
|
7
|
-
*/
|
|
8
|
-
module.exports = (given, options, context) => {
|
|
9
|
-
const errors = [];
|
|
10
|
-
if (!given) return errors;
|
|
11
|
-
|
|
12
|
-
if (!given.type || given.type.toString() !== options.type.toString()) {
|
|
13
|
-
errors.push({
|
|
14
|
-
message: context.rule.message,
|
|
15
|
-
});
|
|
16
|
-
} else if (!given.default || given.default.toString() !== options.match.toString()) {
|
|
17
|
-
errors.push({
|
|
18
|
-
message: context.rule.message,
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
return errors;
|
|
23
|
-
}
|