api-quality-spectral-ruleset 1.5.0 → 1.6.0-beta.1

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
@@ -18,8 +18,11 @@ functions:
18
18
  - apq-path-depth
19
19
  - apq-forbidden-characters
20
20
  - apq-valid-response-schema
21
+ - apq-post-201-location-header
22
+ - apq-response-no-content
21
23
  - apq-valid-openapi-version
22
24
  - apq-collection-query-param-required
25
+ - apq-total-param-default-value
23
26
  - apq-path-param-query-conflict
24
27
  - apq-schema-format
25
28
  - apq-security-required-response
@@ -45,6 +48,11 @@ functions:
45
48
  - apq-mandatory-response-codes
46
49
  - apq-rate-limit-response
47
50
  - apq-forbidden-query-format
51
+ - apq-wso2-scope-defined
52
+ - apq-undefined-response-media-type
53
+ - apq-wso2-auth-type-required
54
+ - apq-response-content-required
55
+ - apq-tags-consistency
48
56
  extends:
49
57
  - spectral:asyncapi
50
58
  - spectral:oas
@@ -89,17 +97,13 @@ rules:
89
97
  pattern: "^[a-zA-Z0-9_\\-., ]+$"
90
98
  apiq:OAR005:
91
99
  description: "A wrong scope may cause problems to import the API definition into WSO2 or allow all users to call the endpoint."
92
- message: "OAR005: Scope in the operation must be defined correctly and match an existing scope."
100
+ message: "{{error}}"
101
+ documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR005.md"
93
102
  severity: "error"
94
- given: "$.paths[*][*].x-scope"
103
+ resolved: false
104
+ given: "$"
95
105
  then:
96
- - function: "truthy"
97
- message: "Scope must be defined in the operation."
98
- - function: enumeration
99
- functionOptions:
100
- values:
101
- - read
102
- - write
106
+ function: apq-wso2-scope-defined
103
107
  apiq:OAR006:
104
108
  description: "Routes must define request media types supported by the API."
105
109
  message: "OAR006: Specify at least one Media Type in the content of the request body."
@@ -116,10 +120,13 @@ rules:
116
120
  message: "OAR007: Specify at least one Media Type in the content of the response body."
117
121
  documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR007.md"
118
122
  severity: "error"
119
- given: "$.paths[*][get,post,put,patch,delete].responses[?(@property != '204')]"
123
+ given:
124
+ - "$.paths[*][post,put,patch]"
125
+ - "$.webhooks[*][post,put,patch]"
126
+ - "$.paths[*][get,post,put,patch,delete].responses[?(@property != '204')]"
127
+ - "$.webhooks[*][get,post,put,patch,delete].responses[?(@property != '204')]"
120
128
  then:
121
- - field: "content"
122
- function: truthy
129
+ function: apq-undefined-response-media-type
123
130
  apiq:OAR008:
124
131
  description: "HTTP verbs not encouraged."
125
132
  message: "{{error}}"
@@ -142,7 +149,13 @@ rules:
142
149
  description: "Default response media type should be defined for responses."
143
150
  message: "OAR010: Default response media type is mandatory."
144
151
  severity: warn
145
- given: "$.paths[*][get,post,put,patch,delete]"
152
+ given:
153
+ - "$.paths[*][post,put,patch]"
154
+ - "$.webhooks[*][post,put,patch]"
155
+ - "$.paths[*][get,post,put,patch,delete].responses[?(@property != '204')]"
156
+ - "$.webhooks[*][get,post,put,patch,delete].responses[?(@property != '204')]"
157
+ - "$.paths[*][get,post,put,patch,delete].responses[?(@property != '204')].content"
158
+ - "$.webhooks[*][get,post,put,patch,delete].responses[?(@property != '204')].content"
146
159
  then:
147
160
  function: apq-response-media-type
148
161
  functionOptions:
@@ -350,20 +363,18 @@ rules:
350
363
  pathValidationStrategy: "/include"
351
364
  apiq:OAR026:
352
365
  description: "The $total parameter default value should be false."
353
- message: "OAR026: The $total parameter default value should be false."
366
+ message: "{{error}}"
354
367
  severity: error
355
368
  given: "$.paths[*].get.parameters[?(@.name == '$total' && @.in == 'query')]"
356
369
  then:
357
- field: "schema.default"
358
- function: falsy
370
+ function: apq-total-param-default-value
359
371
  apiq:OAR027:
360
372
  description: "Location header is required in responses with code 201 from POST operations."
361
- message: "OAR027: Location header is required in responses with code 201 from POST operations."
373
+ message: "{{error}}"
362
374
  severity: error
363
375
  given: "$.paths.*.post.responses['201']"
364
376
  then:
365
- field: "headers.Location.schema.type"
366
- function: truthy
377
+ function: apq-post-201-location-header
367
378
  apiq:OAR028:
368
379
  description: "$filter must be defined as a query parameter in the collection GET operations selected by the configured paths."
369
380
  message: "{{error}}"
@@ -471,9 +482,7 @@ rules:
471
482
  description: "The 201 response schema of a POST operation must have properties named 'data' or 'error' with at least one sub-property."
472
483
  message: "{{error}}"
473
484
  severity: error
474
- given:
475
- - "$.paths.*.post.responses.*.content..schema.properties"
476
- - "$.paths.*.post.responses.*.schema.properties"
485
+ given: "$.paths.*.post.responses['201']"
477
486
  then:
478
487
  function: apq-valid-response-schema
479
488
  functionOptions:
@@ -524,13 +533,14 @@ rules:
524
533
  functionOptions:
525
534
  pattern: "^[a-zA-Z]{4,}_(SC|sc)_[a-zA-Z0-9]{1,}$"
526
535
  apiq:OAR041:
527
- description: Available scopes must be defined at the end of the document, at the root level, inside `x-wso2-security`.
528
- message: "OAR041:Scopes must be defined in `x-wso2-security` at the end of the document, before the paths."
536
+ description: "A WSO2 x-scope on an operation always requires an x-auth-type definition."
537
+ message: "{{error}}"
538
+ documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR041.md"
529
539
  severity: "error"
530
- given: "$.paths[*].get"
540
+ resolved: false
541
+ given: "$"
531
542
  then:
532
- field: "x-aut-type"
533
- function: truthy
543
+ function: apq-wso2-auth-type-required
534
544
  apiq:OAR042:
535
545
  description: "Base path must be compliant with the standard."
536
546
  message: "OAR042: Base path must follow the '/api-<name>/v<version>' standard."
@@ -545,12 +555,19 @@ rules:
545
555
  apiq:OAR043:
546
556
  description: "OpenAPI definition contains structural errors that would be detected by a strict parser or validator."
547
557
  message: "{{error}}"
558
+ documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR043.md"
548
559
  severity: error
549
560
  given:
550
561
  - $.paths.*.*.parameters.*.in
551
562
  - $.components.parameters.*.in
563
+ - $.paths.*.*.parameters.*.type
564
+ - $.components.parameters.*.type
552
565
  - $.paths.*.*.parameters.*.schema.type
553
566
  - $.components.parameters.*.schema.type
567
+ - $.webhooks.*.*.parameters.*.in
568
+ - $.webhooks.*.*.parameters.*.schema.type
569
+ - $.components.pathItems.*.*.parameters.*.in
570
+ - $.components.pathItems.*.*.parameters.*.schema.type
554
571
  then:
555
572
  function: apq-validate-structure
556
573
  apiq:OAR044:
@@ -575,8 +592,7 @@ rules:
575
592
  severity: error
576
593
  given: "$.paths[*][get,post,put,patch,delete].responses[?(@property !== '204')]"
577
594
  then:
578
- field: "content"
579
- function: truthy
595
+ function: apq-response-content-required
580
596
  apiq:OAR046:
581
597
  description: "Each operation SHOULD have a tag."
582
598
  message: "OAR046: You should categorize the operations of your contract with tags."
@@ -598,29 +614,12 @@ rules:
598
614
  type: array
599
615
  minItems: 1
600
616
  apiq:OAR047:
601
- description: "Tags required and each tag must have a short description."
602
- message: "OAR047: Add tags with a short description to each one."
617
+ description: "Tags required and each tag must have a short description, must not be duplicated, and every tag used by an operation must be declared at the top level."
618
+ message: "{{error}}"
603
619
  severity: error
604
620
  given: "$"
605
621
  then:
606
- - field: "tags"
607
- function: schema
608
- functionOptions:
609
- schema:
610
- type: array
611
- minLength: 1
612
- items:
613
- type: object
614
- required:
615
- - name
616
- - description
617
- properties:
618
- name:
619
- type: string
620
- minLength: 1
621
- description:
622
- type: string
623
- minLength: 1
622
+ function: apq-tags-consistency
624
623
  apiq:OAR048:
625
624
  description: APIs must define at most one body parameter.
626
625
  message: "OAR048: An operation can have at most one body parameter"
@@ -634,12 +633,11 @@ rules:
634
633
  function: apq-at-most-one-body-parameter
635
634
  apiq:OAR049:
636
635
  description: "204 No Content MUST NOT return any content."
637
- message: "OAR049: 204 No Content MUST NOT return any content."
636
+ message: "{{error}}"
638
637
  severity: error
639
638
  given: "$.paths[*][get,post,put,patch,delete].responses['204']"
640
639
  then:
641
- field: "content"
642
- function: falsy
640
+ function: apq-response-no-content
643
641
  apiq:OAR050:
644
642
  description: "Provide a summary for each operation."
645
643
  message: "OAR050: Provide a summary for each operation."
@@ -650,7 +648,7 @@ rules:
650
648
  function: truthy
651
649
  apiq:OAR051:
652
650
  description: "Summary and description must be different - not just case variations or semantic duplicates."
653
- message: "OAR051: Summary and description must be meaningfully different from each other."
651
+ message: "{{error}}"
654
652
  severity: "error"
655
653
  given: "$.paths[*][get,post,put,patch,delete]"
656
654
  then:
@@ -875,7 +873,9 @@ rules:
875
873
  description: "All parameters in query must be snake_case."
876
874
  message: "OAR077: All parameters in query must be snake_case."
877
875
  severity: warn
878
- given: "$.paths[*][*].parameters[?(@.in == 'query')]"
876
+ given:
877
+ - "$.paths[*][*].parameters[?(@.in == 'query')]"
878
+ - "$.paths[*].parameters[?(@.in == 'query')]"
879
879
  then:
880
880
  field: "name"
881
881
  function: pattern
@@ -940,7 +940,9 @@ rules:
940
940
  description: "Some formats should not pass through this querystring."
941
941
  message: "{{error}}"
942
942
  severity: error
943
- given: "$.paths[*][get,post,put,patch,delete].parameters[?(@.in == 'query')]"
943
+ given:
944
+ - "$.paths[*][get,post,put,patch,delete].parameters[?(@.in == 'query')]"
945
+ - "$.paths[*].parameters[?(@.in == 'query')]"
944
946
  then:
945
947
  function: apq-forbidden-query-format
946
948
  functionOptions:
@@ -1,15 +1,13 @@
1
1
  const COMBINERS = ['allOf', 'oneOf', 'anyOf'];
2
2
 
3
- // A level is validated unless explicitly turned off with `false`.
3
+ const typeIncludes = (type, name) => (Array.isArray(type) ? type.includes(name) : type === name);
4
+
4
5
  const isOn = (value) => value !== false;
5
6
 
6
- // Whole-body (root) example declared directly on a schema node.
7
7
  const schemaHasRootExample = (schema) =>
8
8
  !!schema && typeof schema === 'object'
9
9
  && (schema.example !== undefined || schema.examples !== undefined);
10
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
11
  const hasParameterExample = (param) => {
14
12
  if (!param || typeof param !== 'object') return false;
15
13
  if (param.example !== undefined || param.examples !== undefined) return true;
@@ -23,9 +21,6 @@ const hasParameterExample = (param) => {
23
21
  return false;
24
22
  };
25
23
 
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
24
  const hasBodyLevelExample = (node) => {
30
25
  if (!node || typeof node !== 'object') return false;
31
26
  if (node.content && typeof node.content === 'object') {
@@ -34,16 +29,15 @@ const hasBodyLevelExample = (node) => {
34
29
  && (mt.example !== undefined || mt.examples !== undefined || schemaHasRootExample(mt.schema)),
35
30
  );
36
31
  }
37
- if (node.examples !== undefined) return true; // OAS2 response-level examples map
38
- if (schemaHasRootExample(node.schema)) return true; // OAS2 root schema example
32
+ if (node.examples !== undefined) return true;
33
+ if (schemaHasRootExample(node.schema)) return true;
39
34
  return false;
40
35
  };
41
36
 
42
- // Per-leaf-property example check (recurses objects, arrays and combiners).
43
37
  const collectPropertyIssues = (schema, issues, basePath) => {
44
38
  if (!schema || typeof schema !== 'object' || schema.$ref) return;
45
39
 
46
- if (schema.type === 'array') {
40
+ if (typeIncludes(schema.type, 'array')) {
47
41
  if (schema.items) collectPropertyIssues(schema.items, issues, [...basePath, 'items']);
48
42
  return;
49
43
  }
@@ -53,7 +47,7 @@ const collectPropertyIssues = (schema, issues, basePath) => {
53
47
  if (!propSchema || typeof propSchema !== 'object' || propSchema.$ref) continue;
54
48
  const propPath = [...basePath, 'properties', propName];
55
49
  const propType = propSchema.type;
56
- if (propType === 'object' || propType === 'array' || (!propType && propSchema.properties)) {
50
+ if (typeIncludes(propType, 'object') || typeIncludes(propType, 'array') || (!propType && propSchema.properties)) {
57
51
  collectPropertyIssues(propSchema, issues, propPath);
58
52
  } else if (propType !== undefined && propSchema.example === undefined && propSchema.examples === undefined) {
59
53
  issues.push({
@@ -71,7 +65,6 @@ const collectPropertyIssues = (schema, issues, basePath) => {
71
65
  });
72
66
  };
73
67
 
74
- // Property-level coverage for a response / requestBody node (OAS3 content.* or OAS2 schema).
75
68
  const collectBodyProperties = (node, issues, basePath) => {
76
69
  if (node.content && typeof node.content === 'object') {
77
70
  Object.entries(node.content).forEach(([mediaType, mt]) => {
@@ -83,10 +76,6 @@ const collectBodyProperties = (node, issues, basePath) => {
83
76
  };
84
77
 
85
78
  /**
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
- *
90
79
  * @param {object} given
91
80
  * @param {object} options
92
81
  * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
@@ -106,13 +95,17 @@ module.exports = function oar031ExamplesCoverage(given, options, context) {
106
95
  const isRequestBody = nodePath.at(-1) === 'requestBody';
107
96
 
108
97
  if (isParameter) {
98
+ const doc = context.document?.data ?? {};
99
+ const isOAP2 = typeof doc.swagger === 'string';
100
+ if (isOAP2 && given.in !== 'body') {
101
+ return issues;
102
+ }
109
103
  if (validateParameter && !hasParameterExample(given)) {
110
104
  issues.push({
111
105
  message: `OAR031: Parameter '${given.name || ''}' must have an example defined`,
112
106
  path: nodePath,
113
107
  });
114
108
  }
115
- // OAS2 body parameters carry a schema, so property-level coverage applies to them too.
116
109
  if (given.in === 'body' && validateProperty && given.schema) {
117
110
  collectPropertyIssues(given.schema, issues, [...nodePath, 'schema']);
118
111
  }
@@ -26,12 +26,13 @@ module.exports = (given, options, context) => {
26
26
  return errors;
27
27
  }
28
28
 
29
- const threshold = options.threshold || 0.6;
29
+ const threshold = typeof options.threshold === 'number' ? options.threshold : 0.6;
30
+ const baseMessage = 'OAR051: Summary and description must be meaningfully different from each other.';
30
31
 
31
32
  // Exact match (case-insensitive)
32
33
  if (propA.trim().toUpperCase() === propB.trim().toUpperCase()) {
33
34
  errors.push({
34
- message: context.rule.message,
35
+ message: baseMessage,
35
36
  path: [...context.path, options.property]
36
37
  });
37
38
  return errors;
@@ -41,7 +42,7 @@ module.exports = (given, options, context) => {
41
42
  const similarity = calculateSimilarity(propA, propB);
42
43
  if (similarity >= threshold) {
43
44
  errors.push({
44
- message: `${context.rule.message} (${Math.round(similarity * 100)}% similar)`,
45
+ message: `${baseMessage} (${Math.round(similarity * 100)}% similar)`,
45
46
  path: [...context.path, options.property]
46
47
  });
47
48
  }
@@ -4,6 +4,10 @@
4
4
  * @param {object} options - Function options
5
5
  * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
6
6
  */
7
+ function typeIncludes(type, name) {
8
+ return Array.isArray(type) ? type.includes(name) : type === name;
9
+ }
10
+
7
11
  module.exports = (given, options, context) => {
8
12
  const results = [];
9
13
 
@@ -17,10 +21,9 @@ module.exports = (given, options, context) => {
17
21
  return;
18
22
  }
19
23
 
20
- // Check if this is a password-related field (name contains "password")
21
24
  const isPasswordField = propName.toLowerCase().includes('password');
22
25
 
23
- if (isPasswordField && propSchema.type === 'string') {
26
+ if (isPasswordField && typeIncludes(propSchema.type, 'string')) {
24
27
  // Password fields must have format: password
25
28
  if (propSchema.format !== 'password') {
26
29
  results.push({
@@ -18,14 +18,16 @@ module.exports = (given, options, context) => {
18
18
  continue;
19
19
  }
20
20
 
21
+ const sharedParams = Array.isArray(pathItem.parameters) ? pathItem.parameters : [];
22
+
21
23
  for (const [operationKey, operation] of Object.entries(pathItem)) {
22
24
  if (!operation || typeof operation !== 'object' ||
23
25
  !httpMethods.includes(operationKey)) {
24
26
  continue;
25
27
  }
26
28
 
27
- const parameters = operation.parameters;
28
- if (!Array.isArray(parameters)) {
29
+ const operationParams = Array.isArray(operation.parameters) ? operation.parameters : [];
30
+ if (sharedParams.length === 0 && operationParams.length === 0) {
29
31
  continue;
30
32
  }
31
33
 
@@ -35,7 +37,13 @@ module.exports = (given, options, context) => {
35
37
  continue;
36
38
  }
37
39
 
38
- parameters.forEach((param, index) => {
40
+ const overridden = new Set(
41
+ operationParams
42
+ .filter((p) => p && p.name && p.in)
43
+ .map((p) => `${p.in}:${p.name}`)
44
+ );
45
+
46
+ operationParams.forEach((param, index) => {
39
47
  if (param && (param.in === 'path' || param.in === 'query')) {
40
48
  errors.push({
41
49
  message: context.rule.message,
@@ -43,6 +51,15 @@ module.exports = (given, options, context) => {
43
51
  });
44
52
  }
45
53
  });
54
+
55
+ sharedParams.forEach((param, index) => {
56
+ if (!param || (param.in !== 'path' && param.in !== 'query')) return;
57
+ if (overridden.has(`${param.in}:${param.name}`)) return;
58
+ errors.push({
59
+ message: context.rule.message,
60
+ path: [...context.path, pathKey, 'parameters', index]
61
+ });
62
+ });
46
63
  }
47
64
  }
48
65
 
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @param {object} given - the 201 response object of a POST operation
3
+ * @param {object} options - unused
4
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
5
+ */
6
+ module.exports = (given, options, context) => {
7
+ if (!given || typeof given !== 'object') {
8
+ return [];
9
+ }
10
+
11
+ const doc = context.document?.data ?? {};
12
+ const isOAP2 = typeof doc.swagger === 'string';
13
+ const path = context.path || [];
14
+ const message = 'OAR027: Location header is required in responses with code 201 from POST operations.';
15
+
16
+ const headers = given.headers;
17
+ const headerKey = headers && typeof headers === 'object'
18
+ ? Object.keys(headers).find((key) => key.toLowerCase() === 'location')
19
+ : undefined;
20
+
21
+ if (!headerKey) {
22
+ return [{ message, path: [...path, 'headers'] }];
23
+ }
24
+
25
+ const header = headers[headerKey];
26
+ const type = isOAP2 ? header && header.type : header && header.schema && header.schema.type;
27
+
28
+ if (!type) {
29
+ const typePath = isOAP2
30
+ ? [...path, 'headers', headerKey, 'type']
31
+ : [...path, 'headers', headerKey, 'schema', 'type'];
32
+ return [{ message, path: typePath }];
33
+ }
34
+
35
+ return [];
36
+ };
@@ -20,10 +20,12 @@ module.exports = (given, options, context) => {
20
20
  const shouldExclude = strategy === '/exclude' ? isListed : !isListed;
21
21
  if (shouldExclude) return [];
22
22
 
23
- const pathItemKeyIndex = context.path?.length - 2;
24
- const pathItem = typeof pathItemKeyIndex === 'number'
25
- ? context.path?.[pathItemKeyIndex]
26
- : null;
23
+ const pathKey = context.path[1];
24
+ const rawPaths = (context.document
25
+ && context.document.parserResult
26
+ && context.document.parserResult.data
27
+ && context.document.parserResult.data.paths) || {};
28
+ const pathItem = rawPaths[pathKey] || null;
27
29
 
28
30
  const pathParams = (pathItem?.parameters || []).filter(
29
31
  p => p?.in === 'path'
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @param {object} given - the response object (e.g. responses['201'])
3
+ * @param {object} options - unused
4
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
5
+ */
6
+ module.exports = (given, options, context) => {
7
+ if (!given || typeof given !== 'object') {
8
+ return [];
9
+ }
10
+
11
+ const doc = (context.document
12
+ && context.document.parserResult
13
+ && context.document.parserResult.data) || {};
14
+ const field = doc.swagger ? 'schema' : 'content';
15
+ const value = given[field];
16
+
17
+ const isEmpty = value === undefined || value === null
18
+ || (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0);
19
+
20
+ if (isEmpty) {
21
+ return [{ message: context.rule.message }];
22
+ }
23
+ return [];
24
+ };
@@ -6,7 +6,7 @@
6
6
  * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
7
7
  */
8
8
 
9
- const NO_BODY_CODES = new Set(['204']);
9
+ const BODY_ALLOWED_METHODS = new Set(['post', 'put', 'patch']);
10
10
 
11
11
  function parseExceptions(value) {
12
12
  return new Set(
@@ -29,39 +29,42 @@ module.exports = (given, options, context) => {
29
29
  const exceptions = parseExceptions(options && options['media-type-exceptions']);
30
30
 
31
31
  const root = context.document.parserResult.data;
32
+ const path = context.path;
33
+ const lastSegment = path[path.length - 1];
32
34
 
33
35
  if (root.swagger) {
34
- const produces = given.produces ?? root.produces;
36
+ if (given.responses === undefined) return errors;
35
37
 
36
- if (
37
- !produces ||
38
- !Array.isArray(produces) ||
39
- !produces.some((mediaType) => isSupported(String(mediaType).toLowerCase(), defaultMediaType, exceptions))
40
- ) {
38
+ const method = path[path.length - 1];
39
+ if (!BODY_ALLOWED_METHODS.has(method)) return errors;
40
+
41
+ const hasOwnProduces = given.produces !== undefined && given.produces !== null;
42
+ const produces = hasOwnProduces ? given.produces : root.produces;
43
+ const supported = Array.isArray(produces) && produces.some((mediaType) => isSupported(String(mediaType).toLowerCase(), defaultMediaType, exceptions));
44
+ if (!supported) {
41
45
  errors.push({
42
46
  message: context.rule.message,
43
- path: [...context.path]
47
+ path: hasOwnProduces ? [...path, 'produces'] : [...path]
44
48
  });
45
49
  }
46
50
 
47
51
  return errors;
48
52
  }
49
53
 
50
- const responses = given.responses;
51
- if (!responses) return errors;
52
-
53
- for (const [statusCode, response] of Object.entries(responses)) {
54
- if (NO_BODY_CODES.has(statusCode)) continue;
54
+ if (given.responses !== undefined) return errors;
55
55
 
56
- const contentTypes = response?.content ? Object.keys(response.content) : [];
56
+ if (lastSegment === 'content') {
57
+ const contentTypes = Object.keys(given);
57
58
  const supported = contentTypes.some((mediaType) => isSupported(mediaType.toLowerCase(), defaultMediaType, exceptions));
58
-
59
59
  if (!supported) {
60
- errors.push({
61
- message: context.rule.message,
62
- path: [...context.path, 'responses', statusCode]
63
- });
60
+ errors.push({ message: context.rule.message, path: [...path] });
64
61
  }
62
+ return errors;
63
+ }
64
+
65
+ const hasContentKey = given.content !== undefined && given.content !== null;
66
+ if (!hasContentKey) {
67
+ errors.push({ message: context.rule.message, path: [...path] });
65
68
  }
66
69
 
67
70
  return errors;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @param {object} given - the 204 response object
3
+ * @param {object} options - unused
4
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
5
+ */
6
+ module.exports = (given, options, context) => {
7
+ if (!given || typeof given !== 'object') {
8
+ return [];
9
+ }
10
+
11
+ const doc = context.document?.data ?? {};
12
+ const isOAP2 = typeof doc.swagger === 'string';
13
+ const field = isOAP2 ? 'schema' : 'content';
14
+ const value = given[field];
15
+
16
+ const isEmpty = value === undefined || value === null
17
+ || (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0);
18
+
19
+ if (isEmpty) {
20
+ return [];
21
+ }
22
+
23
+ return [{
24
+ message: 'OAR049: 204 No Content MUST NOT return any content.',
25
+ path: [...(context.path || []), field],
26
+ }];
27
+ };