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 --version
- 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:
CI: true
- name: Comment PR

View File

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

View File

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

View File

@@ -9,12 +9,12 @@
"engineStrict": true,
"scripts": {
"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",
"coverage:cli": "jest --roots packages/cli/src --coverage",
"coverage:core": "jest --roots packages/core/src --coverage",
"coverage:cli": "npm run jest -- --roots packages/cli/src --coverage",
"coverage:core": "npm run jest -- --roots packages/core/src --coverage",
"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:check": "npx prettier --check \"**/*.{ts,js,yaml,json}\"",
"eslint": "eslint packages/**",

View File

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

View File

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

View File

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

View File

@@ -17,18 +17,23 @@ import {
} from '../../utils';
import { ConfigFixture } from '../fixtures/config';
import { performance } from 'perf_hooks';
import { commandWrapper } from '../../wrapper';
import { Arguments } from 'yargs';
import { blue } from 'colorette';
jest.mock('@redocly/openapi-core');
jest.mock('../../utils');
jest.mock('perf_hooks');
const argvMock: LintOptions = {
jest.mock('../../update-version-notifier', () => ({
version: '1.0.0',
}));
const argvMock = {
apis: ['openapi.yaml'],
'lint-config': 'off',
format: 'codeframe',
};
const versionMock = '1.0.0';
} as Arguments<LintOptions>;
describe('handleLint', () => {
let processExitMock: jest.SpyInstance;
@@ -55,15 +60,14 @@ describe('handleLint', () => {
describe('loadConfig and getEnrtypoints stage', () => {
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(
'Please, provide valid path to the configuration file'
);
expect(loadConfigAndHandleErrors).toHaveBeenCalledTimes(0);
});
it('should call loadConfigAndHandleErrors and getFallbackApisOrExit', async () => {
await handleLint(argvMock, versionMock);
await commandWrapper(handleLint)(argvMock);
expect(loadConfigAndHandleErrors).toHaveBeenCalledWith({
configPath: undefined,
customExtends: undefined,
@@ -73,10 +77,11 @@ describe('handleLint', () => {
});
it('should call loadConfig with args if such exist', async () => {
await handleLint(
{ ...argvMock, config: 'redocly.yaml', extends: ['some/path'] },
versionMock
);
await commandWrapper(handleLint)({
...argvMock,
config: 'redocly.yaml',
extends: ['some/path'],
});
expect(loadConfigAndHandleErrors).toHaveBeenCalledWith({
configPath: 'redocly.yaml',
customExtends: ['some/path'],
@@ -85,25 +90,25 @@ describe('handleLint', () => {
});
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();
});
it('should check if ruleset exist', async () => {
await handleLint(argvMock, versionMock);
await commandWrapper(handleLint)(argvMock);
expect(checkIfRulesetExist).toHaveBeenCalledTimes(1);
});
it('should fail if apis not provided', async () => {
await handleLint({ ...argvMock, apis: [] }, versionMock);
await commandWrapper(handleLint)({ ...argvMock, apis: [] });
expect(getFallbackApisOrExit).toHaveBeenCalledTimes(1);
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 () => {
await handleLint(argvMock, versionMock);
await commandWrapper(handleLint)(argvMock);
expect(performance.now).toHaveBeenCalled();
expect(getMergedConfigMock).toHaveBeenCalled();
expect(lint).toHaveBeenCalled();
@@ -111,56 +116,75 @@ describe('handleLint', () => {
it('should call skipRules,skipPreprocessors and addIgnore with argv', async () => {
(lint as jest.Mock<any, any>).mockResolvedValueOnce(['problem']);
await handleLint(
{
...argvMock,
'skip-preprocessor': ['preprocessor'],
'skip-rule': ['rule'],
'generate-ignore-file': true,
},
versionMock
);
await commandWrapper(handleLint)({
...argvMock,
'skip-preprocessor': ['preprocessor'],
'skip-rule': ['rule'],
'generate-ignore-file': true,
});
expect(ConfigFixture.styleguide.skipRules).toHaveBeenCalledWith(['rule']);
expect(ConfigFixture.styleguide.skipPreprocessors).toHaveBeenCalledWith(['preprocessor']);
});
it('should call formatProblems and getExecutionTime with argv', async () => {
(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(formatProblems).toHaveBeenCalledWith(['problem'], {
format: 'stylish',
maxProblems: 2,
totals: { errors: 0 },
version: versionMock,
version: '1.0.0',
});
expect(getExecutionTime).toHaveBeenCalledWith(42);
});
it('should catch error in handleError if something fails', async () => {
(lint as jest.Mock<any, any>).mockRejectedValueOnce('error');
await handleLint(argvMock, versionMock);
await commandWrapper(handleLint)(argvMock);
expect(handleError).toHaveBeenCalledWith('error', 'openapi.yaml');
});
});
describe('erros and warning handle after lint stage', () => {
it('should call printLintTotals and printLintTotals', async () => {
await handleLint(argvMock, versionMock);
await commandWrapper(handleLint)(argvMock);
expect(printUnusedWarnings).toHaveBeenCalled();
});
it('should call exit with 0 if no errors', async () => {
await handleLint(argvMock, versionMock);
exitCb?.();
(loadConfigAndHandleErrors as jest.Mock).mockImplementation(() => {
return { ...ConfigFixture };
});
await commandWrapper(handleLint)(argvMock);
await exitCb?.();
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 });
await handleLint(argvMock, versionMock);
exitCb?.();
await commandWrapper(handleLint)(argvMock);
await exitCb?.();
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 { handlePush } from '../../commands/push';
import { promptClientToken } from '../../commands/login';
import { ConfigFixture } from '../fixtures/config';
jest.mock('fs');
jest.mock('node-fetch', () => ({
@@ -27,24 +28,30 @@ describe('push-with-region', () => {
it('should call login with default domain when region is US', async () => {
redoclyClient.domain = 'redoc.ly';
await handlePush({
upsert: true,
api: 'spec.json',
destination: '@org/my-api@1.0.0',
branchName: 'test',
});
await handlePush(
{
upsert: true,
api: 'spec.json',
destination: '@org/my-api@1.0.0',
branchName: 'test',
},
ConfigFixture as any
);
expect(mockPromptClientToken).toBeCalledTimes(1);
expect(mockPromptClientToken).toHaveBeenCalledWith(redoclyClient.domain);
});
it('should call login with EU domain when region is EU', async () => {
redoclyClient.domain = 'eu.redocly.com';
await handlePush({
upsert: true,
api: 'spec.json',
destination: '@org/my-api@1.0.0',
branchName: 'test',
});
await handlePush(
{
upsert: true,
api: 'spec.json',
destination: '@org/my-api@1.0.0',
branchName: 'test',
},
ConfigFixture as any
);
expect(mockPromptClientToken).toBeCalledTimes(1);
expect(mockPromptClientToken).toHaveBeenCalledWith(redoclyClient.domain);
});

View File

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

View File

@@ -9,6 +9,9 @@ import {
CircularJSONNotSupportedError,
sortTopLevelKeysForOas,
cleanColors,
HandledError,
cleanArgs,
cleanRawInput,
} from '../utils';
import {
ResolvedApi,
@@ -24,6 +27,7 @@ import * as process from 'process';
jest.mock('os');
jest.mock('colorette');
jest.mock('fs');
describe('isSubdir', () => {
@@ -150,23 +154,34 @@ describe('getFallbackApisOrExit', () => {
jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
jest.spyOn(process, 'exit').mockImplementation();
});
afterEach(() => {
jest.clearAllMocks();
});
it('should exit with error because no path provided', async () => {
const apisConfig = {
apis: {},
};
await getFallbackApisOrExit([''], apisConfig);
expect(process.exit).toHaveBeenCalledWith(1);
expect.assertions(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 () => {
(existsSync as jest.Mock<any, any>).mockImplementationOnce(() => false);
await getFallbackApisOrExit(undefined, config);
expect(process.stderr.write).toHaveBeenCalledWith(
'\n someFile.yaml does not exist or is invalid. Please provide a valid path. \n\n'
);
expect(process.exit).toHaveBeenCalledWith(1);
expect.assertions(3);
try {
await getFallbackApisOrExit(undefined, config);
} catch (e) {
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 return valid array with results if such file exist', async () => {
@@ -189,12 +204,17 @@ describe('getFallbackApisOrExit', () => {
apis: {},
};
(existsSync as jest.Mock<any, any>).mockImplementationOnce(() => false);
await getFallbackApisOrExit(['someFile.yaml'], apisConfig);
expect.assertions(3);
expect(process.stderr.write).toHaveBeenCalledWith(
'\n someFile.yaml does not exist or is invalid. Please provide a valid path. \n\n'
);
expect(process.exit).toHaveBeenCalledWith(1);
try {
await getFallbackApisOrExit(['someFile.yaml'], apisConfig);
} catch (e) {
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 () => {
@@ -202,12 +222,16 @@ describe('getFallbackApisOrExit', () => {
apis: {},
};
(existsSync as jest.Mock<any, any>).mockImplementationOnce(() => false);
await getFallbackApisOrExit(['someFile.yaml', 'someFile2.yaml'], apisConfig);
expect(process.stderr.write).lastCalledWith(
'\n someFile2.yaml does not exist or is invalid. Please provide a valid path. \n\n'
);
expect(process.exit).toHaveBeenCalledWith(1);
expect.assertions(3);
try {
await getFallbackApisOrExit(['someFile.yaml', 'someFile2.yaml'], apisConfig);
} catch (e) {
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 if only one file exist ', async () => {
@@ -220,14 +244,23 @@ describe('getFallbackApisOrExit', () => {
};
const configStub = { apis: apisStub };
(existsSync as jest.Mock<any, any>).mockImplementationOnce((path) => path === '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'
const existSyncMock = (existsSync as jest.Mock<any, any>).mockImplementation((path) =>
path.endsWith('someFile.yaml')
);
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 () => {
@@ -245,7 +278,6 @@ describe('getFallbackApisOrExit', () => {
const result = await getFallbackApisOrExit(undefined, apisConfig);
expect(process.stderr.write).toHaveBeenCalledTimes(0);
expect(process.exit).toHaveBeenCalledTimes(0);
expect(result).toStrictEqual([
{
alias: 'main',
@@ -356,11 +388,13 @@ describe('handleErrors', () => {
const ref = 'openapi/test.yaml';
const redColoretteMocks = red as jest.Mock<any, any>;
const blueColoretteMocks = blue as jest.Mock<any, any>;
beforeEach(() => {
jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
jest.spyOn(process, 'exit').mockImplementation((code) => code as never);
redColoretteMocks.mockImplementation((text) => text);
blueColoretteMocks.mockImplementation((text) => text);
});
afterEach(() => {
@@ -369,9 +403,8 @@ describe('handleErrors', () => {
it('should handle ResolveError', () => {
const resolveError = new ResolveError(new Error('File not found'));
handleError(resolveError, ref);
expect(() => handleError(resolveError, ref)).toThrowError(HandledError);
expect(redColoretteMocks).toHaveBeenCalledTimes(1);
expect(process.exit).toHaveBeenCalledWith(1);
expect(process.stderr.write).toHaveBeenCalledWith(
`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', () => {
const yamlParseError = new YamlParseError(new Error('Invalid yaml'), {} as any);
handleError(yamlParseError, ref);
expect(() => handleError(yamlParseError, ref)).toThrowError(HandledError);
expect(redColoretteMocks).toHaveBeenCalledTimes(1);
expect(process.exit).toHaveBeenCalledWith(1);
expect(process.stderr.write).toHaveBeenCalledWith(
`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', () => {
const circularError = new CircularJSONNotSupportedError(new Error('Circular json'));
handleError(circularError, ref);
expect(process.exit).toHaveBeenCalledWith(1);
expect(() => handleError(circularError, ref)).toThrowError(HandledError);
expect(process.stderr.write).toHaveBeenCalledWith(
`Detected circular reference which can't be converted to JSON.\n` +
`Try to use ${blue('yaml')} output or remove ${blue('--dereferenced')}.\n\n`
@@ -400,12 +431,7 @@ describe('handleErrors', () => {
it('should handle SyntaxError', () => {
const testError = new SyntaxError('Unexpected identifier');
testError.stack = 'test stack';
try {
handleError(testError, ref);
} catch (e) {
expect(e).toEqual(testError);
}
expect(process.exit).toHaveBeenCalledWith(1);
expect(() => handleError(testError, ref)).toThrowError(HandledError);
expect(process.stderr.write).toHaveBeenCalledWith(
'Syntax error: Unexpected identifier test stack\n\n'
);
@@ -413,11 +439,7 @@ describe('handleErrors', () => {
it('should throw unknown error', () => {
const testError = new Error('Test error');
try {
handleError(testError, ref);
} catch (e) {
expect(e).toEqual(testError);
}
expect(() => handleError(testError, ref)).toThrowError(HandledError);
expect(process.stderr.write).toHaveBeenCalledWith(
`Something went wrong when processing openapi/test.yaml:\n\n - Test error.\n\n`
);
@@ -433,24 +455,24 @@ describe('checkIfRulesetExist', () => {
jest.clearAllMocks();
});
it('should exit if rules not provided', () => {
it('should throw an error if rules are not provided', () => {
const rules = {
oas2: {},
oas3_0: {},
oas3_1: {},
};
checkIfRulesetExist(rules);
expect(process.exit).toHaveBeenCalledWith(1);
expect(() => checkIfRulesetExist(rules)).toThrowError(
'⚠️ 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 = {
oas2: { 'operation-4xx-response': 'error' },
oas3_0: {},
oas3_1: {},
} as any;
checkIfRulesetExist(rules);
expect(process.exit).not.toHaveBeenCalled();
});
});
@@ -462,3 +484,49 @@ describe('cleanColors', () => {
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 type { BuildDocsArgv } from './types';
import { getMergedConfig, isAbsoluteUrl } from '@redocly/openapi-core';
import {
exitWithError,
getExecutionTime,
getFallbackApisOrExit,
loadConfigAndHandleErrors,
} from '../../utils';
import { Config, getMergedConfig, isAbsoluteUrl } from '@redocly/openapi-core';
import { exitWithError, getExecutionTime, getFallbackApisOrExit } from '../../utils';
export const handlerBuildCommand = async (argv: BuildDocsArgv) => {
export const handlerBuildCommand = async (argv: BuildDocsArgv, configFromFile: Config) => {
const startedAt = performance.now();
const configFromFile = await loadConfigAndHandleErrors({ configPath: argv.config });
const config = getMergedConfig(configFromFile, argv.api);
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 {
dumpBundle,
getExecutionTime,
@@ -8,32 +16,31 @@ import {
printUnusedWarnings,
saveBundle,
printLintTotals,
loadConfigAndHandleErrors,
checkIfRulesetExist,
sortTopLevelKeysForOas,
} from '../utils';
import type { CommonOptions, OutputExtensions, Skips, Totals } from '../types';
import type { OutputExtensions, Skips, Totals } from '../types';
import { performance } from 'perf_hooks';
import { blue, gray, green, yellow } from 'colorette';
import { writeFileSync } from 'fs';
export type BundleOptions = CommonOptions &
Skips & {
output?: string;
ext: OutputExtensions;
dereferenced?: boolean;
force?: boolean;
lint?: boolean;
metafile?: string;
'remove-unused-components'?: boolean;
'keep-url-references'?: boolean;
};
export type BundleOptions = {
apis?: string[];
'max-problems': number;
extends?: string[];
config?: string;
format: OutputFormat;
output?: string;
ext: OutputExtensions;
dereferenced?: boolean;
force?: boolean;
lint?: boolean;
metafile?: string;
'remove-unused-components'?: boolean;
'keep-url-references'?: boolean;
} & Skips;
export async function handleBundle(argv: BundleOptions, version: string) {
const config = await loadConfigAndHandleErrors({
configPath: argv.config,
customExtends: argv.extends,
});
export async function handleBundle(argv: BundleOptions, config: Config, version: string) {
const removeUnusedComponents =
argv['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);
// defer process exit to allow STDOUT pipe to flush
// see https://github.com/nodejs/node-v0.x-archive/issues/3737#issuecomment-19156072
process.once('exit', () => process.exit(totals.errors === 0 || argv.force ? 0 : 1));
if (!(totals.errors === 0 || argv.force)) {
throw new Error('Bundle failed.');
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,7 +35,14 @@ import {
Referenced,
} 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 { api, outDir, separator } = argv;
validateDefinitionFileName(api!);

View File

@@ -6,22 +6,19 @@ import {
normalizeTypes,
Oas3Types,
Oas2Types,
StatsAccumulator,
StatsName,
BaseResolver,
resolveDocument,
detectOpenAPI,
OasMajorVersion,
openAPIMajor,
normalizeVisitors,
WalkContext,
walkDocument,
Stats,
bundle,
} from '@redocly/openapi-core';
import { getFallbackApisOrExit, loadConfigAndHandleErrors } from '../utils';
import { getFallbackApisOrExit } from '../utils';
import { printExecutionTime } from '../utils';
import type { StatsAccumulator, StatsName, WalkContext, OutputFormat } from '@redocly/openapi-core';
const statsAccumulator: StatsAccumulator = {
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 }) {
const config: Config = await loadConfigAndHandleErrors({ configPath: argv.config });
export type StatsOptions = {
api?: string;
format: OutputFormat;
config?: string;
};
export async function handleStats(argv: StatsOptions, config: Config) {
const [{ path }] = await getFallbackApisOrExit(argv.api ? [argv.api] : [], config);
const externalRefResolver = new BaseResolver(config.resolve);
const { bundle: document } = await bundle({ config, ref: path });

View File

@@ -3,7 +3,7 @@
import './assert-node-version';
import * as yargs from 'yargs';
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 { handleStats } from './commands/stats';
import { handleSplit } from './commands/split';
@@ -13,17 +13,19 @@ import { handleLint } from './commands/lint';
import { handleBundle } from './commands/bundle';
import { handleLogin } from './commands/login';
import { handlerBuildCommand } from './commands/build-docs';
import type { BuildDocsArgv } from './commands/build-docs/types';
import { cacheLatestVersion, notifyUpdateCliVersion } from './update-version-notifier';
const version = require('../package.json').version;
import { commandWrapper } from './wrapper';
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();
yargs
.version('version', 'Show version number.', version)
.help('help', 'Show help.')
.parserConfiguration({ 'greedy-arrays': false })
.parserConfiguration({ 'greedy-arrays': false, 'camel-case-expansion': false })
.command(
'stats [api]',
'Gathering statistics for a document.',
@@ -38,7 +40,7 @@ yargs
}),
(argv) => {
process.env.REDOCLY_CLI_COMMAND = 'stats';
handleStats(argv);
commandWrapper(handleStats)(argv);
}
)
.command(
@@ -62,11 +64,16 @@ yargs
type: 'string',
default: '_',
},
config: {
description: 'Specify path to the config file.',
requiresArg: true,
type: 'string',
},
})
.demandOption('api'),
(argv) => {
process.env.REDOCLY_CLI_COMMAND = 'split';
handleSplit(argv);
commandWrapper(handleSplit)(argv);
}
)
.command(
@@ -108,10 +115,15 @@ yargs
type: 'string',
default: 'openapi.yaml',
},
config: {
description: 'Specify path to the config file.',
requiresArg: true,
type: 'string',
},
}),
(argv) => {
process.env.REDOCLY_CLI_COMMAND = 'join';
handleJoin(argv, version);
commandWrapper(handleJoin)(argv);
}
)
.command(
@@ -151,12 +163,17 @@ yargs
array: true,
type: 'string',
},
config: {
description: 'Specify path to the config file.',
requiresArg: true,
type: 'string',
},
})
.implies('batch-id', 'batch-size')
.implies('batch-size', 'batch-id'),
(argv) => {
process.env.REDOCLY_CLI_COMMAND = 'push';
transformPush(handlePush)(argv);
commandWrapper(transformPush(handlePush))(argv);
}
)
.command(
@@ -215,7 +232,7 @@ yargs
}),
(argv) => {
process.env.REDOCLY_CLI_COMMAND = 'lint';
handleLint(argv, version);
commandWrapper(handleLint)(argv);
}
)
.command(
@@ -297,7 +314,7 @@ yargs
}),
(argv) => {
process.env.REDOCLY_CLI_COMMAND = 'bundle';
handleBundle(argv, version);
commandWrapper(handleBundle)(argv);
}
)
.command(
@@ -314,21 +331,28 @@ yargs
alias: 'r',
choices: regionChoices,
},
config: {
description: 'Specify path to the config file.',
requiresArg: true,
type: 'string',
},
}),
(argv) => {
process.env.REDOCLY_CLI_COMMAND = 'login';
handleLogin(argv);
commandWrapper(handleLogin)(argv);
}
)
.command(
'logout',
'Clear your stored credentials for the Redocly API registry.',
(yargs) => yargs,
async () => {
async (argv) => {
process.env.REDOCLY_CLI_COMMAND = 'logout';
const client = new RedoclyClient();
client.logout();
process.stdout.write('Logged out from the Redocly account. ✋\n');
await commandWrapper(async () => {
const client = new RedoclyClient();
client.logout();
process.stdout.write('Logged out from the Redocly account. ✋\n');
})(argv);
}
)
.command(
@@ -374,7 +398,7 @@ yargs
}),
(argv) => {
process.env.REDOCLY_CLI_COMMAND = 'preview-docs';
previewDocs(argv);
commandWrapper(previewDocs)(argv);
}
)
.command(
@@ -428,7 +452,7 @@ yargs
}),
async (argv) => {
process.env.REDOCLY_CLI_COMMAND = 'build-docs';
handlerBuildCommand(argv as unknown as BuildDocsArgv);
commandWrapper(handlerBuildCommand)(argv as Arguments<BuildDocsArgv>);
}
)
.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 = {
errors: number;
@@ -12,13 +21,16 @@ export type Entrypoint = {
export const outputExtensions = ['json', 'yaml', 'yml'] as ReadonlyArray<BundleOutputFormat>;
export type OutputExtensions = 'json' | 'yaml' | 'yml' | undefined;
export const regionChoices = ['us', 'eu'] as ReadonlyArray<Region>;
export type CommonOptions = {
apis: string[];
'max-problems'?: number;
extends?: string[];
config?: string;
format: OutputFormat;
};
export type CommandOptions =
| StatsOptions
| SplitOptions
| JoinOptions
| PushOptions
| LintOptions
| BundleOptions
| LoginOptions
| PreviewDocsOptions
| BuildDocsArgv;
export type Skips = {
'skip-rule'?: string[];
'skip-decorator'?: string[];

View File

@@ -6,7 +6,7 @@ import fetch from 'node-fetch';
import { cyan, green, yellow } from 'colorette';
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 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 { blue, gray, green, red, yellow } from 'colorette';
import { performance } from 'perf_hooks';
@@ -20,9 +21,13 @@ import {
Config,
Oas3Definition,
Oas2Definition,
RedoclyClient,
} 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 { Arguments } from 'yargs';
import { version } from './update-version-notifier';
import { DESTINATION_REGEX } from './commands/push';
export async function getFallbackApisOrExit(
argsApis: string[] | undefined,
@@ -39,14 +44,10 @@ export async function getFallbackApisOrExit(
if (isNotEmptyArray(filteredInvalidEntrypoints)) {
for (const { path } of filteredInvalidEntrypoints) {
process.stderr.write(
yellow(
`\n ${relative(process.cwd(), path)} ${red(
`does not exist or is invalid. Please provide a valid path. \n\n`
)}`
)
yellow(`\n${relative(process.cwd(), path)} ${red(`does not exist or is invalid.\n\n`)}`)
);
}
process.exit(1);
exitWithError('Please provide a valid path.');
}
return res;
}
@@ -227,30 +228,30 @@ export function pluralize(label: string, num: number) {
export function handleError(e: Error, ref: string) {
switch (e.constructor) {
case HandledError: {
throw e;
}
case ResolveError:
return exitWithError(`Failed to resolve api definition at ${ref}:\n\n - ${e.message}.`);
case YamlParseError:
return exitWithError(`Failed to parse api definition at ${ref}:\n\n - ${e.message}.`);
// TODO: codeframe
case CircularJSONNotSupportedError: {
process.stderr.write(
red(`Detected circular reference which can't be converted to JSON.\n`) +
`Try to use ${blue('yaml')} output or remove ${blue('--dereferenced')}.\n\n`
return exitWithError(
`Detected circular reference which can't be converted to JSON.\n` +
`Try to use ${blue('yaml')} output or remove ${blue('--dereferenced')}.`
);
return process.exit(1);
}
case SyntaxError:
return exitWithError(`Syntax error: ${e.message} ${e.stack?.split('\n\n')?.[0]}`);
default: {
process.stderr.write(
red(`Something went wrong when processing ${ref}:\n\n - ${e.message}.\n\n`)
);
process.exit(1);
throw e;
exitWithError(`Something went wrong when processing ${ref}:\n\n - ${e.message}.`);
}
}
}
export class HandledError extends Error {}
export function printLintTotals(totals: Totals, definitionsCount: number) {
const ignored = totals.ignored
? 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) {
process.stderr.write(red(message) + '\n\n');
process.exit(1);
throw new HandledError(message);
}
/**
@@ -392,12 +393,11 @@ export async function loadConfigAndHandleErrors(
files?: string[];
region?: Region;
} = {}
): Promise<Config> {
): Promise<Config | void> {
try {
return await loadConfig(options);
} catch (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
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',
telemetry: 'on',
styleguide: {
rules: { 'operation-summary': 'error', 'no-empty-servers': 'error' },
plugins: [],
@@ -101,6 +102,7 @@ describe('getMergedConfig', () => {
"operation-summary": "warn",
},
},
"telemetry": "on",
"theme": Object {},
},
"region": undefined,
@@ -149,6 +151,7 @@ describe('getMergedConfig', () => {
},
},
},
"telemetry": "on",
"theme": Object {},
}
`);
@@ -199,6 +202,7 @@ describe('getMergedConfig', () => {
"operation-summary": "error",
},
},
"telemetry": "on",
"theme": Object {},
},
"region": undefined,
@@ -252,6 +256,7 @@ describe('getMergedConfig', () => {
},
},
},
"telemetry": "on",
"theme": Object {},
}
`);

View File

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

View File

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

View File

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

View File

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