api-quality-spectral-ruleset 1.5.0-beta.3 → 1.5.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
@@ -40,6 +40,11 @@ functions:
40
40
  - apq-string-parameter-integrity
41
41
  - apq-example-schema-types
42
42
  - apq-standard-response-schema
43
+ - apq-allowed-http-verbs
44
+ - apq-url-naming-convention
45
+ - apq-mandatory-response-codes
46
+ - apq-rate-limit-response
47
+ - apq-forbidden-query-format
43
48
  extends:
44
49
  - spectral:asyncapi
45
50
  - spectral:oas
@@ -117,12 +122,14 @@ rules:
117
122
  function: truthy
118
123
  apiq:OAR008:
119
124
  description: "HTTP verbs not encouraged."
120
- message: "OAR008: Only the REST standard verbs are allowed (POST, GET, PUT, PATCH and DELETE)."
125
+ message: "{{error}}"
121
126
  documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR008.md"
122
127
  severity: error
123
- given: "$.paths[*][head,options,trace]"
128
+ given: "$.paths[*]"
124
129
  then:
125
- function: falsy
130
+ function: apq-allowed-http-verbs
131
+ functionOptions:
132
+ allowed-verbs: "get,post,put,delete,patch"
126
133
  apiq:OAR009:
127
134
  description: "Default request media type should be defined for operations."
128
135
  message: "OAR009: Default request media type is mandatory."
@@ -138,18 +145,21 @@ rules:
138
145
  given: "$.paths[*][get,post,put,patch,delete]"
139
146
  then:
140
147
  function: apq-response-media-type
148
+ functionOptions:
149
+ default-media-type: "application/json"
150
+ media-type-exceptions: "-"
141
151
  apiq:OAR011:
142
- description: "URLs should follow the specified naming convention: all literal path segments must be in kebab-case."
143
- message: "OAR011: The resource names with more than two words must be compliant with the standard naming convention (kebab-case)."
152
+ description: "URLs should follow the configured naming convention (default kebab-case): all literal path segments must comply."
153
+ message: "{{error}}"
144
154
  documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR011.md"
145
155
  severity: error
146
156
  given:
147
157
  - "$.paths[*]~"
148
158
  - "$.servers[*].url"
149
159
  then:
150
- function: pattern
160
+ function: apq-url-naming-convention
151
161
  functionOptions:
152
- match: "^(https?:\\/\\/[^\\/]+)?(\\/(?:[a-z0-9]+(?:-[a-z0-9]+)*|\\{[^}]+\\}))*\\/?$"
162
+ naming-convention: "kebab-case"
153
163
  apiq:OAR012:
154
164
  description: "Path params, query params, object names and property names should follow the configured naming convention. You can configure snake_case (default), kebab-case, camelCase or UpperCamelCase"
155
165
  message: "OAR012: Path params, query params, object names and property names must follow the configured naming convention."
@@ -215,7 +225,7 @@ rules:
215
225
  then:
216
226
  function: apq-alternate-paths
217
227
  functionOptions:
218
- except: ["me", "get", "search", "delete"]
228
+ exclude_patterns: "get,me,search,delete"
219
229
  apiq:OAR018:
220
230
  description: Operation not recommended for resource path depending on HTTP verb
221
231
  message: "OAR018: Operation not recommended for resource path: {{path}} ({{verb}})"
@@ -355,12 +365,16 @@ rules:
355
365
  field: "headers.Location.schema.type"
356
366
  function: truthy
357
367
  apiq:OAR028:
358
- description: "$filter must be defined as a query parameter in all collection operations (excluding detail endpoints and health checks)."
359
- message: "OAR028: $filter must be defined as a query parameter in this operation."
368
+ description: "$filter must be defined as a query parameter in the collection GET operations selected by the configured paths."
369
+ message: "{{error}}"
360
370
  severity: warn
361
- given: "$.paths[?(!@property.match(/\\/me(\\/|$)/) && !@property.match(/\\/\\{[^}]+\\}$/) && !@property.match(/status|health|ping/))].get"
371
+ given: "$.paths"
362
372
  then:
363
- function: apq-has-filter-query-param
373
+ function: apq-collection-query-param-required
374
+ functionOptions:
375
+ parameterName: "$filter"
376
+ paths: "/examples"
377
+ pathValidationStrategy: "/include"
364
378
  apiq:OAR029:
365
379
  description: "A response not compliant with the standard may cause application issues."
366
380
  message: "{{error}}"
@@ -398,13 +412,13 @@ rules:
398
412
  validateProperty: true
399
413
  apiq:OAR032:
400
414
  description: "Ambiguous path parts not encouraged."
401
- 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."
415
+ message: "{{error}}"
402
416
  severity: error
403
417
  given: "$.paths.*~"
404
418
  then:
405
419
  function: apq-check-ambiguous-path
406
420
  functionOptions:
407
- ambiguous-words: "elements,instances,resources,values,terms,objects,items"
421
+ ambiguous-names: "elementos,instancias,recursos,valores,terminos,objetos,articulos,elements,instances,resources,values,terms,objects,items"
408
422
  apiq:OAR033:
409
423
  description: "Request operation parameters must not include forbidden headers (Accept, Content-Type, Authorization). Note: This validates REQUEST headers, not response headers (see OAR053/114 for response header validation)."
410
424
  message: "OAR033: The request header parameter '{{value}}' is not allowed in operations."
@@ -695,75 +709,59 @@ rules:
695
709
  path-exclusions: "/status"
696
710
  apiq:OAR061:
697
711
  description: "Ensure get have mandatory response codes"
698
- message: "OAR061: Ensure get have the mandatory response codes"
712
+ message: "{{error}}"
699
713
  severity: error
700
- given: "$.paths[?(@property !== '/status')].get.responses"
714
+ given: "$.paths[*][get]"
701
715
  then:
702
- function: schema
716
+ function: apq-mandatory-response-codes
703
717
  functionOptions:
704
- schema:
705
- oneOf:
706
- - required: ['200']
707
- - required: ['202']
708
- - required: ['206']
709
- not:
710
- required: ['201', '204']
718
+ mandatory-response-codes: "200, 202, 206"
719
+ paths: "/status, /another"
720
+ pathValidationStrategy: "/exclude"
711
721
  apiq:OAR062:
712
722
  description: "Ensure post have mandatory response codes"
713
- message: "OAR062: Ensure post have the mandatory response codes"
723
+ message: "{{error}}"
714
724
  severity: error
715
- given: "$.paths[?(@property !== '/status')].post.responses"
725
+ given: "$.paths[*][post]"
716
726
  then:
717
- function: schema
727
+ function: apq-mandatory-response-codes
718
728
  functionOptions:
719
- schema:
720
- oneOf:
721
- - required: ['200']
722
- - required: ['201']
723
- - required: ['202']
724
- - required: ['204']
725
- - required: ['206']
729
+ mandatory-response-codes: "200, 201, 202, 204, 206"
730
+ paths: "/status, /another"
731
+ pathValidationStrategy: "/exclude"
726
732
  apiq:OAR063:
727
733
  description: "Ensure put have mandatory response codes"
728
- message: "OAR063: Ensure put have the mandatory response codes"
734
+ message: "{{error}}"
729
735
  severity: error
730
- given: "$.paths[?(@property !== '/status')].put.responses"
736
+ given: "$.paths[*][put]"
731
737
  then:
732
- function: schema
738
+ function: apq-mandatory-response-codes
733
739
  functionOptions:
734
- schema:
735
- oneOf:
736
- - required: ['200']
737
- - required: ['202']
738
- - required: ['204']
739
- - required: ['206']
740
+ mandatory-response-codes: "200, 202, 204, 206"
741
+ paths: "/status, /another"
742
+ pathValidationStrategy: "/exclude"
740
743
  apiq:OAR064:
741
744
  description: "Ensure patch have mandatory response codes"
742
- message: "OAR064: Ensure patch have the mandatory response codes"
745
+ message: "{{error}}"
743
746
  severity: error
744
- given: "$.paths[?(@property !== '/status')].patch.responses"
747
+ given: "$.paths[*][patch]"
745
748
  then:
746
- function: schema
749
+ function: apq-mandatory-response-codes
747
750
  functionOptions:
748
- schema:
749
- oneOf:
750
- - required: ['200']
751
- - required: ['202']
752
- - required: ['204']
753
- - required: ['206']
751
+ mandatory-response-codes: "200, 202, 204, 206"
752
+ paths: "/status, /another"
753
+ pathValidationStrategy: "/exclude"
754
754
  apiq:OAR065:
755
755
  description: "Ensure delete have mandatory response codes"
756
- message: "OAR065: Ensure delete have the mandatory response codes"
756
+ message: "{{error}}"
757
757
  severity: error
758
- given: "$.paths[?(@property !== '/status')].delete.responses"
758
+ given: "$.paths[*][delete]"
759
759
  then:
760
- function: schema
760
+ function: apq-mandatory-response-codes
761
761
  functionOptions:
762
- schema:
763
- oneOf:
764
- - required: ['200']
765
- - required: ['202']
766
- - required: ['204']
762
+ mandatory-response-codes: "200, 202, 204"
763
+ paths: "/status, /another"
764
+ pathValidationStrategy: "/exclude"
767
765
  apiq:OAR066:
768
766
  description: "RequestBody and Responses schema property names must be compliant with the snake_case naming convention."
769
767
  message: "OAR066: RequestBody and Responses schema property names must be compliant with the snake_case naming convention."
@@ -834,10 +832,12 @@ rules:
834
832
  description: "API should include a 429 response to indicate rate limiting, except for health check paths like /status, /health, /health-check, /ping, /liveness, /readiness."
835
833
  message: "OAR073: API should include a 429 response to indicate rate limiting."
836
834
  severity: error
837
- given: "$.paths[?(@property !== '/status' && @property !== '/health' && @property !== '/health-check' && @property !== '/ping' && @property !== '/liveness' && @property !== '/readiness')][*].responses"
835
+ given: "$.paths[*][get,post,put,patch,delete]"
838
836
  then:
839
- field: "429"
840
- function: truthy
837
+ function: apq-rate-limit-response
838
+ functionOptions:
839
+ paths: "/status, /health, /health-check, /ping, /liveness, /readiness"
840
+ pathValidationStrategy: "/exclude"
841
841
  apiq:OAR074:
842
842
  description: "Numeric parameters should define minimum and maximum, or a format restriction."
843
843
  message: "OAR074: Numeric parameter should define both 'minimum' and 'maximum', or a 'format' restriction."
@@ -898,7 +898,8 @@ rules:
898
898
  then:
899
899
  function: apq-require-response-on-path-params
900
900
  functionOptions:
901
- response: "404"
901
+ paths: "/status"
902
+ pathValidationStrategy: "/exclude"
902
903
  apiq:OAR080:
903
904
  description: "The security scheme must be among those allowed by the organization and must be complete."
904
905
  message: "OAR080: The security scheme '{{property}}' must be among those allowed by the organization and must be complete."
@@ -936,15 +937,16 @@ rules:
936
937
  functionOptions:
937
938
  notMatch: "^(email|password)$"
938
939
  apiq:OAR084:
939
- description: "The format password should not pass through the querystring."
940
- message: "OAR084: The format password should not be used in querystring parameters."
940
+ description: "Some formats should not pass through this querystring."
941
+ message: "{{error}}"
941
942
  severity: error
942
943
  given: "$.paths[*][get,post,put,patch,delete].parameters[?(@.in == 'query')]"
943
944
  then:
944
- field: "schema.format"
945
- function: pattern
945
+ function: apq-forbidden-query-format
946
946
  functionOptions:
947
- notMatch: "^password$"
947
+ forbidden-query-formats: "password"
948
+ paths: "/examples"
949
+ pathValidationStrategy: "/include"
948
950
  apiq:OAR085:
949
951
  description: "The OpenAPI version must be one of: 2.0, 3.0.0, 3.0.1, 3.0.2, 3.0.3, 3.0.4, 3.1.0, 3.1.1, 3.1.2, 3.2.0."
950
952
  message: "{{error}}"
@@ -0,0 +1,31 @@
1
+ const HTTP_VERBS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
2
+
3
+ /**
4
+ * @param {object} given
5
+ * @param {object} options
6
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
7
+ */
8
+ module.exports = (given, options, context) => {
9
+ if (!given || typeof given !== 'object') {
10
+ return [];
11
+ }
12
+
13
+ const allowedVerbs = ((options && options['allowed-verbs']) || '')
14
+ .split(',')
15
+ .map((verb) => verb.trim().toLowerCase())
16
+ .filter(Boolean);
17
+
18
+ const basePath = context.path || [];
19
+ const errors = [];
20
+
21
+ HTTP_VERBS.forEach((verb) => {
22
+ if (Object.prototype.hasOwnProperty.call(given, verb) && !allowedVerbs.includes(verb)) {
23
+ errors.push({
24
+ message: `HTTP verb '${verb}' is not encouraged. Only the following verbs are allowed: ${allowedVerbs.join(', ')}.`,
25
+ path: [...basePath, verb]
26
+ });
27
+ }
28
+ });
29
+
30
+ return errors;
31
+ };
@@ -1,18 +1,22 @@
1
-
2
1
  const isVariable = (part) => {
3
2
  return (part.startsWith('{') && part.endsWith('}'));
4
3
  }
5
4
 
6
- module.exports = (given, { except }, context) => {
5
+ module.exports = (given, options, context) => {
7
6
  const result = [];
8
7
  const paths = given || [];
9
8
  if (paths.length === 0) return result;
10
9
 
10
+ const excludePatterns = ((options && options['exclude_patterns']) || '')
11
+ .split(',')
12
+ .map((pattern) => pattern.trim())
13
+ .filter(Boolean);
14
+
11
15
  const parts = paths.substr(1).split('/').filter(p => p.length > 0);
12
16
  if (parts.length === 0) return result;
13
17
  const firstPart = parts.shift();
14
18
  let previousIsVar;
15
- if (except && except.includes(firstPart)) {
19
+ if (excludePatterns.includes(firstPart)) {
16
20
  previousIsVar = true;
17
21
  } else if (isVariable(firstPart)) {
18
22
  return [{ message: context.rule.message }];
@@ -21,7 +25,7 @@ module.exports = (given, { except }, context) => {
21
25
  }
22
26
 
23
27
  for (const part of parts) {
24
- if (except && except.includes(part)) {
28
+ if (excludePatterns.includes(part)) {
25
29
  previousIsVar = true;
26
30
  continue;
27
31
  }
@@ -1,52 +1,38 @@
1
+ const PARAM_REGEX = /\{[^}{]*}/g;
2
+
3
+ const DEFAULT_AMBIGUOUS_NAMES =
4
+ 'elementos,instancias,recursos,valores,terminos,objetos,articulos,elements,instances,resources,values,terms,objects,items';
5
+
1
6
  /**
2
- * Validates that URL path segments use clear, unambiguous names
3
7
  * @param {string} given - The path key (e.g. "/users/{id}/items")
4
- * @param {object} options - Function options
5
- * @param {string} options.ambiguous-words - Comma-separated list of ambiguous words
8
+ * @param {object} options
9
+ * @param {string} options['ambiguous-names']
6
10
  * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
7
11
  */
8
12
  module.exports = (given, options, context) => {
9
- const results = [];
10
-
11
13
  if (typeof given !== 'string') {
12
- return results;
14
+ return [];
13
15
  }
14
16
 
15
- // Default ambiguous words (from the original regex)
16
- const defaultAmbiguousWords = [
17
- 'elements', 'instances', 'resources', 'values', 'terms', 'objects', 'items'
18
- ];
19
-
20
- // Parse custom ambiguous words from options if provided
21
- let ambiguousWords = defaultAmbiguousWords;
22
- if (options && options['ambiguous-words']) {
23
- ambiguousWords = options['ambiguous-words']
17
+ const forbiddenValues = new Set(
18
+ ((options && options['ambiguous-names']) || DEFAULT_AMBIGUOUS_NAMES)
24
19
  .split(',')
25
- .map(w => w.trim().toLowerCase())
26
- .filter(Boolean);
27
- }
20
+ .map((value) => value.trim())
21
+ .filter(Boolean)
22
+ );
28
23
 
29
- // Split path into segments and filter out parameters
30
- const segments = given
31
- .split('/')
32
- .filter(segment => {
33
- // Keep only static segments (not empty, not {param})
34
- return segment.length > 0 && !segment.startsWith('{');
35
- });
24
+ const pathWithoutParams = given.replace(PARAM_REGEX, '');
25
+ const pathParts = pathWithoutParams.split('/').filter(Boolean);
26
+ const forbidden = pathParts.filter((part) => forbiddenValues.has(part));
36
27
 
37
- // Check each segment for ambiguous words
38
- for (const segment of segments) {
39
- const lowerSegment = segment.toLowerCase();
40
- for (const word of ambiguousWords) {
41
- if (lowerSegment.includes(word)) {
42
- results.push({
43
- message: `${context.rule.message || 'OAR032'}: Path segment '${segment}' is ambiguous. Avoid using words like '${word}' in resource names.`,
44
- path: context.path
45
- });
46
- break; // Report only once per segment
47
- }
48
- }
28
+ if (forbidden.length === 0) {
29
+ return [];
49
30
  }
50
31
 
51
- return results;
32
+ return [
33
+ {
34
+ message: `Ambiguous name(s) found in path: ${forbidden.join(', ')}.`,
35
+ path: context.path
36
+ }
37
+ ];
52
38
  };
@@ -0,0 +1,29 @@
1
+ module.exports = (parameter, options, context) => {
2
+ if (!parameter || typeof parameter !== 'object') return [];
3
+
4
+ const forbiddenFormats = ((options && options['forbidden-query-formats']) || '')
5
+ .split(',')
6
+ .map((value) => value.trim())
7
+ .filter(Boolean);
8
+ if (forbiddenFormats.length === 0) return [];
9
+
10
+ const paths = ((options && options.paths) || '')
11
+ .split(',')
12
+ .map((path) => path.trim())
13
+ .filter(Boolean);
14
+ const strategy = (options && options.pathValidationStrategy) || '/include';
15
+
16
+ const currentPath = context.path[1];
17
+ const isListed = paths.includes(currentPath);
18
+ const shouldExclude = strategy === '/exclude' ? isListed : !isListed;
19
+ if (shouldExclude) return [];
20
+
21
+ const root = context.document.parserResult.data;
22
+ const format = root.swagger ? parameter.format : parameter.schema && parameter.schema.format;
23
+
24
+ if (typeof format === 'string' && forbiddenFormats.includes(format)) {
25
+ return [{ message: context.rule.message }];
26
+ }
27
+
28
+ return [];
29
+ };
@@ -0,0 +1,36 @@
1
+ module.exports = (operation, options, context) => {
2
+ if (!operation || typeof operation !== 'object') return [];
3
+
4
+ const mandatoryCodes = ((options && options['mandatory-response-codes']) || '')
5
+ .split(',')
6
+ .map((code) => code.trim())
7
+ .filter(Boolean);
8
+ if (mandatoryCodes.length === 0) return [];
9
+
10
+ const paths = ((options && options.paths) || '')
11
+ .split(',')
12
+ .map((path) => path.trim())
13
+ .filter(Boolean);
14
+ const strategy = (options && options.pathValidationStrategy) || '/exclude';
15
+
16
+ const currentPath = context.path[context.path.length - 2];
17
+ const isListed = paths.includes(currentPath);
18
+ const shouldExclude = strategy === '/exclude' ? isListed : !isListed;
19
+ if (shouldExclude) return [];
20
+
21
+ const message = `Mandatory response code(s) required: ${mandatoryCodes.join(', ')}.`;
22
+
23
+ const responses = operation.responses;
24
+ if (!responses || typeof responses !== 'object') {
25
+ return [{ message }];
26
+ }
27
+
28
+ const responseCodes = Object.keys(responses).map((code) => code.trim());
29
+ const hasMandatoryCode = mandatoryCodes.some((code) => responseCodes.includes(code));
30
+
31
+ if (!hasMandatoryCode) {
32
+ return [{ message }];
33
+ }
34
+
35
+ return [];
36
+ };
@@ -0,0 +1,21 @@
1
+ module.exports = (operation, options, context) => {
2
+ if (!operation || typeof operation !== 'object' || Array.isArray(operation)) return [];
3
+
4
+ const paths = ((options && options.paths) || '')
5
+ .split(',')
6
+ .map((path) => path.trim())
7
+ .filter(Boolean);
8
+ const strategy = (options && options.pathValidationStrategy) || '/exclude';
9
+
10
+ const currentPath = context.path[context.path.length - 2];
11
+ const isListed = paths.includes(currentPath);
12
+ const shouldExclude = strategy === '/exclude' ? isListed : !isListed;
13
+ if (shouldExclude) return [];
14
+
15
+ const responses = operation.responses;
16
+ if (!responses || typeof responses !== 'object' || !('429' in responses)) {
17
+ return [{ message: context.rule.message }];
18
+ }
19
+
20
+ return [];
21
+ };
@@ -1,9 +1,8 @@
1
1
  /**
2
- * Requires one or more HTTP response codes when path parameters are present.
3
- *
4
2
  * @param {object} given
5
3
  * @param {object} options
6
- * @param {string} options.response Comma-separated HTTP status codes (e.g. "400,404,200")
4
+ * @param {string} options.paths Comma-separated list of paths to include/exclude (exact match)
5
+ * @param {string} options.pathValidationStrategy "/include" or "/exclude" (default "/exclude")
7
6
  * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
8
7
  */
9
8
  module.exports = (given, options, context) => {
@@ -11,21 +10,15 @@ module.exports = (given, options, context) => {
11
10
  return [];
12
11
  }
13
12
 
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
13
+ const excludedPaths = ((options && options.paths) || '')
22
14
  .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
- }
15
+ .map((path) => path.trim())
16
+ .filter(Boolean);
17
+ const strategy = (options && options.pathValidationStrategy) || '/exclude';
18
+ const currentPath = context.path[context.path.length - 2];
19
+ const isListed = excludedPaths.includes(currentPath);
20
+ const shouldExclude = strategy === '/exclude' ? isListed : !isListed;
21
+ if (shouldExclude) return [];
29
22
 
30
23
  const pathItemKeyIndex = context.path?.length - 2;
31
24
  const pathItem = typeof pathItemKeyIndex === 'number'
@@ -46,11 +39,7 @@ module.exports = (given, options, context) => {
46
39
 
47
40
  const responses = given?.responses || {};
48
41
 
49
- const hasRequiredResponse = requiredResponses.some(
50
- code => responses[code]
51
- );
52
-
53
- if (!hasRequiredResponse) {
42
+ if (!responses['404']) {
54
43
  return [
55
44
  {
56
45
  message: context.rule.message,
@@ -59,4 +48,4 @@ module.exports = (given, options, context) => {
59
48
  }
60
49
 
61
50
  return [];
62
- };
51
+ };
@@ -1,21 +1,43 @@
1
1
  /**
2
2
  * @param {object} given
3
3
  * @param {object} options
4
+ * @param {string} options['default-media-type']
5
+ * @param {string} options['media-type-exceptions']
4
6
  * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
5
7
  */
6
8
 
7
9
  const NO_BODY_CODES = new Set(['204']);
8
10
 
11
+ function parseExceptions(value) {
12
+ return new Set(
13
+ String(value ?? '-')
14
+ .split(',')
15
+ .map((mediaType) => mediaType.trim().toLowerCase())
16
+ .filter(Boolean)
17
+ );
18
+ }
19
+
20
+ function isSupported(mediaType, defaultMediaType, exceptions) {
21
+ return mediaType === defaultMediaType || exceptions.has(mediaType);
22
+ }
23
+
9
24
  module.exports = (given, options, context) => {
10
25
  const errors = [];
11
26
  if (!given) return errors;
12
27
 
28
+ const defaultMediaType = ((options && options['default-media-type']) || 'application/json').toLowerCase();
29
+ const exceptions = parseExceptions(options && options['media-type-exceptions']);
30
+
13
31
  const root = context.document.parserResult.data;
14
32
 
15
33
  if (root.swagger) {
16
34
  const produces = given.produces ?? root.produces;
17
35
 
18
- if (!produces || !Array.isArray(produces) || !produces.includes('application/json')) {
36
+ if (
37
+ !produces ||
38
+ !Array.isArray(produces) ||
39
+ !produces.some((mediaType) => isSupported(String(mediaType).toLowerCase(), defaultMediaType, exceptions))
40
+ ) {
19
41
  errors.push({
20
42
  message: context.rule.message,
21
43
  path: [...context.path]
@@ -31,7 +53,10 @@ module.exports = (given, options, context) => {
31
53
  for (const [statusCode, response] of Object.entries(responses)) {
32
54
  if (NO_BODY_CODES.has(statusCode)) continue;
33
55
 
34
- if (!response?.content?.['application/json']) {
56
+ const contentTypes = response?.content ? Object.keys(response.content) : [];
57
+ const supported = contentTypes.some((mediaType) => isSupported(mediaType.toLowerCase(), defaultMediaType, exceptions));
58
+
59
+ if (!supported) {
35
60
  errors.push({
36
61
  message: context.rule.message,
37
62
  path: [...context.path, 'responses', statusCode]
@@ -40,4 +65,4 @@ module.exports = (given, options, context) => {
40
65
  }
41
66
 
42
67
  return errors;
43
- };
68
+ };
@@ -0,0 +1,59 @@
1
+ const NAMING_REGEX = {
2
+ snake_case: /^[a-z0-9_$]*$/,
3
+ 'kebab-case': /^[a-z0-9-.]*$/,
4
+ camelCase: /^[a-z]+(?:[A-Z][a-z]+)*([A-Z])?$/,
5
+ UpperCamelCase: /^[A-Z]+(?:[a-z]+)*([A-Z])?$/
6
+ };
7
+
8
+ const PARAM_REGEX = /\{[^}{]*}/g;
9
+ const URL_PREFIX_REGEX = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]*/;
10
+
11
+ function stripParams(name) {
12
+ return name.indexOf('/') >= 0 ? name.replace(PARAM_REGEX, '') : name;
13
+ }
14
+
15
+ function extractUrlPath(value) {
16
+ const match = URL_PREFIX_REGEX.exec(value);
17
+ return match ? value.slice(match[0].length) : value;
18
+ }
19
+
20
+ function isValid(name, convention) {
21
+ const regex = NAMING_REGEX[convention];
22
+ if (convention === 'camelCase' || convention === 'UpperCamelCase') {
23
+ const joined = name.replace(/\//g, '');
24
+ if (joined.includes('_') || joined.includes('-')) return false;
25
+ return regex.test(joined);
26
+ }
27
+ const separator = convention === 'kebab-case' ? '-' : '_';
28
+ return regex.test(name.replace(/\//g, separator));
29
+ }
30
+
31
+ /**
32
+ * @param {string} given
33
+ * @param {object} options
34
+ * @param {string} options['naming-convention']
35
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
36
+ */
37
+ module.exports = (given, options, context) => {
38
+ if (typeof given !== 'string') {
39
+ return [];
40
+ }
41
+
42
+ const convention = (options && options['naming-convention']) || 'kebab-case';
43
+ if (!NAMING_REGEX[convention]) {
44
+ return [];
45
+ }
46
+
47
+ const name = stripParams(extractUrlPath(given));
48
+
49
+ if (!isValid(name, convention)) {
50
+ return [
51
+ {
52
+ message: `Value '${given}' does not follow the '${convention}' naming convention.`,
53
+ path: context.path
54
+ }
55
+ ];
56
+ }
57
+
58
+ return [];
59
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "api-quality-spectral-ruleset",
3
- "version": "1.5.0-beta.3",
3
+ "version": "1.5.0",
4
4
  "description": "Spectral ruleset by API Quality",
5
5
  "main": "apq-spectral.yaml",
6
6
  "files": [