Repository: Redocly/redocly-cli (ts)

Issue:
Add an option to exclude webhooks from operation-4xx-response rule
**Is your feature request related to a problem? Please describe.**
In terms of our [operation-4xx-response/](https://redocly.com/docs/cli/rules/operation-4xx-response/) rule we treat webhooks the same as path items (as it's intended by the spec). 
However, webhooks ofter times are treated differently than path items so it makes sense to provide an option to exclude them from the rule. 

**Describe the solution you'd like**
Add an option to exclude webhooks from the operation-4xx-response rule and similar ones.

**Describe alternatives you've considered**
Avoid applying the rule to the webhooks at all.

**Additional context**
https://redoc-ly.slack.com/archives/C02UHE7EL3E/p1669127864676219

Pull request:
feat: add an option to include webhooks to operation-4xx-response rule
## What/Why/How?
In terms of our [operation-4xx-response/](https://redocly.com/docs/cli/rules/operation-4xx-response/) rule we treat webhooks the same as path items (as it's intended by the spec).
However, webhooks ofter times are treated differently than path items so it makes sense to provide an option to exclude them from the rule.

## Reference
Closes #955 

## Testing
Locally.

## Screenshots (optional)
N/a

## Check yourself

- [x] Code is linted
- [ ] Tested with redoc/reference-docs/workflows
- [x] All new/updated code is covered with tests

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines


Patch:
diff --git a/docs/rules/operation-2xx-response.md b/docs/rules/operation-2xx-response.md
index 1547b625..98b5cdf8 100644
--- a/docs/rules/operation-2xx-response.md
+++ b/docs/rules/operation-2xx-response.md
@@ -23,6 +23,7 @@ You can greatly improve the developer and user experience of your APIs by making
 |Option|Type|Description|
 |---|---|---|
 |severity|string|Possible values: `off`, `warn`, `error`. Default `warn` (in `recommended` configuration). |
+|validateWebhooks|boolean|Determines if responses inside webhooks are validated. Default `false`. |
 
 An example configuration:
 
@@ -31,6 +32,15 @@ rules:
   operation-2xx-response: error
 ```
 
+The following example enables validation of responses inside webhooks:
+
+```yaml
+rules:
+  operation-2xx-response: 
+    severity: error
+    validateWebhooks: true
+```
+
 ## Examples
 
 Given this configuration:
diff --git a/docs/rules/operation-4xx-response.md b/docs/rules/operation-4xx-response.md
index 5059092d..6d6c54c0 100644
--- a/docs/rules/operation-4xx-response.md
+++ b/docs/rules/operation-4xx-response.md
@@ -21,6 +21,7 @@ While this thinking has mostly changed (for the better in our opinion), it does
 |Option|Type|Description|
 |---|---|---|
 |severity|string|Possible values: `off`, `warn`, `error`. Default `warn` (in `recommended` configuration). |
+|validateWebhooks|boolean|Determines if responses inside webhooks are validated. Default `false`. |
 
 An example configuration:
 
@@ -29,6 +30,15 @@ rules:
   operation-4xx-response: error
 ```
 
+The following example enables validation of responses inside webhooks:
+
+```yaml
+rules:
+  operation-4xx-response: 
+    severity: error
+    validateWebhooks: true
+```
+
 ## Examples
 
 Given this configuration:
diff --git a/packages/core/src/rules/common/operation-2xx-response.ts b/packages/core/src/rules/common/operation-2xx-response.ts
index e9a3980d..1b0959eb 100644
--- a/packages/core/src/rules/common/operation-2xx-response.ts
+++ b/packages/core/src/rules/common/operation-2xx-response.ts
@@ -1,16 +1,24 @@
 import { Oas3Rule, Oas2Rule } from '../../visitors';
 import { UserContext } from '../../walk';
+import { validateResponseCodes } from '../utils';
 
-export const Operation2xxResponse: Oas3Rule | Oas2Rule = () => {
+export const Operation2xxResponse: Oas3Rule | Oas2Rule = ({ validateWebhooks }) => {
   return {
-    Responses(responses: Record<string, object>, { report }: UserContext) {
-      const codes = Object.keys(responses || {});
-      if (!codes.some((code) => code === 'default' || /2[Xx0-9]{2}/.test(code))) {
-        report({
-          message: 'Operation must have at least one `2XX` response.',
-          location: { reportOnKey: true },
-        });
-      }
+    Paths: {
+      Responses(responses: Record<string, object>, { report }: UserContext) {
+        const codes = Object.keys(responses || {});
+
+        validateResponseCodes(codes, '2XX', { report } as UserContext);
+      },
+    },
+    WebhooksMap: {
+      Responses(responses: Record<string, object>, { report }: UserContext) {
+        if (!validateWebhooks) return;
+
+        const codes = Object.keys(responses || {});
+
+        validateResponseCodes(codes, '2XX', { report } as UserContext);
+      },
     },
   };
 };
diff --git a/packages/core/src/rules/common/operation-4xx-response.ts b/packages/core/src/rules/common/operation-4xx-response.ts
index eae1d3a4..fdfba473 100644
--- a/packages/core/src/rules/common/operation-4xx-response.ts
+++ b/packages/core/src/rules/common/operation-4xx-response.ts
@@ -1,17 +1,24 @@
 import { Oas3Rule, Oas2Rule } from '../../visitors';
 import { UserContext } from '../../walk';
+import { validateResponseCodes } from '../utils';
 
-export const Operation4xxResponse: Oas3Rule | Oas2Rule = () => {
+export const Operation4xxResponse: Oas3Rule | Oas2Rule = ({ validateWebhooks }) => {
   return {
-    Responses(responses: Record<string, object>, { report }: UserContext) {
-      const codes = Object.keys(responses || {});
+    Paths: {
+      Responses(responses: Record<string, object>, { report }: UserContext) {
+        const codes = Object.keys(responses || {});
 
-      if (!codes.some((code) => /4[Xx0-9]{2}/.test(code))) {
-        report({
-          message: 'Operation must have at least one `4XX` response.',
-          location: { reportOnKey: true },
-        });
-      }
+        validateResponseCodes(codes, '4XX', { report } as UserContext);
+      },
+    },
+    WebhooksMap: {
+      Responses(responses: Record<string, object>, { report }: UserContext) {
+        if (!validateWebhooks) return;
+
+        const codes = Object.keys(responses || {});
+
+        validateResponseCodes(codes, '4XX', { report } as UserContext);
+      },
     },
   };
 };
diff --git a/packages/core/src/rules/utils.ts b/packages/core/src/rules/utils.ts
index 7ce0df22..63334687 100644
--- a/packages/core/src/rules/utils.ts
+++ b/packages/core/src/rules/utils.ts
@@ -164,3 +164,24 @@ export function validateSchemaEnumType(
     });
   }
 }
+
+export function validateResponseCodes(
+  responseCodes: string[],
+  codeRange: string,
+  { report }: UserContext
+) {
+  const responseCodeRegexp = new RegExp(`^${codeRange[0]}[0-9Xx]{2}$`);
+
+  const containsNeededCode = responseCodes.some(
+    (code) =>
+      (codeRange === '2XX' && code === 'default') || // It's OK to replace 2xx codes with the default
+      responseCodeRegexp.test(code)
+  );
+
+  if (!containsNeededCode) {
+    report({
+      message: `Operation must have at least one \`${codeRange}\` response.`,
+      location: { reportOnKey: true },
+    });
+  }
+}


Test patch:
diff --git a/packages/core/src/rules/common/__tests__/operation-2xx-response.test.ts b/packages/core/src/rules/common/__tests__/operation-2xx-response.test.ts
index d3da9e5b..38acfa17 100644
--- a/packages/core/src/rules/common/__tests__/operation-2xx-response.test.ts
+++ b/packages/core/src/rules/common/__tests__/operation-2xx-response.test.ts
@@ -125,4 +125,68 @@ describe('Oas3 operation-2xx-response', () => {
       ]
     `);
   });
+
+  it('should report missing 2xx response in webhooks when enabled', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+          openapi: 3.1.0
+          webhooks:
+            '/test/':
+              put:
+                responses:
+                  400:
+                    description: success response
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({
+        'operation-2xx-response': { severity: 'error', validateWebhooks: true },
+      }),
+    });
+
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/webhooks/~1test~1/put/responses",
+              "reportOnKey": true,
+              "source": "foobar.yaml",
+            },
+          ],
+          "message": "Operation must have at least one \`2XX\` response.",
+          "ruleId": "operation-2xx-response",
+          "severity": "error",
+          "suggest": Array [],
+        },
+      ]
+    `);
+  });
+
+  it('should not report missing 2xx response in webhooks when not enabled', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+          openapi: 3.0.0
+          x-webhooks:
+            '/test/':
+              put:
+                responses:
+                  400:
+                    description: success response
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({ 'operation-2xx-response': 'error' }),
+    });
+
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`Array []`);
+  });
 });
diff --git a/packages/core/src/rules/common/__tests__/operation-4xx-response.test.ts b/packages/core/src/rules/common/__tests__/operation-4xx-response.test.ts
index 8c87102c..dbceaf3d 100644
--- a/packages/core/src/rules/common/__tests__/operation-4xx-response.test.ts
+++ b/packages/core/src/rules/common/__tests__/operation-4xx-response.test.ts
@@ -164,4 +164,68 @@ describe('Oas3 operation-4xx-response', () => {
       ]
     `);
   });
+
+  it('should report missing 4xx response in webhooks when enabled', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+          openapi: 3.1.0
+          webhooks:
+            '/test/':
+              put:
+                responses:
+                  200:
+                    description: success response
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({
+        'operation-4xx-response': { severity: 'error', validateWebhooks: true },
+      }),
+    });
+
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/webhooks/~1test~1/put/responses",
+              "reportOnKey": true,
+              "source": "foobar.yaml",
+            },
+          ],
+          "message": "Operation must have at least one \`4XX\` response.",
+          "ruleId": "operation-4xx-response",
+          "severity": "error",
+          "suggest": Array [],
+        },
+      ]
+    `);
+  });
+
+  it('should not report missing 4xx response in webhooks when not enabled', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+          openapi: 3.0.0
+          x-webhooks:
+            '/test/':
+              put:
+                responses:
+                  200:
+                    description: success response
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({ 'operation-4xx-response': 'error' }),
+    });
+
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`Array []`);
+  });
 });