feat: extend split and join commands to produce JSON output (#1305)

This commit is contained in:
Ihor Karpiuk
2023-10-26 14:42:06 +03:00
committed by GitHub
parent 72b225a698
commit 1510e471b7
22 changed files with 1103 additions and 39 deletions

View File

@@ -0,0 +1,5 @@
---
'@redocly/cli': minor
---
Add JSON output support to the `split` and `join` commands.

View File

@@ -161,6 +161,19 @@ describe('E2E', () => {
const result = getCommandOutput(args, folderPath); const result = getCommandOutput(args, folderPath);
(<any>expect(result)).toMatchSpecificSnapshot(join(folderPath, 'snapshot.js')); (<any>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);
(<any>expect(result)).toMatchSpecificSnapshot(join(folderPath, 'snapshot.js'));
});
}); });
describe('join', () => { describe('join', () => {
@@ -214,6 +227,46 @@ describe('E2E', () => {
const result = getCommandOutput(args, testPath); const result = getCommandOutput(args, testPath);
(<any>expect(result)).toMatchSpecificSnapshot(join(testPath, 'snapshot.js')); (<any>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);
(<any>expect(result)).toMatchSpecificSnapshot(join(testPath, parameters.snapshot));
});
});
}); });
describe('bundle', () => { describe('bundle', () => {

View File

@@ -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

View File

@@ -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"
}
}
}
}
}
}

View File

@@ -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": "<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 <test>ms
`;

View File

@@ -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 <test>ms
`;

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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": "<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 <test>ms
`;

View File

@@ -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"
}
}
}
}
}
}

View File

@@ -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": "<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 <test>ms
`;

View File

@@ -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. 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 ### Usage
@@ -43,7 +43,7 @@ redocly join --version
| --help | boolean | Show help. | | --help | boolean | Show help. |
| --lint | boolean | Lint API description files. | | --lint | boolean | Lint API description files. |
| --lint-config | string | Specify the severity level for the configuration file. <br/> **Possible values:** `warn`, `error`, `off`. Default value is `warn`. | | --lint-config | string | Specify the severity level for the configuration file. <br/> **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-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-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. | | --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 ### 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 ```bash Command
redocly join --output=openapi-custom.yaml redocly join --output=openapi-custom.yaml

View File

@@ -31,6 +31,7 @@ export const doesYamlFileExist = jest.fn();
export const bundleDocument = jest.fn(() => Promise.resolve({ problems: {} })); export const bundleDocument = jest.fn(() => Promise.resolve({ problems: {} }));
export const detectSpec = jest.fn(); export const detectSpec = jest.fn();
export const isAbsoluteUrl = jest.fn(); export const isAbsoluteUrl = jest.fn();
export const stringifyYaml = jest.fn((data) => data);
export class BaseResolver { export class BaseResolver {
cache = new Map<string, Promise<Document | ResolveError>>(); cache = new Map<string, Promise<Document | ResolveError>>();

View File

@@ -17,3 +17,5 @@ export const writeYaml = jest.fn();
export const loadConfigAndHandleErrors = jest.fn(() => ConfigFixture); export const loadConfigAndHandleErrors = jest.fn(() => ConfigFixture);
export const checkIfRulesetExist = jest.fn(); export const checkIfRulesetExist = jest.fn();
export const sortTopLevelKeysForOas = jest.fn((document) => document); export const sortTopLevelKeysForOas = jest.fn((document) => document);
export const getAndValidateFileExtension = jest.fn((fileName: string) => fileName.split('.').pop());
export const writeToFileByExtension = jest.fn();

View File

@@ -1,11 +1,12 @@
import { handleJoin } from '../../commands/join'; import { handleJoin } from '../../commands/join';
import { exitWithError, writeYaml } from '../../utils'; import { exitWithError, writeToFileByExtension, writeYaml } from '../../utils';
import { yellow } from 'colorette'; import { yellow } from 'colorette';
import { detectSpec } from '@redocly/openapi-core'; import { detectSpec } from '@redocly/openapi-core';
import { loadConfig } from '../../__mocks__/@redocly/openapi-core'; import { loadConfig } from '../../__mocks__/@redocly/openapi-core';
import { ConfigFixture } from '../fixtures/config'; import { ConfigFixture } from '../fixtures/config';
jest.mock('../../utils'); jest.mock('../../utils');
jest.mock('colorette'); jest.mock('colorette');
describe('handleJoin fails', () => { 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'); (detectSpec as jest.Mock).mockReturnValue('oas3_0');
await handleJoin( await handleJoin(
{ {
@@ -90,10 +91,14 @@ describe('handleJoin fails', () => {
'cli-version' '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'); (detectSpec as jest.Mock).mockReturnValue('oas3_1');
await handleJoin( await handleJoin(
{ {
@@ -103,10 +108,14 @@ describe('handleJoin fails', () => {
'cli-version' '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'); (detectSpec as jest.Mock).mockReturnValue('oas3_0');
await handleJoin( await handleJoin(
{ {
@@ -117,7 +126,28 @@ describe('handleJoin fails', () => {
'cli-version' '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 () => { it('should call skipDecorators and skipPreprocessors', async () => {

View File

@@ -12,6 +12,10 @@ import {
HandledError, HandledError,
cleanArgs, cleanArgs,
cleanRawInput, cleanRawInput,
getAndValidateFileExtension,
writeYaml,
writeJson,
writeToFileByExtension,
} from '../utils'; } from '../utils';
import { import {
ResolvedApi, ResolvedApi,
@@ -19,11 +23,13 @@ import {
isAbsoluteUrl, isAbsoluteUrl,
ResolveError, ResolveError,
YamlParseError, YamlParseError,
stringifyYaml,
} from '@redocly/openapi-core'; } from '@redocly/openapi-core';
import { blue, red, yellow } from 'colorette'; import { blue, red, yellow } from 'colorette';
import { existsSync, statSync } from 'fs'; import { existsSync, statSync, writeFileSync } from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as process from 'process'; import * as process from 'process';
import * as utils from '../utils';
jest.mock('os'); jest.mock('os');
jest.mock('colorette'); jest.mock('colorette');
@@ -554,4 +560,42 @@ describe('cleanRawInput', () => {
'redocly lint file-json --format stylish --extends=minimal --skip-rule operation-4xx-response' '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<any, any>).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<any, any>).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`);
});
});
}); });

View File

@@ -25,9 +25,10 @@ import {
printExecutionTime, printExecutionTime,
handleError, handleError,
printLintTotals, printLintTotals,
writeYaml,
exitWithError, exitWithError,
sortTopLevelKeysForOas, sortTopLevelKeysForOas,
getAndValidateFileExtension,
writeToFileByExtension,
} from '../utils'; } from '../utils';
import { isObject, isString, keysOf } from '../js-utils'; import { isObject, isString, keysOf } from '../js-utils';
import { import {
@@ -70,16 +71,19 @@ export type JoinOptions = {
export async function handleJoin(argv: JoinOptions, config: Config, packageVersion: string) { export async function handleJoin(argv: JoinOptions, config: Config, packageVersion: string) {
const startedAt = performance.now(); const startedAt = performance.now();
if (argv.apis.length < 2) { if (argv.apis.length < 2) {
return exitWithError(`At least 2 apis should be provided. \n\n`); return exitWithError(`At least 2 apis should be provided. \n\n`);
} }
const fileExtension = getAndValidateFileExtension(argv.output || argv.apis[0]);
const { const {
'prefix-components-with-info-prop': prefixComponentsWithInfoProp, 'prefix-components-with-info-prop': prefixComponentsWithInfoProp,
'prefix-tags-with-filename': prefixTagsWithFilename, 'prefix-tags-with-filename': prefixTagsWithFilename,
'prefix-tags-with-info-prop': prefixTagsWithInfoProp, 'prefix-tags-with-info-prop': prefixTagsWithInfoProp,
'without-x-tag-groups': withoutXTagGroups, 'without-x-tag-groups': withoutXTagGroups,
output: specFilename = 'openapi.yaml', output: specFilename = `openapi.${fileExtension}`,
} = argv; } = argv;
const usedTagsOptions = [ const usedTagsOptions = [
@@ -229,7 +233,8 @@ export async function handleJoin(argv: JoinOptions, config: Config, packageVersi
return exitWithError(`Please fix conflicts before running ${yellow('join')}.`); return exitWithError(`Please fix conflicts before running ${yellow('join')}.`);
} }
writeYaml(sortTopLevelKeysForOas(joinedDef), specFilename, noRefs); writeToFileByExtension(sortTopLevelKeysForOas(joinedDef), specFilename, noRefs);
printExecutionTime('join', startedAt, specFilename); printExecutionTime('join', startedAt, specFilename);
function populateTags({ function populateTags({

View File

@@ -3,12 +3,13 @@ import * as path from 'path';
import * as openapiCore from '@redocly/openapi-core'; import * as openapiCore from '@redocly/openapi-core';
import { ComponentsFiles } from '../types'; import { ComponentsFiles } from '../types';
import { blue, green } from 'colorette'; import { blue, green } from 'colorette';
import { writeToFileByExtension } from '../../../utils';
const utils = require('../../../utils'); const utils = require('../../../utils');
jest.mock('../../../utils', () => ({ jest.mock('../../../utils', () => ({
...jest.requireActual('../../../utils'), ...jest.requireActual('../../../utils'),
writeYaml: jest.fn(), writeToFileByExtension: jest.fn(),
})); }));
jest.mock('@redocly/openapi-core', () => ({ jest.mock('@redocly/openapi-core', () => ({
@@ -65,7 +66,9 @@ describe('#split', () => {
openapiDir, openapiDir,
path.join(openapiDir, 'paths'), path.join(openapiDir, 'paths'),
componentsFiles, componentsFiles,
'_' '_',
undefined,
'yaml'
); );
expect(openapiCore.slash).toHaveBeenCalledWith('paths/test.yaml'); expect(openapiCore.slash).toHaveBeenCalledWith('paths/test.yaml');
@@ -82,7 +85,9 @@ describe('#split', () => {
openapiDir, openapiDir,
path.join(openapiDir, 'webhooks'), path.join(openapiDir, 'webhooks'),
componentsFiles, componentsFiles,
'webhook_' 'webhook_',
undefined,
'yaml'
); );
expect(openapiCore.slash).toHaveBeenCalledWith('webhooks/test.yaml'); expect(openapiCore.slash).toHaveBeenCalledWith('webhooks/test.yaml');
@@ -99,7 +104,9 @@ describe('#split', () => {
openapiDir, openapiDir,
path.join(openapiDir, 'webhooks'), path.join(openapiDir, 'webhooks'),
componentsFiles, componentsFiles,
'webhook_' 'webhook_',
undefined,
'yaml'
); );
expect(openapiCore.slash).toHaveBeenCalledWith('webhooks/test.yaml'); expect(openapiCore.slash).toHaveBeenCalledWith('webhooks/test.yaml');
@@ -118,7 +125,9 @@ describe('#split', () => {
openapiDir, openapiDir,
path.join(openapiDir, 'paths'), path.join(openapiDir, 'paths'),
componentsFiles, componentsFiles,
'_' '_',
undefined,
'yaml'
); );
expect(utils.escapeLanguageName).nthCalledWith(1, 'C#'); expect(utils.escapeLanguageName).nthCalledWith(1, 'C#');

View File

@@ -10,10 +10,11 @@ import {
printExecutionTime, printExecutionTime,
pathToFilename, pathToFilename,
readYaml, readYaml,
writeYaml,
exitWithError, exitWithError,
escapeLanguageName, escapeLanguageName,
langToExt, langToExt,
writeToFileByExtension,
getAndValidateFileExtension,
} from '../../utils'; } from '../../utils';
import { isString, isObject, isEmptyObject } from '../../js-utils'; import { isString, isObject, isEmptyObject } from '../../js-utils';
import { import {
@@ -46,8 +47,9 @@ export async function handleSplit(argv: SplitOptions) {
const startedAt = performance.now(); const startedAt = performance.now();
const { api, outDir, separator } = argv; const { api, outDir, separator } = argv;
validateDefinitionFileName(api!); validateDefinitionFileName(api!);
const ext = getAndValidateFileExtension(api);
const openapi = readYaml(api!) as Oas3Definition | Oas3_1Definition; const openapi = readYaml(api!) as Oas3Definition | Oas3_1Definition;
splitDefinition(openapi, outDir, separator); splitDefinition(openapi, outDir, separator, ext);
process.stderr.write( process.stderr.write(
`🪓 Document: ${blue(api!)} ${green('is successfully split')} `🪓 Document: ${blue(api!)} ${green('is successfully split')}
and all related files are saved to the directory: ${blue(outDir)} \n` and all related files are saved to the directory: ${blue(outDir)} \n`
@@ -58,18 +60,21 @@ export async function handleSplit(argv: SplitOptions) {
function splitDefinition( function splitDefinition(
openapi: Oas3Definition | Oas3_1Definition, openapi: Oas3Definition | Oas3_1Definition,
openapiDir: string, openapiDir: string,
pathSeparator: string pathSeparator: string,
ext: string
) { ) {
fs.mkdirSync(openapiDir, { recursive: true }); fs.mkdirSync(openapiDir, { recursive: true });
const componentsFiles: ComponentsFiles = {}; const componentsFiles: ComponentsFiles = {};
iterateComponents(openapi, openapiDir, componentsFiles); iterateComponents(openapi, openapiDir, componentsFiles, ext);
iteratePathItems( iteratePathItems(
openapi.paths, openapi.paths,
openapiDir, openapiDir,
path.join(openapiDir, 'paths'), path.join(openapiDir, 'paths'),
componentsFiles, componentsFiles,
pathSeparator pathSeparator,
undefined,
ext
); );
const webhooks = const webhooks =
(openapi as Oas3_1Definition).webhooks || (openapi as Oas3Definition)['x-webhooks']; (openapi as Oas3_1Definition).webhooks || (openapi as Oas3Definition)['x-webhooks'];
@@ -80,11 +85,12 @@ function splitDefinition(
path.join(openapiDir, 'webhooks'), path.join(openapiDir, 'webhooks'),
componentsFiles, componentsFiles,
pathSeparator, pathSeparator,
'webhook_' 'webhook_',
ext
); );
replace$Refs(openapi, openapiDir, componentsFiles); replace$Refs(openapi, openapiDir, componentsFiles);
writeYaml(openapi, path.join(openapiDir, 'openapi.yaml')); writeToFileByExtension(openapi, path.join(openapiDir, `openapi.${ext}`));
} }
function isStartsWithComponents(node: string) { function isStartsWithComponents(node: string) {
@@ -135,7 +141,7 @@ function traverseDirectoryDeepCallback(
if (isNotYaml(filename)) return; if (isNotYaml(filename)) return;
const pathData = readYaml(filename); const pathData = readYaml(filename);
replace$Refs(pathData, directory, componentsFiles); replace$Refs(pathData, directory, componentsFiles);
writeYaml(pathData, filename); writeToFileByExtension(pathData, filename);
} }
function crawl(object: any, visitor: any) { function crawl(object: any, visitor: any) {
@@ -251,8 +257,8 @@ function extractFileNameFromPath(filename: string) {
return path.basename(filename, path.extname(filename)); return path.basename(filename, path.extname(filename));
} }
function getFileNamePath(componentDirPath: string, componentName: string) { function getFileNamePath(componentDirPath: string, componentName: string, ext: string) {
return path.join(componentDirPath, componentName) + '.yaml'; return path.join(componentDirPath, componentName) + `.${ext}`;
} }
function gatherComponentsFiles( function gatherComponentsFiles(
@@ -278,13 +284,14 @@ function iteratePathItems(
outDir: string, outDir: string,
componentsFiles: object, componentsFiles: object,
pathSeparator: string, pathSeparator: string,
codeSamplesPathPrefix: string = '' codeSamplesPathPrefix: string = '',
ext: string
) { ) {
if (!pathItems) return; if (!pathItems) return;
fs.mkdirSync(outDir, { recursive: true }); fs.mkdirSync(outDir, { recursive: true });
for (const pathName of Object.keys(pathItems)) { 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; const pathData = pathItems[pathName] as Oas3PathItem;
if (isRef(pathData)) continue; if (isRef(pathData)) continue;
@@ -314,7 +321,7 @@ function iteratePathItems(
}; };
} }
} }
writeYaml(pathData, pathFile); writeToFileByExtension(pathData, pathFile);
pathItems[pathName] = { pathItems[pathName] = {
$ref: slash(path.relative(openapiDir, pathFile)), $ref: slash(path.relative(openapiDir, pathFile)),
}; };
@@ -326,7 +333,8 @@ function iteratePathItems(
function iterateComponents( function iterateComponents(
openapi: Oas3Definition | Oas3_1Definition, openapi: Oas3Definition | Oas3_1Definition,
openapiDir: string, openapiDir: string,
componentsFiles: ComponentsFiles componentsFiles: ComponentsFiles,
ext: string
) { ) {
const { components } = openapi; const { components } = openapi;
if (components) { if (components) {
@@ -340,7 +348,7 @@ function iterateComponents(
function iterateAndGatherComponentsFiles(componentType: Oas3ComponentName) { function iterateAndGatherComponentsFiles(componentType: Oas3ComponentName) {
const componentDirPath = path.join(componentsDir, componentType); const componentDirPath = path.join(componentsDir, componentType);
for (const componentName of Object.keys(components?.[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); gatherComponentsFiles(components!, componentsFiles, componentType, componentName, filename);
} }
} }
@@ -350,7 +358,7 @@ function iterateComponents(
const componentDirPath = path.join(componentsDir, componentType); const componentDirPath = path.join(componentsDir, componentType);
createComponentDir(componentDirPath, componentType); createComponentDir(componentDirPath, componentType);
for (const componentName of Object.keys(components?.[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]; const componentData = components?.[componentType]?.[componentName];
replace$Refs(componentData, path.dirname(filename), componentsFiles); replace$Refs(componentData, path.dirname(filename), componentsFiles);
implicitlyReferenceDiscriminator( implicitlyReferenceDiscriminator(
@@ -369,7 +377,7 @@ function iterateComponents(
) )
); );
} else { } else {
writeYaml(componentData, filename); writeToFileByExtension(componentData, filename);
} }
if (isNotSecurityComponentType(componentType)) { if (isNotSecurityComponentType(componentType)) {

View File

@@ -127,7 +127,6 @@ yargs
describe: 'Output file', describe: 'Output file',
alias: 'o', alias: 'o',
type: 'string', type: 'string',
default: 'openapi.yaml',
}, },
config: { config: {
description: 'Path to the config file.', description: 'Path to the config file.',

View File

@@ -25,7 +25,14 @@ import {
RedoclyClient, RedoclyClient,
} from '@redocly/openapi-core'; } from '@redocly/openapi-core';
import { ConfigValidationError } from '@redocly/openapi-core/lib/config'; 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 { isEmptyObject } from '@redocly/openapi-core/lib/utils';
import { Arguments } from 'yargs'; import { Arguments } from 'yargs';
import { version } from './update-version-notifier'; import { version } from './update-version-notifier';
@@ -209,6 +216,17 @@ export function readYaml(filename: string) {
return parseYaml(fs.readFileSync(filename, 'utf-8'), { filename }); 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) { export function writeYaml(data: any, filename: string, noRefs = false) {
const content = stringifyYaml(data, { noRefs }); const content = stringifyYaml(data, { noRefs });
@@ -220,6 +238,27 @@ export function writeYaml(data: any, filename: string, noRefs = false) {
fs.writeFileSync(filename, content); 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<OutputExtensions> {
const ext = fileName.split('.').pop();
if (['yaml', 'yml', 'json'].includes(ext!)) {
return ext as NonNullable<OutputExtensions>;
}
process.stderr.write(yellow(`Unsupported file extension: ${ext}. Using yaml.\n`));
return 'yaml';
}
export function pluralize(label: string, num: number) { export function pluralize(label: string, num: number) {
if (label.endsWith('is')) { if (label.endsWith('is')) {
[label] = label.split(' '); [label] = label.split(' ');