api-quality-spectral-ruleset 1.0.0 → 1.1.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 CHANGED
@@ -7,6 +7,11 @@ functions:
7
7
  - apq-resources-by-verb
8
8
  - apq-at-most-one-body-parameter
9
9
  - apq-standard-response-codes
10
+ - apq-response-headers
11
+ - apq-require-response-on-path-params
12
+ - apq-required-fields-exist
13
+ - apq-security-check
14
+ - apq-custom-field
10
15
  extends:
11
16
  - spectral:asyncapi
12
17
  - spectral:oas
@@ -481,6 +486,17 @@ rules:
481
486
  then:
482
487
  field: "x-aut-type"
483
488
  function: truthy
489
+ apiq:OAR042:
490
+ description: "Base path must be compliant with the standard."
491
+ message: "OAR042: Base path must follow the '/api-<name>/v<version>' standard."
492
+ severity: "error"
493
+ given:
494
+ - "$.basePath"
495
+ - "$.servers[*].url"
496
+ then:
497
+ function: pattern
498
+ functionOptions:
499
+ match: "^(/api-[^/]+/v[0-9]+|https?:\\/\\/[^/]+\\/api-[^/]+\\/v[0-9]+)$"
484
500
  apiq:OAR044:
485
501
  description: "Declared media type should conform to RFC6838 and RFC7231."
486
502
  message: "OAR044: Declared media type range should conform to RFC7231."
@@ -590,6 +606,20 @@ rules:
590
606
  then:
591
607
  field: format
592
608
  function: truthy
609
+ apiq:OAR053:
610
+ description: "There are mandatory response headers and others that are not allowed."
611
+ message: "{{error}}"
612
+ documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR053.md"
613
+ severity: error
614
+ given: "$.paths[*][*].responses[*]"
615
+ then:
616
+ - function: apq-response-headers
617
+ functionOptions:
618
+ mandatory-headers: "x-trace-id,x-request-id"
619
+ allowed-headers: "idcorrelacion,x-correlacionid,x-global-trasaction-id,x-power-by,x-trace-id,x-request-id"
620
+ included-response-codes: "*"
621
+ excluded-response-codes: "204"
622
+ path-exclusions: "/status,/prueba"
593
623
  apiq:OAR054:
594
624
  description: "Ensure the host matches the specified format"
595
625
  message: "OAR054: Hostname must be a subdomain of the organization's domain name."
@@ -812,20 +842,22 @@ rules:
812
842
  match: ^\$?_?[a-z]+(_[a-z]+)*$
813
843
  apiq:OAR078:
814
844
  description: "All API methods must have security defined."
815
- message: "OAR078: The operation '{{property}}' must have security defined."
845
+ message: "{{error}}"
846
+ documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR078.md"
816
847
  severity: error
817
- given: "$.paths[*][get,post,put,patch,delete]"
848
+ given: "$"
818
849
  then:
819
- field: "security"
820
- function: truthy
850
+ function: apq-security-check
821
851
  apiq:OAR079:
822
852
  description: "Operations with path parameters should include a 404 Not Found response."
823
853
  message: "OAR079: Path parameter present, but missing 404 Not Found response."
854
+ documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR079.md"
824
855
  severity: "warn"
825
- given: "$.paths[*][*]"
856
+ given: "$.paths[*][get,post,put,patch,delete]"
826
857
  then:
827
- field: responses.404
828
- function: truthy
858
+ function: apq-require-response-on-path-params
859
+ functionOptions:
860
+ response: "404"
829
861
  apiq:OAR080:
830
862
  description: "The security scheme must be among those allowed by the organization and must be complete."
831
863
  message: "OAR080: The security scheme '{{property}}' must be among those allowed by the organization and must be complete."
@@ -1101,19 +1133,35 @@ rules:
1101
1133
  then:
1102
1134
  field: "contact"
1103
1135
  function: truthy
1136
+ apiq:OAR113:
1137
+ description: "Field or extension must be at the assigned location"
1138
+ message: "{{error}}"
1139
+ documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR113.md"
1140
+ severity: warn
1141
+ given: "$"
1142
+ then:
1143
+ function: apq-custom-field
1144
+ functionOptions:
1145
+ fieldName: "x-custom-example"
1146
+ fieldLocation: "path,operation_get,response_200"
1104
1147
  apiq:OAR114:
1105
- description: There are mandatory response headers and others that are not allowed.
1106
- message: "OAR114: Missing mandatory response x-api-key and x-trace-id headers, or forbidden headers are present."
1107
- documentationUrl: https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR114.md
1108
- severity: error
1109
- given: "$.paths[*][*].responses[*].headers"
1148
+ description: "There are mandatory response headers and others that are not allowed."
1149
+ message: "{{error}}"
1150
+ documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR114.md"
1151
+ severity: "error"
1152
+ given: "$.paths[*][*].responses[*]"
1110
1153
  then:
1111
- - function: schema
1154
+ - function: apq-response-headers
1112
1155
  functionOptions:
1113
- schema:
1114
- type: object
1115
- required:
1116
- - x-api-key
1117
- - x-trace-id
1118
- propertyNames:
1119
- pattern: "^(x-api-key|x-trace-id|traceId|dateTime)$"
1156
+ mandatory-headers: "x-api-key,x-trace-id"
1157
+ allowed-headers: "x-api-key,x-trace-id,traceId,dateTime"
1158
+ apiq:OAR115:
1159
+ description: "All fields listed in the required array must be defined in the schema properties."
1160
+ message: "{{error}}"
1161
+ documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR115.md"
1162
+ severity: warn
1163
+ given:
1164
+ - "$.components.schemas[*]"
1165
+ - "$.definitions[*]"
1166
+ then:
1167
+ function: apq-required-fields-exist
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @param {object} given
3
+ * @param {object} options
4
+ * @param {string} options.fieldName
5
+ * @param {string} options.fieldLocation
6
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
7
+ */
8
+ module.exports = (given, options, context) => {
9
+ const { fieldName, fieldLocation } = options || {};
10
+ const ruleCode = context.rule.name.split(':').pop();
11
+
12
+ if (!given || !fieldName || !fieldLocation) return [];
13
+
14
+ const locations = fieldLocation.split(',').map(l => l.trim());
15
+ const results = [];
16
+
17
+ const hasField = node =>
18
+ node && typeof node === 'object' && Object.prototype.hasOwnProperty.call(node, fieldName);
19
+
20
+ const addError = path =>
21
+ results.push({
22
+ message: `${ruleCode}: Field or extension ${fieldName} must be at the assigned location`,
23
+ path,
24
+ });
25
+
26
+ if (hasField(given)) {
27
+ return [];
28
+ }
29
+
30
+ const rootLocations = ['path', 'components'];
31
+
32
+ const missingRoot = rootLocations.find(
33
+ loc => locations.includes(loc) && !given[loc === 'path' ? 'paths' : loc]
34
+ );
35
+
36
+ if (missingRoot) {
37
+ return [
38
+ {
39
+ message: `${ruleCode}: The location ${missingRoot} is not present.`,
40
+ path: [],
41
+ },
42
+ ];
43
+ }
44
+
45
+ const opLoc = locations.find(l => l.startsWith('operation_'));
46
+ const respLoc = locations.find(l => l.startsWith('response_'));
47
+
48
+ const verb = opLoc?.split('_')[1];
49
+ const status = respLoc?.split('_')[1];
50
+
51
+ Object.entries(given.paths || {}).forEach(([pathKey, pathItem]) => {
52
+ if (hasField(pathItem)) return;
53
+
54
+ if (!verb || !pathItem[verb]) return;
55
+
56
+ const operation = pathItem[verb];
57
+
58
+ if (hasField(operation)) return;
59
+
60
+ if (!operation.responses || !operation.responses[status]) return;
61
+
62
+ const response = operation.responses[status];
63
+
64
+ if (!hasField(response)) {
65
+ addError(['paths', pathKey, verb, 'responses', status]);
66
+ }
67
+ });
68
+
69
+ return results;
70
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Requires one or more HTTP response codes when path parameters are present.
3
+ *
4
+ * @param {object} given
5
+ * @param {object} options
6
+ * @param {string} options.response Comma-separated HTTP status codes (e.g. "400,404,200")
7
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
8
+ */
9
+ module.exports = (given, options, context) => {
10
+ if (!given || typeof given !== 'object') {
11
+ return [];
12
+ }
13
+
14
+ const ruleCode = context.rule?.name?.split(':').pop();
15
+ const responseOpt = options?.response;
16
+
17
+ if (typeof responseOpt !== 'string') {
18
+ throw new TypeError(`${ruleCode}: "response" option must be a comma-separated string of HTTP status codes`);
19
+ }
20
+
21
+ const requiredResponses = responseOpt
22
+ .split(',')
23
+ .map(r => r.trim())
24
+ .filter(r => /^\d{3}$/.test(r));
25
+
26
+ if (requiredResponses.length === 0) {
27
+ throw new Error(`${ruleCode}: No valid HTTP status codes found in "response" option`);
28
+ }
29
+
30
+ const pathItemKeyIndex = context.path?.length - 2;
31
+ const pathItem = typeof pathItemKeyIndex === 'number'
32
+ ? context.path?.[pathItemKeyIndex]
33
+ : null;
34
+
35
+ const pathParams = (pathItem?.parameters || []).filter(
36
+ p => p?.in === 'path'
37
+ );
38
+
39
+ const operationParams = (given.parameters || []).filter(
40
+ p => p?.in === 'path'
41
+ );
42
+
43
+ if (pathParams.length === 0 && operationParams.length === 0) {
44
+ return [];
45
+ }
46
+
47
+ const responses = given?.responses || {};
48
+
49
+ const hasRequiredResponse = requiredResponses.some(
50
+ code => responses[code]
51
+ );
52
+
53
+ if (!hasRequiredResponse) {
54
+ return [
55
+ {
56
+ message: context.rule.message,
57
+ }
58
+ ];
59
+ }
60
+
61
+ return [];
62
+ };
@@ -0,0 +1,27 @@
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
+ const results = [];
8
+
9
+ if (!given || given.type !== 'object') return results;
10
+
11
+ const { required, properties } = given;
12
+ if (!Array.isArray(required)) return results;
13
+
14
+ const propertyNames = new Set(Object.keys(properties || {}));
15
+ const ruleCode = context.rule.name.split(':').pop();
16
+
17
+ required.forEach((field, index) => {
18
+ if (!propertyNames.has(field)) {
19
+ results.push({
20
+ message: `${ruleCode}: This value does not exist, '${field}' must be defined in the schema properties.`,
21
+ path: [...context.path, 'required', index]
22
+ });
23
+ }
24
+ });
25
+
26
+ return results;
27
+ };
@@ -0,0 +1,94 @@
1
+ module.exports = (responseNode, options = {}, context) => {
2
+ if (!responseNode || typeof responseNode !== 'object') {
3
+ return [];
4
+ }
5
+
6
+ const results = [];
7
+
8
+ const {
9
+ 'mandatory-headers': mandatoryHeadersOpt = '',
10
+ 'allowed-headers': allowedHeadersOpt = '',
11
+ 'included-response-codes': includedCodesOpt = '*',
12
+ 'excluded-response-codes': excludedCodesOpt = '',
13
+ 'path-exclusions': pathExclusionsOpt = '',
14
+ } = options;
15
+
16
+ const mandatoryHeaders = mandatoryHeadersOpt
17
+ .split(',')
18
+ .map(h => h.toLowerCase().trim())
19
+ .filter(Boolean);
20
+
21
+ const allowedHeaders = allowedHeadersOpt
22
+ .split(',')
23
+ .map(h => h.toLowerCase().trim())
24
+ .filter(Boolean);
25
+
26
+ const includedCodes = includedCodesOpt
27
+ .split(',')
28
+ .map(c => c.trim())
29
+ .filter(Boolean);
30
+
31
+ const excludedCodes = excludedCodesOpt
32
+ .split(',')
33
+ .map(c => c.trim())
34
+ .filter(Boolean);
35
+
36
+ const pathExclusions = pathExclusionsOpt
37
+ .split(',')
38
+ .map(p => p.trim())
39
+ .filter(Boolean);
40
+
41
+ const path = context.path?.find(p => typeof p === 'string' && p.startsWith('/'));
42
+ const responseCode = context.path?.[context.path.length - 1];
43
+
44
+ if (path && pathExclusions.includes(path)) {
45
+ return [];
46
+ }
47
+
48
+ if (excludedCodes.includes(String(responseCode))) {
49
+ return [];
50
+ }
51
+
52
+ if (!includedCodes.includes('*') && !includedCodes.includes(String(responseCode))) {
53
+ return [];
54
+ }
55
+
56
+ let headers = responseNode.headers;
57
+
58
+ if (!headers && responseNode.$ref && context.document?.resolved) {
59
+ try {
60
+ const ref = responseNode.$ref.replace(/^#\//, '').split('/');
61
+ let resolved = context.document.resolved;
62
+ for (const part of ref) {
63
+ resolved = resolved?.[part];
64
+ }
65
+ headers = resolved?.headers;
66
+ } catch (_) {
67
+ }
68
+ }
69
+
70
+ const headerNames = headers && typeof headers === 'object'
71
+ ? Object.keys(headers).map(h => h.toLowerCase().trim())
72
+ : [];
73
+ const ruleCode = context.rule.name.split(':').pop();
74
+
75
+ if (mandatoryHeaders.length > 0) {
76
+ const missing = mandatoryHeaders.filter(h => !headerNames.includes(h));
77
+ if (missing.length > 0) {
78
+ results.push({
79
+ message: `${ruleCode}: Headers [${missing.join(', ')}] are required`,
80
+ });
81
+ }
82
+ }
83
+
84
+ if (allowedHeaders.length > 0 && headerNames.length > 0) {
85
+ const forbidden = headerNames.filter(h => !allowedHeaders.includes(h));
86
+ if (forbidden.length > 0) {
87
+ results.push({
88
+ message: `${ruleCode}: Header not allowed`,
89
+ });
90
+ }
91
+ }
92
+
93
+ return results;
94
+ };
@@ -0,0 +1,35 @@
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) return [];
8
+
9
+ const results = [];
10
+ const ruleCode = context.rule.name.split(':').pop();
11
+
12
+ const globalSecurity = given.security ?? given.swagger?.security;
13
+ if (globalSecurity) {
14
+ return results;
15
+ }
16
+
17
+ const methods = ['get', 'post', 'put', 'patch', 'delete'];
18
+
19
+ const pathsObj = given.paths || {};
20
+ for (const [pathName, pathItem] of Object.entries(pathsObj)) {
21
+ for (const method of methods) {
22
+ const operation = pathItem[method];
23
+ if (!operation) continue;
24
+
25
+ if (!operation.security || operation.security.length === 0) {
26
+ results.push({
27
+ message: `${ruleCode}: The operation '${method} ${pathName}' must have security defined.`,
28
+ path: ['paths', pathName, method, 'security']
29
+ });
30
+ }
31
+ }
32
+ }
33
+
34
+ return results;
35
+ };
@@ -1,9 +1,14 @@
1
- module.exports = function apqStandardResponseCodes(targetVal, options, context) {
1
+ /**
2
+ * @param {object} given
3
+ * @param {object} options
4
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
5
+ */
6
+ module.exports = function apqStandardResponseCodes(given, options, context) {
2
7
  const results = [];
3
8
 
4
- if (!targetVal || !context?.path) return results;
9
+ if (!given || !context?.path) return results;
5
10
 
6
- const responses = targetVal.responses;
11
+ const responses = given.responses;
7
12
  if (!responses || typeof responses !== 'object') return results;
8
13
 
9
14
  const definedCodes = Object.keys(responses);
@@ -17,15 +22,15 @@ module.exports = function apqStandardResponseCodes(targetVal, options, context)
17
22
 
18
23
  const exclusions = options?.['resources-exclusions'] || [];
19
24
  if (exclusions.some(ex => {
20
- const [exVerb, exPath] = ex.split(':');
21
- return exVerb.toLowerCase() === verb.toLowerCase() && new RegExp(`^${exPath}$`).test(resourcePath);
22
- })) return results;
25
+ const [exVerb, exPath] = ex.split(':');
26
+ return exVerb.toLowerCase() === verb.toLowerCase() && new RegExp(`^${exPath}$`).test(resourcePath);
27
+ })) return results;
23
28
 
24
29
  const rulesConfig = options?.['required-codes-by-resources-paths'];
25
30
  if (!rulesConfig) return results;
26
31
 
27
32
  const rules = rulesConfig
28
- .split(/[\n;]/)
33
+ .split(/[\n;]/)
29
34
  .map(r => r.trim())
30
35
  .filter(Boolean)
31
36
  .map(rule => {
@@ -49,19 +54,19 @@ module.exports = function apqStandardResponseCodes(targetVal, options, context)
49
54
 
50
55
  if (!matchedRule) return results;
51
56
 
52
- matchedRule.requiredCodes.forEach(ruleCode => {
57
+ matchedRule.requiredCodes.forEach(respCode => {
53
58
  let missing = false;
54
59
  let msg = "";
55
60
 
56
- if (ruleCode.includes('|')) {
57
- const alternatives = ruleCode.split('|');
61
+ if (respCode.includes('|')) {
62
+ const alternatives = respCode.split('|');
58
63
  if (!alternatives.some(c => definedCodes.includes(c))) {
59
64
  missing = true;
60
65
  msg = `OAR039: Response code ${alternatives.join(' or ')} must be defined.`;
61
66
  }
62
- } else if (!definedCodes.includes(ruleCode)) {
67
+ } else if (!definedCodes.includes(respCode)) {
63
68
  missing = true;
64
- msg = `OAR039: Response code ${ruleCode} must be defined.`;
69
+ msg = `OAR039: Response code ${respCode} must be defined.`;
65
70
  }
66
71
 
67
72
  if (missing) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "api-quality-spectral-ruleset",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Spectral ruleset by API Quality",
5
5
  "main": "apq-spectral.yaml",
6
6
  "files": [