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: "OAR060: All query parameter must be optional (required: false)."
721
+ message: "{{error}}"
720
722
  severity: error
721
- given: "$.paths[*][get,post,put,patch,delete].parameters[?(@.in == 'query')]"
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: falsy
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "api-quality-spectral-ruleset",
3
- "version": "1.5.0-beta.1",
3
+ "version": "1.5.0-beta.2",
4
4
  "description": "Spectral ruleset by API Quality",
5
5
  "main": "apq-spectral.yaml",
6
6
  "files": [