api-quality-spectral-ruleset 1.5.0 → 1.6.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.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @param {object} given - the whole document ($)
3
+ * @param {object} options - unused
4
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
5
+ */
6
+ module.exports = (given, options, context) => {
7
+ const errors = [];
8
+ const doc = given || {};
9
+ const tags = Array.isArray(doc.tags) ? doc.tags : [];
10
+
11
+ if (tags.length === 0) {
12
+ return [{
13
+ message: 'OAR047: Add tags with a short description to each one.',
14
+ path: ['tags'],
15
+ }];
16
+ }
17
+
18
+ const declaredNames = new Set();
19
+
20
+ tags.forEach((tag, index) => {
21
+ if (!tag || typeof tag !== 'object') return;
22
+ const { name, description } = tag;
23
+
24
+ if (typeof name === 'string' && name.length > 0) {
25
+ if (declaredNames.has(name)) {
26
+ errors.push({
27
+ message: 'OAR047: Remove this duplicate tag',
28
+ path: ['tags', index, 'name'],
29
+ });
30
+ } else {
31
+ declaredNames.add(name);
32
+ }
33
+ } else {
34
+ errors.push({
35
+ message: 'OAR047: Add tags with a short description to each one.',
36
+ path: ['tags', index],
37
+ });
38
+ }
39
+
40
+ if (typeof description !== 'string' || description.trim().length === 0) {
41
+ errors.push({
42
+ message: 'OAR047: Add a short description to this tag',
43
+ path: ['tags', index, 'description'],
44
+ });
45
+ }
46
+ });
47
+
48
+ const verbs = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
49
+ const walkOperations = (paths, rootKey) => {
50
+ Object.entries(paths || {}).forEach(([pathKey, pathItem]) => {
51
+ if (!pathItem || typeof pathItem !== 'object') return;
52
+ verbs.forEach((verb) => {
53
+ const operation = pathItem[verb];
54
+ if (!operation || typeof operation !== 'object' || !Array.isArray(operation.tags)) return;
55
+ operation.tags.forEach((tagName, tagIndex) => {
56
+ if (typeof tagName === 'string' && !declaredNames.has(tagName)) {
57
+ errors.push({
58
+ message: 'OAR047: This tag should be declared in the tags section of the contract',
59
+ path: [rootKey, pathKey, verb, 'tags', tagIndex],
60
+ });
61
+ }
62
+ });
63
+ });
64
+ });
65
+ };
66
+
67
+ walkOperations(doc.paths, 'paths');
68
+ if (doc.webhooks) walkOperations(doc.webhooks, 'webhooks');
69
+
70
+ return errors;
71
+ };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @param {object} given - the matched $total parameter (already resolved by Spectral)
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') return [];
8
+
9
+ const doc = context.document?.data ?? {};
10
+ const isOAP2 = typeof doc.swagger === 'string';
11
+
12
+ const defaultValue = isOAP2 ? given.default : given.schema && given.schema.default;
13
+
14
+ if (defaultValue === undefined || defaultValue === false) return [];
15
+
16
+ const path = isOAP2
17
+ ? [...context.path, 'default']
18
+ : [...context.path, 'schema', 'default'];
19
+
20
+ return [{
21
+ message: 'OAR026: The $total parameter default value should be false.',
22
+ path,
23
+ }];
24
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @param {object} given
3
+ * @param {object} options
4
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
5
+ */
6
+
7
+ const BODY_ALLOWED_METHODS = new Set(['post', 'put', 'patch']);
8
+
9
+ function hasMediaTypes(list) {
10
+ return Array.isArray(list) && list.length > 0;
11
+ }
12
+
13
+ module.exports = (given, options, context) => {
14
+ const errors = [];
15
+ if (!given) return errors;
16
+
17
+ const root = context.document.parserResult.data;
18
+
19
+ if (root.swagger) {
20
+ if (given.responses === undefined) return errors;
21
+
22
+ const method = context.path[context.path.length - 1];
23
+ if (!BODY_ALLOWED_METHODS.has(method)) return errors;
24
+
25
+ if (!hasMediaTypes(given.produces) && !hasMediaTypes(root.produces)) {
26
+ errors.push({
27
+ message: context.rule.message,
28
+ path: [...context.path]
29
+ });
30
+ }
31
+
32
+ return errors;
33
+ }
34
+
35
+ if (given.responses !== undefined) return errors;
36
+
37
+ const contentTypes = given.content ? Object.keys(given.content) : [];
38
+ if (contentTypes.length === 0) {
39
+ errors.push({
40
+ message: context.rule.message,
41
+ path: [...context.path]
42
+ });
43
+ }
44
+
45
+ return errors;
46
+ };
@@ -1,40 +1,91 @@
1
+ function mergedProperties(schema, visited) {
2
+ const properties = {};
3
+ if (!schema || typeof schema !== 'object' || visited.has(schema)) {
4
+ return properties;
5
+ }
6
+ visited.add(schema);
7
+
8
+ if (schema.properties && typeof schema.properties === 'object') {
9
+ Object.assign(properties, schema.properties);
10
+ }
11
+
12
+ if (Array.isArray(schema.allOf)) {
13
+ for (const subSchema of schema.allOf) {
14
+ Object.assign(properties, mergedProperties(subSchema, visited));
15
+ }
16
+ }
17
+
18
+ return properties;
19
+ }
20
+
1
21
  /**
2
- * @param {object} targetVal
22
+ * @param {object} given - the 201 response object of a POST operation
3
23
  * @param {object} options
4
24
  * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
5
25
  */
6
- module.exports = (targetVal, options, context) => {
7
- const errors = [];
26
+ module.exports = (given, options, context) => {
27
+ if (!given || typeof given !== 'object') {
28
+ return [];
29
+ }
30
+
8
31
  const dataProperty = (options && options['data-property']) || 'data';
9
32
  const VALID_NAMES = [dataProperty, 'error'];
33
+ const doc = context.document?.data ?? {};
34
+ const isOAP2 = typeof doc.swagger === 'string';
35
+ const basePath = context.path || [];
36
+ const errors = [];
10
37
 
11
- if (!targetVal || typeof targetVal !== 'object') {
12
- return errors;
13
- }
38
+ const checkSchema = (schema, schemaPath) => {
39
+ if (!schema || typeof schema !== 'object') {
40
+ errors.push({
41
+ message: `OAR038: 201 response must define a schema with a '${dataProperty}' or 'error' property.`,
42
+ path: schemaPath,
43
+ });
44
+ return;
45
+ }
14
46
 
15
- const path = context.path || [];
16
- const responsesIdx = path.lastIndexOf('responses');
17
- if (responsesIdx >= 0 && path[responsesIdx + 1] !== '201') {
18
- return errors;
19
- }
47
+ const properties = mergedProperties(schema, new WeakSet());
48
+ const propNames = Object.keys(properties);
20
49
 
21
- for (const [propName, propValue] of Object.entries(targetVal)) {
22
- if (!VALID_NAMES.includes(propName)) {
50
+ if (propNames.length === 0) {
23
51
  errors.push({
24
- message: `OAR038: Response property must be named '${dataProperty}' or 'error'. Got '${propName}'.`,
25
- path: [...path, propName]
52
+ message: `OAR038: Response schema must have properties named '${dataProperty}' or 'error'.`,
53
+ path: schemaPath,
26
54
  });
27
- } else {
28
- const subProps = propValue && propValue.properties;
29
- const hasSubProps = subProps && Object.keys(subProps).length > 0;
30
- if (!hasSubProps) {
55
+ return;
56
+ }
57
+
58
+ for (const propName of propNames) {
59
+ if (!VALID_NAMES.includes(propName)) {
60
+ errors.push({
61
+ message: `OAR038: Response property must be named '${dataProperty}' or 'error'. Got '${propName}'.`,
62
+ path: [...schemaPath, 'properties', propName],
63
+ });
64
+ continue;
65
+ }
66
+ const subProps = mergedProperties(properties[propName], new WeakSet());
67
+ if (Object.keys(subProps).length === 0) {
31
68
  errors.push({
32
69
  message: `OAR038: Property '${propName}' must have at least one sub-property.`,
33
- path: [...path, propName]
70
+ path: [...schemaPath, 'properties', propName],
34
71
  });
35
72
  }
36
73
  }
74
+ };
75
+
76
+ if (isOAP2) {
77
+ checkSchema(given.schema, [...basePath, 'schema']);
78
+ return errors;
79
+ }
80
+
81
+ const content = given.content;
82
+ if (content && typeof content === 'object' && Object.keys(content).length > 0) {
83
+ for (const [mediaType, mediaObj] of Object.entries(content)) {
84
+ checkSchema(mediaObj && mediaObj.schema, [...basePath, 'content', mediaType, 'schema']);
85
+ }
86
+ return errors;
37
87
  }
38
88
 
89
+ checkSchema(given.schema, [...basePath, 'schema']);
39
90
  return errors;
40
91
  };
@@ -1,3 +1,24 @@
1
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
2
+
3
+ function getAt(root, path) {
4
+ let node = root;
5
+ for (let i = 0; i < path.length; i += 1) {
6
+ if (!isPlainObject(node) && !Array.isArray(node)) return undefined;
7
+ node = node[path[i]];
8
+ }
9
+ return node;
10
+ }
11
+
12
+ function isReachedViaRef(context, fieldName) {
13
+ const source = context && context.document ? context.document.data : undefined;
14
+ const path = (context && context.path) || [];
15
+ if (!isPlainObject(source) && !Array.isArray(source)) return false;
16
+ const isNestedUnderSchema = fieldName === 'type' && path[path.length - 2] === 'schema';
17
+ const parameterPath = isNestedUnderSchema ? path.slice(0, -2) : path.slice(0, -1);
18
+ const raw = getAt(source, parameterPath);
19
+ return isPlainObject(raw) && typeof raw.$ref === 'string';
20
+ }
21
+
1
22
  /**
2
23
  * @param {string} given - The field value
3
24
  * @param {object} options
@@ -8,26 +29,34 @@ module.exports = (given, options, context) => {
8
29
 
9
30
  const doc = context.document?.data ?? {};
10
31
  const isOAP2 = typeof doc.swagger === 'string';
32
+ const openapi = typeof doc.openapi === 'string' ? doc.openapi : '';
33
+ const isOAP32 = openapi.indexOf('3.2') === 0;
34
+ const isNewerOAS = openapi.indexOf('3.1') === 0 || isOAP32;
11
35
 
12
36
  const VALID_IN_VALUES_OAP2 = ['query', 'header', 'path', 'formData', 'body'];
13
37
  const VALID_IN_VALUES_OAP3 = ['path', 'query', 'header', 'cookie'];
14
- const VALID_IN_VALUES = isOAP2 ? VALID_IN_VALUES_OAP2 : VALID_IN_VALUES_OAP3;
38
+ const VALID_IN_VALUES_OAP32 = VALID_IN_VALUES_OAP3.concat(['querystring']);
39
+ const VALID_IN_VALUES = isOAP2 ? VALID_IN_VALUES_OAP2 : (isOAP32 ? VALID_IN_VALUES_OAP32 : VALID_IN_VALUES_OAP3);
15
40
 
16
41
  const VALID_SCHEMA_TYPES = ['object', 'string', 'number', 'integer', 'boolean', 'array', 'null'];
17
42
 
18
43
  const nodePath = context.path || [];
19
44
  const fieldName = nodePath[nodePath.length - 1];
20
45
 
46
+ if ((fieldName === 'in' || fieldName === 'type') && isReachedViaRef(context, fieldName)) return [];
47
+
21
48
  if (fieldName === 'in') {
22
49
  if (!VALID_IN_VALUES.includes(String(given))) {
23
- return [{
50
+ const issue = {
24
51
  message: `in: Expected one of [${VALID_IN_VALUES.map(v => `"${v}"`).join(', ')}]`,
25
- }];
52
+ };
53
+ if (isOAP2) issue.path = nodePath.slice(0, -1);
54
+ return [issue];
26
55
  }
27
56
  }
28
57
 
29
- if (fieldName === 'type') {
30
- if (!VALID_SCHEMA_TYPES.includes(String(given))) {
58
+ if (fieldName === 'type' && !isNewerOAS) {
59
+ if (!VALID_SCHEMA_TYPES.includes(given)) {
31
60
  return [{
32
61
  message: `type: Expected one of [${VALID_SCHEMA_TYPES.map(v => `"${v}"`).join(', ')}]`,
33
62
  }];
@@ -0,0 +1,134 @@
1
+ const MESSAGE = 'WSO2 x-scope requires x-auth-type definition';
2
+
3
+ const VERBS_V2 = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch'];
4
+ const VERBS_V3 = VERBS_V2.concat(['trace']);
5
+ const VERBS_V32 = VERBS_V3.concat(['query']);
6
+
7
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
8
+
9
+ function documentVersion(root) {
10
+ if (Object.prototype.hasOwnProperty.call(root, 'swagger')) return 'v2';
11
+ const openapi = root.openapi;
12
+ if (typeof openapi === 'string') {
13
+ if (openapi.indexOf('3.2') === 0) return 'v32';
14
+ if (openapi.indexOf('3.1') === 0) return 'v31';
15
+ }
16
+ return 'v3';
17
+ }
18
+
19
+ function surfaceFor(version) {
20
+ return {
21
+ verbs: version === 'v32' ? VERBS_V32 : (version === 'v2' ? VERBS_V2 : VERBS_V3),
22
+ components: version !== 'v2',
23
+ callbacks: version !== 'v2',
24
+ webhooks: version === 'v31' || version === 'v32',
25
+ pathItems: version === 'v31' || version === 'v32',
26
+ additionalOperations: version === 'v32',
27
+ };
28
+ }
29
+
30
+ function collectOperations(root, surface) {
31
+ const operations = [];
32
+
33
+ const visitOperation = (op, path) => {
34
+ if (isPlainObject(op)) operations.push({ op, path });
35
+ };
36
+
37
+ const visitOperationWithCallbacks = (op, path) => {
38
+ visitOperation(op, path);
39
+ if (!surface.callbacks || !isPlainObject(op) || !isPlainObject(op.callbacks)) return;
40
+ Object.keys(op.callbacks).forEach((expressionKey) => {
41
+ if (expressionKey.charAt(0) === 'x') return;
42
+ const callback = op.callbacks[expressionKey];
43
+ if (!isPlainObject(callback)) return;
44
+ Object.keys(callback).forEach((pathKey) => {
45
+ if (pathKey.charAt(0) === 'x') return;
46
+ // eslint-disable-next-line no-use-before-define
47
+ visitPathItem(callback[pathKey], path.concat(['callbacks', expressionKey, pathKey]));
48
+ });
49
+ });
50
+ };
51
+
52
+ const visitPathItem = (pathItem, path) => {
53
+ if (!isPlainObject(pathItem)) return;
54
+ surface.verbs.forEach((verb) => {
55
+ if (Object.prototype.hasOwnProperty.call(pathItem, verb)) {
56
+ visitOperationWithCallbacks(pathItem[verb], path.concat([verb]));
57
+ }
58
+ });
59
+ if (surface.additionalOperations && isPlainObject(pathItem.additionalOperations)) {
60
+ Object.keys(pathItem.additionalOperations).forEach((method) => {
61
+ visitOperationWithCallbacks(pathItem.additionalOperations[method], path.concat(['additionalOperations', method]));
62
+ });
63
+ }
64
+ };
65
+
66
+ if (isPlainObject(root.paths)) {
67
+ Object.keys(root.paths).forEach((pathKey) => {
68
+ if (pathKey.charAt(0) !== '/') return;
69
+ visitPathItem(root.paths[pathKey], ['paths', pathKey]);
70
+ });
71
+ }
72
+
73
+ if (surface.webhooks && isPlainObject(root.webhooks)) {
74
+ Object.keys(root.webhooks).forEach((name) => {
75
+ visitPathItem(root.webhooks[name], ['webhooks', name]);
76
+ });
77
+ }
78
+
79
+ if (surface.components && isPlainObject(root.components)) {
80
+ const components = root.components;
81
+ if (surface.callbacks && isPlainObject(components.callbacks)) {
82
+ Object.keys(components.callbacks).forEach((name) => {
83
+ const callback = components.callbacks[name];
84
+ if (!isPlainObject(callback)) return;
85
+ Object.keys(callback).forEach((pathKey) => {
86
+ if (pathKey.charAt(0) === 'x') return;
87
+ visitPathItem(callback[pathKey], ['components', 'callbacks', name, pathKey]);
88
+ });
89
+ });
90
+ }
91
+ if (surface.pathItems && isPlainObject(components.pathItems)) {
92
+ Object.keys(components.pathItems).forEach((name) => {
93
+ visitPathItem(components.pathItems[name], ['components', 'pathItems', name]);
94
+ });
95
+ }
96
+ if (surface.webhooks && isPlainObject(components.webhooks)) {
97
+ Object.keys(components.webhooks).forEach((name) => {
98
+ visitPathItem(components.webhooks[name], ['components', 'webhooks', name]);
99
+ });
100
+ }
101
+ }
102
+
103
+ return operations;
104
+ }
105
+
106
+ function issuePrefix(context) {
107
+ const name = (context && context.rule && context.rule.name) || '';
108
+ const code = name.indexOf(':') === -1 ? name : name.split(':').pop();
109
+ return code ? `${code}: ` : '';
110
+ }
111
+
112
+ /**
113
+ * @param {object} given the whole raw document, since the rule is anchored with `given: "$"`
114
+ * @param {null} _options OAR041 has no rule properties on either engine
115
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
116
+ * @returns {Array<{message: string, path: Array<string|number>}>}
117
+ */
118
+ module.exports = (given, _options, context) => {
119
+ if (!isPlainObject(given)) return [];
120
+
121
+ const version = documentVersion(given);
122
+ const surface = surfaceFor(version);
123
+ const prefix = issuePrefix(context);
124
+ const results = [];
125
+
126
+ collectOperations(given, surface).forEach(({ op, path }) => {
127
+ if (!Object.prototype.hasOwnProperty.call(op, 'x-scope')) return;
128
+ if (Object.prototype.hasOwnProperty.call(op, 'x-auth-type')) return;
129
+
130
+ results.push({ message: `${prefix}${MESSAGE}`, path: path.concat(['x-scope']) });
131
+ });
132
+
133
+ return results;
134
+ };