api-quality-spectral-ruleset 1.3.0 → 1.4.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
@@ -220,7 +220,7 @@ rules:
220
220
  then:
221
221
  function: apq-alternate-paths
222
222
  functionOptions:
223
- except: ["me", "get", "search"]
223
+ except: ["me", "get", "search", "delete"]
224
224
  apiq:OAR018:
225
225
  description: Operation not recommended for resource path depending on HTTP verb
226
226
  message: "OAR018: Operation not recommended for resource path: {{path}} ({{verb}})"
@@ -475,6 +475,11 @@ rules:
475
475
  - $.paths.*.*.responses[?(@property !== "204")]
476
476
  then:
477
477
  function: apq-check-examples-coverage
478
+ functionOptions:
479
+ validateResponse: true
480
+ validateRequestBody: true
481
+ validateParameter: true
482
+ validateProperty: true
478
483
  apiq:OAR032:
479
484
  description: "Ambiguous path parts not encouraged."
480
485
  message: "OAR032: When reading a URL, it must be possible to interpret which resources it is referring to in a concrete way. Ambiguous names in resources must be avoided."
@@ -523,7 +528,7 @@ rules:
523
528
  functionOptions:
524
529
  notMatch: "^(Cookie|Set-Cookie)$"
525
530
  apiq:OAR037:
526
- description: "Schema should use well-defined type and format."
531
+ description: "If a string schema declares a format, it must be a valid format value."
527
532
  message: "OAR037: String schemas must specify a valid format (date, date-time, password, byte, binary, email, uuid, uri, hostname, ipv4, ipv6, HEX, HEX(16), json, xml, or base64)."
528
533
  severity: error
529
534
  given: "$..[?(@.type=='string')]"
@@ -615,11 +620,14 @@ rules:
615
620
  description: "Declared media type should conform to RFC6838 and RFC7231."
616
621
  message: "OAR044: Declared media type range should conform to RFC7231."
617
622
  severity: "error"
618
- given: "$.paths.*.*.responses.*.content.*~"
623
+ given:
624
+ - "$.paths.*.*.responses.*.content.*~"
625
+ - "$.paths.*.*.requestBody.content.*~"
626
+ - "$.components.requestBodies.*.content.*~"
619
627
  then:
620
628
  function: pattern
621
629
  functionOptions:
622
- match: "^application\/[a-zA-Z0-9-_]+$"
630
+ match: "^(\\*|[a-zA-Z0-9][a-zA-Z0-9!#$&\\-^_.+]*)/(\\*|[a-zA-Z0-9][a-zA-Z0-9!#$&\\-^_.+]*)(;\\s*[a-zA-Z0-9]+=[\\w\\-]+)*$"
623
631
  apiq:OAR045:
624
632
  description: "Response schema is required for responses with status codes 201 and others that return content."
625
633
  message: "OAR045: Response schema is required for status code '{{property}}'."
@@ -1,47 +1,92 @@
1
1
  const COMBINERS = ['allOf', 'oneOf', 'anyOf'];
2
2
 
3
- const checkSchema = (schema) => {
4
- if (!schema || typeof schema !== 'object') return false;
5
- if (schema.example !== undefined || schema.examples !== undefined) return true;
6
- if (schema.properties) return Object.values(schema.properties).some(checkSchema);
7
- if (schema.items) return checkSchema(schema.items);
8
- return COMBINERS.some(c => Array.isArray(schema[c]) && schema[c].some(checkSchema));
3
+ // A level is validated unless explicitly turned off with `false`.
4
+ const isOn = (value) => value !== false;
5
+
6
+ // Whole-body (root) example declared directly on a schema node.
7
+ const schemaHasRootExample = (schema) =>
8
+ !!schema && typeof schema === 'object'
9
+ && (schema.example !== undefined || schema.examples !== undefined);
10
+
11
+ // Parameter-level example: param.example/examples, the param schema's root example,
12
+ // or a media-type example under param.content (OAS3). OAS2 params use param.example.
13
+ const hasParameterExample = (param) => {
14
+ if (!param || typeof param !== 'object') return false;
15
+ if (param.example !== undefined || param.examples !== undefined) return true;
16
+ if (schemaHasRootExample(param.schema)) return true;
17
+ if (param.content && typeof param.content === 'object') {
18
+ return Object.values(param.content).some(
19
+ (mt) => mt && typeof mt === 'object'
20
+ && (mt.example !== undefined || mt.examples !== undefined || schemaHasRootExample(mt.schema)),
21
+ );
22
+ }
23
+ return false;
9
24
  };
10
25
 
11
- const hasExample = (node) => {
26
+ // Body-level (whole response / requestBody) example: a media-type example/examples or a
27
+ // root schema example. OAS3 -> content.<mt>.{example|examples|schema.example}.
28
+ // OAS2 -> node.examples map or node.schema root example.
29
+ const hasBodyLevelExample = (node) => {
12
30
  if (!node || typeof node !== 'object') return false;
13
- if (node.example !== undefined || node.examples !== undefined) return true;
14
- if (node.content) return Object.values(node.content).some(hasExample);
15
- if (node.schema) return checkSchema(node.schema);
16
- return !!(node.properties || node.items || node.type) && checkSchema(node);
31
+ if (node.content && typeof node.content === 'object') {
32
+ return Object.values(node.content).some(
33
+ (mt) => mt && typeof mt === 'object'
34
+ && (mt.example !== undefined || mt.examples !== undefined || schemaHasRootExample(mt.schema)),
35
+ );
36
+ }
37
+ if (node.examples !== undefined) return true; // OAS2 response-level examples map
38
+ if (schemaHasRootExample(node.schema)) return true; // OAS2 root schema example
39
+ return false;
17
40
  };
18
41
 
19
- const collectPropertyIssues = (schema, issues) => {
42
+ // Per-leaf-property example check (recurses objects, arrays and combiners).
43
+ const collectPropertyIssues = (schema, issues, basePath) => {
20
44
  if (!schema || typeof schema !== 'object' || schema.$ref) return;
21
45
 
22
46
  if (schema.type === 'array') {
23
- schema.items && collectPropertyIssues(schema.items, issues);
47
+ if (schema.items) collectPropertyIssues(schema.items, issues, [...basePath, 'items']);
24
48
  return;
25
49
  }
26
50
 
27
51
  if (schema.properties) {
28
52
  for (const [propName, propSchema] of Object.entries(schema.properties)) {
29
53
  if (!propSchema || typeof propSchema !== 'object' || propSchema.$ref) continue;
54
+ const propPath = [...basePath, 'properties', propName];
30
55
  const propType = propSchema.type;
31
56
  if (propType === 'object' || propType === 'array' || (!propType && propSchema.properties)) {
32
- collectPropertyIssues(propSchema, issues);
57
+ collectPropertyIssues(propSchema, issues, propPath);
33
58
  } else if (propType !== undefined && propSchema.example === undefined && propSchema.examples === undefined) {
34
- issues.push({ message: `OAR031: Property '${propName}' is missing an example.` });
59
+ issues.push({
60
+ message: `OAR031: Property '${propName}' is missing an example.`,
61
+ path: propPath,
62
+ });
35
63
  }
36
64
  }
37
65
  }
38
66
 
39
- COMBINERS.forEach(c => {
40
- if (Array.isArray(schema[c])) schema[c].forEach(sub => collectPropertyIssues(sub, issues));
67
+ COMBINERS.forEach((c) => {
68
+ if (Array.isArray(schema[c])) {
69
+ schema[c].forEach((sub, i) => collectPropertyIssues(sub, issues, [...basePath, c, i]));
70
+ }
41
71
  });
42
72
  };
43
73
 
74
+ // Property-level coverage for a response / requestBody node (OAS3 content.* or OAS2 schema).
75
+ const collectBodyProperties = (node, issues, basePath) => {
76
+ if (node.content && typeof node.content === 'object') {
77
+ Object.entries(node.content).forEach(([mediaType, mt]) => {
78
+ if (mt?.schema) collectPropertyIssues(mt.schema, issues, [...basePath, 'content', mediaType, 'schema']);
79
+ });
80
+ } else if (node.schema) {
81
+ collectPropertyIssues(node.schema, issues, [...basePath, 'schema']);
82
+ }
83
+ };
84
+
44
85
  /**
86
+ * OAR031 — examples coverage, validated independently per level. Each level can be
87
+ * switched off via functionOptions (all default on):
88
+ * validateResponse, validateRequestBody, validateParameter, validateProperty
89
+ *
45
90
  * @param {object} given
46
91
  * @param {object} options
47
92
  * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
@@ -49,23 +94,42 @@ const collectPropertyIssues = (schema, issues) => {
49
94
  module.exports = function oar031ExamplesCoverage(given, options, context) {
50
95
  if (!given || typeof given !== 'object') return [];
51
96
 
97
+ const opts = options || {};
98
+ const validateResponse = isOn(opts.validateResponse);
99
+ const validateRequestBody = isOn(opts.validateRequestBody);
100
+ const validateParameter = isOn(opts.validateParameter);
101
+ const validateProperty = isOn(opts.validateProperty);
102
+
52
103
  const issues = [];
53
104
  const nodePath = context.path || [];
54
105
  const isParameter = nodePath.length >= 2 && nodePath.at(-2) === 'parameters';
55
- const isBodyParam = isParameter && given.in === 'body';
106
+ const isRequestBody = nodePath.at(-1) === 'requestBody';
56
107
 
57
- if (!hasExample(given)) {
58
- issues.push({ message: 'OAR031: Must have one or more examples defined' });
59
- }
60
-
61
- if (!isParameter || isBodyParam) {
62
- if (given.content) {
63
- Object.values(given.content).forEach(mt => {
64
- if (mt?.schema) collectPropertyIssues(mt.schema, issues);
108
+ if (isParameter) {
109
+ if (validateParameter && !hasParameterExample(given)) {
110
+ issues.push({
111
+ message: `OAR031: Parameter '${given.name || ''}' must have an example defined`,
112
+ path: nodePath,
65
113
  });
66
- } else if (given.schema) {
67
- collectPropertyIssues(given.schema, issues);
68
114
  }
115
+ // OAS2 body parameters carry a schema, so property-level coverage applies to them too.
116
+ if (given.in === 'body' && validateProperty && given.schema) {
117
+ collectPropertyIssues(given.schema, issues, [...nodePath, 'schema']);
118
+ }
119
+ return issues;
120
+ }
121
+
122
+ const levelOn = isRequestBody ? validateRequestBody : validateResponse;
123
+ const levelMessage = isRequestBody
124
+ ? 'OAR031: Request body must have an example defined'
125
+ : 'OAR031: Response must have an example defined';
126
+
127
+ if (levelOn && !hasBodyLevelExample(given)) {
128
+ issues.push({ message: levelMessage, path: nodePath });
129
+ }
130
+
131
+ if (validateProperty) {
132
+ collectBodyProperties(given, issues, nodePath);
69
133
  }
70
134
 
71
135
  return issues;
@@ -6,7 +6,7 @@ const VALID_FORMATS = new Set([
6
6
 
7
7
  module.exports = (targetVal, _options, context) => {
8
8
  const format = targetVal.format;
9
- if (format === undefined || format === null || !VALID_FORMATS.has(String(format).toLowerCase())) {
9
+ if (format !== undefined && format !== null && !VALID_FORMATS.has(String(format).toLowerCase())) {
10
10
  return [{ message: context.rule.message }];
11
11
  }
12
12
  return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "api-quality-spectral-ruleset",
3
- "version": "1.3.0",
3
+ "version": "1.4.0-beta.2",
4
4
  "description": "Spectral ruleset by API Quality",
5
5
  "main": "apq-spectral.yaml",
6
6
  "files": [