feat: add output as a per-API configuration option (#1708)

This commit is contained in:
Andrew Tatomyr
2024-09-06 13:56:02 +03:00
committed by GitHub
parent 4f75b4fea5
commit e1ddf8e12e
11 changed files with 307 additions and 106 deletions

View File

@@ -0,0 +1,5 @@
---
"@redocly/cli": minor
---
Added support for the `output` option in the per-API configuration so that the destination file can be specified in configuration.

View File

@@ -22,23 +22,23 @@ redocly bundle --version
## Options
| Option | Type | Description |
| -------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| apis | [string] | List of API description root filenames or names assigned in the `apis` section of your Redocly configuration file. Default values are all names defined in the `apis` section within your configuration file. |
| --config | string | Specify path to the [configuration file](#use-alternative-configuration-file). |
| --dereferenced, -d | boolean | Generate fully dereferenced bundle. |
| --ext | string | Specify bundled file extension. Possible values are `json`, `yaml`, or `yml`. Default value is `yaml`. |
| --extends | [string] | Can be used in combination with `--lint` to [extend a specific configuration](./lint.md#extend-configuration). Default values are taken from the Redocly configuration file. |
| --force, -f | boolean | Generate bundle output even when errors occur. |
| --help | boolean | Show help. |
| --keep-url-references, -k | boolean | Keep absolute url references. |
| --lint-config | string | Specify the severity level for the configuration file. <br/> **Possible values:** `warn`, `error`, `off`. Default value is `warn`. |
| --metafile | string | Path for the bundle metadata file. |
| --output, -o | string | Name or folder for the bundle file. If you don't specify the file extension, `.yaml` is used by default. If the specified folder doesn't exist, it's created automatically. **If the file specified as the bundler's output already exists, it's overwritten.** |
| --remove-unused-components | boolean | Remove unused components from the `bundle` output. |
| --skip-decorator | [string] | Ignore certain decorators. See the [Skip preprocessor, rule, or decorator section](#skip-preprocessor-rule-or-decorator). |
| --skip-preprocessor | [string] | Ignore certain preprocessors. See the [Skip preprocessor, rule, or decorator section](#skip-preprocessor-rule-or-decorator). |
| --version | boolean | Show version number. |
| Option | Type | Description |
| -------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| apis | [string] | List of API description root filenames or names assigned in the `apis` section of your Redocly configuration file. Default values are all names defined in the `apis` section within your configuration file. |
| --config | string | Specify path to the [configuration file](#use-alternative-configuration-file). |
| --dereferenced, -d | boolean | Generate fully dereferenced bundle. |
| --ext | string | Specify bundled file extension. Possible values are `json`, `yaml`, or `yml`. Default value is `yaml`. |
| --extends | [string] | Can be used in combination with `--lint` to [extend a specific configuration](./lint.md#extend-configuration). Default values are taken from the Redocly configuration file. |
| --force, -f | boolean | Generate bundle output even when errors occur. |
| --help | boolean | Show help. |
| --keep-url-references, -k | boolean | Keep absolute url references. |
| --lint-config | string | Specify the severity level for the configuration file. <br/> **Possible values:** `warn`, `error`, `off`. Default value is `warn`. |
| --metafile | string | Path for the bundle metadata file. |
| --output, -o | string | Name or folder for the bundle file. If you don't specify the file extension, `.yaml` is used by default. If the specified folder doesn't exist, it's created automatically. **If the file specified as the bundler's output already exists, it's overwritten.** Use this option when bundling a single API only; otherwise use the `output` option in per-API configuration. |
| --remove-unused-components | boolean | Remove unused components from the `bundle` output. |
| --skip-decorator | [string] | Ignore certain decorators. See the [Skip preprocessor, rule, or decorator section](#skip-preprocessor-rule-or-decorator). |
| --skip-preprocessor | [string] | Ignore certain preprocessors. See the [Skip preprocessor, rule, or decorator section](#skip-preprocessor-rule-or-decorator). |
| --version | boolean | Show version number. |
## Examples
@@ -65,6 +65,27 @@ dist/openapi.json
dist/museum.json
</pre>
You can specify the default `output` location for a bundled API in the `apis` section of your Redocly configuration file.
This is especially useful when bundling multiple APIs.
```yaml
apis:
orders@v1:
root: orders/openapi.yaml
output: dist/orders.json
accounts@v1:
root: accounts/openapi.yaml
output: dist/accounts.json
```
Given the `redocly.yaml` configuration file above, the following command bundles the APIs `foo` and `bar` into the `dist/` folder.
```bash
redocly bundle
```
Please note, that providing an API to the `bundle` command results in the command bundling only the specified API.
### Create a fully dereferenced bundle
A fully dereferenced bundle does not use `$ref` at all, all the references are resolved and placed into the API description file. This can be useful if you need to prepare an OpenAPI file to be used by another tool that does not understand the `$ref` syntax.

View File

@@ -52,6 +52,12 @@ If your project contains multiple APIs, the `apis` configuration section allows
- [Decorators object](./decorators.md)
- Preprocessors run before linting, and follow the same structure as decorators. We recommend the use of decorators over preprocessors in most cases.
---
- output
- Output file path
- When running `bundle` without specifying an API, the bundled API description is saved to this location.
{% /table %}
## Examples
@@ -73,6 +79,20 @@ apis:
operation-summary: off
```
The following example shows `redocly.yaml` configuration file with settings for multiple APIs outputs.
```yaml
apis:
main@v1:
root: openapi-v1.yaml
output: v1/bundled.yaml
main@v2:
root: openapi-v2.yaml
output: v2/bundled.yaml
```
When running `redocly bundle` with this config, the bundled API descriptions are saved to the corresponding location.
## Related options
- [extends](./extends.md) sets the base ruleset to use.

View File

@@ -1,7 +1,12 @@
import { bundle, getTotals, getMergedConfig } from '@redocly/openapi-core';
import { bundle, getTotals, getMergedConfig, Config } from '@redocly/openapi-core';
import { BundleOptions, handleBundle } from '../../commands/bundle';
import { handleError } from '../../utils/miscellaneous';
import {
getFallbackApisOrExit,
getOutputFileName,
handleError,
saveBundle,
} from '../../utils/miscellaneous';
import { commandWrapper } from '../../wrapper';
import SpyInstance = jest.SpyInstance;
import { Arguments } from 'yargs';
@@ -9,24 +14,31 @@ import { Arguments } from 'yargs';
jest.mock('@redocly/openapi-core');
jest.mock('../../utils/miscellaneous');
// @ts-ignore
getOutputFileName = jest.requireActual('../../utils/miscellaneous').getOutputFileName;
(getMergedConfig as jest.Mock).mockImplementation((config) => config);
describe('bundle', () => {
let processExitMock: SpyInstance;
let exitCb: any;
let stderrWriteMock: any;
let stdoutWriteMock: any;
beforeEach(() => {
processExitMock = jest.spyOn(process, 'exit').mockImplementation();
jest.spyOn(process, 'once').mockImplementation((_e, cb) => {
exitCb = cb;
return process.on(_e, cb);
});
jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
stderrWriteMock = jest.spyOn(process.stderr, 'write').mockImplementation(jest.fn());
stdoutWriteMock = jest.spyOn(process.stdout, 'write').mockImplementation(jest.fn());
});
afterEach(() => {
(bundle as jest.Mock).mockClear();
(getTotals as jest.Mock).mockReset();
stderrWriteMock.mockRestore();
stdoutWriteMock.mockRestore();
});
it('bundles definitions', async () => {
@@ -114,4 +126,119 @@ describe('bundle', () => {
expect(handleError).toHaveBeenCalledTimes(0);
});
it('should store bundled API descriptions in the output files described in the apis section of config IF no positional apis provided AND output is specified for both apis', async () => {
const apis = {
foo: {
root: 'foo.yaml',
output: 'output/foo.yaml',
},
bar: {
root: 'bar.yaml',
output: 'output/bar.json',
},
};
const config = {
apis,
styleguide: {
skipPreprocessors: jest.fn(),
skipDecorators: jest.fn(),
},
} as unknown as Config;
// @ts-ignore
getFallbackApisOrExit = jest
.fn()
.mockResolvedValueOnce(
Object.entries(apis).map(([alias, { root, ...api }]) => ({ ...api, path: root, alias }))
);
(getTotals as jest.Mock).mockReturnValue({
errors: 0,
warnings: 0,
ignored: 0,
});
await handleBundle({
argv: { apis: [] }, // positional
version: 'test',
config,
});
expect(saveBundle).toBeCalledTimes(2);
expect(saveBundle).toHaveBeenNthCalledWith(1, 'output/foo.yaml', expect.any(String));
expect(saveBundle).toHaveBeenNthCalledWith(2, 'output/bar.json', expect.any(String));
});
it('should store bundled API descriptions in the output files described in the apis section of config AND print the bundled api without the output specified to the terminal IF no positional apis provided AND output is specified for one api', async () => {
const apis = {
foo: {
root: 'foo.yaml',
output: 'output/foo.yaml',
},
bar: {
root: 'bar.yaml',
},
};
const config = {
apis,
styleguide: {
skipPreprocessors: jest.fn(),
skipDecorators: jest.fn(),
},
} as unknown as Config;
// @ts-ignore
getFallbackApisOrExit = jest
.fn()
.mockResolvedValueOnce(
Object.entries(apis).map(([alias, { root, ...api }]) => ({ ...api, path: root, alias }))
);
(getTotals as jest.Mock).mockReturnValue({
errors: 0,
warnings: 0,
ignored: 0,
});
await handleBundle({
argv: { apis: [] }, // positional
version: 'test',
config,
});
expect(saveBundle).toBeCalledTimes(1);
expect(saveBundle).toHaveBeenCalledWith('output/foo.yaml', expect.any(String));
expect(process.stdout.write).toHaveBeenCalledTimes(1);
});
describe('per api output', () => {
it('should NOT store bundled API descriptions in the output files described in the apis section of config IF no there is a positional api provided', async () => {
const apis = {
foo: {
root: 'foo.yaml',
output: 'output/foo.yaml',
},
};
const config = {
apis,
styleguide: {
skipPreprocessors: jest.fn(),
skipDecorators: jest.fn(),
},
} as unknown as Config;
// @ts-ignore
getFallbackApisOrExit = jest.fn().mockResolvedValueOnce([{ path: 'openapi.yaml' }]);
(getTotals as jest.Mock).mockReturnValue({
errors: 0,
warnings: 0,
ignored: 0,
});
await handleBundle({
argv: { apis: ['openapi.yaml'] }, // positional
version: 'test',
config,
});
expect(saveBundle).toBeCalledTimes(0);
expect(process.stdout.write).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -27,6 +27,7 @@ import { blue, red, yellow } from 'colorette';
import { existsSync, statSync } from 'fs';
import * as path from 'path';
import * as process from 'process';
import { ConfigApis } from '../types';
jest.mock('os');
jest.mock('colorette');
@@ -79,20 +80,6 @@ describe('pathToFilename', () => {
});
});
describe('getFallbackApisOrExit', () => {
it('should find alias by filename', async () => {
(existsSync as jest.Mock<any, any>).mockImplementationOnce(() => true);
const entry = await getFallbackApisOrExit(['./test.yaml'], {
apis: {
main: {
root: 'test.yaml',
},
},
} as any);
expect(entry).toEqual([{ path: './test.yaml', alias: 'main' }]);
});
});
describe('printConfigLintTotals', () => {
const totalProblemsMock: Totals = {
errors: 1,
@@ -190,6 +177,7 @@ describe('getFallbackApisOrExit', () => {
{
alias: 'main',
path: 'someFile.yaml',
output: undefined,
},
]);
});
@@ -277,6 +265,43 @@ describe('getFallbackApisOrExit', () => {
{
alias: 'main',
path: 'https://someLinkt/petstore.yaml?main',
output: undefined,
},
]);
(isAbsoluteUrl as jest.Mock<any, any>).mockReset();
});
it('should find alias by filename', async () => {
(existsSync as jest.Mock<any, any>).mockImplementationOnce(() => true);
const entry = await getFallbackApisOrExit(['./test.yaml'], {
apis: {
main: {
root: 'test.yaml',
styleguide: {},
},
},
});
expect(entry).toEqual([{ path: './test.yaml', alias: 'main' }]);
});
it('should return apis from config with paths and outputs resolved relatively to the config location', async () => {
(existsSync as jest.Mock<any, any>).mockImplementationOnce(() => true);
const entry = await getFallbackApisOrExit(undefined, {
apis: {
main: {
root: 'test.yaml',
output: 'output/test.yaml',
styleguide: {},
},
},
configFile: 'project-folder/redocly.yaml',
});
expect(entry).toEqual([
{
path: expect.stringMatching(/project\-folder\/test\.yaml$/),
output: expect.stringMatching(/project\-folder\/output\/test\.yaml$/),
alias: 'main',
},
]);
});
@@ -591,28 +616,28 @@ describe('cleanRawInput', () => {
expect(stderrMock).toHaveBeenCalledWith(`Unsupported file extension: xml. Using yaml.\n`);
});
});
});
describe('writeToFileByExtension', () => {
beforeEach(() => {
jest.spyOn(process.stderr, 'write').mockImplementation(jest.fn());
(yellow as jest.Mock<any, any>).mockImplementation((text: string) => text);
});
describe('writeToFileByExtension', () => {
beforeEach(() => {
jest.spyOn(process.stderr, 'write').mockImplementation(jest.fn());
(yellow as jest.Mock<any, any>).mockImplementation((text: string) => text);
});
afterEach(() => {
jest.restoreAllMocks();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should call stringifyYaml function', () => {
writeToFileByExtension('test data', 'test.yaml');
expect(stringifyYaml).toHaveBeenCalledWith('test data', { noRefs: false });
expect(process.stderr.write).toHaveBeenCalledWith(`test data`);
});
it('should call stringifyYaml function', () => {
writeToFileByExtension('test data', 'test.yaml');
expect(stringifyYaml).toHaveBeenCalledWith('test data', { noRefs: false });
expect(process.stderr.write).toHaveBeenCalledWith(`test data`);
});
it('should call JSON.stringify function', () => {
const stringifySpy = jest.spyOn(JSON, 'stringify').mockImplementation((data) => data);
writeToFileByExtension('test data', 'test.json');
expect(stringifySpy).toHaveBeenCalledWith('test data', null, 2);
expect(process.stderr.write).toHaveBeenCalledWith(`test data`);
});
it('should call JSON.stringify function', () => {
const stringifySpy = jest.spyOn(JSON, 'stringify').mockImplementation((data) => data);
writeToFileByExtension('test data', 'test.json');
expect(stringifySpy).toHaveBeenCalledWith('test data', null, 2);
expect(process.stderr.write).toHaveBeenCalledWith(`test data`);
});
});

View File

@@ -21,7 +21,7 @@ export type BundleOptions = {
apis?: string[];
extends?: string[];
output?: string;
ext: OutputExtensions;
ext?: OutputExtensions;
dereferenced?: boolean;
force?: boolean;
metafile?: string;
@@ -45,7 +45,7 @@ export async function handleBundle({
checkForDeprecatedOptions(argv, deprecatedOptions);
for (const { path, alias } of apis) {
for (const { path, alias, output } of apis) {
try {
const startedAt = performance.now();
const resolvedConfig = getMergedConfig(config, alias);
@@ -70,19 +70,19 @@ export async function handleBundle({
});
const fileTotals = getTotals(problems);
const { outputFile, ext } = getOutputFileName(path, apis.length, argv.output, argv.ext);
const { outputFile, ext } = getOutputFileName(path, output || argv.output, argv.ext);
if (fileTotals.errors === 0 || argv.force) {
if (!argv.output) {
const output = dumpBundle(
if (!outputFile) {
const bundled = dumpBundle(
sortTopLevelKeysForOas(result.parsed),
argv.ext || 'yaml',
argv.dereferenced
);
process.stdout.write(output);
process.stdout.write(bundled);
} else {
const output = dumpBundle(sortTopLevelKeysForOas(result.parsed), ext, argv.dereferenced);
saveBundle(outputFile, output);
const bundled = dumpBundle(sortTopLevelKeysForOas(result.parsed), ext, argv.dereferenced);
saveBundle(outputFile, bundled);
}
}
@@ -111,9 +111,9 @@ export async function handleBundle({
if (fileTotals.errors > 0) {
if (argv.force) {
process.stderr.write(
`❓ Created a bundle for ${blue(path)} at ${blue(outputFile)} with errors ${green(
elapsed
)}.\n${yellow('Errors ignored because of --force')}.\n`
`❓ Created a bundle for ${blue(path)} at ${blue(
outputFile || 'stdout'
)} with errors ${green(elapsed)}.\n${yellow('Errors ignored because of --force')}.\n`
);
} else {
process.stderr.write(
@@ -124,7 +124,9 @@ export async function handleBundle({
}
} else {
process.stderr.write(
`📦 Created a bundle for ${blue(path)} at ${blue(outputFile)} ${green(elapsed)}.\n`
`📦 Created a bundle for ${blue(path)} at ${blue(outputFile || 'stdout')} ${green(
elapsed
)}.\n`
);
}

View File

@@ -23,6 +23,7 @@ export type Totals = {
export type Entrypoint = {
path: string;
alias?: string;
output?: string;
};
export const outputExtensions = ['json', 'yaml', 'yml'] as ReadonlyArray<BundleOutputFormat>;
export type OutputExtensions = 'json' | 'yaml' | 'yml' | undefined;

View File

@@ -20,3 +20,4 @@ export const sortTopLevelKeysForOas = jest.fn((document) => document);
export const getAndValidateFileExtension = jest.fn((fileName: string) => fileName.split('.').pop());
export const writeToFileByExtension = jest.fn();
export const checkForDeprecatedOptions = jest.fn();
export const saveBundle = jest.fn();

View File

@@ -16,7 +16,13 @@ import {
loadConfig,
RedoclyClient,
} from '@redocly/openapi-core';
import { isEmptyObject, isPlainObject, pluralize } from '@redocly/openapi-core/lib/utils';
import {
isEmptyObject,
isNotEmptyArray,
isNotEmptyObject,
isPlainObject,
pluralize,
} from '@redocly/openapi-core/lib/utils';
import { ConfigValidationError } from '@redocly/openapi-core/lib/config';
import { deprecatedRefDocsSchema } from '@redocly/config/lib/reference-docs-config-schema';
import { outputExtensions } from '../types';
@@ -42,8 +48,7 @@ export async function getFallbackApisOrExit(
config: ConfigApis
): Promise<Entrypoint[]> {
const { apis } = config;
const shouldFallbackToAllDefinitions =
!isNotEmptyArray(argsApis) && apis && Object.keys(apis).length > 0;
const shouldFallbackToAllDefinitions = !isNotEmptyArray(argsApis) && isNotEmptyObject(apis);
const res = shouldFallbackToAllDefinitions
? fallbackToAllDefinitions(apis, config)
: await expandGlobsInEntrypoints(argsApis!, config);
@@ -64,10 +69,6 @@ function getConfigDirectory(config: ConfigApis) {
return config.configFile ? dirname(config.configFile) : process.cwd();
}
function isNotEmptyArray<T>(args?: T[]): boolean {
return Array.isArray(args) && !!args.length;
}
function isApiPathValid(apiPath: string): string | void {
if (!apiPath.trim()) {
exitWithError('Path cannot be empty.');
@@ -80,15 +81,21 @@ function fallbackToAllDefinitions(
apis: Record<string, ResolvedApi>,
config: ConfigApis
): Entrypoint[] {
return Object.entries(apis).map(([alias, { root }]) => ({
return Object.entries(apis).map(([alias, { root, output }]) => ({
path: isAbsoluteUrl(root) ? root : resolve(getConfigDirectory(config), root),
alias,
output: output && resolve(getConfigDirectory(config), output),
}));
}
function getAliasOrPath(config: ConfigApis, aliasOrPath: string): Entrypoint {
return config.apis[aliasOrPath]
? { path: config.apis[aliasOrPath]?.root, alias: aliasOrPath }
const aliasApi = config.apis[aliasOrPath];
return aliasApi
? {
path: aliasApi.root,
alias: aliasOrPath,
output: aliasApi.output,
}
: {
path: aliasOrPath,
// find alias by path, take the first match
@@ -99,10 +106,10 @@ function getAliasOrPath(config: ConfigApis, aliasOrPath: string): Entrypoint {
};
}
async function expandGlobsInEntrypoints(args: string[], config: ConfigApis) {
async function expandGlobsInEntrypoints(argApis: string[], config: ConfigApis) {
return (
await Promise.all(
(args as string[]).map(async (aliasOrPath) => {
argApis.map(async (aliasOrPath) => {
return glob.hasMagic(aliasOrPath) && !isAbsoluteUrl(aliasOrPath)
? (await promisify(glob)(aliasOrPath)).map((g: string) => getAliasOrPath(config, g))
: getAliasOrPath(config, aliasOrPath);
@@ -356,33 +363,20 @@ export function printConfigLintTotals(totals: Totals, command?: string | number)
}
}
export function getOutputFileName(
entrypoint: string,
entries: number,
output?: string,
ext?: BundleOutputFormat
) {
if (!output) {
return { outputFile: 'stdout', ext: ext || 'yaml' };
export function getOutputFileName(entrypoint: string, output?: string, ext?: BundleOutputFormat) {
let outputFile = output;
if (!outputFile) {
return { ext: ext || 'yaml' };
}
let outputFile = output;
if (entries > 1) {
ext = ext || (extname(entrypoint).substring(1) as BundleOutputFormat);
if (!outputExtensions.includes(ext as any)) {
throw new Error(`Invalid file extension: ${ext}.`);
}
outputFile = join(output, basename(entrypoint, extname(entrypoint))) + '.' + ext;
} else {
if (output) {
ext = ext || (extname(output).substring(1) as BundleOutputFormat);
}
ext = ext || (extname(entrypoint).substring(1) as BundleOutputFormat);
if (!outputExtensions.includes(ext as any)) {
throw new Error(`Invalid file extension: ${ext}.`);
}
outputFile = join(dirname(outputFile), basename(outputFile, extname(outputFile))) + '.' + ext;
if (outputFile) {
ext = ext || (extname(outputFile).substring(1) as BundleOutputFormat);
}
ext = ext || (extname(entrypoint).substring(1) as BundleOutputFormat);
if (!outputExtensions.includes(ext)) {
throw new Error(`Invalid file extension: ${ext}.`);
}
outputFile = join(dirname(outputFile), basename(outputFile, extname(outputFile))) + '.' + ext;
return { outputFile, ext };
}

View File

@@ -209,6 +209,7 @@ export type DeprecatedInRawConfig = {
export type Api = {
root: string;
output?: string;
styleguide?: ApiStyleguideRawConfig;
} & ThemeConfig;

View File

@@ -54,10 +54,18 @@ export function isEmptyObject(value: unknown): value is Record<string, unknown>
return isPlainObject(value) && Object.keys(value).length === 0;
}
export function isNotEmptyObject(obj: unknown): boolean {
return isPlainObject(obj) && !isEmptyObject(obj);
}
export function isEmptyArray(value: unknown) {
return Array.isArray(value) && value.length === 0;
}
export function isNotEmptyArray<T>(args?: T[]): boolean {
return !!args && Array.isArray(args) && !!args.length;
}
export async function readFileFromUrl(url: string, config: HttpResolveConfig) {
const headers: Record<string, string> = {};
for (const header of config.headers) {
@@ -179,10 +187,6 @@ export function slash(path: string): string {
return path.replace(/\\/g, '/');
}
export function isNotEmptyObject(obj: any) {
return !!obj && Object.keys(obj).length > 0;
}
// TODO: use it everywhere
export function isString(value: unknown): value is string {
return typeof value === 'string';