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.
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,9 +48,11 @@ functions:
45
48
  - apq-mandatory-response-codes
46
49
  - apq-rate-limit-response
47
50
  - apq-forbidden-query-format
48
- extends:
49
- - spectral:asyncapi
50
- - spectral:oas
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
51
56
  rules:
52
57
  apiq:OAR001:
53
58
  description: "For security reasons and as a REST best practice, the HTTPS protocol is mandatory."
@@ -89,17 +94,13 @@ rules:
89
94
  pattern: "^[a-zA-Z0-9_\\-., ]+$"
90
95
  apiq:OAR005:
91
96
  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."
97
+ message: "{{error}}"
98
+ documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR005.md"
93
99
  severity: "error"
94
- given: "$.paths[*][*].x-scope"
100
+ resolved: false
101
+ given: "$"
95
102
  then:
96
- - function: "truthy"
97
- message: "Scope must be defined in the operation."
98
- - function: enumeration
99
- functionOptions:
100
- values:
101
- - read
102
- - write
103
+ function: apq-wso2-scope-defined
103
104
  apiq:OAR006:
104
105
  description: "Routes must define request media types supported by the API."
105
106
  message: "OAR006: Specify at least one Media Type in the content of the request body."
@@ -116,10 +117,13 @@ rules:
116
117
  message: "OAR007: Specify at least one Media Type in the content of the response body."
117
118
  documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR007.md"
118
119
  severity: "error"
119
- given: "$.paths[*][get,post,put,patch,delete].responses[?(@property != '204')]"
120
+ given:
121
+ - "$.paths[*][post,put,patch]"
122
+ - "$.webhooks[*][post,put,patch]"
123
+ - "$.paths[*][get,post,put,patch,delete].responses[?(@property != '204')]"
124
+ - "$.webhooks[*][get,post,put,patch,delete].responses[?(@property != '204')]"
120
125
  then:
121
- - field: "content"
122
- function: truthy
126
+ function: apq-undefined-response-media-type
123
127
  apiq:OAR008:
124
128
  description: "HTTP verbs not encouraged."
125
129
  message: "{{error}}"
@@ -142,7 +146,13 @@ rules:
142
146
  description: "Default response media type should be defined for responses."
143
147
  message: "OAR010: Default response media type is mandatory."
144
148
  severity: warn
145
- given: "$.paths[*][get,post,put,patch,delete]"
149
+ given:
150
+ - "$.paths[*][post,put,patch]"
151
+ - "$.webhooks[*][post,put,patch]"
152
+ - "$.paths[*][get,post,put,patch,delete].responses[?(@property != '204')]"
153
+ - "$.webhooks[*][get,post,put,patch,delete].responses[?(@property != '204')]"
154
+ - "$.paths[*][get,post,put,patch,delete].responses[?(@property != '204')].content"
155
+ - "$.webhooks[*][get,post,put,patch,delete].responses[?(@property != '204')].content"
146
156
  then:
147
157
  function: apq-response-media-type
148
158
  functionOptions:
@@ -350,20 +360,18 @@ rules:
350
360
  pathValidationStrategy: "/include"
351
361
  apiq:OAR026:
352
362
  description: "The $total parameter default value should be false."
353
- message: "OAR026: The $total parameter default value should be false."
363
+ message: "{{error}}"
354
364
  severity: error
355
365
  given: "$.paths[*].get.parameters[?(@.name == '$total' && @.in == 'query')]"
356
366
  then:
357
- field: "schema.default"
358
- function: falsy
367
+ function: apq-total-param-default-value
359
368
  apiq:OAR027:
360
369
  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."
370
+ message: "{{error}}"
362
371
  severity: error
363
372
  given: "$.paths.*.post.responses['201']"
364
373
  then:
365
- field: "headers.Location.schema.type"
366
- function: truthy
374
+ function: apq-post-201-location-header
367
375
  apiq:OAR028:
368
376
  description: "$filter must be defined as a query parameter in the collection GET operations selected by the configured paths."
369
377
  message: "{{error}}"
@@ -471,9 +479,7 @@ rules:
471
479
  description: "The 201 response schema of a POST operation must have properties named 'data' or 'error' with at least one sub-property."
472
480
  message: "{{error}}"
473
481
  severity: error
474
- given:
475
- - "$.paths.*.post.responses.*.content..schema.properties"
476
- - "$.paths.*.post.responses.*.schema.properties"
482
+ given: "$.paths.*.post.responses['201']"
477
483
  then:
478
484
  function: apq-valid-response-schema
479
485
  functionOptions:
@@ -524,13 +530,14 @@ rules:
524
530
  functionOptions:
525
531
  pattern: "^[a-zA-Z]{4,}_(SC|sc)_[a-zA-Z0-9]{1,}$"
526
532
  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."
533
+ description: "A WSO2 x-scope on an operation always requires an x-auth-type definition."
534
+ message: "{{error}}"
535
+ documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR041.md"
529
536
  severity: "error"
530
- given: "$.paths[*].get"
537
+ resolved: false
538
+ given: "$"
531
539
  then:
532
- field: "x-aut-type"
533
- function: truthy
540
+ function: apq-wso2-auth-type-required
534
541
  apiq:OAR042:
535
542
  description: "Base path must be compliant with the standard."
536
543
  message: "OAR042: Base path must follow the '/api-<name>/v<version>' standard."
@@ -545,12 +552,19 @@ rules:
545
552
  apiq:OAR043:
546
553
  description: "OpenAPI definition contains structural errors that would be detected by a strict parser or validator."
547
554
  message: "{{error}}"
555
+ documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR043.md"
548
556
  severity: error
549
557
  given:
550
558
  - $.paths.*.*.parameters.*.in
551
559
  - $.components.parameters.*.in
560
+ - $.paths.*.*.parameters.*.type
561
+ - $.components.parameters.*.type
552
562
  - $.paths.*.*.parameters.*.schema.type
553
563
  - $.components.parameters.*.schema.type
564
+ - $.webhooks.*.*.parameters.*.in
565
+ - $.webhooks.*.*.parameters.*.schema.type
566
+ - $.components.pathItems.*.*.parameters.*.in
567
+ - $.components.pathItems.*.*.parameters.*.schema.type
554
568
  then:
555
569
  function: apq-validate-structure
556
570
  apiq:OAR044:
@@ -575,8 +589,7 @@ rules:
575
589
  severity: error
576
590
  given: "$.paths[*][get,post,put,patch,delete].responses[?(@property !== '204')]"
577
591
  then:
578
- field: "content"
579
- function: truthy
592
+ function: apq-response-content-required
580
593
  apiq:OAR046:
581
594
  description: "Each operation SHOULD have a tag."
582
595
  message: "OAR046: You should categorize the operations of your contract with tags."
@@ -598,48 +611,28 @@ rules:
598
611
  type: array
599
612
  minItems: 1
600
613
  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."
614
+ 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."
615
+ message: "{{error}}"
603
616
  severity: error
604
617
  given: "$"
605
618
  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
619
+ function: apq-tags-consistency
624
620
  apiq:OAR048:
625
621
  description: APIs must define at most one body parameter.
626
622
  message: "OAR048: An operation can have at most one body parameter"
627
623
  documentationUrl: "https://github.com/apiaddicts/apquality-spectral/blob/main/docs/resources/OAR048.md"
628
624
  severity: error
629
- formats:
630
- - oas2
631
625
  given: "$.paths[*][*]"
632
626
  then:
633
627
  field: parameters
634
628
  function: apq-at-most-one-body-parameter
635
629
  apiq:OAR049:
636
630
  description: "204 No Content MUST NOT return any content."
637
- message: "OAR049: 204 No Content MUST NOT return any content."
631
+ message: "{{error}}"
638
632
  severity: error
639
633
  given: "$.paths[*][get,post,put,patch,delete].responses['204']"
640
634
  then:
641
- field: "content"
642
- function: falsy
635
+ function: apq-response-no-content
643
636
  apiq:OAR050:
644
637
  description: "Provide a summary for each operation."
645
638
  message: "OAR050: Provide a summary for each operation."
@@ -650,7 +643,7 @@ rules:
650
643
  function: truthy
651
644
  apiq:OAR051:
652
645
  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."
646
+ message: "{{error}}"
654
647
  severity: "error"
655
648
  given: "$.paths[*][get,post,put,patch,delete]"
656
649
  then:
@@ -856,6 +849,10 @@ rules:
856
849
  given:
857
850
  - "$.paths[*][*].parameters[*]"
858
851
  - "$.paths[*].parameters[*]"
852
+ - "$.paths[*].additionalOperations[*].parameters[*]"
853
+ - "$.webhooks[*][*].parameters[*]"
854
+ - "$.webhooks[*].parameters[*]"
855
+ - "$.webhooks[*].additionalOperations[*].parameters[*]"
859
856
  - "$.components.parameters[*]"
860
857
  - "$.parameters[*]"
861
858
  then:
@@ -875,7 +872,9 @@ rules:
875
872
  description: "All parameters in query must be snake_case."
876
873
  message: "OAR077: All parameters in query must be snake_case."
877
874
  severity: warn
878
- given: "$.paths[*][*].parameters[?(@.in == 'query')]"
875
+ given:
876
+ - "$.paths[*][*].parameters[?(@.in == 'query')]"
877
+ - "$.paths[*].parameters[?(@.in == 'query')]"
879
878
  then:
880
879
  field: "name"
881
880
  function: pattern
@@ -940,7 +939,9 @@ rules:
940
939
  description: "Some formats should not pass through this querystring."
941
940
  message: "{{error}}"
942
941
  severity: error
943
- given: "$.paths[*][get,post,put,patch,delete].parameters[?(@.in == 'query')]"
942
+ given:
943
+ - "$.paths[*][get,post,put,patch,delete].parameters[?(@.in == 'query')]"
944
+ - "$.paths[*].parameters[?(@.in == 'query')]"
944
945
  then:
945
946
  function: apq-forbidden-query-format
946
947
  functionOptions:
@@ -989,6 +990,7 @@ rules:
989
990
  description: "The $ref of a request body must end with the suffix Body."
990
991
  message: "OAR089: The $ref of a request body must end with the suffix Body."
991
992
  severity: "warn"
993
+ resolved: false
992
994
  given: "$..requestBody.$ref"
993
995
  then:
994
996
  function: pattern
@@ -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
+ };
@@ -1,9 +1,17 @@
1
1
  const DEFAULT_INTEGRITY = 'minLength,maxLength,pattern,enum';
2
2
 
3
+ const SELF_CONSTRAINED_FORMATS = new Set([
4
+ 'date', 'date-time', 'uuid', 'ipv4', 'ipv6',
5
+ ]);
6
+
3
7
  function isStringType(type) {
4
8
  return type === 'string' || (Array.isArray(type) && type.indexOf('string') > -1);
5
9
  }
6
10
 
11
+ function hasSelfConstrainedFormat(format) {
12
+ return typeof format === 'string' && SELF_CONSTRAINED_FORMATS.has(format.trim().toLowerCase());
13
+ }
14
+
7
15
  /**
8
16
  * @param {object} targetVal a Parameter Object
9
17
  * @param {object} options
@@ -20,6 +28,8 @@ module.exports = (targetVal, options, context) => {
20
28
 
21
29
  if (!isStringType(container.type)) return [];
22
30
 
31
+ if (hasSelfConstrainedFormat(container.format)) return [];
32
+
23
33
  const raw = options && options.parameter_integrity !== undefined && options.parameter_integrity !== null
24
34
  ? String(options.parameter_integrity)
25
35
  : DEFAULT_INTEGRITY;