chore: add command wrapper and telemetry (#1136)

This commit is contained in:
Andrew Tatomyr
2023-06-26 10:39:50 +03:00
committed by GitHub
parent e2656631cf
commit f4ba6a7bdd
31 changed files with 866 additions and 474 deletions

View File

@@ -28,7 +28,7 @@ jobs:
- run: redocly-next --version - run: redocly-next --version
- run: redocly --version - run: redocly --version
- name: Run Benchmark - name: Run Benchmark
run: hyperfine -i --warmup 3 'redocly lint packages/core/src/benchmark/benches/rebilly.yaml' 'redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml' --export-markdown benchmark_check.md --export-json benchmark_check.json run: hyperfine -i --warmup 3 'REDOCLY_TELEMETRY=off redocly lint packages/core/src/benchmark/benches/rebilly.yaml' 'REDOCLY_TELEMETRY=off redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml' --export-markdown benchmark_check.md --export-json benchmark_check.json
env: env:
CI: true CI: true
- name: Comment PR - name: Comment PR

View File

@@ -14,6 +14,7 @@ Options:
--help Show help. [boolean] --help Show help. [boolean]
--outDir Output directory where files will be saved. [string] [required] --outDir Output directory where files will be saved. [string] [required]
--separator File path separator used while splitting. [string] [default: "_"] --separator File path separator used while splitting. [string] [default: "_"]
--config Specify path to the config file. [string]
Missing required argument: outDir Missing required argument: outDir

View File

@@ -18,9 +18,9 @@ module.exports = {
lines: 80, lines: 80,
}, },
'packages/cli/': { 'packages/cli/': {
statements: 54, statements: 55,
branches: 45, branches: 46,
functions: 53, functions: 55,
lines: 55, lines: 55,
}, },
}, },

View File

@@ -9,12 +9,12 @@
"engineStrict": true, "engineStrict": true,
"scripts": { "scripts": {
"test": "npm run typecheck && npm run unit", "test": "npm run typecheck && npm run unit",
"jest": "jest ./packages", "jest": "REDOCLY_TELEMETRY=off jest ./packages",
"unit": "npm run jest -- --coverage --coverageReporters lcov text-summary", "unit": "npm run jest -- --coverage --coverageReporters lcov text-summary",
"coverage:cli": "jest --roots packages/cli/src --coverage", "coverage:cli": "npm run jest -- --roots packages/cli/src --coverage",
"coverage:core": "jest --roots packages/core/src --coverage", "coverage:core": "npm run jest -- --roots packages/core/src --coverage",
"typecheck": "tsc --noEmit --skipLibCheck", "typecheck": "tsc --noEmit --skipLibCheck",
"e2e": "npm run webpack-bundle -- --mode=none && jest --roots=./__tests__/", "e2e": "npm run webpack-bundle -- --mode=none && REDOCLY_TELEMETRY=off jest --roots=./__tests__/",
"prettier": "npx prettier --write \"**/*.{ts,js,yaml,json}\"", "prettier": "npx prettier --write \"**/*.{ts,js,yaml,json}\"",
"prettier:check": "npx prettier --check \"**/*.{ts,js,yaml,json}\"", "prettier:check": "npx prettier --check \"**/*.{ts,js,yaml,json}\"",
"eslint": "eslint packages/**", "eslint": "eslint packages/**",

View File

@@ -44,16 +44,19 @@ describe('build-docs', () => {
it('should work correctly when calling handlerBuildCommand', async () => { it('should work correctly when calling handlerBuildCommand', async () => {
const processExitMock = jest.spyOn(process, 'exit').mockImplementation(); const processExitMock = jest.spyOn(process, 'exit').mockImplementation();
await handlerBuildCommand({ await handlerBuildCommand(
o: '', {
cdn: false, o: '',
title: 'test', cdn: false,
disableGoogleFont: false, title: 'test',
template: '', disableGoogleFont: false,
templateOptions: {}, template: '',
theme: { openapi: {} }, templateOptions: {},
api: '../some-path/openapi.yaml', theme: { openapi: {} },
} as BuildDocsArgv); api: '../some-path/openapi.yaml',
} as BuildDocsArgv,
{} as any
);
expect(loadAndBundleSpec).toBeCalledTimes(1); expect(loadAndBundleSpec).toBeCalledTimes(1);
expect(getFallbackApisOrExit).toBeCalledTimes(1); expect(getFallbackApisOrExit).toBeCalledTimes(1);
expect(processExitMock).toBeCalledTimes(0); expect(processExitMock).toBeCalledTimes(0);

View File

@@ -1,8 +1,10 @@
import { lint, bundle, getTotals, getMergedConfig } from '@redocly/openapi-core'; import { lint, bundle, getTotals, getMergedConfig } from '@redocly/openapi-core';
import { handleBundle } from '../../commands/bundle'; import { BundleOptions, handleBundle } from '../../commands/bundle';
import { handleError } from '../../utils'; import { handleError } from '../../utils';
import { commandWrapper } from '../../wrapper';
import SpyInstance = jest.SpyInstance; import SpyInstance = jest.SpyInstance;
import { Arguments } from 'yargs';
jest.mock('@redocly/openapi-core'); jest.mock('@redocly/openapi-core');
jest.mock('../../utils'); jest.mock('../../utils');
@@ -31,14 +33,11 @@ describe('bundle', () => {
it('bundles definitions w/o linting', async () => { it('bundles definitions w/o linting', async () => {
const apis = ['foo.yaml', 'bar.yaml']; const apis = ['foo.yaml', 'bar.yaml'];
await handleBundle( await commandWrapper(handleBundle)({
{ apis,
apis, ext: 'yaml',
ext: 'yaml', format: 'codeframe',
format: 'codeframe', } as Arguments<BundleOptions>);
},
'1.0.0'
);
expect(lint).toBeCalledTimes(0); expect(lint).toBeCalledTimes(0);
expect(bundle).toBeCalledTimes(apis.length); expect(bundle).toBeCalledTimes(apis.length);
@@ -47,16 +46,13 @@ describe('bundle', () => {
it('exits with code 0 when bundles definitions', async () => { it('exits with code 0 when bundles definitions', async () => {
const apis = ['foo.yaml', 'bar.yaml', 'foobar.yaml']; const apis = ['foo.yaml', 'bar.yaml', 'foobar.yaml'];
await handleBundle( await commandWrapper(handleBundle)({
{ apis,
apis, ext: 'yaml',
ext: 'yaml', format: 'codeframe',
format: 'codeframe', } as Arguments<BundleOptions>);
},
'1.0.0'
);
exitCb?.(); await exitCb?.();
expect(processExitMock).toHaveBeenCalledWith(0); expect(processExitMock).toHaveBeenCalledWith(0);
}); });
@@ -69,15 +65,12 @@ describe('bundle', () => {
ignored: 0, ignored: 0,
}); });
await handleBundle( await commandWrapper(handleBundle)({
{ apis,
apis, ext: 'yaml',
ext: 'yaml', format: 'codeframe',
format: 'codeframe', lint: true,
lint: true, } as Arguments<BundleOptions>);
},
'1.0.0'
);
expect(lint).toBeCalledTimes(apis.length); expect(lint).toBeCalledTimes(apis.length);
expect(bundle).toBeCalledTimes(apis.length); expect(bundle).toBeCalledTimes(apis.length);
@@ -86,17 +79,14 @@ describe('bundle', () => {
it('exits with code 0 when bundles definitions w/linting w/o errors', async () => { it('exits with code 0 when bundles definitions w/linting w/o errors', async () => {
const apis = ['foo.yaml', 'bar.yaml', 'foobar.yaml']; const apis = ['foo.yaml', 'bar.yaml', 'foobar.yaml'];
await handleBundle( await commandWrapper(handleBundle)({
{ apis,
apis, ext: 'yaml',
ext: 'yaml', format: 'codeframe',
format: 'codeframe', lint: true,
lint: true, } as Arguments<BundleOptions>);
},
'1.0.0'
);
exitCb?.(); await exitCb?.();
expect(processExitMock).toHaveBeenCalledWith(0); expect(processExitMock).toHaveBeenCalledWith(0);
}); });
@@ -109,18 +99,15 @@ describe('bundle', () => {
ignored: 0, ignored: 0,
}); });
await handleBundle( await commandWrapper(handleBundle)({
{ apis,
apis, ext: 'yaml',
ext: 'yaml', format: 'codeframe',
format: 'codeframe', lint: true,
lint: true, } as Arguments<BundleOptions>);
},
'1.0.0'
);
expect(lint).toBeCalledTimes(apis.length); expect(lint).toBeCalledTimes(apis.length);
exitCb?.(); await exitCb?.();
expect(processExitMock).toHaveBeenCalledWith(1); expect(processExitMock).toHaveBeenCalledWith(1);
}); });
@@ -131,15 +118,12 @@ describe('bundle', () => {
throw new Error('Invalid definition'); throw new Error('Invalid definition');
}); });
await handleBundle( await commandWrapper(handleBundle)({
{ apis,
apis, ext: 'json',
ext: 'json', format: 'codeframe',
format: 'codeframe', lint: false,
lint: false, } as Arguments<BundleOptions>);
},
'1.0.0'
);
expect(handleError).toHaveBeenCalledTimes(1); expect(handleError).toHaveBeenCalledTimes(1);
expect(handleError).toHaveBeenCalledWith(new Error('Invalid definition'), 'invalid.json'); expect(handleError).toHaveBeenCalledWith(new Error('Invalid definition'), 'invalid.json');
@@ -154,15 +138,12 @@ describe('bundle', () => {
ignored: 0, ignored: 0,
}); });
await handleBundle( await commandWrapper(handleBundle)({
{ apis,
apis, ext: 'yaml',
ext: 'yaml', format: 'codeframe',
format: 'codeframe', lint: false,
lint: false, } as Arguments<BundleOptions>);
},
'1.0.0'
);
expect(handleError).toHaveBeenCalledTimes(0); expect(handleError).toHaveBeenCalledTimes(0);
}); });

View File

@@ -3,6 +3,7 @@ import { exitWithError, writeYaml } from '../../utils';
import { yellow } from 'colorette'; import { yellow } from 'colorette';
import { detectOpenAPI } from '@redocly/openapi-core'; import { detectOpenAPI } from '@redocly/openapi-core';
import { loadConfig } from '../../__mocks__/@redocly/openapi-core'; import { loadConfig } from '../../__mocks__/@redocly/openapi-core';
import { ConfigFixture } from '../fixtures/config';
jest.mock('../../utils'); jest.mock('../../utils');
jest.mock('colorette'); jest.mock('colorette');
@@ -12,7 +13,7 @@ describe('handleJoin fails', () => {
colloreteYellowMock.mockImplementation((string: string) => string); colloreteYellowMock.mockImplementation((string: string) => string);
it('should call exitWithError because only one entrypoint', async () => { it('should call exitWithError because only one entrypoint', async () => {
await handleJoin({ apis: ['first.yaml'] }, 'cli-version'); await handleJoin({ apis: ['first.yaml'] }, {} as any, 'cli-version');
expect(exitWithError).toHaveBeenCalledWith(`At least 2 apis should be provided. \n\n`); expect(exitWithError).toHaveBeenCalledWith(`At least 2 apis should be provided. \n\n`);
}); });
@@ -24,6 +25,7 @@ describe('handleJoin fails', () => {
'without-x-tag-groups': true, 'without-x-tag-groups': true,
'prefix-tags-with-filename': true, 'prefix-tags-with-filename': true,
}, },
{} as any,
'cli-version' 'cli-version'
); );
@@ -39,6 +41,7 @@ describe('handleJoin fails', () => {
'without-x-tag-groups': true, 'without-x-tag-groups': true,
'prefix-tags-with-filename': true, 'prefix-tags-with-filename': true,
}, },
{} as any,
'cli-version' 'cli-version'
); );
@@ -52,6 +55,7 @@ describe('handleJoin fails', () => {
{ {
apis: ['first.yaml', 'second.yaml'], apis: ['first.yaml', 'second.yaml'],
}, },
ConfigFixture as any,
'cli-version' 'cli-version'
); );
expect(exitWithError).toHaveBeenCalledWith('Only OpenAPI 3 is supported: undefined \n\n'); expect(exitWithError).toHaveBeenCalledWith('Only OpenAPI 3 is supported: undefined \n\n');
@@ -63,6 +67,7 @@ describe('handleJoin fails', () => {
{ {
apis: ['first.yaml', 'second.yaml'], apis: ['first.yaml', 'second.yaml'],
}, },
ConfigFixture as any,
'cli-version' 'cli-version'
); );
@@ -76,6 +81,7 @@ describe('handleJoin fails', () => {
apis: ['first.yaml', 'second.yaml'], apis: ['first.yaml', 'second.yaml'],
output: 'output.yml', output: 'output.yml',
}, },
ConfigFixture as any,
'cli-version' 'cli-version'
); );
@@ -88,6 +94,7 @@ describe('handleJoin fails', () => {
{ {
apis: ['first.yaml', 'second.yaml'], apis: ['first.yaml', 'second.yaml'],
}, },
ConfigFixture as any,
'cli-version' 'cli-version'
); );
@@ -104,6 +111,7 @@ describe('handleJoin fails', () => {
decorate: true, decorate: true,
preprocess: true, preprocess: true,
}, },
ConfigFixture as any,
'cli-version' 'cli-version'
); );

View File

@@ -17,18 +17,23 @@ import {
} from '../../utils'; } from '../../utils';
import { ConfigFixture } from '../fixtures/config'; import { ConfigFixture } from '../fixtures/config';
import { performance } from 'perf_hooks'; import { performance } from 'perf_hooks';
import { commandWrapper } from '../../wrapper';
import { Arguments } from 'yargs';
import { blue } from 'colorette';
jest.mock('@redocly/openapi-core'); jest.mock('@redocly/openapi-core');
jest.mock('../../utils'); jest.mock('../../utils');
jest.mock('perf_hooks'); jest.mock('perf_hooks');
const argvMock: LintOptions = { jest.mock('../../update-version-notifier', () => ({
version: '1.0.0',
}));
const argvMock = {
apis: ['openapi.yaml'], apis: ['openapi.yaml'],
'lint-config': 'off', 'lint-config': 'off',
format: 'codeframe', format: 'codeframe',
}; } as Arguments<LintOptions>;
const versionMock = '1.0.0';
describe('handleLint', () => { describe('handleLint', () => {
let processExitMock: jest.SpyInstance; let processExitMock: jest.SpyInstance;
@@ -55,15 +60,14 @@ describe('handleLint', () => {
describe('loadConfig and getEnrtypoints stage', () => { describe('loadConfig and getEnrtypoints stage', () => {
it('should fail if config file does not exist', async () => { it('should fail if config file does not exist', async () => {
await handleLint({ ...argvMock, config: 'config.yaml' }, versionMock); await commandWrapper(handleLint)({ ...argvMock, config: 'config.yaml' });
expect(exitWithError).toHaveBeenCalledWith( expect(exitWithError).toHaveBeenCalledWith(
'Please, provide valid path to the configuration file' 'Please, provide valid path to the configuration file'
); );
expect(loadConfigAndHandleErrors).toHaveBeenCalledTimes(0);
}); });
it('should call loadConfigAndHandleErrors and getFallbackApisOrExit', async () => { it('should call loadConfigAndHandleErrors and getFallbackApisOrExit', async () => {
await handleLint(argvMock, versionMock); await commandWrapper(handleLint)(argvMock);
expect(loadConfigAndHandleErrors).toHaveBeenCalledWith({ expect(loadConfigAndHandleErrors).toHaveBeenCalledWith({
configPath: undefined, configPath: undefined,
customExtends: undefined, customExtends: undefined,
@@ -73,10 +77,11 @@ describe('handleLint', () => {
}); });
it('should call loadConfig with args if such exist', async () => { it('should call loadConfig with args if such exist', async () => {
await handleLint( await commandWrapper(handleLint)({
{ ...argvMock, config: 'redocly.yaml', extends: ['some/path'] }, ...argvMock,
versionMock config: 'redocly.yaml',
); extends: ['some/path'],
});
expect(loadConfigAndHandleErrors).toHaveBeenCalledWith({ expect(loadConfigAndHandleErrors).toHaveBeenCalledWith({
configPath: 'redocly.yaml', configPath: 'redocly.yaml',
customExtends: ['some/path'], customExtends: ['some/path'],
@@ -85,25 +90,25 @@ describe('handleLint', () => {
}); });
it('should call mergedConfig with clear ignore if `generate-ignore-file` argv', async () => { it('should call mergedConfig with clear ignore if `generate-ignore-file` argv', async () => {
await handleLint({ ...argvMock, 'generate-ignore-file': true }, versionMock); await commandWrapper(handleLint)({ ...argvMock, 'generate-ignore-file': true });
expect(getMergedConfigMock).toHaveBeenCalled(); expect(getMergedConfigMock).toHaveBeenCalled();
}); });
it('should check if ruleset exist', async () => { it('should check if ruleset exist', async () => {
await handleLint(argvMock, versionMock); await commandWrapper(handleLint)(argvMock);
expect(checkIfRulesetExist).toHaveBeenCalledTimes(1); expect(checkIfRulesetExist).toHaveBeenCalledTimes(1);
}); });
it('should fail if apis not provided', async () => { it('should fail if apis not provided', async () => {
await handleLint({ ...argvMock, apis: [] }, versionMock); await commandWrapper(handleLint)({ ...argvMock, apis: [] });
expect(getFallbackApisOrExit).toHaveBeenCalledTimes(1); expect(getFallbackApisOrExit).toHaveBeenCalledTimes(1);
expect(exitWithError).toHaveBeenCalledWith('No APIs were provided'); expect(exitWithError).toHaveBeenCalledWith('No APIs were provided');
}); });
}); });
describe('loop through entrypints and lint stage', () => { describe('loop through entrypoints and lint stage', () => {
it('should call getMergedConfig and lint ', async () => { it('should call getMergedConfig and lint ', async () => {
await handleLint(argvMock, versionMock); await commandWrapper(handleLint)(argvMock);
expect(performance.now).toHaveBeenCalled(); expect(performance.now).toHaveBeenCalled();
expect(getMergedConfigMock).toHaveBeenCalled(); expect(getMergedConfigMock).toHaveBeenCalled();
expect(lint).toHaveBeenCalled(); expect(lint).toHaveBeenCalled();
@@ -111,56 +116,75 @@ describe('handleLint', () => {
it('should call skipRules,skipPreprocessors and addIgnore with argv', async () => { it('should call skipRules,skipPreprocessors and addIgnore with argv', async () => {
(lint as jest.Mock<any, any>).mockResolvedValueOnce(['problem']); (lint as jest.Mock<any, any>).mockResolvedValueOnce(['problem']);
await handleLint( await commandWrapper(handleLint)({
{ ...argvMock,
...argvMock, 'skip-preprocessor': ['preprocessor'],
'skip-preprocessor': ['preprocessor'], 'skip-rule': ['rule'],
'skip-rule': ['rule'], 'generate-ignore-file': true,
'generate-ignore-file': true, });
},
versionMock
);
expect(ConfigFixture.styleguide.skipRules).toHaveBeenCalledWith(['rule']); expect(ConfigFixture.styleguide.skipRules).toHaveBeenCalledWith(['rule']);
expect(ConfigFixture.styleguide.skipPreprocessors).toHaveBeenCalledWith(['preprocessor']); expect(ConfigFixture.styleguide.skipPreprocessors).toHaveBeenCalledWith(['preprocessor']);
}); });
it('should call formatProblems and getExecutionTime with argv', async () => { it('should call formatProblems and getExecutionTime with argv', async () => {
(lint as jest.Mock<any, any>).mockResolvedValueOnce(['problem']); (lint as jest.Mock<any, any>).mockResolvedValueOnce(['problem']);
await handleLint({ ...argvMock, 'max-problems': 2, format: 'stylish' }, versionMock); await commandWrapper(handleLint)({ ...argvMock, 'max-problems': 2, format: 'stylish' });
expect(getTotals).toHaveBeenCalledWith(['problem']); expect(getTotals).toHaveBeenCalledWith(['problem']);
expect(formatProblems).toHaveBeenCalledWith(['problem'], { expect(formatProblems).toHaveBeenCalledWith(['problem'], {
format: 'stylish', format: 'stylish',
maxProblems: 2, maxProblems: 2,
totals: { errors: 0 }, totals: { errors: 0 },
version: versionMock, version: '1.0.0',
}); });
expect(getExecutionTime).toHaveBeenCalledWith(42); expect(getExecutionTime).toHaveBeenCalledWith(42);
}); });
it('should catch error in handleError if something fails', async () => { it('should catch error in handleError if something fails', async () => {
(lint as jest.Mock<any, any>).mockRejectedValueOnce('error'); (lint as jest.Mock<any, any>).mockRejectedValueOnce('error');
await handleLint(argvMock, versionMock); await commandWrapper(handleLint)(argvMock);
expect(handleError).toHaveBeenCalledWith('error', 'openapi.yaml'); expect(handleError).toHaveBeenCalledWith('error', 'openapi.yaml');
}); });
}); });
describe('erros and warning handle after lint stage', () => { describe('erros and warning handle after lint stage', () => {
it('should call printLintTotals and printLintTotals', async () => { it('should call printLintTotals and printLintTotals', async () => {
await handleLint(argvMock, versionMock); await commandWrapper(handleLint)(argvMock);
expect(printUnusedWarnings).toHaveBeenCalled(); expect(printUnusedWarnings).toHaveBeenCalled();
}); });
it('should call exit with 0 if no errors', async () => { it('should call exit with 0 if no errors', async () => {
await handleLint(argvMock, versionMock); (loadConfigAndHandleErrors as jest.Mock).mockImplementation(() => {
exitCb?.(); return { ...ConfigFixture };
});
await commandWrapper(handleLint)(argvMock);
await exitCb?.();
expect(processExitMock).toHaveBeenCalledWith(0); expect(processExitMock).toHaveBeenCalledWith(0);
}); });
it('should exit with 1 if tootals error > 0', async () => { it('should exit with 1 if total errors > 0', async () => {
(getTotals as jest.Mock<any, any>).mockReturnValueOnce({ errors: 1 }); (getTotals as jest.Mock<any, any>).mockReturnValueOnce({ errors: 1 });
await handleLint(argvMock, versionMock); await commandWrapper(handleLint)(argvMock);
exitCb?.(); await exitCb?.();
expect(processExitMock).toHaveBeenCalledWith(1); expect(processExitMock).toHaveBeenCalledWith(1);
}); });
it('should use recommended fallback if no config', async () => {
(getMergedConfig as jest.Mock).mockImplementation(() => {
return {
styleguide: {
recommendedFallback: true,
rules: {},
skipRules: jest.fn(),
skipPreprocessors: jest.fn(),
},
};
});
await commandWrapper(handleLint)(argvMock);
expect(process.stderr.write).toHaveBeenCalledWith(
`No configurations were provided -- using built in ${blue(
'recommended'
)} configuration by default.\n\n`
);
});
}); });
}); });

View File

@@ -1,6 +1,7 @@
import { getMergedConfig } from '@redocly/openapi-core'; import { getMergedConfig } from '@redocly/openapi-core';
import { handlePush } from '../../commands/push'; import { handlePush } from '../../commands/push';
import { promptClientToken } from '../../commands/login'; import { promptClientToken } from '../../commands/login';
import { ConfigFixture } from '../fixtures/config';
jest.mock('fs'); jest.mock('fs');
jest.mock('node-fetch', () => ({ jest.mock('node-fetch', () => ({
@@ -27,24 +28,30 @@ describe('push-with-region', () => {
it('should call login with default domain when region is US', async () => { it('should call login with default domain when region is US', async () => {
redoclyClient.domain = 'redoc.ly'; redoclyClient.domain = 'redoc.ly';
await handlePush({ await handlePush(
upsert: true, {
api: 'spec.json', upsert: true,
destination: '@org/my-api@1.0.0', api: 'spec.json',
branchName: 'test', destination: '@org/my-api@1.0.0',
}); branchName: 'test',
},
ConfigFixture as any
);
expect(mockPromptClientToken).toBeCalledTimes(1); expect(mockPromptClientToken).toBeCalledTimes(1);
expect(mockPromptClientToken).toHaveBeenCalledWith(redoclyClient.domain); expect(mockPromptClientToken).toHaveBeenCalledWith(redoclyClient.domain);
}); });
it('should call login with EU domain when region is EU', async () => { it('should call login with EU domain when region is EU', async () => {
redoclyClient.domain = 'eu.redocly.com'; redoclyClient.domain = 'eu.redocly.com';
await handlePush({ await handlePush(
upsert: true, {
api: 'spec.json', upsert: true,
destination: '@org/my-api@1.0.0', api: 'spec.json',
branchName: 'test', destination: '@org/my-api@1.0.0',
}); branchName: 'test',
},
ConfigFixture as any
);
expect(mockPromptClientToken).toBeCalledTimes(1); expect(mockPromptClientToken).toBeCalledTimes(1);
expect(mockPromptClientToken).toHaveBeenCalledWith(redoclyClient.domain); expect(mockPromptClientToken).toHaveBeenCalledWith(redoclyClient.domain);
}); });

View File

@@ -1,6 +1,6 @@
import * as fs from 'fs'; import * as fs from 'fs';
import { Config, getMergedConfig } from '@redocly/openapi-core'; import { Config, getMergedConfig } from '@redocly/openapi-core';
import { exitWithError, loadConfigAndHandleErrors } from '../../utils'; import { exitWithError } from '../../utils';
import { getApiRoot, getDestinationProps, handlePush, transformPush } from '../../commands/push'; import { getApiRoot, getDestinationProps, handlePush, transformPush } from '../../commands/push';
import { ConfigFixture } from '../fixtures/config'; import { ConfigFixture } from '../fixtures/config';
import { yellow } from 'colorette'; import { yellow } from 'colorette';
@@ -25,15 +25,18 @@ describe('push', () => {
}); });
it('pushes definition', async () => { it('pushes definition', async () => {
await handlePush({ await handlePush(
upsert: true, {
api: 'spec.json', upsert: true,
destination: '@org/my-api@1.0.0', api: 'spec.json',
branchName: 'test', destination: '@org/my-api@1.0.0',
public: true, branchName: 'test',
'batch-id': '123', public: true,
'batch-size': 2, 'batch-id': '123',
}); 'batch-size': 2,
},
ConfigFixture as any
);
expect(redoclyClient.registryApi.prepareFileUpload).toBeCalledTimes(1); expect(redoclyClient.registryApi.prepareFileUpload).toBeCalledTimes(1);
expect(redoclyClient.registryApi.pushApi).toBeCalledTimes(1); expect(redoclyClient.registryApi.pushApi).toBeCalledTimes(1);
@@ -52,50 +55,61 @@ describe('push', () => {
}); });
it('fails if batchId value is an empty string', async () => { it('fails if batchId value is an empty string', async () => {
await handlePush({ await handlePush(
upsert: true, {
api: 'spec.json', upsert: true,
destination: '@org/my-api@1.0.0', api: 'spec.json',
branchName: 'test', destination: '@org/my-api@1.0.0',
public: true, branchName: 'test',
'batch-id': ' ', public: true,
'batch-size': 2, 'batch-id': ' ',
}); 'batch-size': 2,
},
ConfigFixture as any
);
expect(exitWithError).toBeCalledTimes(1); expect(exitWithError).toBeCalledTimes(1);
}); });
it('fails if batchSize value is less than 2', async () => { it('fails if batchSize value is less than 2', async () => {
await handlePush({ await handlePush(
upsert: true, {
api: 'spec.json', upsert: true,
destination: '@org/my-api@1.0.0', api: 'spec.json',
branchName: 'test', destination: '@org/my-api@1.0.0',
public: true, branchName: 'test',
'batch-id': '123', public: true,
'batch-size': 1, 'batch-id': '123',
}); 'batch-size': 1,
},
ConfigFixture as any
);
expect(exitWithError).toBeCalledTimes(1); expect(exitWithError).toBeCalledTimes(1);
}); });
it('push with --files', async () => { it('push with --files', async () => {
(loadConfigAndHandleErrors as jest.Mock).mockImplementation(({ files }) => { // (loadConfigAndHandleErrors as jest.Mock).mockImplementation(({ files }) => {
return { ...ConfigFixture, files }; // return { ...ConfigFixture, files };
}); // });
const mockConfig = { ...ConfigFixture, files: ['./resouces/1.md', './resouces/2.md'] } as any;
//@ts-ignore //@ts-ignore
fs.statSync.mockImplementation(() => { fs.statSync.mockImplementation(() => {
return { isDirectory: () => false, size: 10 }; return { isDirectory: () => false, size: 10 };
}); });
await handlePush({ await handlePush(
upsert: true, {
api: 'spec.json', upsert: true,
destination: '@org/my-api@1.0.0', api: 'spec.json',
public: true, destination: '@org/my-api@1.0.0',
files: ['./resouces/1.md', './resouces/2.md'], public: true,
}); files: ['./resouces/1.md', './resouces/2.md'],
},
mockConfig
);
expect(redoclyClient.registryApi.pushApi).toHaveBeenLastCalledWith({ expect(redoclyClient.registryApi.pushApi).toHaveBeenLastCalledWith({
filePaths: ['filePath', 'filePath', 'filePath'], filePaths: ['filePath', 'filePath', 'filePath'],
@@ -110,15 +124,18 @@ describe('push', () => {
}); });
it('push should fail if organization not provided', async () => { it('push should fail if organization not provided', async () => {
await handlePush({ await handlePush(
upsert: true, {
api: 'spec.json', upsert: true,
destination: 'test@v1', api: 'spec.json',
branchName: 'test', destination: 'test@v1',
public: true, branchName: 'test',
'batch-id': '123', public: true,
'batch-size': 2, 'batch-id': '123',
}); 'batch-size': 2,
},
ConfigFixture as any
);
expect(exitWithError).toBeCalledTimes(1); expect(exitWithError).toBeCalledTimes(1);
expect(exitWithError).toBeCalledWith( expect(exitWithError).toBeCalledWith(
@@ -129,18 +146,19 @@ describe('push', () => {
}); });
it('push should work with organization in config', async () => { it('push should work with organization in config', async () => {
(loadConfigAndHandleErrors as jest.Mock).mockImplementation(() => { const mockConfig = { ...ConfigFixture, organization: 'test_org' } as any;
return { ...ConfigFixture, organization: 'test_org' }; await handlePush(
}); {
await handlePush({ upsert: true,
upsert: true, api: 'spec.json',
api: 'spec.json', destination: 'my-api@1.0.0',
destination: 'my-api@1.0.0', branchName: 'test',
branchName: 'test', public: true,
public: true, 'batch-id': '123',
'batch-id': '123', 'batch-size': 2,
'batch-size': 2, },
}); mockConfig
);
expect(redoclyClient.registryApi.pushApi).toBeCalledTimes(1); expect(redoclyClient.registryApi.pushApi).toBeCalledTimes(1);
expect(redoclyClient.registryApi.pushApi).toHaveBeenLastCalledWith({ expect(redoclyClient.registryApi.pushApi).toHaveBeenLastCalledWith({
@@ -158,36 +176,40 @@ describe('push', () => {
}); });
it('push should work if destination not provided and api in config is provided', async () => { it('push should work if destination not provided and api in config is provided', async () => {
(loadConfigAndHandleErrors as jest.Mock).mockImplementation(() => { const mockConfig = {
return { ...ConfigFixture,
...ConfigFixture, organization: 'test_org',
organization: 'test_org', apis: { 'my-api@1.0.0': { root: 'path' } },
apis: { 'my-api@1.0.0': { root: 'path' } }, } as any;
};
}); await handlePush(
await handlePush({ {
upsert: true, upsert: true,
api: 'spec.json', api: 'spec.json',
branchName: 'test', branchName: 'test',
public: true, public: true,
'batch-id': '123', 'batch-id': '123',
'batch-size': 2, 'batch-size': 2,
}); },
mockConfig
);
expect(redoclyClient.registryApi.pushApi).toBeCalledTimes(1); expect(redoclyClient.registryApi.pushApi).toBeCalledTimes(1);
}); });
it('push should fail if destination and apis not provided', async () => { it('push should fail if destination and apis not provided', async () => {
(loadConfigAndHandleErrors as jest.Mock).mockImplementation(() => { const mockConfig = { organization: 'test_org', apis: {} } as any;
return { organization: 'test_org', apis: {} };
}); await handlePush(
await handlePush({ {
upsert: true, upsert: true,
branchName: 'test', branchName: 'test',
public: true, public: true,
'batch-id': '123', 'batch-id': '123',
'batch-size': 2, 'batch-size': 2,
}); },
mockConfig
);
expect(exitWithError).toBeCalledTimes(1); expect(exitWithError).toBeCalledTimes(1);
expect(exitWithError).toHaveBeenLastCalledWith( expect(exitWithError).toHaveBeenLastCalledWith(
@@ -197,21 +219,24 @@ describe('push', () => {
it('push should work and encode name with spaces', async () => { it('push should work and encode name with spaces', async () => {
const encodeURIComponentSpy = jest.spyOn(global, 'encodeURIComponent'); const encodeURIComponentSpy = jest.spyOn(global, 'encodeURIComponent');
(loadConfigAndHandleErrors as jest.Mock).mockImplementation(() => {
return { const mockConfig = {
...ConfigFixture, ...ConfigFixture,
organization: 'test_org', organization: 'test_org',
apis: { 'my test api@v1': { root: 'path' } }, apis: { 'my test api@v1': { root: 'path' } },
}; } as any;
});
await handlePush({ await handlePush(
upsert: true, {
destination: 'my test api@v1', upsert: true,
branchName: 'test', destination: 'my test api@v1',
public: true, branchName: 'test',
'batch-id': '123', public: true,
'batch-size': 2, 'batch-id': '123',
}); 'batch-size': 2,
},
mockConfig
);
expect(encodeURIComponentSpy).toHaveReturnedWith('my%20test%20api'); expect(encodeURIComponentSpy).toHaveReturnedWith('my%20test%20api');
expect(redoclyClient.registryApi.pushApi).toBeCalledTimes(1); expect(redoclyClient.registryApi.pushApi).toBeCalledTimes(1);
@@ -221,66 +246,96 @@ describe('push', () => {
describe('transformPush', () => { describe('transformPush', () => {
it('should adapt the existing syntax', () => { it('should adapt the existing syntax', () => {
const cb = jest.fn(); const cb = jest.fn();
transformPush(cb)({ transformPush(cb)(
maybeApiOrDestination: 'openapi.yaml', {
maybeDestination: '@testing_org/main@v1', maybeApiOrDestination: 'openapi.yaml',
}); maybeDestination: '@testing_org/main@v1',
expect(cb).toBeCalledWith({ },
api: 'openapi.yaml', {} as any
destination: '@testing_org/main@v1', );
}); expect(cb).toBeCalledWith(
{
api: 'openapi.yaml',
destination: '@testing_org/main@v1',
},
{}
);
}); });
it('should adapt the existing syntax (including branchName)', () => { it('should adapt the existing syntax (including branchName)', () => {
const cb = jest.fn(); const cb = jest.fn();
transformPush(cb)({ transformPush(cb)(
maybeApiOrDestination: 'openapi.yaml', {
maybeDestination: '@testing_org/main@v1', maybeApiOrDestination: 'openapi.yaml',
maybeBranchName: 'other', maybeDestination: '@testing_org/main@v1',
}); maybeBranchName: 'other',
expect(cb).toBeCalledWith({ },
api: 'openapi.yaml', {} as any
destination: '@testing_org/main@v1', );
branchName: 'other', expect(cb).toBeCalledWith(
}); {
api: 'openapi.yaml',
destination: '@testing_org/main@v1',
branchName: 'other',
},
{}
);
}); });
it('should use --branch option firstly', () => { it('should use --branch option firstly', () => {
const cb = jest.fn(); const cb = jest.fn();
transformPush(cb)({ transformPush(cb)(
maybeApiOrDestination: 'openapi.yaml', {
maybeDestination: '@testing_org/main@v1', maybeApiOrDestination: 'openapi.yaml',
maybeBranchName: 'other', maybeDestination: '@testing_org/main@v1',
branch: 'priority-branch', maybeBranchName: 'other',
}); branch: 'priority-branch',
expect(cb).toBeCalledWith({ },
api: 'openapi.yaml', {} as any
destination: '@testing_org/main@v1', );
branchName: 'priority-branch', expect(cb).toBeCalledWith(
}); {
api: 'openapi.yaml',
destination: '@testing_org/main@v1',
branchName: 'priority-branch',
},
{}
);
}); });
it('should work for a destination only', () => { it('should work for a destination only', () => {
const cb = jest.fn(); const cb = jest.fn();
transformPush(cb)({ transformPush(cb)(
maybeApiOrDestination: '@testing_org/main@v1', {
}); maybeApiOrDestination: '@testing_org/main@v1',
expect(cb).toBeCalledWith({ },
destination: '@testing_org/main@v1', {} as any
}); );
expect(cb).toBeCalledWith(
{
destination: '@testing_org/main@v1',
},
{}
);
}); });
it('should accept aliases for the old syntax', () => { it('should accept aliases for the old syntax', () => {
const cb = jest.fn(); const cb = jest.fn();
transformPush(cb)({ transformPush(cb)(
maybeApiOrDestination: 'alias', {
maybeDestination: '@testing_org/main@v1', maybeApiOrDestination: 'alias',
}); maybeDestination: '@testing_org/main@v1',
expect(cb).toBeCalledWith({ },
destination: '@testing_org/main@v1', {} as any
api: 'alias', );
}); expect(cb).toBeCalledWith(
{
destination: '@testing_org/main@v1',
api: 'alias',
},
{}
);
}); });
it('should accept no arguments at all', () => { it('should accept no arguments at all', () => {
const cb = jest.fn(); const cb = jest.fn();
transformPush(cb)({}); transformPush(cb)({}, {} as any);
expect(cb).toBeCalledWith({}); expect(cb).toBeCalledWith({}, {});
}); });
}); });

View File

@@ -9,6 +9,9 @@ import {
CircularJSONNotSupportedError, CircularJSONNotSupportedError,
sortTopLevelKeysForOas, sortTopLevelKeysForOas,
cleanColors, cleanColors,
HandledError,
cleanArgs,
cleanRawInput,
} from '../utils'; } from '../utils';
import { import {
ResolvedApi, ResolvedApi,
@@ -24,6 +27,7 @@ import * as process from 'process';
jest.mock('os'); jest.mock('os');
jest.mock('colorette'); jest.mock('colorette');
jest.mock('fs'); jest.mock('fs');
describe('isSubdir', () => { describe('isSubdir', () => {
@@ -150,23 +154,34 @@ describe('getFallbackApisOrExit', () => {
jest.spyOn(process.stderr, 'write').mockImplementation(() => true); jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
jest.spyOn(process, 'exit').mockImplementation(); jest.spyOn(process, 'exit').mockImplementation();
}); });
afterEach(() => {
jest.clearAllMocks();
});
it('should exit with error because no path provided', async () => { it('should exit with error because no path provided', async () => {
const apisConfig = { const apisConfig = {
apis: {}, apis: {},
}; };
await getFallbackApisOrExit([''], apisConfig); expect.assertions(1);
expect(process.exit).toHaveBeenCalledWith(1); try {
await getFallbackApisOrExit([''], apisConfig);
} catch (e) {
expect(e.message).toEqual('Path cannot be empty.');
}
}); });
it('should error if file from config do not exist', async () => { it('should error if file from config do not exist', async () => {
(existsSync as jest.Mock<any, any>).mockImplementationOnce(() => false); (existsSync as jest.Mock<any, any>).mockImplementationOnce(() => false);
await getFallbackApisOrExit(undefined, config); expect.assertions(3);
try {
expect(process.stderr.write).toHaveBeenCalledWith( await getFallbackApisOrExit(undefined, config);
'\n someFile.yaml does not exist or is invalid. Please provide a valid path. \n\n' } catch (e) {
); expect(process.stderr.write).toHaveBeenCalledWith(
expect(process.exit).toHaveBeenCalledWith(1); '\nsomeFile.yaml does not exist or is invalid.\n\n'
);
expect(process.stderr.write).toHaveBeenCalledWith('Please provide a valid path.\n\n');
expect(e.message).toEqual('Please provide a valid path.');
}
}); });
it('should return valid array with results if such file exist', async () => { it('should return valid array with results if such file exist', async () => {
@@ -189,12 +204,17 @@ describe('getFallbackApisOrExit', () => {
apis: {}, apis: {},
}; };
(existsSync as jest.Mock<any, any>).mockImplementationOnce(() => false); (existsSync as jest.Mock<any, any>).mockImplementationOnce(() => false);
await getFallbackApisOrExit(['someFile.yaml'], apisConfig); expect.assertions(3);
expect(process.stderr.write).toHaveBeenCalledWith( try {
'\n someFile.yaml does not exist or is invalid. Please provide a valid path. \n\n' await getFallbackApisOrExit(['someFile.yaml'], apisConfig);
); } catch (e) {
expect(process.exit).toHaveBeenCalledWith(1); expect(process.stderr.write).toHaveBeenCalledWith(
'\nsomeFile.yaml does not exist or is invalid.\n\n'
);
expect(process.stderr.write).toHaveBeenCalledWith('Please provide a valid path.\n\n');
expect(e.message).toEqual('Please provide a valid path.');
}
}); });
it('should exit with error in case if invalid 2 path provided as args', async () => { it('should exit with error in case if invalid 2 path provided as args', async () => {
@@ -202,12 +222,16 @@ describe('getFallbackApisOrExit', () => {
apis: {}, apis: {},
}; };
(existsSync as jest.Mock<any, any>).mockImplementationOnce(() => false); (existsSync as jest.Mock<any, any>).mockImplementationOnce(() => false);
await getFallbackApisOrExit(['someFile.yaml', 'someFile2.yaml'], apisConfig); expect.assertions(3);
try {
expect(process.stderr.write).lastCalledWith( await getFallbackApisOrExit(['someFile.yaml', 'someFile2.yaml'], apisConfig);
'\n someFile2.yaml does not exist or is invalid. Please provide a valid path. \n\n' } catch (e) {
); expect(process.stderr.write).toHaveBeenCalledWith(
expect(process.exit).toHaveBeenCalledWith(1); '\nsomeFile.yaml does not exist or is invalid.\n\n'
);
expect(process.stderr.write).toHaveBeenCalledWith('Please provide a valid path.\n\n');
expect(e.message).toEqual('Please provide a valid path.');
}
}); });
it('should exit with error if only one file exist ', async () => { it('should exit with error if only one file exist ', async () => {
@@ -220,14 +244,23 @@ describe('getFallbackApisOrExit', () => {
}; };
const configStub = { apis: apisStub }; const configStub = { apis: apisStub };
(existsSync as jest.Mock<any, any>).mockImplementationOnce((path) => path === 'someFile.yaml'); const existSyncMock = (existsSync as jest.Mock<any, any>).mockImplementation((path) =>
path.endsWith('someFile.yaml')
await getFallbackApisOrExit(undefined, configStub);
expect(process.stderr.write).toBeCalledWith(
'\n notExist.yaml does not exist or is invalid. Please provide a valid path. \n\n'
); );
expect(process.exit).toHaveBeenCalledWith(1);
expect.assertions(4);
try {
await getFallbackApisOrExit(undefined, configStub);
} catch (e) {
expect(process.stderr.write).toHaveBeenCalledWith(
'\nnotExist.yaml does not exist or is invalid.\n\n'
);
expect(process.stderr.write).toHaveBeenCalledWith('Please provide a valid path.\n\n');
expect(process.stderr.write).toHaveBeenCalledTimes(2);
expect(e.message).toEqual('Please provide a valid path.');
}
existSyncMock.mockClear();
}); });
it('should work ok if it is url passed', async () => { it('should work ok if it is url passed', async () => {
@@ -245,7 +278,6 @@ describe('getFallbackApisOrExit', () => {
const result = await getFallbackApisOrExit(undefined, apisConfig); const result = await getFallbackApisOrExit(undefined, apisConfig);
expect(process.stderr.write).toHaveBeenCalledTimes(0); expect(process.stderr.write).toHaveBeenCalledTimes(0);
expect(process.exit).toHaveBeenCalledTimes(0);
expect(result).toStrictEqual([ expect(result).toStrictEqual([
{ {
alias: 'main', alias: 'main',
@@ -356,11 +388,13 @@ describe('handleErrors', () => {
const ref = 'openapi/test.yaml'; const ref = 'openapi/test.yaml';
const redColoretteMocks = red as jest.Mock<any, any>; const redColoretteMocks = red as jest.Mock<any, any>;
const blueColoretteMocks = blue as jest.Mock<any, any>;
beforeEach(() => { beforeEach(() => {
jest.spyOn(process.stderr, 'write').mockImplementation(() => true); jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
jest.spyOn(process, 'exit').mockImplementation((code) => code as never); jest.spyOn(process, 'exit').mockImplementation((code) => code as never);
redColoretteMocks.mockImplementation((text) => text); redColoretteMocks.mockImplementation((text) => text);
blueColoretteMocks.mockImplementation((text) => text);
}); });
afterEach(() => { afterEach(() => {
@@ -369,9 +403,8 @@ describe('handleErrors', () => {
it('should handle ResolveError', () => { it('should handle ResolveError', () => {
const resolveError = new ResolveError(new Error('File not found')); const resolveError = new ResolveError(new Error('File not found'));
handleError(resolveError, ref); expect(() => handleError(resolveError, ref)).toThrowError(HandledError);
expect(redColoretteMocks).toHaveBeenCalledTimes(1); expect(redColoretteMocks).toHaveBeenCalledTimes(1);
expect(process.exit).toHaveBeenCalledWith(1);
expect(process.stderr.write).toHaveBeenCalledWith( expect(process.stderr.write).toHaveBeenCalledWith(
`Failed to resolve api definition at openapi/test.yaml:\n\n - File not found.\n\n` `Failed to resolve api definition at openapi/test.yaml:\n\n - File not found.\n\n`
); );
@@ -379,9 +412,8 @@ describe('handleErrors', () => {
it('should handle YamlParseError', () => { it('should handle YamlParseError', () => {
const yamlParseError = new YamlParseError(new Error('Invalid yaml'), {} as any); const yamlParseError = new YamlParseError(new Error('Invalid yaml'), {} as any);
handleError(yamlParseError, ref); expect(() => handleError(yamlParseError, ref)).toThrowError(HandledError);
expect(redColoretteMocks).toHaveBeenCalledTimes(1); expect(redColoretteMocks).toHaveBeenCalledTimes(1);
expect(process.exit).toHaveBeenCalledWith(1);
expect(process.stderr.write).toHaveBeenCalledWith( expect(process.stderr.write).toHaveBeenCalledWith(
`Failed to parse api definition at openapi/test.yaml:\n\n - Invalid yaml.\n\n` `Failed to parse api definition at openapi/test.yaml:\n\n - Invalid yaml.\n\n`
); );
@@ -389,8 +421,7 @@ describe('handleErrors', () => {
it('should handle CircularJSONNotSupportedError', () => { it('should handle CircularJSONNotSupportedError', () => {
const circularError = new CircularJSONNotSupportedError(new Error('Circular json')); const circularError = new CircularJSONNotSupportedError(new Error('Circular json'));
handleError(circularError, ref); expect(() => handleError(circularError, ref)).toThrowError(HandledError);
expect(process.exit).toHaveBeenCalledWith(1);
expect(process.stderr.write).toHaveBeenCalledWith( expect(process.stderr.write).toHaveBeenCalledWith(
`Detected circular reference which can't be converted to JSON.\n` + `Detected circular reference which can't be converted to JSON.\n` +
`Try to use ${blue('yaml')} output or remove ${blue('--dereferenced')}.\n\n` `Try to use ${blue('yaml')} output or remove ${blue('--dereferenced')}.\n\n`
@@ -400,12 +431,7 @@ describe('handleErrors', () => {
it('should handle SyntaxError', () => { it('should handle SyntaxError', () => {
const testError = new SyntaxError('Unexpected identifier'); const testError = new SyntaxError('Unexpected identifier');
testError.stack = 'test stack'; testError.stack = 'test stack';
try { expect(() => handleError(testError, ref)).toThrowError(HandledError);
handleError(testError, ref);
} catch (e) {
expect(e).toEqual(testError);
}
expect(process.exit).toHaveBeenCalledWith(1);
expect(process.stderr.write).toHaveBeenCalledWith( expect(process.stderr.write).toHaveBeenCalledWith(
'Syntax error: Unexpected identifier test stack\n\n' 'Syntax error: Unexpected identifier test stack\n\n'
); );
@@ -413,11 +439,7 @@ describe('handleErrors', () => {
it('should throw unknown error', () => { it('should throw unknown error', () => {
const testError = new Error('Test error'); const testError = new Error('Test error');
try { expect(() => handleError(testError, ref)).toThrowError(HandledError);
handleError(testError, ref);
} catch (e) {
expect(e).toEqual(testError);
}
expect(process.stderr.write).toHaveBeenCalledWith( expect(process.stderr.write).toHaveBeenCalledWith(
`Something went wrong when processing openapi/test.yaml:\n\n - Test error.\n\n` `Something went wrong when processing openapi/test.yaml:\n\n - Test error.\n\n`
); );
@@ -433,24 +455,24 @@ describe('checkIfRulesetExist', () => {
jest.clearAllMocks(); jest.clearAllMocks();
}); });
it('should exit if rules not provided', () => { it('should throw an error if rules are not provided', () => {
const rules = { const rules = {
oas2: {}, oas2: {},
oas3_0: {}, oas3_0: {},
oas3_1: {}, oas3_1: {},
}; };
checkIfRulesetExist(rules); expect(() => checkIfRulesetExist(rules)).toThrowError(
expect(process.exit).toHaveBeenCalledWith(1); '⚠️ No rules were configured. Learn how to configure rules: https://redocly.com/docs/cli/rules/'
);
}); });
it('should not exit if rules provided', () => { it('should not throw an error if rules are provided', () => {
const rules = { const rules = {
oas2: { 'operation-4xx-response': 'error' }, oas2: { 'operation-4xx-response': 'error' },
oas3_0: {}, oas3_0: {},
oas3_1: {}, oas3_1: {},
} as any; } as any;
checkIfRulesetExist(rules); checkIfRulesetExist(rules);
expect(process.exit).not.toHaveBeenCalled();
}); });
}); });
@@ -462,3 +484,49 @@ describe('cleanColors', () => {
expect(result).not.toMatch(/\x1b\[\d+m/g); expect(result).not.toMatch(/\x1b\[\d+m/g);
}); });
}); });
describe('cleanArgs', () => {
beforeEach(() => {
// @ts-ignore
isAbsoluteUrl = jest.requireActual('@redocly/openapi-core').isAbsoluteUrl;
});
it('should remove potentially sensitive data from args', () => {
const testArgs = {
config: 'some-folder/redocly.yaml',
apis: ['main@v1', 'openapi.yaml', 'http://some.url/openapi.yaml'],
format: 'codeframe',
};
expect(cleanArgs(testArgs)).toEqual({
config: '***.yaml',
apis: ['main@v1', '***.yaml', 'http://***'],
format: 'codeframe',
});
});
it('should remove potentially sensitive data from a push destination', () => {
const testArgs = {
destination: '@org/name@version',
};
expect(cleanArgs(testArgs)).toEqual({
destination: '@***/name@version',
});
});
});
describe('cleanRawInput', () => {
it('should remove potentially sensitive data from raw CLI input', () => {
// @ts-ignore
isAbsoluteUrl = jest.requireActual('@redocly/openapi-core').isAbsoluteUrl;
const rawInput = [
'redocly',
'lint',
'main@v1',
'openapi.yaml',
'http://some.url/openapi.yaml',
'--config=some-folder/redocly.yaml',
];
expect(cleanRawInput(rawInput)).toEqual(
'redocly lint main@v1 ***.yaml http://*** --config=***.yaml'
);
});
});

View File

@@ -0,0 +1,43 @@
import { loadConfigAndHandleErrors, sendTelemetry } from '../utils';
import * as process from 'process';
import { commandWrapper } from '../wrapper';
import { handleLint } from '../commands/lint';
import nodeFetch from 'node-fetch';
jest.mock('node-fetch');
jest.mock('../utils', () => ({
sendTelemetry: jest.fn(),
loadConfigAndHandleErrors: jest.fn(),
}));
jest.mock('../commands/lint', () => ({
handleLint: jest.fn(),
lintConfigCallback: jest.fn(),
}));
describe('commandWrapper', () => {
it('should send telemetry if there is "telemetry: on" in the config', async () => {
(loadConfigAndHandleErrors as jest.Mock).mockImplementation(() => {
return { telemetry: 'on', styleguide: { recommendedFallback: true } };
});
process.env.REDOCLY_TELEMETRY = 'on';
const wrappedHandler = commandWrapper(handleLint);
await wrappedHandler({} as any);
expect(handleLint).toHaveBeenCalledTimes(1);
expect(sendTelemetry).toHaveBeenCalledTimes(1);
expect(sendTelemetry).toHaveBeenCalledWith({}, 0, false);
});
it('should NOT send telemetry if there is "telemetry: off" in the config', async () => {
(loadConfigAndHandleErrors as jest.Mock).mockImplementation(() => {
return { telemetry: 'off', styleguide: { recommendedFallback: true } };
});
process.env.REDOCLY_TELEMETRY = 'on';
const wrappedHandler = commandWrapper(handleLint);
await wrappedHandler({} as any);
expect(handleLint).toHaveBeenCalledTimes(1);
expect(sendTelemetry).toHaveBeenCalledTimes(0);
});
});

View File

@@ -5,18 +5,12 @@ import { performance } from 'perf_hooks';
import { getObjectOrJSON, getPageHTML } from './utils'; import { getObjectOrJSON, getPageHTML } from './utils';
import type { BuildDocsArgv } from './types'; import type { BuildDocsArgv } from './types';
import { getMergedConfig, isAbsoluteUrl } from '@redocly/openapi-core'; import { Config, getMergedConfig, isAbsoluteUrl } from '@redocly/openapi-core';
import { import { exitWithError, getExecutionTime, getFallbackApisOrExit } from '../../utils';
exitWithError,
getExecutionTime,
getFallbackApisOrExit,
loadConfigAndHandleErrors,
} from '../../utils';
export const handlerBuildCommand = async (argv: BuildDocsArgv) => { export const handlerBuildCommand = async (argv: BuildDocsArgv, configFromFile: Config) => {
const startedAt = performance.now(); const startedAt = performance.now();
const configFromFile = await loadConfigAndHandleErrors({ configPath: argv.config });
const config = getMergedConfig(configFromFile, argv.api); const config = getMergedConfig(configFromFile, argv.api);
const apis = await getFallbackApisOrExit(argv.api ? [argv.api] : [], config); const apis = await getFallbackApisOrExit(argv.api ? [argv.api] : [], config);

View File

@@ -1,4 +1,12 @@
import { formatProblems, getTotals, getMergedConfig, lint, bundle } from '@redocly/openapi-core'; import {
formatProblems,
getTotals,
getMergedConfig,
lint,
bundle,
Config,
OutputFormat,
} from '@redocly/openapi-core';
import { import {
dumpBundle, dumpBundle,
getExecutionTime, getExecutionTime,
@@ -8,32 +16,31 @@ import {
printUnusedWarnings, printUnusedWarnings,
saveBundle, saveBundle,
printLintTotals, printLintTotals,
loadConfigAndHandleErrors,
checkIfRulesetExist, checkIfRulesetExist,
sortTopLevelKeysForOas, sortTopLevelKeysForOas,
} from '../utils'; } from '../utils';
import type { CommonOptions, OutputExtensions, Skips, Totals } from '../types'; import type { OutputExtensions, Skips, Totals } from '../types';
import { performance } from 'perf_hooks'; import { performance } from 'perf_hooks';
import { blue, gray, green, yellow } from 'colorette'; import { blue, gray, green, yellow } from 'colorette';
import { writeFileSync } from 'fs'; import { writeFileSync } from 'fs';
export type BundleOptions = CommonOptions & export type BundleOptions = {
Skips & { apis?: string[];
output?: string; 'max-problems': number;
ext: OutputExtensions; extends?: string[];
dereferenced?: boolean; config?: string;
force?: boolean; format: OutputFormat;
lint?: boolean; output?: string;
metafile?: string; ext: OutputExtensions;
'remove-unused-components'?: boolean; dereferenced?: boolean;
'keep-url-references'?: boolean; force?: boolean;
}; lint?: boolean;
metafile?: string;
'remove-unused-components'?: boolean;
'keep-url-references'?: boolean;
} & Skips;
export async function handleBundle(argv: BundleOptions, version: string) { export async function handleBundle(argv: BundleOptions, config: Config, version: string) {
const config = await loadConfigAndHandleErrors({
configPath: argv.config,
customExtends: argv.extends,
});
const removeUnusedComponents = const removeUnusedComponents =
argv['remove-unused-components'] || argv['remove-unused-components'] ||
config.rawConfig?.styleguide?.decorators?.hasOwnProperty('remove-unused-components'); config.rawConfig?.styleguide?.decorators?.hasOwnProperty('remove-unused-components');
@@ -164,7 +171,7 @@ export async function handleBundle(argv: BundleOptions, version: string) {
printUnusedWarnings(config.styleguide); printUnusedWarnings(config.styleguide);
// defer process exit to allow STDOUT pipe to flush if (!(totals.errors === 0 || argv.force)) {
// see https://github.com/nodejs/node-v0.x-archive/issues/3737#issuecomment-19156072 throw new Error('Bundle failed.');
process.once('exit', () => process.exit(totals.errors === 0 || argv.force ? 0 : 1)); }
} }

View File

@@ -26,7 +26,6 @@ import {
printLintTotals, printLintTotals,
writeYaml, writeYaml,
exitWithError, exitWithError,
loadConfigAndHandleErrors,
sortTopLevelKeysForOas, sortTopLevelKeysForOas,
} from '../utils'; } from '../utils';
import { isObject, isString, keysOf } from '../js-utils'; import { isObject, isString, keysOf } from '../js-utils';
@@ -48,7 +47,7 @@ type JoinDocumentContext = {
componentsPrefix: string | undefined; componentsPrefix: string | undefined;
}; };
type JoinArgv = { export type JoinOptions = {
apis: string[]; apis: string[];
lint?: boolean; lint?: boolean;
decorate?: boolean; decorate?: boolean;
@@ -58,9 +57,12 @@ type JoinArgv = {
'prefix-components-with-info-prop'?: string; 'prefix-components-with-info-prop'?: string;
'without-x-tag-groups'?: boolean; 'without-x-tag-groups'?: boolean;
output?: string; output?: string;
config?: string;
extends?: undefined;
'lint-config'?: undefined;
}; };
export async function handleJoin(argv: JoinArgv, 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`);
@@ -86,7 +88,6 @@ export async function handleJoin(argv: JoinArgv, packageVersion: string) {
); );
} }
const config: Config = await loadConfigAndHandleErrors();
const apis = await getFallbackApisOrExit(argv.apis, config); const apis = await getFallbackApisOrExit(argv.apis, config);
const externalRefResolver = new BaseResolver(config.resolve); const externalRefResolver = new BaseResolver(config.resolve);
const documents = await Promise.all( const documents = await Promise.all(

View File

@@ -1,6 +1,5 @@
import { import {
Config, Config,
doesYamlFileExist,
findConfig, findConfig,
formatProblems, formatProblems,
getMergedConfig, getMergedConfig,
@@ -8,9 +7,6 @@ import {
lint, lint,
lintConfig, lintConfig,
makeDocumentFromString, makeDocumentFromString,
ProblemSeverity,
RawConfig,
RuleSeverity,
stringifyYaml, stringifyYaml,
} from '@redocly/openapi-core'; } from '@redocly/openapi-core';
import { import {
@@ -19,37 +15,31 @@ import {
getExecutionTime, getExecutionTime,
getFallbackApisOrExit, getFallbackApisOrExit,
handleError, handleError,
loadConfigAndHandleErrors,
pluralize, pluralize,
printConfigLintTotals, printConfigLintTotals,
printLintTotals, printLintTotals,
printUnusedWarnings, printUnusedWarnings,
} from '../utils'; } from '../utils';
import type { CommonOptions, Skips, Totals } from '../types'; import type { OutputFormat, ProblemSeverity, RawConfig, RuleSeverity } from '@redocly/openapi-core';
import type { CommandOptions, Skips, Totals } from '../types';
import { blue, gray } from 'colorette'; import { blue, gray } from 'colorette';
import { performance } from 'perf_hooks'; import { performance } from 'perf_hooks';
export type LintOptions = CommonOptions & export type LintOptions = {
Omit<Skips, 'skip-decorator'> & { apis?: string[];
'generate-ignore-file'?: boolean; 'max-problems': number;
'lint-config': RuleSeverity; extends?: string[];
}; config?: string;
format: OutputFormat;
export async function handleLint(argv: LintOptions, version: string) { 'generate-ignore-file'?: boolean;
if (argv.config && !doesYamlFileExist(argv.config)) { 'lint-config'?: RuleSeverity;
return exitWithError('Please, provide valid path to the configuration file'); } & Omit<Skips, 'skip-decorator'>;
}
const config: Config = await loadConfigAndHandleErrors({
configPath: argv.config,
customExtends: argv.extends,
processRawConfig: lintConfigCallback(argv, version),
});
export async function handleLint(argv: LintOptions, config: Config, version: string) {
const apis = await getFallbackApisOrExit(argv.apis, config); const apis = await getFallbackApisOrExit(argv.apis, config);
if (!apis.length) { if (!apis.length) {
return exitWithError('No APIs were provided'); exitWithError('No APIs were provided');
} }
if (argv['generate-ignore-file']) { if (argv['generate-ignore-file']) {
@@ -120,14 +110,15 @@ export async function handleLint(argv: LintOptions, version: string) {
printUnusedWarnings(config.styleguide); printUnusedWarnings(config.styleguide);
// defer process exit to allow STDOUT pipe to flush if (!(totals.errors === 0 || argv['generate-ignore-file'])) {
// see https://github.com/nodejs/node-v0.x-archive/issues/3737#issuecomment-19156072 throw new Error('Lint failed.');
process.once('exit', () => }
process.exit(totals.errors === 0 || argv['generate-ignore-file'] ? 0 : 1)
);
} }
function lintConfigCallback(argv: LintOptions, version: string) { export function lintConfigCallback(
argv: CommandOptions & Record<string, undefined>,
version: string
) {
if (argv['lint-config'] === 'off') { if (argv['lint-config'] === 'off') {
return; return;
} }
@@ -138,7 +129,6 @@ function lintConfigCallback(argv: LintOptions, version: string) {
} }
return async (config: RawConfig) => { return async (config: RawConfig) => {
const { 'max-problems': maxProblems, format } = argv;
const configPath = findConfig(argv.config) || ''; const configPath = findConfig(argv.config) || '';
const stringYaml = stringifyYaml(config); const stringYaml = stringifyYaml(config);
const configContent = makeDocumentFromString(stringYaml, configPath); const configContent = makeDocumentFromString(stringYaml, configPath);
@@ -150,8 +140,8 @@ function lintConfigCallback(argv: LintOptions, version: string) {
const fileTotals = getTotals(problems); const fileTotals = getTotals(problems);
formatProblems(problems, { formatProblems(problems, {
format, format: argv.format,
maxProblems, maxProblems: argv['max-problems'],
totals: fileTotals, totals: fileTotals,
version, version,
}); });

View File

@@ -1,6 +1,6 @@
import { Region, RedoclyClient } from '@redocly/openapi-core'; import { Region, RedoclyClient, Config } from '@redocly/openapi-core';
import { blue, green, gray } from 'colorette'; import { blue, green, gray } from 'colorette';
import { loadConfigAndHandleErrors, promptUser } from '../utils'; import { promptUser } from '../utils';
export function promptClientToken(domain: string) { export function promptClientToken(domain: string) {
return promptUser( return promptUser(
@@ -11,8 +11,14 @@ export function promptClientToken(domain: string) {
); );
} }
export async function handleLogin(argv: { verbose?: boolean; region?: Region }) { export type LoginOptions = {
const region = argv.region || (await loadConfigAndHandleErrors()).region; verbose?: boolean;
region?: Region;
config?: string;
};
export async function handleLogin(argv: LoginOptions, config: Config) {
const region = argv.region || config.region;
const client = new RedoclyClient(region); const client = new RedoclyClient(region);
const clientToken = await promptClientToken(client.domain); const clientToken = await promptClientToken(client.domain);
process.stdout.write(gray('\n Logging in...\n')); process.stdout.write(gray('\n Logging in...\n'));

View File

@@ -7,24 +7,25 @@ import {
RedoclyClient, RedoclyClient,
getTotals, getTotals,
getMergedConfig, getMergedConfig,
Config,
} from '@redocly/openapi-core'; } from '@redocly/openapi-core';
import { getFallbackApisOrExit, loadConfigAndHandleErrors } from '../../utils'; import { getFallbackApisOrExit, loadConfigAndHandleErrors } from '../../utils';
import startPreviewServer from './preview-server/preview-server'; import startPreviewServer from './preview-server/preview-server';
import type { Skips } from '../../types'; import type { Skips } from '../../types';
export async function previewDocs( export type PreviewDocsOptions = {
argv: { port: number;
port: number; host: string;
host: string; 'use-community-edition'?: boolean;
'use-community-edition'?: boolean; config?: string;
config?: string; api?: string;
api?: string; force?: boolean;
force?: boolean; } & Omit<Skips, 'skip-rule'>;
} & Omit<Skips, 'skip-rule'>
) { export async function previewDocs(argv: PreviewDocsOptions, configFromFile: Config) {
let isAuthorizedWithRedocly = false; let isAuthorizedWithRedocly = false;
let redocOptions: any = {}; let redocOptions: any = {};
let config = await reloadConfig(); let config = await reloadConfig(configFromFile);
const apis = await getFallbackApisOrExit(argv.api ? [argv.api] : [], config); const apis = await getFallbackApisOrExit(argv.api ? [argv.api] : [], config);
const api = apis[0]; const api = apis[0];
@@ -127,8 +128,14 @@ export async function previewDocs(
); );
}); });
async function reloadConfig() { async function reloadConfig(config?: Config) {
const config = await loadConfigAndHandleErrors({ configPath: argv.config }); if (!config) {
try {
config = (await loadConfigAndHandleErrors({ configPath: argv.config })) as Config;
} catch (err) {
config = new Config({ apis: {}, styleguide: {} });
}
}
const redoclyClient = new RedoclyClient(); const redoclyClient = new RedoclyClient();
isAuthorizedWithRedocly = await redoclyClient.isAuthorizedWithRedocly(); isAuthorizedWithRedocly = await redoclyClient.isAuthorizedWithRedocly();
const resolvedConfig = getMergedConfig(config, argv.api); const resolvedConfig = getMergedConfig(config, argv.api);

View File

@@ -21,16 +21,15 @@ import {
getFallbackApisOrExit, getFallbackApisOrExit,
pluralize, pluralize,
dumpBundle, dumpBundle,
loadConfigAndHandleErrors,
} from '../utils'; } from '../utils';
import { promptClientToken } from './login'; import { promptClientToken } from './login';
const DEFAULT_VERSION = 'latest'; const DEFAULT_VERSION = 'latest';
const DESTINATION_REGEX = export const DESTINATION_REGEX =
/^(@(?<organizationId>[\w\-\s]+)\/)?(?<name>[^@]*)@(?<version>[\w\.\-]+)$/; /^(@(?<organizationId>[\w\-\s]+)\/)?(?<name>[^@]*)@(?<version>[\w\.\-]+)$/;
type PushArgs = { export type PushOptions = {
api?: string; api?: string;
destination?: string; destination?: string;
branchName?: string; branchName?: string;
@@ -41,10 +40,10 @@ type PushArgs = {
'skip-decorator'?: string[]; 'skip-decorator'?: string[];
public?: boolean; public?: boolean;
files?: string[]; files?: string[];
config?: string;
}; };
export async function handlePush(argv: PushArgs): Promise<void> { export async function handlePush(argv: PushOptions, config: Config): Promise<void> {
const config = await loadConfigAndHandleErrors({ region: argv.region, files: argv.files });
const client = new RedoclyClient(config.region); const client = new RedoclyClient(config.region);
const isAuthorized = await client.isAuthorizedWithRedoclyByRegion(); const isAuthorized = await client.isAuthorizedWithRedoclyByRegion();
if (!isAuthorized) { if (!isAuthorized) {
@@ -329,7 +328,7 @@ export function getDestinationProps(
} }
} }
type BarePushArgs = Omit<PushArgs, 'api' | 'destination' | 'branchName'> & { type BarePushArgs = Omit<PushOptions, 'api' | 'destination' | 'branchName'> & {
maybeApiOrDestination?: string; maybeApiOrDestination?: string;
maybeDestination?: string; maybeDestination?: string;
maybeBranchName?: string; maybeBranchName?: string;
@@ -338,7 +337,10 @@ type BarePushArgs = Omit<PushArgs, 'api' | 'destination' | 'branchName'> & {
export const transformPush = export const transformPush =
(callback: typeof handlePush) => (callback: typeof handlePush) =>
({ maybeApiOrDestination, maybeDestination, maybeBranchName, branch, ...rest }: BarePushArgs) => { (
{ maybeApiOrDestination, maybeDestination, maybeBranchName, branch, ...rest }: BarePushArgs,
config: Config
) => {
if (maybeBranchName) { if (maybeBranchName) {
process.stderr.write( process.stderr.write(
yellow( yellow(
@@ -348,12 +350,15 @@ export const transformPush =
} }
const api = maybeDestination ? maybeApiOrDestination : undefined; const api = maybeDestination ? maybeApiOrDestination : undefined;
const destination = maybeDestination || maybeApiOrDestination; const destination = maybeDestination || maybeApiOrDestination;
return callback({ return callback(
...rest, {
destination, ...rest,
api, destination,
branchName: branch ?? maybeBranchName, api,
}); branchName: branch ?? maybeBranchName,
},
config
);
}; };
export function getApiRoot({ export function getApiRoot({

View File

@@ -35,7 +35,14 @@ import {
Referenced, Referenced,
} from './types'; } from './types';
export async function handleSplit(argv: { api: string; outDir: string; separator: string }) { export type SplitOptions = {
api: string;
outDir: string;
separator: string;
config?: string;
};
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!);

View File

@@ -6,22 +6,19 @@ import {
normalizeTypes, normalizeTypes,
Oas3Types, Oas3Types,
Oas2Types, Oas2Types,
StatsAccumulator,
StatsName,
BaseResolver, BaseResolver,
resolveDocument, resolveDocument,
detectOpenAPI, detectOpenAPI,
OasMajorVersion, OasMajorVersion,
openAPIMajor, openAPIMajor,
normalizeVisitors, normalizeVisitors,
WalkContext,
walkDocument, walkDocument,
Stats, Stats,
bundle, bundle,
} from '@redocly/openapi-core'; } from '@redocly/openapi-core';
import { getFallbackApisOrExit } from '../utils';
import { getFallbackApisOrExit, loadConfigAndHandleErrors } from '../utils';
import { printExecutionTime } from '../utils'; import { printExecutionTime } from '../utils';
import type { StatsAccumulator, StatsName, WalkContext, OutputFormat } from '@redocly/openapi-core';
const statsAccumulator: StatsAccumulator = { const statsAccumulator: StatsAccumulator = {
refs: { metric: '🚗 References', total: 0, color: 'red', items: new Set() }, refs: { metric: '🚗 References', total: 0, color: 'red', items: new Set() },
@@ -64,8 +61,13 @@ function printStats(statsAccumulator: StatsAccumulator, api: string, format: str
} }
} }
export async function handleStats(argv: { config?: string; api?: string; format: string }) { export type StatsOptions = {
const config: Config = await loadConfigAndHandleErrors({ configPath: argv.config }); api?: string;
format: OutputFormat;
config?: string;
};
export async function handleStats(argv: StatsOptions, config: Config) {
const [{ path }] = await getFallbackApisOrExit(argv.api ? [argv.api] : [], config); const [{ path }] = await getFallbackApisOrExit(argv.api ? [argv.api] : [], config);
const externalRefResolver = new BaseResolver(config.resolve); const externalRefResolver = new BaseResolver(config.resolve);
const { bundle: document } = await bundle({ config, ref: path }); const { bundle: document } = await bundle({ config, ref: path });

View File

@@ -3,7 +3,7 @@
import './assert-node-version'; import './assert-node-version';
import * as yargs from 'yargs'; import * as yargs from 'yargs';
import { outputExtensions, regionChoices } from './types'; import { outputExtensions, regionChoices } from './types';
import { RedoclyClient, OutputFormat, RuleSeverity } from '@redocly/openapi-core'; import { RedoclyClient } from '@redocly/openapi-core';
import { previewDocs } from './commands/preview-docs'; import { previewDocs } from './commands/preview-docs';
import { handleStats } from './commands/stats'; import { handleStats } from './commands/stats';
import { handleSplit } from './commands/split'; import { handleSplit } from './commands/split';
@@ -13,17 +13,19 @@ import { handleLint } from './commands/lint';
import { handleBundle } from './commands/bundle'; import { handleBundle } from './commands/bundle';
import { handleLogin } from './commands/login'; import { handleLogin } from './commands/login';
import { handlerBuildCommand } from './commands/build-docs'; import { handlerBuildCommand } from './commands/build-docs';
import type { BuildDocsArgv } from './commands/build-docs/types';
import { cacheLatestVersion, notifyUpdateCliVersion } from './update-version-notifier'; import { cacheLatestVersion, notifyUpdateCliVersion } from './update-version-notifier';
import { commandWrapper } from './wrapper';
const version = require('../package.json').version; import { version } from './update-version-notifier';
import type { Arguments } from 'yargs';
import type { OutputFormat, RuleSeverity } from '@redocly/openapi-core';
import type { BuildDocsArgv } from './commands/build-docs/types';
cacheLatestVersion(); cacheLatestVersion();
yargs yargs
.version('version', 'Show version number.', version) .version('version', 'Show version number.', version)
.help('help', 'Show help.') .help('help', 'Show help.')
.parserConfiguration({ 'greedy-arrays': false }) .parserConfiguration({ 'greedy-arrays': false, 'camel-case-expansion': false })
.command( .command(
'stats [api]', 'stats [api]',
'Gathering statistics for a document.', 'Gathering statistics for a document.',
@@ -38,7 +40,7 @@ yargs
}), }),
(argv) => { (argv) => {
process.env.REDOCLY_CLI_COMMAND = 'stats'; process.env.REDOCLY_CLI_COMMAND = 'stats';
handleStats(argv); commandWrapper(handleStats)(argv);
} }
) )
.command( .command(
@@ -62,11 +64,16 @@ yargs
type: 'string', type: 'string',
default: '_', default: '_',
}, },
config: {
description: 'Specify path to the config file.',
requiresArg: true,
type: 'string',
},
}) })
.demandOption('api'), .demandOption('api'),
(argv) => { (argv) => {
process.env.REDOCLY_CLI_COMMAND = 'split'; process.env.REDOCLY_CLI_COMMAND = 'split';
handleSplit(argv); commandWrapper(handleSplit)(argv);
} }
) )
.command( .command(
@@ -108,10 +115,15 @@ yargs
type: 'string', type: 'string',
default: 'openapi.yaml', default: 'openapi.yaml',
}, },
config: {
description: 'Specify path to the config file.',
requiresArg: true,
type: 'string',
},
}), }),
(argv) => { (argv) => {
process.env.REDOCLY_CLI_COMMAND = 'join'; process.env.REDOCLY_CLI_COMMAND = 'join';
handleJoin(argv, version); commandWrapper(handleJoin)(argv);
} }
) )
.command( .command(
@@ -151,12 +163,17 @@ yargs
array: true, array: true,
type: 'string', type: 'string',
}, },
config: {
description: 'Specify path to the config file.',
requiresArg: true,
type: 'string',
},
}) })
.implies('batch-id', 'batch-size') .implies('batch-id', 'batch-size')
.implies('batch-size', 'batch-id'), .implies('batch-size', 'batch-id'),
(argv) => { (argv) => {
process.env.REDOCLY_CLI_COMMAND = 'push'; process.env.REDOCLY_CLI_COMMAND = 'push';
transformPush(handlePush)(argv); commandWrapper(transformPush(handlePush))(argv);
} }
) )
.command( .command(
@@ -215,7 +232,7 @@ yargs
}), }),
(argv) => { (argv) => {
process.env.REDOCLY_CLI_COMMAND = 'lint'; process.env.REDOCLY_CLI_COMMAND = 'lint';
handleLint(argv, version); commandWrapper(handleLint)(argv);
} }
) )
.command( .command(
@@ -297,7 +314,7 @@ yargs
}), }),
(argv) => { (argv) => {
process.env.REDOCLY_CLI_COMMAND = 'bundle'; process.env.REDOCLY_CLI_COMMAND = 'bundle';
handleBundle(argv, version); commandWrapper(handleBundle)(argv);
} }
) )
.command( .command(
@@ -314,21 +331,28 @@ yargs
alias: 'r', alias: 'r',
choices: regionChoices, choices: regionChoices,
}, },
config: {
description: 'Specify path to the config file.',
requiresArg: true,
type: 'string',
},
}), }),
(argv) => { (argv) => {
process.env.REDOCLY_CLI_COMMAND = 'login'; process.env.REDOCLY_CLI_COMMAND = 'login';
handleLogin(argv); commandWrapper(handleLogin)(argv);
} }
) )
.command( .command(
'logout', 'logout',
'Clear your stored credentials for the Redocly API registry.', 'Clear your stored credentials for the Redocly API registry.',
(yargs) => yargs, (yargs) => yargs,
async () => { async (argv) => {
process.env.REDOCLY_CLI_COMMAND = 'logout'; process.env.REDOCLY_CLI_COMMAND = 'logout';
const client = new RedoclyClient(); await commandWrapper(async () => {
client.logout(); const client = new RedoclyClient();
process.stdout.write('Logged out from the Redocly account. ✋\n'); client.logout();
process.stdout.write('Logged out from the Redocly account. ✋\n');
})(argv);
} }
) )
.command( .command(
@@ -374,7 +398,7 @@ yargs
}), }),
(argv) => { (argv) => {
process.env.REDOCLY_CLI_COMMAND = 'preview-docs'; process.env.REDOCLY_CLI_COMMAND = 'preview-docs';
previewDocs(argv); commandWrapper(previewDocs)(argv);
} }
) )
.command( .command(
@@ -428,7 +452,7 @@ yargs
}), }),
async (argv) => { async (argv) => {
process.env.REDOCLY_CLI_COMMAND = 'build-docs'; process.env.REDOCLY_CLI_COMMAND = 'build-docs';
handlerBuildCommand(argv as unknown as BuildDocsArgv); commandWrapper(handlerBuildCommand)(argv as Arguments<BuildDocsArgv>);
} }
) )
.completion('completion', 'Generate completion script.') .completion('completion', 'Generate completion script.')

View File

@@ -1,4 +1,13 @@
import type { BundleOutputFormat, OutputFormat, Region, Config } from '@redocly/openapi-core'; import type { BundleOutputFormat, Region, Config } from '@redocly/openapi-core';
import type { LintOptions } from './commands/lint';
import type { BundleOptions } from './commands/bundle';
import type { JoinOptions } from './commands/join';
import type { LoginOptions } from './commands/login';
import type { PushOptions } from './commands/push';
import type { StatsOptions } from './commands/stats';
import type { SplitOptions } from './commands/split';
import type { PreviewDocsOptions } from './commands/preview-docs';
import type { BuildDocsArgv } from './commands/build-docs/types';
export type Totals = { export type Totals = {
errors: number; errors: number;
@@ -12,13 +21,16 @@ export type Entrypoint = {
export const outputExtensions = ['json', 'yaml', 'yml'] as ReadonlyArray<BundleOutputFormat>; export const outputExtensions = ['json', 'yaml', 'yml'] as ReadonlyArray<BundleOutputFormat>;
export type OutputExtensions = 'json' | 'yaml' | 'yml' | undefined; export type OutputExtensions = 'json' | 'yaml' | 'yml' | undefined;
export const regionChoices = ['us', 'eu'] as ReadonlyArray<Region>; export const regionChoices = ['us', 'eu'] as ReadonlyArray<Region>;
export type CommonOptions = { export type CommandOptions =
apis: string[]; | StatsOptions
'max-problems'?: number; | SplitOptions
extends?: string[]; | JoinOptions
config?: string; | PushOptions
format: OutputFormat; | LintOptions
}; | BundleOptions
| LoginOptions
| PreviewDocsOptions
| BuildDocsArgv;
export type Skips = { export type Skips = {
'skip-rule'?: string[]; 'skip-rule'?: string[];
'skip-decorator'?: string[]; 'skip-decorator'?: string[];

View File

@@ -6,7 +6,7 @@ import fetch from 'node-fetch';
import { cyan, green, yellow } from 'colorette'; import { cyan, green, yellow } from 'colorette';
import { cleanColors } from './utils'; import { cleanColors } from './utils';
const { version, name } = require('../package.json'); export const { version, name } = require('../package.json');
const VERSION_CACHE_FILE = 'redocly-cli-version'; const VERSION_CACHE_FILE = 'redocly-cli-version';
const SPACE_TO_BORDER = 4; const SPACE_TO_BORDER = 4;

View File

@@ -1,3 +1,4 @@
import fetch from 'node-fetch';
import { basename, dirname, extname, join, resolve, relative, isAbsolute } from 'path'; import { basename, dirname, extname, join, resolve, relative, isAbsolute } from 'path';
import { blue, gray, green, red, yellow } from 'colorette'; import { blue, gray, green, red, yellow } from 'colorette';
import { performance } from 'perf_hooks'; import { performance } from 'perf_hooks';
@@ -20,9 +21,13 @@ import {
Config, Config,
Oas3Definition, Oas3Definition,
Oas2Definition, Oas2Definition,
RedoclyClient,
} from '@redocly/openapi-core'; } from '@redocly/openapi-core';
import { Totals, outputExtensions, Entrypoint, ConfigApis } from './types'; import { Totals, outputExtensions, Entrypoint, ConfigApis, CommandOptions } from './types';
import { isEmptyObject } from '@redocly/openapi-core/lib/utils'; import { isEmptyObject } from '@redocly/openapi-core/lib/utils';
import { Arguments } from 'yargs';
import { version } from './update-version-notifier';
import { DESTINATION_REGEX } from './commands/push';
export async function getFallbackApisOrExit( export async function getFallbackApisOrExit(
argsApis: string[] | undefined, argsApis: string[] | undefined,
@@ -39,14 +44,10 @@ export async function getFallbackApisOrExit(
if (isNotEmptyArray(filteredInvalidEntrypoints)) { if (isNotEmptyArray(filteredInvalidEntrypoints)) {
for (const { path } of filteredInvalidEntrypoints) { for (const { path } of filteredInvalidEntrypoints) {
process.stderr.write( process.stderr.write(
yellow( yellow(`\n${relative(process.cwd(), path)} ${red(`does not exist or is invalid.\n\n`)}`)
`\n ${relative(process.cwd(), path)} ${red(
`does not exist or is invalid. Please provide a valid path. \n\n`
)}`
)
); );
} }
process.exit(1); exitWithError('Please provide a valid path.');
} }
return res; return res;
} }
@@ -227,30 +228,30 @@ export function pluralize(label: string, num: number) {
export function handleError(e: Error, ref: string) { export function handleError(e: Error, ref: string) {
switch (e.constructor) { switch (e.constructor) {
case HandledError: {
throw e;
}
case ResolveError: case ResolveError:
return exitWithError(`Failed to resolve api definition at ${ref}:\n\n - ${e.message}.`); return exitWithError(`Failed to resolve api definition at ${ref}:\n\n - ${e.message}.`);
case YamlParseError: case YamlParseError:
return exitWithError(`Failed to parse api definition at ${ref}:\n\n - ${e.message}.`); return exitWithError(`Failed to parse api definition at ${ref}:\n\n - ${e.message}.`);
// TODO: codeframe // TODO: codeframe
case CircularJSONNotSupportedError: { case CircularJSONNotSupportedError: {
process.stderr.write( return exitWithError(
red(`Detected circular reference which can't be converted to JSON.\n`) + `Detected circular reference which can't be converted to JSON.\n` +
`Try to use ${blue('yaml')} output or remove ${blue('--dereferenced')}.\n\n` `Try to use ${blue('yaml')} output or remove ${blue('--dereferenced')}.`
); );
return process.exit(1);
} }
case SyntaxError: case SyntaxError:
return exitWithError(`Syntax error: ${e.message} ${e.stack?.split('\n\n')?.[0]}`); return exitWithError(`Syntax error: ${e.message} ${e.stack?.split('\n\n')?.[0]}`);
default: { default: {
process.stderr.write( exitWithError(`Something went wrong when processing ${ref}:\n\n - ${e.message}.`);
red(`Something went wrong when processing ${ref}:\n\n - ${e.message}.\n\n`)
);
process.exit(1);
throw e;
} }
} }
} }
export class HandledError extends Error {}
export function printLintTotals(totals: Totals, definitionsCount: number) { export function printLintTotals(totals: Totals, definitionsCount: number) {
const ignored = totals.ignored const ignored = totals.ignored
? yellow(`${totals.ignored} ${pluralize('problem is', totals.ignored)} explicitly ignored.\n\n`) ? yellow(`${totals.ignored} ${pluralize('problem is', totals.ignored)} explicitly ignored.\n\n`)
@@ -373,7 +374,7 @@ export function printUnusedWarnings(config: StyleguideConfig) {
export function exitWithError(message: string) { export function exitWithError(message: string) {
process.stderr.write(red(message) + '\n\n'); process.stderr.write(red(message) + '\n\n');
process.exit(1); throw new HandledError(message);
} }
/** /**
@@ -392,12 +393,11 @@ export async function loadConfigAndHandleErrors(
files?: string[]; files?: string[];
region?: Region; region?: Region;
} = {} } = {}
): Promise<Config> { ): Promise<Config | void> {
try { try {
return await loadConfig(options); return await loadConfig(options);
} catch (e) { } catch (e) {
handleError(e, ''); handleError(e, '');
return new Config({ apis: {}, styleguide: {} });
} }
} }
@@ -479,3 +479,96 @@ export function cleanColors(input: string): string {
// eslint-disable-next-line no-control-regex // eslint-disable-next-line no-control-regex
return input.replace(/\x1b\[\d+m/g, ''); return input.replace(/\x1b\[\d+m/g, '');
} }
export async function sendTelemetry(
argv: Arguments | undefined,
exit_code: ExitCode,
has_config: boolean | undefined
): Promise<void> {
try {
if (!argv) {
return;
}
const {
_: [command],
$0: _,
...args
} = argv;
const event_time = new Date().toISOString();
const redoclyClient = new RedoclyClient();
const node_version = process.version;
const logged_in = await redoclyClient.isAuthorizedWithRedoclyByRegion();
const data: Analytics = {
event: 'cli_command',
event_time,
logged_in,
command,
arguments: cleanArgs(args),
node_version,
version,
exit_code,
environment: process.env.REDOCLY_ENVIRONMENT,
raw_input: cleanRawInput(process.argv.slice(2)),
has_config,
};
await fetch(`https://api.redocly.com/registry/telemetry/cli`, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(data),
});
} catch (err) {
// Do nothing.
}
}
export type ExitCode = 0 | 1 | 2;
export type Analytics = {
event: string;
event_time: string;
logged_in: boolean;
command: string | number;
arguments: Record<string, unknown>;
node_version: string;
version: string;
exit_code: ExitCode;
environment?: string;
raw_input: string;
has_config?: boolean;
};
function cleanString(value?: string): string | undefined {
if (!value) {
return value;
}
if (isAbsoluteUrl(value)) {
return value.split('://')[0] + '://***';
}
if (value.endsWith('.json') || value.endsWith('.yaml') || value.endsWith('.yml')) {
return value.replace(/^(.*)\.(yaml|yml|json)$/gi, (_, __, ext) => '***.' + ext);
}
if (DESTINATION_REGEX.test(value)) {
return value.replace(/^@[\w\-\s]+\//, () => '@***/');
}
return value;
}
export function cleanArgs(args: CommandOptions) {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(args)) {
if (typeof value === 'string') {
result[key] = cleanString(value);
} else if (Array.isArray(value)) {
result[key] = value.map(cleanString);
} else {
result[key] = value;
}
}
return result;
}
export function cleanRawInput(argv: string[]) {
return argv.map((entry) => entry.split('=').map(cleanString).join('=')).join(' ');
}

View File

@@ -0,0 +1,42 @@
import { Config, Region, doesYamlFileExist } from '@redocly/openapi-core';
import type { Arguments } from 'yargs';
import { version } from './update-version-notifier';
import { ExitCode, exitWithError, loadConfigAndHandleErrors, sendTelemetry } from './utils';
import { lintConfigCallback } from './commands/lint';
import type { CommandOptions } from './types';
export function commandWrapper<T extends CommandOptions>(
commandHandler: (argv: T, config: Config, version: string) => Promise<void>
) {
return async (argv: Arguments<T>) => {
let code: ExitCode = 2;
let hasConfig;
let telemetry;
try {
if (argv.config && !doesYamlFileExist(argv.config)) {
exitWithError('Please, provide valid path to the configuration file');
}
const config: Config = (await loadConfigAndHandleErrors({
configPath: argv.config,
customExtends: argv.extends as string[] | undefined,
region: argv.region as Region,
files: argv.file as string[] | undefined,
processRawConfig: lintConfigCallback(argv as T & Record<string, undefined>, version),
})) as Config;
telemetry = config.telemetry;
hasConfig = !config.styleguide.recommendedFallback;
code = 1;
await commandHandler(argv, config, version);
code = 0;
} catch (err) {
// Do nothing
} finally {
if (process.env.REDOCLY_TELEMETRY !== 'off' && telemetry !== 'off') {
await sendTelemetry(argv, code, hasConfig);
}
process.once('beforeExit', () => {
process.exit(code);
});
}
};
}

View File

@@ -22,6 +22,7 @@ const testConfig: Config = {
}, },
}, },
organization: 'redocly-test', organization: 'redocly-test',
telemetry: 'on',
styleguide: { styleguide: {
rules: { 'operation-summary': 'error', 'no-empty-servers': 'error' }, rules: { 'operation-summary': 'error', 'no-empty-servers': 'error' },
plugins: [], plugins: [],
@@ -101,6 +102,7 @@ describe('getMergedConfig', () => {
"operation-summary": "warn", "operation-summary": "warn",
}, },
}, },
"telemetry": "on",
"theme": Object {}, "theme": Object {},
}, },
"region": undefined, "region": undefined,
@@ -149,6 +151,7 @@ describe('getMergedConfig', () => {
}, },
}, },
}, },
"telemetry": "on",
"theme": Object {}, "theme": Object {},
} }
`); `);
@@ -199,6 +202,7 @@ describe('getMergedConfig', () => {
"operation-summary": "error", "operation-summary": "error",
}, },
}, },
"telemetry": "on",
"theme": Object {}, "theme": Object {},
}, },
"region": undefined, "region": undefined,
@@ -252,6 +256,7 @@ describe('getMergedConfig', () => {
}, },
}, },
}, },
"telemetry": "on",
"theme": Object {}, "theme": Object {},
} }
`); `);

View File

@@ -18,6 +18,7 @@ import type {
ResolvedStyleguideConfig, ResolvedStyleguideConfig,
RuleConfig, RuleConfig,
RuleSettings, RuleSettings,
Telemetry,
ThemeRawConfig, ThemeRawConfig,
} from './types'; } from './types';
import { getResolveConfig } from './utils'; import { getResolveConfig } from './utils';
@@ -317,6 +318,7 @@ export class Config {
theme: ThemeRawConfig; theme: ThemeRawConfig;
organization?: string; organization?: string;
files: string[]; files: string[];
telemetry?: Telemetry;
constructor(public rawConfig: ResolvedConfig, public configFile?: string) { constructor(public rawConfig: ResolvedConfig, public configFile?: string) {
this.apis = rawConfig.apis || {}; this.apis = rawConfig.apis || {};
this.styleguide = new StyleguideConfig(rawConfig.styleguide || {}, configFile); this.styleguide = new StyleguideConfig(rawConfig.styleguide || {}, configFile);
@@ -325,5 +327,6 @@ export class Config {
this.region = rawConfig.region; this.region = rawConfig.region;
this.organization = rawConfig.organization; this.organization = rawConfig.organization;
this.files = rawConfig.files || []; this.files = rawConfig.files || [];
this.telemetry = rawConfig.telemetry;
} }
} }

View File

@@ -139,6 +139,7 @@ export type ResolveConfig = {
}; };
export type Region = 'us' | 'eu'; export type Region = 'us' | 'eu';
export type Telemetry = 'on' | 'off';
export type AccessTokens = { [region in Region]?: string }; export type AccessTokens = { [region in Region]?: string };
@@ -171,6 +172,7 @@ export type RawConfig = {
region?: Region; region?: Region;
organization?: string; organization?: string;
files?: string[]; files?: string[];
telemetry?: Telemetry;
} & ThemeConfig; } & ThemeConfig;
export type FlatApi = Omit<Api, 'styleguide'> & export type FlatApi = Omit<Api, 'styleguide'> &

View File

@@ -8,6 +8,7 @@ import type {
import type { AccessTokens, Region } from '../config/types'; import type { AccessTokens, Region } from '../config/types';
import { DEFAULT_REGION, DOMAINS } from '../config/config'; import { DEFAULT_REGION, DOMAINS } from '../config/config';
import { isNotEmptyObject } from '../utils'; import { isNotEmptyObject } from '../utils';
const version = require('../../package.json').version; const version = require('../../package.json').version;
export class RegistryApi { export class RegistryApi {

View File

@@ -172,6 +172,7 @@ const ConfigRoot: NodeType = {
'features.openapi': 'ConfigReferenceDocs', // deprecated 'features.openapi': 'ConfigReferenceDocs', // deprecated
'features.mockServer': 'ConfigMockServer', // deprecated 'features.mockServer': 'ConfigMockServer', // deprecated
region: { enum: ['us', 'eu'] }, region: { enum: ['us', 'eu'] },
telemetry: { enum: ['on', 'off'] },
resolve: { resolve: {
properties: { properties: {
http: 'ConfigHTTP', http: 'ConfigHTTP',