Repository: Redocly/redocly-cli
Pull request #989 (2023-01-08 23:12:06)

docs: add missing guide to the list on the cli guides page
## What/Why/How?

The guide was added to the sidebar (in the linked PR). This PR adds the guide to the index list (tiles) on the https://redocly.com/docs/cli/guides/ page

## Reference

Related to Redocly/marketing-site-portal#1032

## Testing

## Screenshots (optional)

## Check yourself

- [ ] Code is linted
- [ ] Tested with redoc/reference-docs/workflows
- [ ] All new/updated code is covered with tests

## Security

- [ ] Security impact of change has been considered
- [ ] Code follows company security practices and guidelines


github-actions[bot]: <!-- jest coverage report action for options with hash 3339ae53a38c44382c0fb4491bdb282d -->

## Coverage report 



| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | Category   | Percentage | Covered / Total |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------- | :--------- | :-------------: |
|                                                                           🟡                                                                           | Statements | 73.95%     |    3417/4621    |
|                                                                           🟡                                                                           | Branches   | 65.83%     |    1936/2941    |
|                                                                           🟡                                                                           | Functions  | 66.59%     |     580/871     |
|                                                                           🟡                                                                           | Lines      | 73.97%     |    3177/4295    |


## Test suite run success
540 tests passing in 85 suites.

<p align="right">Report generated by <a href="https://github.com/ArtiomTr/jest-coverage-report-action">🧪jest coverage report action</a> from 73e9ba7194c543b8520f7f5ed2bdd7bb2263b338</p>


Patch:
diff --git a/docs/commands/join.md b/docs/commands/join.md
index 8bfc5ffa..26086937 100644
--- a/docs/commands/join.md
+++ b/docs/commands/join.md
@@ -19,7 +19,7 @@ With Redocly CLI, you can solve this problem by using the `join` command that ca
 
 To easily distinguish the origin of OpenAPI objects and properties, you can optionally instruct the `join` command to append custom prefixes to them.
 
-The `join` command accepts both YAML and JSON files, which you can mix in the resulting `openapi.yaml` file. Setting a custom name for this file is not supported, which also means that if there is a file called `openapi.yaml` in the working directory, it will be overwritten.
+The `join` command accepts both YAML and JSON files, which you can mix in the resulting `openapi.yaml` file. Setting a custom name for this file can be achieved by providing it through the `--output` argument. Any existing file will be overwritten.
 
 Apart from providing individual API definition files as the input, you can also specify the path to a folder that contains multiple API definition files and match them with a wildcard (for example, `myproject/openapi/*.yaml`). The `join` command will collect all matching files and combine them into one file.
 
@@ -27,11 +27,13 @@ Apart from providing individual API definition files as the input, you can also
 
 ```bash
 redocly join <api> <api>...
+redocly join <api> <api>... -o <outputName>
 redocly join <path-to-folder>/<wildcard-pattern> [--lint]
 redocly join [--help] [--prefix-components-with-info-prop] [--prefix-tags-with-info-prop] [--prefix-tags-with-filename]
 
 redocly join first-api.yaml second-api.yaml
 redocly join first-api.yaml second-api.json
+redocly join first-api.yaml second-api.json -o openapi-custom.yaml
 redocly join ./*.yaml
 redocly join --version
 ```
@@ -48,6 +50,7 @@ apis | array | **REQUIRED.**  1. Array of paths to API definition files that you
 --prefix-tags-with-info-prop | boolean | Prefix tags with property value from info object. See the [prefix-tags-with-info-prop](#prefix-tags-with-info-prop) section.
 --without-x-tag-groups | boolean | Skip automated `x-tagGroups` creation. See the [without-x-tag-groups](#without-x-tag-groups) section.
 --version | boolean | Show version number.
+--output, -o | string | Name for the joined output file. Defaults to `openapi.yaml`. **If the file already exists, it's overwritten.**
 
 ## Examples
 
@@ -237,3 +240,11 @@ components:
               type: integer
               format: int64
 ```
+
+### Custom output file
+
+By default, the CLI tool writes the joined file as `openapi.yaml` in the current working directory. Use the optional `--output` argument to provide an alternative output file path.
+
+```bash Command
+redocly join --output=openapi-custom.yaml
+```
diff --git a/docs/guides/index.mdx b/docs/guides/index.mdx
index be15a810..5181f09e 100644
--- a/docs/guides/index.mdx
+++ b/docs/guides/index.mdx
@@ -29,5 +29,8 @@ Learn how to work with Redocly CLI.
     <WideTile to="./change-token-url.md" header="Change the OAuth2 token URL">
       How to change the OAuth2 token URL.
     </WideTile>
+    <WideTile to="./hide-specification-extensions.md" header="Hide OpenAPI specification extensions">
+      How to create a custom decorator to hide OpenAPI specification extensions.
+    </WideTile>
   </Flex>
 </div>
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/cli/src/commands/join.ts b/packages/cli/src/commands/join.ts
index 68ebd9a7..61aa1165 100644
--- a/packages/cli/src/commands/join.ts
+++ b/packages/cli/src/commands/join.ts
@@ -51,6 +51,7 @@ type JoinArgv = {
   'prefix-tags-with-filename'?: boolean;
   'prefix-components-with-info-prop'?: string;
   'without-x-tag-groups'?: boolean;
+  output?: string;
 };
 
 export async function handleJoin(argv: JoinArgv, packageVersion: string) {
@@ -64,6 +65,7 @@ export async function handleJoin(argv: JoinArgv, packageVersion: string) {
     'prefix-tags-with-filename': prefixTagsWithFilename,
     'prefix-tags-with-info-prop': prefixTagsWithInfoProp,
     'without-x-tag-groups': withoutXTagGroups,
+    output: specFilename = 'openapi.yaml',
   } = argv;
 
   const usedTagsOptions = [
@@ -180,7 +182,6 @@ export async function handleJoin(argv: JoinArgv, packageVersion: string) {
   }
 
   iteratePotentialConflicts(potentialConflicts, withoutXTagGroups);
-  const specFilename = 'openapi.yaml';
   const noRefs = true;
 
   if (potentialConflictsTotal) {
diff --git a/packages/cli/src/commands/split/index.ts b/packages/cli/src/commands/split/index.ts
index 3de3ece6..5eeddca7 100644
--- a/packages/cli/src/commands/split/index.ts
+++ b/packages/cli/src/commands/split/index.ts
@@ -13,6 +13,7 @@ import {
   writeYaml,
   exitWithError,
   escapeLanguageName,
+  langToExt,
 } from '../../utils';
 import { isString, isObject, isEmptyObject } from '../../js-utils';
 import {
@@ -106,20 +107,6 @@ function validateDefinitionFileName(fileName: string) {
   return true;
 }
 
-function langToExt(lang: string) {
-  const langObj: any = {
-    php: '.php',
-    'c#': '.cs',
-    shell: '.sh',
-    curl: '.sh',
-    bash: '.sh',
-    javascript: '.js',
-    js: '.js',
-    python: '.py',
-  };
-  return langObj[lang];
-}
-
 function traverseDirectoryDeep(directory: string, callback: any, componentsFiles: object) {
   if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) return;
   const files = fs.readdirSync(directory);
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index d98b18c7..07f139cf 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -95,6 +95,12 @@ yargs
             description: 'Skip automated x-tagGroups creation',
             type: 'boolean',
           },
+          output: {
+            describe: 'Output file',
+            alias: 'o',
+            type: 'string',
+            default: 'openapi.yaml',
+          },
         }),
     (argv) => {
       process.env.REDOCLY_CLI_COMMAND = 'join';
diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts
index b65a7c11..f762a0da 100644
--- a/packages/cli/src/utils.ts
+++ b/packages/cli/src/utils.ts
@@ -122,6 +122,20 @@ export function escapeLanguageName(lang: string) {
   return lang.replace(/#/g, '_sharp').replace(/\//, '_').replace(/\s/g, '');
 }
 
+export function langToExt(lang: string) {
+  const langObj: any = {
+    php: '.php',
+    'c#': '.cs',
+    shell: '.sh',
+    curl: '.sh',
+    bash: '.sh',
+    javascript: '.js',
+    js: '.js',
+    python: '.py',
+  };
+  return langObj[lang.toLowerCase()];
+}
+
 export class CircularJSONNotSupportedError extends Error {
   constructor(public originalError: Error) {
     super(originalError.message);
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/cli/src/__tests__/commands/join.test.ts b/packages/cli/src/__tests__/commands/join.test.ts
index a5ebbe60..72994fe0 100644
--- a/packages/cli/src/__tests__/commands/join.test.ts
+++ b/packages/cli/src/__tests__/commands/join.test.ts
@@ -64,6 +64,20 @@ describe('handleJoin fails', () => {
       },
       'cli-version'
     );
-    expect(writeYaml).toHaveBeenCalled();
+
+    expect(writeYaml).toHaveBeenCalledWith(expect.any(Object), 'openapi.yaml', expect.any(Boolean));
+  });
+
+  it('should call writeYaml function with custom output file', async () => {
+    (detectOpenAPI as jest.Mock).mockReturnValue('oas3_0');
+    await handleJoin(
+      {
+        apis: ['first.yaml', 'second.yaml'],
+        output: 'output.yml',
+      },
+      'cli-version'
+    );
+
+    expect(writeYaml).toHaveBeenCalledWith(expect.any(Object), 'output.yml', expect.any(Boolean));
   });
 });
diff --git a/packages/cli/src/__tests__/utils.test.ts b/packages/cli/src/__tests__/utils.test.ts
index 0b67f62c..d39bac4a 100644
--- a/packages/cli/src/__tests__/utils.test.ts
+++ b/packages/cli/src/__tests__/utils.test.ts
@@ -1,4 +1,10 @@
-import { getFallbackApisOrExit, isSubdir, pathToFilename, printConfigLintTotals } from '../utils';
+import {
+  getFallbackApisOrExit,
+  isSubdir,
+  pathToFilename,
+  printConfigLintTotals,
+  langToExt,
+} from '../utils';
 import { ResolvedApi, Totals, isAbsoluteUrl } from '@redocly/openapi-core';
 import { red, yellow } from 'colorette';
 import { existsSync } from 'fs';
@@ -236,3 +242,22 @@ describe('getFallbackApisOrExit', () => {
     ]);
   });
 });
+
+describe('langToExt', () => {
+  it.each([
+    ['php', '.php'],
+    ['c#', '.cs'],
+    ['shell', '.sh'],
+    ['curl', '.sh'],
+    ['bash', '.sh'],
+    ['javascript', '.js'],
+    ['js', '.js'],
+    ['python', '.py'],
+  ])('should infer file extension from lang - %s', (lang, expected) => {
+    expect(langToExt(lang)).toBe(expected);
+  });
+
+  it('should ignore case when inferring file extension', () => {
+    expect(langToExt('JavaScript')).toBe('.js');
+  });
+});
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 []`);
+  });
 });