api-quality-spectral-ruleset 1.5.0-beta.1 → 1.5.0-beta.2
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
|
@@ -30,6 +30,8 @@ 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
|
|
35
37
|
extends:
|
|
@@ -716,12 +718,18 @@ rules:
|
|
|
716
718
|
match: ^(http(s)?:\/\/.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.apiquality.io\b([-a-zA-Z0-9@:%_\+.~#?&\/=]*)$
|
|
717
719
|
apiq:OAR060:
|
|
718
720
|
description: "All query parameters must be defined as optional."
|
|
719
|
-
message: "
|
|
721
|
+
message: "{{error}}"
|
|
720
722
|
severity: error
|
|
721
|
-
given:
|
|
723
|
+
given:
|
|
724
|
+
- "$.paths[*][get,put,post,delete,options,head,patch,trace].parameters[?(@.in == 'query')]"
|
|
725
|
+
- "$.paths[*].parameters[?(@.in == 'query')]"
|
|
726
|
+
- "$.components.parameters[?(@.in == 'query')]"
|
|
727
|
+
- "$.parameters[?(@.in == 'query')]"
|
|
722
728
|
then:
|
|
723
729
|
field: "required"
|
|
724
|
-
function:
|
|
730
|
+
function: apq-query-params-optional
|
|
731
|
+
functionOptions:
|
|
732
|
+
path-exclusions: "/status"
|
|
725
733
|
apiq:OAR061:
|
|
726
734
|
description: "Ensure get have mandatory response codes"
|
|
727
735
|
message: "OAR061: Ensure get have the mandatory response codes"
|
|
@@ -1261,4 +1269,14 @@ rules:
|
|
|
1261
1269
|
- "$.components.schemas[*]"
|
|
1262
1270
|
- "$.definitions[*]"
|
|
1263
1271
|
then:
|
|
1264
|
-
function: apq-required-fields-exist
|
|
1272
|
+
function: apq-required-fields-exist
|
|
1273
|
+
apiq:OAR116:
|
|
1274
|
+
description: "Every API path must match the configured regular expression."
|
|
1275
|
+
message: "{{error}}"
|
|
1276
|
+
documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR116.md"
|
|
1277
|
+
severity: error
|
|
1278
|
+
given: "$.paths.*~"
|
|
1279
|
+
then:
|
|
1280
|
+
function: apq-path-pattern
|
|
1281
|
+
functionOptions:
|
|
1282
|
+
pattern: "^/"
|
|
@@ -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
|
+
};
|