Repository: Redocly/redocly-cli (ts)

Issue:
Support OAS3.1 for joining
**Is your feature request related to a problem? Please describe.**

Currently, all features except "join" support OAS3.1.
I am considering changing the openapi version from OAS3.0 to OAS3.1, but join is causing the following error.

```
Only OpenAPI 3 is supported: /home/mibe/git/rcp/sbc-api-doc/openapi/v1/favorite_robots.yaml 
```

**Describe the solution you'd like**

When I run join, it only checks for OAS3.0, but please include OAS3.1 as well.

https://github.com/Redocly/redocly-cli/blob/28e7e8308fd0ba6380cf2a2b3a4b5f10115322f3/packages/cli/src/commands/join.ts#L122-L126



Pull request description:
Support OAS3.1 for joining (fixes #1021) 
## What/Why/How?

Currently, all features except "join" support OAS3.1.

## Reference

Closes https://github.com/Redocly/redocly-cli/issues/1021

## Testing

Added a unit test.

## Screenshots (optional)

## Check yourself

- [X] Code is linted
- [ ] Tested with redoc/reference-docs/workflows - _not sure what this means. I have ran it with my own 3.1 specs_.
- [X] All new/updated code is covered with tests

## Security

- [X] Security impact of change has been considered
- [ ] Code follows company security practices and guidelines - _not sure_.


Patch:
diff --git a/.changeset/hot-pens-fetch.md b/.changeset/hot-pens-fetch.md
new file mode 100644
index 00000000..a75604be
--- /dev/null
+++ b/.changeset/hot-pens-fetch.md
@@ -0,0 +1,5 @@
+---
+'@redocly/cli': minor
+---
+
+Added join support for OAS 3.1 definitions.
diff --git a/packages/cli/src/commands/join.ts b/packages/cli/src/commands/join.ts
index 7d683853..26c46aa5 100644
--- a/packages/cli/src/commands/join.ts
+++ b/packages/cli/src/commands/join.ts
@@ -29,7 +29,7 @@ import {
   sortTopLevelKeysForOas,
 } from '../utils';
 import { isObject, isString, keysOf } from '../js-utils';
-import { Oas3Parameter, Oas3PathItem, Oas3Server } from '@redocly/openapi-core/lib/typings/openapi';
+import { Oas3Parameter, Oas3PathItem, Oas3Server, Oas3_1Definition } from '@redocly/openapi-core/lib/typings/openapi';
 import { OPENAPI3_METHOD } from './split/types';
 import { BundleResult } from '@redocly/openapi-core/lib/bundle';
 
@@ -141,12 +141,23 @@ export async function handleJoin(argv: JoinOptions, config: Config, packageVersi
     }
   }
 
+  let oasVersion : OasVersion | null = null;
   for (const document of documents) {
     try {
       const version = detectOpenAPI(document.parsed);
-      if (version !== OasVersion.Version3_0) {
+
+      if (version !== OasVersion.Version3_0 && version !== OasVersion.Version3_1) {
+        return exitWithError(
+          `Only OpenAPI 3.0 and OpenAPI 3.1 are supported: ${blue(
+            document.source.absoluteRef
+          )} \n\n`
+        );
+      }
+
+      oasVersion = oasVersion ?? version;
+      if (oasVersion !== version) {
         return exitWithError(
-          `Only OpenAPI 3 is supported: ${blue(document.source.absoluteRef)} \n\n`
+          `All APIs must use the same OpenAPI version: ${blue(document.source.absoluteRef)} \n\n`
         );
       }
     } catch (e) {
@@ -165,7 +176,7 @@ export async function handleJoin(argv: JoinOptions, config: Config, packageVersi
     tags: {},
     paths: {},
     components: {},
-    xWebhooks: {},
+    webhooks: {},
   };
 
   addInfoSectionAndSpecVersion(documents, prefixComponentsWithInfoProp);
@@ -200,7 +211,7 @@ export async function handleJoin(argv: JoinOptions, config: Config, packageVersi
     collectExternalDocs(openapi, context);
     collectPaths(openapi, context);
     collectComponents(openapi, context);
-    collectXWebhooks(openapi, context);
+    collectWebhooks(oasVersion!, openapi, context);
     if (componentsPrefix) {
       replace$Refs(openapi, componentsPrefix);
     }
@@ -573,32 +584,33 @@ export async function handleJoin(argv: JoinOptions, config: Config, packageVersi
     }
   }
 
-  function collectXWebhooks(
-    openapi: Oas3Definition,
+  function collectWebhooks(
+    oasVersion: OasVersion,
+    openapi: Oas3_1Definition,
     { apiFilename, api, potentialConflicts, tagsPrefix, componentsPrefix }: JoinDocumentContext
   ) {
-    const xWebhooks = 'x-webhooks';
-    const openapiXWebhooks = openapi[xWebhooks];
-    if (openapiXWebhooks) {
-      if (!joinedDef.hasOwnProperty(xWebhooks)) {
-        joinedDef[xWebhooks] = {};
+    const webhooks = oasVersion === OasVersion.Version3_1 ? 'webhooks' : 'x-webhooks';
+    const openapiWebhooks = openapi[webhooks];
+    if (openapiWebhooks) {
+      if (!joinedDef.hasOwnProperty(webhooks)) {
+        joinedDef[webhooks] = {};
       }
-      for (const webhook of Object.keys(openapiXWebhooks)) {
-        joinedDef[xWebhooks][webhook] = openapiXWebhooks[webhook];
+      for (const webhook of Object.keys(openapiWebhooks)) {
+        joinedDef[webhooks][webhook] = openapiWebhooks[webhook];
 
-        if (!potentialConflicts.xWebhooks.hasOwnProperty(webhook)) {
-          potentialConflicts.xWebhooks[webhook] = {};
+        if (!potentialConflicts.webhooks.hasOwnProperty(webhook)) {
+          potentialConflicts.webhooks[webhook] = {};
         }
-        for (const operation of Object.keys(openapiXWebhooks[webhook])) {
-          potentialConflicts.xWebhooks[webhook][operation] = [
-            ...(potentialConflicts.xWebhooks[webhook][operation] || []),
+        for (const operation of Object.keys(openapiWebhooks[webhook])) {
+          potentialConflicts.webhooks[webhook][operation] = [
+            ...(potentialConflicts.webhooks[webhook][operation] || []),
             api,
           ];
         }
-        for (const operationKey of Object.keys(joinedDef[xWebhooks][webhook])) {
-          const { tags } = joinedDef[xWebhooks][webhook][operationKey];
+        for (const operationKey of Object.keys(joinedDef[webhooks][webhook])) {
+          const { tags } = joinedDef[webhooks][webhook][operationKey];
           if (tags) {
-            joinedDef[xWebhooks][webhook][operationKey].tags = tags.map((tag: string) =>
+            joinedDef[webhooks][webhook][operationKey].tags = tags.map((tag: string) =>
               addPrefix(tag, tagsPrefix)
             );
             populateTags({


Test patch:
diff --git a/packages/cli/src/__tests__/commands/join.test.ts b/packages/cli/src/__tests__/commands/join.test.ts
index 1787d690..b5da8b42 100644
--- a/packages/cli/src/__tests__/commands/join.test.ts
+++ b/packages/cli/src/__tests__/commands/join.test.ts
@@ -50,7 +50,7 @@ describe('handleJoin fails', () => {
     );
   });
 
-  it('should call exitWithError because Only OpenAPI 3 is supported', async () => {
+  it('should call exitWithError because Only OpenAPI 3.0 and OpenAPI 3.1 are supported', async () => {
     await handleJoin(
       {
         apis: ['first.yaml', 'second.yaml'],
@@ -58,7 +58,26 @@ describe('handleJoin fails', () => {
       ConfigFixture as any,
       'cli-version'
     );
-    expect(exitWithError).toHaveBeenCalledWith('Only OpenAPI 3 is supported: undefined \n\n');
+    expect(exitWithError).toHaveBeenCalledWith(
+      'Only OpenAPI 3.0 and OpenAPI 3.1 are supported: undefined \n\n'
+    );
+  });
+
+  it('should call exitWithError if mixing OpenAPI 3.0 and 3.1', async () => {
+    (detectOpenAPI as jest.Mock)
+      .mockImplementationOnce(() => 'oas3_0')
+      .mockImplementationOnce(() => 'oas3_1');
+    await handleJoin(
+      {
+        apis: ['first.yaml', 'second.yaml'],
+      },
+      ConfigFixture as any,
+      'cli-version'
+    );
+
+    expect(exitWithError).toHaveBeenCalledWith(
+      'All APIs must use the same OpenAPI version: undefined \n\n'
+    );
   });
 
   it('should call writeYaml function', async () => {
@@ -74,6 +93,19 @@ describe('handleJoin fails', () => {
     expect(writeYaml).toHaveBeenCalledWith(expect.any(Object), 'openapi.yaml', expect.any(Boolean));
   });
 
+  it('should call writeYaml function for OpenAPI 3.1', async () => {
+    (detectOpenAPI as jest.Mock).mockReturnValue('oas3_1');
+    await handleJoin(
+      {
+        apis: ['first.yaml', 'second.yaml'],
+      },
+      ConfigFixture as any,
+      'cli-version'
+    );
+
+    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(