diff --git a/.changeset/plenty-mugs-suffer.md b/.changeset/plenty-mugs-suffer.md new file mode 100644 index 000000000..d585dd51a --- /dev/null +++ b/.changeset/plenty-mugs-suffer.md @@ -0,0 +1,5 @@ +--- +'@redocly/cli': minor +--- + +Add JSON output support to the `split` and `join` commands. diff --git a/__tests__/commands.test.ts b/__tests__/commands.test.ts index e88f475c5..fb6722884 100644 --- a/__tests__/commands.test.ts +++ b/__tests__/commands.test.ts @@ -161,6 +161,19 @@ describe('E2E', () => { const result = getCommandOutput(args, folderPath); (expect(result)).toMatchSpecificSnapshot(join(folderPath, 'snapshot.js')); }); + + test('openapi json file', () => { + const folderPath = join(__dirname, `split/openapi-json-file`); + const file = '../../../__tests__/split/openapi-json-file/openapi.json'; + + const args = getParams('../../../packages/cli/src/index.ts', 'split', [ + file, + '--outDir=output', + ]); + + const result = getCommandOutput(args, folderPath); + (expect(result)).toMatchSpecificSnapshot(join(folderPath, 'snapshot.js')); + }); }); describe('join', () => { @@ -214,6 +227,46 @@ describe('E2E', () => { const result = getCommandOutput(args, testPath); (expect(result)).toMatchSpecificSnapshot(join(testPath, 'snapshot.js')); }); + + describe('files with different extensions', () => { + const joinParameters: { + name: string; + folder: string; + entrypoints: string[]; + snapshot: string; + output?: string; + }[] = [ + { + name: 'first entrypoint is a json file', + folder: 'json-and-yaml-input', + entrypoints: ['foo.json', 'bar.yaml'], + snapshot: 'json-output.snapshot.js', + }, + { + name: 'first entrypoint is a yaml file', + folder: 'json-and-yaml-input', + entrypoints: ['bar.yaml', 'foo.json'], + snapshot: 'yaml-output.snapshot.js', + }, + { + name: 'json output file', + folder: 'yaml-input-and-json-output', + entrypoints: ['foo.yaml', 'bar.yaml'], + output: 'openapi.json', + snapshot: 'snapshot.js', + }, + ]; + + test.each(joinParameters)('test with option: %s', (parameters) => { + const testPath = join(__dirname, `join/${parameters.folder}`); + const argsWithOption = parameters.output + ? [...parameters.entrypoints, ...[`-o=${parameters.output}`]] + : parameters.entrypoints; + const args = getParams('../../../packages/cli/src/index.ts', 'join', argsWithOption); + const result = getCommandOutput(args, testPath); + (expect(result)).toMatchSpecificSnapshot(join(testPath, parameters.snapshot)); + }); + }); }); describe('bundle', () => { diff --git a/__tests__/join/json-and-yaml-input/bar.yaml b/__tests__/join/json-and-yaml-input/bar.yaml new file mode 100644 index 000000000..8f24d5778 --- /dev/null +++ b/__tests__/join/json-and-yaml-input/bar.yaml @@ -0,0 +1,23 @@ +openapi: 3.0.0 +info: + title: Example API + description: This is an example API. + version: 1.0.0 +servers: + - url: https://redocly-example.com/api +paths: + /users/{userId}: + parameters: + - name: userId + in: path + description: ID of the user + required: true + schema: + type: integer + get: + summary: Get user by ID + responses: + '200': + description: OK + '404': + description: Not found diff --git a/__tests__/join/json-and-yaml-input/foo.json b/__tests__/join/json-and-yaml-input/foo.json new file mode 100644 index 000000000..433ff97f4 --- /dev/null +++ b/__tests__/join/json-and-yaml-input/foo.json @@ -0,0 +1,49 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Example API", + "description": "This is an example API.", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://redocly-example.com/api" + } + ], + "paths": { + "/users/{userId}/orders/{orderId}": { + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "ID of the user", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "orderId", + "in": "path", + "description": "ID of the order", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "get": { + "x-private": true, + "summary": "Get an order by ID for a specific user", + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not found" + } + } + } + } + } +} diff --git a/__tests__/join/json-and-yaml-input/json-output.snapshot.js b/__tests__/join/json-and-yaml-input/json-output.snapshot.js new file mode 100644 index 000000000..7a6c9b210 --- /dev/null +++ b/__tests__/join/json-and-yaml-input/json-output.snapshot.js @@ -0,0 +1,117 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`E2E join files with different extensions test with option: { + name: 'first entrypoint is a json file', + folder: 'json-and-yaml-input', + entrypoints: [Array], + snapshot: 'json-output.snapshot.js' +} 1`] = ` + +{ + "openapi": "3.0.0", + "info": { + "title": "Example API", + "description": "This is an example API.", + "version": "" + }, + "servers": [ + { + "url": "https://redocly-example.com/api" + } + ], + "tags": [ + { + "name": "foo_other", + "x-displayName": "other" + }, + { + "name": "bar_other", + "x-displayName": "other" + } + ], + "paths": { + "/users/{userId}/orders/{orderId}": { + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "ID of the user", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "orderId", + "in": "path", + "description": "ID of the order", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "get": { + "x-private": true, + "summary": "Get an order by ID for a specific user", + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not found" + } + }, + "tags": [ + "foo_other" + ] + } + }, + "/users/{userId}": { + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "ID of the user", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "get": { + "summary": "Get user by ID", + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not found" + } + }, + "tags": [ + "bar_other" + ] + } + } + }, + "components": {}, + "x-tagGroups": [ + { + "name": "foo", + "tags": [ + "foo_other" + ] + }, + { + "name": "bar", + "tags": [ + "bar_other" + ] + } + ] +} +openapi.json: join processed in ms + + +`; diff --git a/__tests__/join/json-and-yaml-input/yaml-output.snapshot.js b/__tests__/join/json-and-yaml-input/yaml-output.snapshot.js new file mode 100644 index 000000000..87fcac647 --- /dev/null +++ b/__tests__/join/json-and-yaml-input/yaml-output.snapshot.js @@ -0,0 +1,76 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`E2E join files with different extensions test with option: { + name: 'first entrypoint is a yaml file', + folder: 'json-and-yaml-input', + entrypoints: [Array], + snapshot: 'yaml-output.snapshot.js' +} 1`] = ` + +openapi: 3.0.0 +info: + title: Example API + description: This is an example API. + version: 1.0.0 +servers: + - url: https://redocly-example.com/api +tags: + - name: bar_other + x-displayName: other + - name: foo_other + x-displayName: other +paths: + /users/{userId}: + parameters: + - name: userId + in: path + description: ID of the user + required: true + schema: + type: integer + get: + summary: Get user by ID + responses: + '200': + description: OK + '404': + description: Not found + tags: + - bar_other + /users/{userId}/orders/{orderId}: + parameters: + - name: userId + in: path + description: ID of the user + required: true + schema: + type: integer + - name: orderId + in: path + description: ID of the order + required: true + schema: + type: integer + get: + x-private: true + summary: Get an order by ID for a specific user + responses: + '200': + description: OK + '404': + description: Not found + tags: + - foo_other +components: {} +x-tagGroups: + - name: bar + tags: + - bar_other + - name: foo + tags: + - foo_other + +openapi.yaml: join processed in ms + + +`; diff --git a/__tests__/join/multi-references-to-one-file/openapi.yaml b/__tests__/join/multi-references-to-one-file/openapi.yaml new file mode 100644 index 000000000..ce36283ae --- /dev/null +++ b/__tests__/join/multi-references-to-one-file/openapi.yaml @@ -0,0 +1,84 @@ +openapi: 3.0.3 +info: + title: Sample API + description: My sample api + version: 0.0.1 + license: + name: Internal + url: https://mycompany.com/license +tags: + - name: GetSingleFoo + description: Get a single foo + x-displayName: GetSingleFoo + - name: Foo + description: All foo operations + x-displayName: Foo + - name: foo_other + x-displayName: other + - name: CreateBar + description: Create a new Bar + x-displayName: CreateBar + - name: bar_other + x-displayName: other +paths: + /foo/{id}: + get: + summary: Returns a single foo + operationId: getFoo + responses: + '200': + description: One single Food + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + tags: + - foo_other + /bar/: + post: + summary: Create a single bar + operationId: createBar + responses: + '200': + description: One single bar + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + tags: + - bar_other +components: + schemas: + FooObject: + type: object + properties: + x: + type: string + 'y': + type: string + Response: + type: object + required: + - id + - name + properties: + id: + type: string + format: uuid + name: + type: string + description: + type: string + subFoo: + $ref: '#/components/schemas/FooObject' +x-tagGroups: + - name: foo + tags: + - GetSingleFoo + - Foo + - foo_other + description: My sample api + - name: bar + tags: + - CreateBar + - bar_other diff --git a/__tests__/join/yaml-input-and-json-output/bar.yaml b/__tests__/join/yaml-input-and-json-output/bar.yaml new file mode 100644 index 000000000..8f24d5778 --- /dev/null +++ b/__tests__/join/yaml-input-and-json-output/bar.yaml @@ -0,0 +1,23 @@ +openapi: 3.0.0 +info: + title: Example API + description: This is an example API. + version: 1.0.0 +servers: + - url: https://redocly-example.com/api +paths: + /users/{userId}: + parameters: + - name: userId + in: path + description: ID of the user + required: true + schema: + type: integer + get: + summary: Get user by ID + responses: + '200': + description: OK + '404': + description: Not found diff --git a/__tests__/join/yaml-input-and-json-output/foo.yaml b/__tests__/join/yaml-input-and-json-output/foo.yaml new file mode 100644 index 000000000..2ecbd051b --- /dev/null +++ b/__tests__/join/yaml-input-and-json-output/foo.yaml @@ -0,0 +1,30 @@ +openapi: 3.0.0 +info: + title: Example API + description: This is an example API. + version: 1.0.0 +servers: + - url: https://redocly-example.com/api +paths: + /users/{userId}/orders/{orderId}: + parameters: + - name: userId + in: path + description: ID of the user + required: true + schema: + type: integer + - name: orderId + in: path + description: ID of the order + required: true + schema: + type: integer + get: + x-private: true + summary: Get an order by ID for a specific user + responses: + '200': + description: OK + '404': + description: Not found diff --git a/__tests__/join/yaml-input-and-json-output/snapshot.js b/__tests__/join/yaml-input-and-json-output/snapshot.js new file mode 100644 index 000000000..0fe3348f1 --- /dev/null +++ b/__tests__/join/yaml-input-and-json-output/snapshot.js @@ -0,0 +1,118 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`E2E join files with different extensions test with option: { + name: 'json output file', + folder: 'yaml-input-and-json-output', + entrypoints: [Array], + output: 'openapi.json', + snapshot: 'snapshot.js' +} 1`] = ` + +{ + "openapi": "3.0.0", + "info": { + "title": "Example API", + "description": "This is an example API.", + "version": "" + }, + "servers": [ + { + "url": "https://redocly-example.com/api" + } + ], + "tags": [ + { + "name": "foo_other", + "x-displayName": "other" + }, + { + "name": "bar_other", + "x-displayName": "other" + } + ], + "paths": { + "/users/{userId}/orders/{orderId}": { + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "ID of the user", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "orderId", + "in": "path", + "description": "ID of the order", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "get": { + "x-private": true, + "summary": "Get an order by ID for a specific user", + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not found" + } + }, + "tags": [ + "foo_other" + ] + } + }, + "/users/{userId}": { + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "ID of the user", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "get": { + "summary": "Get user by ID", + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not found" + } + }, + "tags": [ + "bar_other" + ] + } + } + }, + "components": {}, + "x-tagGroups": [ + { + "name": "foo", + "tags": [ + "foo_other" + ] + }, + { + "name": "bar", + "tags": [ + "bar_other" + ] + } + ] +} +openapi.json: join processed in ms + + +`; diff --git a/__tests__/split/openapi-json-file/openapi.json b/__tests__/split/openapi-json-file/openapi.json new file mode 100644 index 000000000..ece4f7be6 --- /dev/null +++ b/__tests__/split/openapi-json-file/openapi.json @@ -0,0 +1,165 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": "Swagger Petstore", + "license": { + "name": "MIT" + } + }, + "servers": [ + { + "url": "http://petstore.swagger.io/v1" + } + ], + "paths": { + "/pets": { + "get": { + "summary": "List all pets", + "operationId": "listPets", + "tags": ["pets"], + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "How many items to return at one time (max 100)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "A paged array of pets", + "headers": { + "x-next": { + "description": "A link to the next page of responses", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pets" + } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "summary": "Create a pet", + "operationId": "createPets", + "tags": ["pets"], + "responses": { + "201": { + "description": "Null response" + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/pets/{petId}": { + "get": { + "summary": "Info for a specific pet", + "operationId": "showPetById", + "tags": ["pets"], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "description": "The id of the pet to retrieve", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Expected response to a valid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + }, + "Pets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + }, + "Error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + } + } + } + } + } +} diff --git a/__tests__/split/openapi-json-file/snapshot.js b/__tests__/split/openapi-json-file/snapshot.js new file mode 100644 index 000000000..4eddec7d3 --- /dev/null +++ b/__tests__/split/openapi-json-file/snapshot.js @@ -0,0 +1,184 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`E2E split openapi json file 1`] = ` + +{ + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } +}{ + "type": "array", + "items": { + "$ref": "./Pet.json" + } +}{ + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + } + } +}{ + "get": { + "summary": "List all pets", + "operationId": "listPets", + "tags": [ + "pets" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "How many items to return at one time (max 100)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "A paged array of pets", + "headers": { + "x-next": { + "description": "A link to the next page of responses", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pets" + } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "summary": "Create a pet", + "operationId": "createPets", + "tags": [ + "pets" + ], + "responses": { + "201": { + "description": "Null response" + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +}{ + "get": { + "summary": "Info for a specific pet", + "operationId": "showPetById", + "tags": [ + "pets" + ], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "description": "The id of the pet to retrieve", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Expected response to a valid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +}{ + "openapi": "3.0.0", + "info": { + "version": "", + "title": "Swagger Petstore", + "license": { + "name": "MIT" + } + }, + "servers": [ + { + "url": "http://petstore.swagger.io/v1" + } + ], + "paths": { + "/pets": { + "$ref": "paths/pets.json" + }, + "/pets/{petId}": { + "$ref": "paths/pets_{petId}.json" + } + } +}🪓 Document: ../../../__tests__/split/openapi-json-file/openapi.json is successfully split + and all related files are saved to the directory: output + +../../../__tests__/split/openapi-json-file/openapi.json: split processed in ms + + +`; diff --git a/docs/commands/join.md b/docs/commands/join.md index 8e5c24d31..1e8c5bc3a 100644 --- a/docs/commands/join.md +++ b/docs/commands/join.md @@ -14,9 +14,9 @@ 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 can be achieved by providing it through the `--output` argument. Any existing file is overwritten. +The `join` command accepts both YAML and JSON files, which you can mix in the resulting `openapi.yaml` or `openapi.json` file. Setting a custom name and extension for this file can be achieved by providing it through the `--output` argument. Any existing file is overwritten. If the `--output` option is not provided, the command uses the extension of the first entry point file. -Apart from providing individual API description files as the input, you can also specify the path to a folder that contains multiple API description files and match them with a wildcard (for example, `myproject/openapi/*.yaml`). The `join` command collects all matching files and combines them into one file. +Apart from providing individual API description files as the input, you can also specify the path to a folder that contains multiple API description files and match them with a wildcard (for example, `myproject/openapi/*.(yaml/json)`). The `join` command collects all matching files and combines them into one file. ### Usage @@ -43,7 +43,7 @@ redocly join --version | --help | boolean | Show help. | | --lint | boolean | Lint API description files. | | --lint-config | string | Specify the severity level for the configuration file.
**Possible values:** `warn`, `error`, `off`. Default value is `warn`. | -| --output, -o | string | Name for the joined output file. Defaults to `openapi.yaml`. **If the file already exists, it's overwritten.** | +| --output, -o | string | Name for the joined output file. Defaults to `openapi.yaml` or `openapi.json` (Depends on the extension of the first input file). **If the file already exists, it's overwritten.** | | --prefix-components-with-info-prop | string | Prefix components with property value from info object. See the [prefix-components-with-info-prop section](#prefix-components-with-info-prop) below. | | --prefix-tags-with-filename | string | Prefix tags with property value from file name. See the [prefix-tags-with-filename section](#prefix-tags-with-filename) below. | | --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. | @@ -274,7 +274,7 @@ components: ### 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. +By default, the CLI tool writes the joined file as `openapi.yaml` or `openapi.json` 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/packages/cli/src/__mocks__/@redocly/openapi-core.ts b/packages/cli/src/__mocks__/@redocly/openapi-core.ts index b0164e26d..8347ad5b3 100644 --- a/packages/cli/src/__mocks__/@redocly/openapi-core.ts +++ b/packages/cli/src/__mocks__/@redocly/openapi-core.ts @@ -31,6 +31,7 @@ export const doesYamlFileExist = jest.fn(); export const bundleDocument = jest.fn(() => Promise.resolve({ problems: {} })); export const detectSpec = jest.fn(); export const isAbsoluteUrl = jest.fn(); +export const stringifyYaml = jest.fn((data) => data); export class BaseResolver { cache = new Map>(); diff --git a/packages/cli/src/__mocks__/utils.ts b/packages/cli/src/__mocks__/utils.ts index bebdc01d5..83824b25c 100644 --- a/packages/cli/src/__mocks__/utils.ts +++ b/packages/cli/src/__mocks__/utils.ts @@ -17,3 +17,5 @@ export const writeYaml = jest.fn(); export const loadConfigAndHandleErrors = jest.fn(() => ConfigFixture); export const checkIfRulesetExist = jest.fn(); export const sortTopLevelKeysForOas = jest.fn((document) => document); +export const getAndValidateFileExtension = jest.fn((fileName: string) => fileName.split('.').pop()); +export const writeToFileByExtension = jest.fn(); diff --git a/packages/cli/src/__tests__/commands/join.test.ts b/packages/cli/src/__tests__/commands/join.test.ts index 22a604f41..e9f995fc5 100644 --- a/packages/cli/src/__tests__/commands/join.test.ts +++ b/packages/cli/src/__tests__/commands/join.test.ts @@ -1,11 +1,12 @@ import { handleJoin } from '../../commands/join'; -import { exitWithError, writeYaml } from '../../utils'; +import { exitWithError, writeToFileByExtension, writeYaml } from '../../utils'; import { yellow } from 'colorette'; import { detectSpec } from '@redocly/openapi-core'; import { loadConfig } from '../../__mocks__/@redocly/openapi-core'; import { ConfigFixture } from '../fixtures/config'; jest.mock('../../utils'); + jest.mock('colorette'); describe('handleJoin fails', () => { @@ -80,7 +81,7 @@ describe('handleJoin fails', () => { ); }); - it('should call writeYaml function', async () => { + it('should call writeToFileByExtension function', async () => { (detectSpec as jest.Mock).mockReturnValue('oas3_0'); await handleJoin( { @@ -90,10 +91,14 @@ describe('handleJoin fails', () => { 'cli-version' ); - expect(writeYaml).toHaveBeenCalledWith(expect.any(Object), 'openapi.yaml', expect.any(Boolean)); + expect(writeToFileByExtension).toHaveBeenCalledWith( + expect.any(Object), + 'openapi.yaml', + expect.any(Boolean) + ); }); - it('should call writeYaml function for OpenAPI 3.1', async () => { + it('should call writeToFileByExtension function for OpenAPI 3.1', async () => { (detectSpec as jest.Mock).mockReturnValue('oas3_1'); await handleJoin( { @@ -103,10 +108,14 @@ describe('handleJoin fails', () => { 'cli-version' ); - expect(writeYaml).toHaveBeenCalledWith(expect.any(Object), 'openapi.yaml', expect.any(Boolean)); + expect(writeToFileByExtension).toHaveBeenCalledWith( + expect.any(Object), + 'openapi.yaml', + expect.any(Boolean) + ); }); - it('should call writeYaml function with custom output file', async () => { + it('should call writeToFileByExtension function with custom output file', async () => { (detectSpec as jest.Mock).mockReturnValue('oas3_0'); await handleJoin( { @@ -117,7 +126,28 @@ describe('handleJoin fails', () => { 'cli-version' ); - expect(writeYaml).toHaveBeenCalledWith(expect.any(Object), 'output.yml', expect.any(Boolean)); + expect(writeToFileByExtension).toHaveBeenCalledWith( + expect.any(Object), + 'output.yml', + expect.any(Boolean) + ); + }); + + it('should call writeToFileByExtension function with json file extension', async () => { + (detectSpec as jest.Mock).mockReturnValue('oas3_0'); + await handleJoin( + { + apis: ['first.json', 'second.yaml'], + }, + ConfigFixture as any, + 'cli-version' + ); + + expect(writeToFileByExtension).toHaveBeenCalledWith( + expect.any(Object), + 'openapi.json', + expect.any(Boolean) + ); }); it('should call skipDecorators and skipPreprocessors', async () => { diff --git a/packages/cli/src/__tests__/utils.test.ts b/packages/cli/src/__tests__/utils.test.ts index 48cfde5eb..1817bb521 100644 --- a/packages/cli/src/__tests__/utils.test.ts +++ b/packages/cli/src/__tests__/utils.test.ts @@ -12,6 +12,10 @@ import { HandledError, cleanArgs, cleanRawInput, + getAndValidateFileExtension, + writeYaml, + writeJson, + writeToFileByExtension, } from '../utils'; import { ResolvedApi, @@ -19,11 +23,13 @@ import { isAbsoluteUrl, ResolveError, YamlParseError, + stringifyYaml, } from '@redocly/openapi-core'; import { blue, red, yellow } from 'colorette'; -import { existsSync, statSync } from 'fs'; +import { existsSync, statSync, writeFileSync } from 'fs'; import * as path from 'path'; import * as process from 'process'; +import * as utils from '../utils'; jest.mock('os'); jest.mock('colorette'); @@ -554,4 +560,42 @@ describe('cleanRawInput', () => { 'redocly lint file-json --format stylish --extends=minimal --skip-rule operation-4xx-response' ); }); + + describe('validateFileExtension', () => { + it('should return current file extension', () => { + expect(getAndValidateFileExtension('test.json')).toEqual('json'); + }); + + it('should return yaml and print warning if file extension does not supported', () => { + const stderrMock = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + (yellow as jest.Mock).mockImplementation((text: string) => text); + + expect(getAndValidateFileExtension('test.xml')).toEqual('yaml'); + expect(stderrMock).toHaveBeenCalledWith(`Unsupported file extension: xml. Using yaml.\n`); + }); + }); + + describe('writeToFileByExtension', () => { + beforeEach(() => { + jest.spyOn(process.stderr, 'write').mockImplementation(jest.fn()); + (yellow as jest.Mock).mockImplementation((text: string) => text); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should call stringifyYaml function', () => { + writeToFileByExtension('test data', 'test.yaml'); + expect(stringifyYaml).toHaveBeenCalledWith('test data', { noRefs: false }); + expect(process.stderr.write).toHaveBeenCalledWith(`test data`); + }); + + it('should call JSON.stringify function', () => { + const stringifySpy = jest.spyOn(JSON, 'stringify').mockImplementation((data) => data); + writeToFileByExtension('test data', 'test.json'); + expect(stringifySpy).toHaveBeenCalledWith('test data', null, 2); + expect(process.stderr.write).toHaveBeenCalledWith(`test data`); + }); + }); }); diff --git a/packages/cli/src/commands/join.ts b/packages/cli/src/commands/join.ts index 087e4befa..49e230013 100644 --- a/packages/cli/src/commands/join.ts +++ b/packages/cli/src/commands/join.ts @@ -25,9 +25,10 @@ import { printExecutionTime, handleError, printLintTotals, - writeYaml, exitWithError, sortTopLevelKeysForOas, + getAndValidateFileExtension, + writeToFileByExtension, } from '../utils'; import { isObject, isString, keysOf } from '../js-utils'; import { @@ -70,16 +71,19 @@ export type JoinOptions = { export async function handleJoin(argv: JoinOptions, config: Config, packageVersion: string) { const startedAt = performance.now(); + if (argv.apis.length < 2) { return exitWithError(`At least 2 apis should be provided. \n\n`); } + const fileExtension = getAndValidateFileExtension(argv.output || argv.apis[0]); + const { 'prefix-components-with-info-prop': prefixComponentsWithInfoProp, 'prefix-tags-with-filename': prefixTagsWithFilename, 'prefix-tags-with-info-prop': prefixTagsWithInfoProp, 'without-x-tag-groups': withoutXTagGroups, - output: specFilename = 'openapi.yaml', + output: specFilename = `openapi.${fileExtension}`, } = argv; const usedTagsOptions = [ @@ -229,7 +233,8 @@ export async function handleJoin(argv: JoinOptions, config: Config, packageVersi return exitWithError(`Please fix conflicts before running ${yellow('join')}.`); } - writeYaml(sortTopLevelKeysForOas(joinedDef), specFilename, noRefs); + writeToFileByExtension(sortTopLevelKeysForOas(joinedDef), specFilename, noRefs); + printExecutionTime('join', startedAt, specFilename); function populateTags({ diff --git a/packages/cli/src/commands/split/__tests__/index.test.ts b/packages/cli/src/commands/split/__tests__/index.test.ts index 700962446..beb2fe378 100644 --- a/packages/cli/src/commands/split/__tests__/index.test.ts +++ b/packages/cli/src/commands/split/__tests__/index.test.ts @@ -3,12 +3,13 @@ import * as path from 'path'; import * as openapiCore from '@redocly/openapi-core'; import { ComponentsFiles } from '../types'; import { blue, green } from 'colorette'; +import { writeToFileByExtension } from '../../../utils'; const utils = require('../../../utils'); jest.mock('../../../utils', () => ({ ...jest.requireActual('../../../utils'), - writeYaml: jest.fn(), + writeToFileByExtension: jest.fn(), })); jest.mock('@redocly/openapi-core', () => ({ @@ -65,7 +66,9 @@ describe('#split', () => { openapiDir, path.join(openapiDir, 'paths'), componentsFiles, - '_' + '_', + undefined, + 'yaml' ); expect(openapiCore.slash).toHaveBeenCalledWith('paths/test.yaml'); @@ -82,7 +85,9 @@ describe('#split', () => { openapiDir, path.join(openapiDir, 'webhooks'), componentsFiles, - 'webhook_' + 'webhook_', + undefined, + 'yaml' ); expect(openapiCore.slash).toHaveBeenCalledWith('webhooks/test.yaml'); @@ -99,7 +104,9 @@ describe('#split', () => { openapiDir, path.join(openapiDir, 'webhooks'), componentsFiles, - 'webhook_' + 'webhook_', + undefined, + 'yaml' ); expect(openapiCore.slash).toHaveBeenCalledWith('webhooks/test.yaml'); @@ -118,7 +125,9 @@ describe('#split', () => { openapiDir, path.join(openapiDir, 'paths'), componentsFiles, - '_' + '_', + undefined, + 'yaml' ); expect(utils.escapeLanguageName).nthCalledWith(1, 'C#'); diff --git a/packages/cli/src/commands/split/index.ts b/packages/cli/src/commands/split/index.ts index 1f22ae3f1..758d30ac4 100644 --- a/packages/cli/src/commands/split/index.ts +++ b/packages/cli/src/commands/split/index.ts @@ -10,10 +10,11 @@ import { printExecutionTime, pathToFilename, readYaml, - writeYaml, exitWithError, escapeLanguageName, langToExt, + writeToFileByExtension, + getAndValidateFileExtension, } from '../../utils'; import { isString, isObject, isEmptyObject } from '../../js-utils'; import { @@ -46,8 +47,9 @@ export async function handleSplit(argv: SplitOptions) { const startedAt = performance.now(); const { api, outDir, separator } = argv; validateDefinitionFileName(api!); + const ext = getAndValidateFileExtension(api); const openapi = readYaml(api!) as Oas3Definition | Oas3_1Definition; - splitDefinition(openapi, outDir, separator); + splitDefinition(openapi, outDir, separator, ext); process.stderr.write( `🪓 Document: ${blue(api!)} ${green('is successfully split')} and all related files are saved to the directory: ${blue(outDir)} \n` @@ -58,18 +60,21 @@ export async function handleSplit(argv: SplitOptions) { function splitDefinition( openapi: Oas3Definition | Oas3_1Definition, openapiDir: string, - pathSeparator: string + pathSeparator: string, + ext: string ) { fs.mkdirSync(openapiDir, { recursive: true }); const componentsFiles: ComponentsFiles = {}; - iterateComponents(openapi, openapiDir, componentsFiles); + iterateComponents(openapi, openapiDir, componentsFiles, ext); iteratePathItems( openapi.paths, openapiDir, path.join(openapiDir, 'paths'), componentsFiles, - pathSeparator + pathSeparator, + undefined, + ext ); const webhooks = (openapi as Oas3_1Definition).webhooks || (openapi as Oas3Definition)['x-webhooks']; @@ -80,11 +85,12 @@ function splitDefinition( path.join(openapiDir, 'webhooks'), componentsFiles, pathSeparator, - 'webhook_' + 'webhook_', + ext ); replace$Refs(openapi, openapiDir, componentsFiles); - writeYaml(openapi, path.join(openapiDir, 'openapi.yaml')); + writeToFileByExtension(openapi, path.join(openapiDir, `openapi.${ext}`)); } function isStartsWithComponents(node: string) { @@ -135,7 +141,7 @@ function traverseDirectoryDeepCallback( if (isNotYaml(filename)) return; const pathData = readYaml(filename); replace$Refs(pathData, directory, componentsFiles); - writeYaml(pathData, filename); + writeToFileByExtension(pathData, filename); } function crawl(object: any, visitor: any) { @@ -251,8 +257,8 @@ function extractFileNameFromPath(filename: string) { return path.basename(filename, path.extname(filename)); } -function getFileNamePath(componentDirPath: string, componentName: string) { - return path.join(componentDirPath, componentName) + '.yaml'; +function getFileNamePath(componentDirPath: string, componentName: string, ext: string) { + return path.join(componentDirPath, componentName) + `.${ext}`; } function gatherComponentsFiles( @@ -278,13 +284,14 @@ function iteratePathItems( outDir: string, componentsFiles: object, pathSeparator: string, - codeSamplesPathPrefix: string = '' + codeSamplesPathPrefix: string = '', + ext: string ) { if (!pathItems) return; fs.mkdirSync(outDir, { recursive: true }); for (const pathName of Object.keys(pathItems)) { - const pathFile = `${path.join(outDir, pathToFilename(pathName, pathSeparator))}.yaml`; + const pathFile = `${path.join(outDir, pathToFilename(pathName, pathSeparator))}.${ext}`; const pathData = pathItems[pathName] as Oas3PathItem; if (isRef(pathData)) continue; @@ -314,7 +321,7 @@ function iteratePathItems( }; } } - writeYaml(pathData, pathFile); + writeToFileByExtension(pathData, pathFile); pathItems[pathName] = { $ref: slash(path.relative(openapiDir, pathFile)), }; @@ -326,7 +333,8 @@ function iteratePathItems( function iterateComponents( openapi: Oas3Definition | Oas3_1Definition, openapiDir: string, - componentsFiles: ComponentsFiles + componentsFiles: ComponentsFiles, + ext: string ) { const { components } = openapi; if (components) { @@ -340,7 +348,7 @@ function iterateComponents( function iterateAndGatherComponentsFiles(componentType: Oas3ComponentName) { const componentDirPath = path.join(componentsDir, componentType); for (const componentName of Object.keys(components?.[componentType] || {})) { - const filename = getFileNamePath(componentDirPath, componentName); + const filename = getFileNamePath(componentDirPath, componentName, ext); gatherComponentsFiles(components!, componentsFiles, componentType, componentName, filename); } } @@ -350,7 +358,7 @@ function iterateComponents( const componentDirPath = path.join(componentsDir, componentType); createComponentDir(componentDirPath, componentType); for (const componentName of Object.keys(components?.[componentType] || {})) { - const filename = getFileNamePath(componentDirPath, componentName); + const filename = getFileNamePath(componentDirPath, componentName, ext); const componentData = components?.[componentType]?.[componentName]; replace$Refs(componentData, path.dirname(filename), componentsFiles); implicitlyReferenceDiscriminator( @@ -369,7 +377,7 @@ function iterateComponents( ) ); } else { - writeYaml(componentData, filename); + writeToFileByExtension(componentData, filename); } if (isNotSecurityComponentType(componentType)) { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0243c756d..37369c549 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -127,7 +127,6 @@ yargs describe: 'Output file', alias: 'o', type: 'string', - default: 'openapi.yaml', }, config: { description: 'Path to the config file.', diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 0e3fe865f..274316537 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -25,7 +25,14 @@ import { RedoclyClient, } from '@redocly/openapi-core'; import { ConfigValidationError } from '@redocly/openapi-core/lib/config'; -import { Totals, outputExtensions, Entrypoint, ConfigApis, CommandOptions } from './types'; +import { + Totals, + outputExtensions, + Entrypoint, + ConfigApis, + CommandOptions, + OutputExtensions, +} from './types'; import { isEmptyObject } from '@redocly/openapi-core/lib/utils'; import { Arguments } from 'yargs'; import { version } from './update-version-notifier'; @@ -209,6 +216,17 @@ export function readYaml(filename: string) { return parseYaml(fs.readFileSync(filename, 'utf-8'), { filename }); } +export function writeToFileByExtension(data: unknown, filePath: string, noRefs?: boolean) { + const ext = getAndValidateFileExtension(filePath); + + if (ext === 'json') { + writeJson(data, filePath); + return; + } + + writeYaml(data, filePath, noRefs); +} + export function writeYaml(data: any, filename: string, noRefs = false) { const content = stringifyYaml(data, { noRefs }); @@ -220,6 +238,27 @@ export function writeYaml(data: any, filename: string, noRefs = false) { fs.writeFileSync(filename, content); } +export function writeJson(data: unknown, filename: string) { + const content = JSON.stringify(data, null, 2); + + if (process.env.NODE_ENV === 'test') { + process.stderr.write(content); + return; + } + fs.mkdirSync(dirname(filename), { recursive: true }); + fs.writeFileSync(filename, content); +} + +export function getAndValidateFileExtension(fileName: string): NonNullable { + const ext = fileName.split('.').pop(); + + if (['yaml', 'yml', 'json'].includes(ext!)) { + return ext as NonNullable; + } + process.stderr.write(yellow(`Unsupported file extension: ${ext}. Using yaml.\n`)); + return 'yaml'; +} + export function pluralize(label: string, num: number) { if (label.endsWith('is')) { [label] = label.split(' ');