@redocly/openapi-core
Version:
See https://github.com/Redocly/redocly-cli
669 lines (589 loc) • 21.8 kB
text/typescript
import * as util from 'util';
import { colorize } from '../../logger';
import { Asserts, asserts } from '../../rules/common/assertions/asserts';
import { resolveStyleguideConfig, resolveApis, resolveConfig } from '../config-resolvers';
import recommended from '../recommended';
const path = require('path');
import type { StyleguideRawConfig, RawConfig, PluginStyleguideConfig } from '../types';
const configPath = path.join(__dirname, 'fixtures/resolve-config/redocly.yaml');
const baseStyleguideConfig: StyleguideRawConfig = {
rules: {
'operation-2xx-response': 'warn',
},
};
const minimalStyleguidePreset = resolveStyleguideConfig({
styleguideConfig: { ...baseStyleguideConfig, extends: ['minimal'] },
});
const recommendedStyleguidePreset = resolveStyleguideConfig({
styleguideConfig: { ...baseStyleguideConfig, extends: ['recommended'] },
});
const removeAbsolutePath = (item: string) =>
item.match(/^.*\/packages\/core\/src\/config\/__tests__\/fixtures\/(.*)$/)![1];
describe('resolveStyleguideConfig', () => {
it('should return the config with no recommended', async () => {
const styleguide = await resolveStyleguideConfig({ styleguideConfig: baseStyleguideConfig });
expect(styleguide.plugins?.length).toEqual(1);
expect(styleguide.plugins?.[0].id).toEqual('');
expect(styleguide.rules).toEqual({
'operation-2xx-response': 'warn',
});
});
it('should return the config with correct order by preset', async () => {
expect(
await resolveStyleguideConfig({
styleguideConfig: { ...baseStyleguideConfig, extends: ['minimal', 'recommended'] },
})
).toEqual(await recommendedStyleguidePreset);
expect(
await resolveStyleguideConfig({
styleguideConfig: { ...baseStyleguideConfig, extends: ['recommended', 'minimal'] },
})
).toEqual(await minimalStyleguidePreset);
});
it('should return the same styleguideConfig when extends is empty array', async () => {
const configWithEmptyExtends = await resolveStyleguideConfig({
styleguideConfig: { ...baseStyleguideConfig, extends: [] },
});
expect(configWithEmptyExtends.plugins?.length).toEqual(1);
expect(configWithEmptyExtends.plugins?.[0].id).toEqual('');
expect(configWithEmptyExtends.rules).toEqual({
'operation-2xx-response': 'warn',
});
});
it('should resolve extends with local file config', async () => {
const config = {
...baseStyleguideConfig,
extends: ['local-config.yaml'],
};
const { plugins, ...styleguide } = await resolveStyleguideConfig({
styleguideConfig: config,
configPath,
});
expect(styleguide?.rules?.['operation-2xx-response']).toEqual('warn');
expect(plugins).toBeDefined();
expect(plugins?.length).toBe(2);
expect(styleguide.extendPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/redocly.yaml',
'resolve-config/local-config.yaml',
'resolve-config/redocly.yaml',
]);
expect(styleguide.pluginPaths!.map(removeAbsolutePath)).toEqual(['resolve-config/plugin.js']);
expect(styleguide.rules).toEqual({
'boolean-parameter-prefixes': 'error',
'local/operation-id-not-test': 'error',
'no-invalid-media-type-examples': 'error',
'operation-2xx-response': 'warn',
'operation-description': 'error',
'path-http-verbs-order': 'error',
});
});
it('should instantiate the plugin once', async () => {
// Called by plugin during init
const deprecateSpy = jest.spyOn(util, 'deprecate');
const config = {
...baseStyleguideConfig,
extends: ['local-config-with-plugin-init.yaml'],
};
await resolveStyleguideConfig({
styleguideConfig: config,
configPath,
});
expect(deprecateSpy).toHaveBeenCalledTimes(1);
await resolveStyleguideConfig({
styleguideConfig: config,
configPath,
});
// Should not execute the init logic again
expect(deprecateSpy).toHaveBeenCalledTimes(1);
});
it('should resolve realm plugin properties', async () => {
const config = {
...baseStyleguideConfig,
extends: ['local-config-with-realm-plugin.yaml'],
};
const { plugins } = await resolveStyleguideConfig({
styleguideConfig: config,
configPath,
});
const localPlugin = plugins?.find((p) => p.id === 'realm-plugin');
expect(localPlugin).toBeDefined();
expect(localPlugin).toMatchObject({
id: 'realm-plugin',
processContent: expect.any(Function),
afterRoutesCreated: expect.any(Function),
loaders: {
'test-loader': expect.any(Function),
},
requiredEntitlements: ['test-entitlement'],
ssoConfigSchema: { type: 'object', additionalProperties: true },
redoclyConfigSchema: { type: 'object', additionalProperties: false },
ejectIgnore: ['Navbar.tsx', 'Footer.tsx'],
});
});
it('should resolve local file config with esm plugin', async () => {
const config = {
...baseStyleguideConfig,
extends: ['local-config-with-esm.yaml'],
};
const { plugins, ...styleguide } = await resolveStyleguideConfig({
styleguideConfig: config,
configPath,
});
expect(styleguide?.rules?.['operation-2xx-response']).toEqual('warn');
expect(plugins).toBeDefined();
expect(plugins?.length).toBe(2);
const localPlugin = plugins?.find((p) => p.id === 'test-plugin');
expect(localPlugin).toBeDefined();
expect(localPlugin).toMatchObject({
id: 'test-plugin',
rules: {
oas3: {
'test-plugin/oas3-rule-name': 'oas3-rule-stub',
},
},
});
expect(styleguide.extendPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/redocly.yaml',
'resolve-config/local-config-with-esm.yaml',
'resolve-config/redocly.yaml',
]);
expect(styleguide.pluginPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/plugin-esm.mjs',
]);
expect(styleguide.rules).toEqual({
'operation-2xx-response': 'warn',
});
});
it('should resolve local file config with commonjs plugin with a default export function', async () => {
const config = {
...baseStyleguideConfig,
extends: ['local-config-with-commonjs-export-function.yaml'],
};
const { plugins, ...styleguide } = await resolveStyleguideConfig({
styleguideConfig: config,
configPath,
});
expect(styleguide?.rules?.['operation-2xx-response']).toEqual('warn');
expect(plugins).toBeDefined();
expect(plugins?.length).toBe(2);
const localPlugin = plugins?.find((p) => p.id === 'test-plugin');
expect(localPlugin).toBeDefined();
expect(localPlugin).toMatchObject({
id: 'test-plugin',
rules: {
oas3: {
'test-plugin/oas3-rule-name': 'oas3-rule-stub',
},
},
});
expect(styleguide.extendPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/redocly.yaml',
'resolve-config/local-config-with-commonjs-export-function.yaml',
'resolve-config/redocly.yaml',
]);
expect(styleguide.pluginPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/plugin-with-export-function.cjs',
]);
expect(styleguide.rules).toEqual({
'operation-2xx-response': 'warn',
});
});
// TODO: fix circular test
it.skip('should throw circular error', () => {
const config = {
...baseStyleguideConfig,
extends: ['local-config-with-circular.yaml'],
};
expect(() => {
resolveStyleguideConfig({ styleguideConfig: config, configPath });
}).toThrow('Circular dependency in config file');
});
it('should resolve extends with local file config which contains path to nested config', async () => {
const styleguideConfig = {
extends: ['local-config-with-file.yaml'],
};
const { plugins, ...styleguide } = await resolveStyleguideConfig({
styleguideConfig,
configPath,
});
expect(styleguide?.rules?.['no-invalid-media-type-examples']).toEqual('warn');
expect(styleguide?.rules?.['operation-4xx-response']).toEqual('off');
expect(styleguide?.rules?.['operation-2xx-response']).toEqual('error');
expect(plugins).toBeDefined();
expect(plugins?.length).toBe(3);
expect(styleguide.extendPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/redocly.yaml',
'resolve-config/local-config-with-file.yaml',
'resolve-config/api/nested-config.yaml',
'resolve-config/redocly.yaml',
]);
expect(styleguide.pluginPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/api/plugin.js',
'resolve-config/plugin.js',
'resolve-config/api/plugin.js',
]);
delete styleguide.extendPaths;
delete styleguide.pluginPaths;
expect(styleguide).toMatchSnapshot();
});
it('should resolve custom assertion from plugin', async () => {
const styleguideConfig = {
extends: ['local-config-with-custom-function.yaml'],
};
const { plugins } = await resolveStyleguideConfig({
styleguideConfig,
configPath,
});
expect(plugins).toBeDefined();
expect(plugins?.length).toBe(2);
expect(asserts['test-plugin/checkWordsCount' as keyof Asserts]).toBeDefined();
});
it('should throw error when custom assertion load not exist plugin', async () => {
const styleguideConfig = {
extends: ['local-config-with-wrong-custom-function.yaml'],
};
try {
await resolveStyleguideConfig({
styleguideConfig,
configPath,
});
} catch (e) {
expect(e.message.toString()).toContain(
`Plugin ${colorize.red(
'test-plugin'
)} doesn't export assertions function with name ${colorize.red('checkWordsCount2')}.`
);
}
expect(asserts['test-plugin/checkWordsCount' as keyof Asserts]).toBeDefined();
});
it('should correctly merge assertions from nested config', async () => {
const styleguideConfig = {
extends: ['local-config-with-file.yaml'],
};
const styleguide = await resolveStyleguideConfig({
styleguideConfig,
configPath,
});
expect(Array.isArray(styleguide.rules?.assertions)).toEqual(true);
expect(styleguide.rules?.assertions).toMatchObject([
{
subject: 'PathItem',
property: 'get',
message: 'Every path item must have a GET operation.',
defined: true,
assertionId: 'rule/path-item-get-defined',
},
{
subject: 'Tag',
property: 'description',
message: 'Tag description must be at least 13 characters and end with a full stop.',
severity: 'error',
minLength: 13,
pattern: '/\\.$/',
assertionId: 'rule/tag-description',
},
]);
});
it('should resolve extends with url file config which contains path to nested config', async () => {
const styleguideConfig = {
// This points to ./fixtures/resolve-remote-configs/remote-config.yaml
extends: [
'https://raw.githubusercontent.com/Redocly/redocly-cli/main/packages/core/src/config/__tests__/fixtures/resolve-remote-configs/remote-config.yaml',
],
};
const { plugins, ...styleguide } = await resolveStyleguideConfig({
styleguideConfig,
configPath,
});
expect(styleguide?.rules?.['operation-4xx-response']).toEqual('error');
expect(styleguide?.rules?.['operation-2xx-response']).toEqual('error');
expect(Object.keys(styleguide.rules || {}).length).toBe(2);
expect(styleguide.extendPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/redocly.yaml',
'resolve-config/redocly.yaml',
]);
expect(styleguide.pluginPaths!.map(removeAbsolutePath)).toEqual([]);
});
it('should resolve `recommended-strict` ruleset correctly', async () => {
const expectedStrict = JSON.parse(
JSON.stringify(recommended)
) as PluginStyleguideConfig<'built-in'>;
for (const section of Object.values(expectedStrict)) {
for (let ruleName in section as any) {
// @ts-ignore
if (section[ruleName] === 'warn') {
// @ts-ignore
section[ruleName] = 'error';
}
// @ts-ignore
if (section[ruleName]?.severity === 'warn') {
// @ts-ignore
section[ruleName].severity = 'error';
}
}
}
const recommendedStrictPreset = JSON.parse(
JSON.stringify(
await resolveStyleguideConfig({
styleguideConfig: { extends: ['recommended-strict'] },
})
)
);
expect(recommendedStrictPreset).toMatchObject(expectedStrict);
});
});
describe('resolveApis', () => {
it('should resolve apis styleguideConfig and merge minimal extends', async () => {
const baseStyleguideConfig: StyleguideRawConfig = {
oas3_1Rules: {
'operation-2xx-response': 'error',
},
};
const mergedStyleguidePreset = resolveStyleguideConfig({
styleguideConfig: { ...baseStyleguideConfig, extends: ['minimal'] },
});
const rawConfig: RawConfig = {
apis: {
petstore: {
root: 'some/path',
styleguide: {
oas3_1Rules: {
'operation-2xx-response': 'error',
},
},
},
},
styleguide: {
extends: ['minimal'],
},
};
const apisResult = await resolveApis({ rawConfig });
expect(apisResult['petstore'].styleguide).toEqual(await mergedStyleguidePreset);
});
it('should not merge recommended extends by default by every level', async () => {
const rawConfig: RawConfig = {
apis: {
petstore: {
root: 'some/path',
styleguide: {},
},
},
styleguide: {},
};
const apisResult = await resolveApis({ rawConfig, configPath });
expect(apisResult['petstore'].styleguide.extendPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/redocly.yaml',
]);
expect(apisResult['petstore'].styleguide.pluginPaths!.map(removeAbsolutePath)).toEqual([]);
expect(apisResult['petstore'].styleguide.rules).toEqual({});
//@ts-ignore
expect(apisResult['petstore'].styleguide.plugins.length).toEqual(1);
//@ts-ignore
expect(apisResult['petstore'].styleguide.plugins[0].id).toEqual('');
});
it('should resolve apis styleguideConfig when it contains file and not set recommended', async () => {
const rawConfig: RawConfig = {
apis: {
petstore: {
root: 'some/path',
styleguide: {
rules: {
'operation-4xx-response': 'error',
},
},
},
},
styleguide: {
rules: {
'operation-2xx-response': 'warn',
},
},
};
const apisResult = await resolveApis({ rawConfig, configPath });
expect(apisResult['petstore'].styleguide.rules).toEqual({
'operation-2xx-response': 'warn',
'operation-4xx-response': 'error',
});
//@ts-ignore
expect(apisResult['petstore'].styleguide.plugins.length).toEqual(1);
//@ts-ignore
expect(apisResult['petstore'].styleguide.plugins[0].id).toEqual('');
expect(apisResult['petstore'].styleguide.extendPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/redocly.yaml',
]);
expect(apisResult['petstore'].styleguide.pluginPaths!.map(removeAbsolutePath)).toEqual([]);
});
it('should resolve apis styleguideConfig when it contains file', async () => {
const rawConfig: RawConfig = {
apis: {
petstore: {
root: 'some/path',
styleguide: {
extends: ['local-config.yaml'],
rules: {
'operation-4xx-response': 'error',
},
},
},
},
styleguide: {
extends: ['minimal'],
rules: {
'operation-2xx-response': 'warn',
},
},
};
const apisResult = await resolveApis({ rawConfig, configPath });
expect(apisResult['petstore'].styleguide.rules).toBeDefined();
expect(apisResult['petstore'].styleguide.rules?.['operation-2xx-response']).toEqual('warn'); // think about prioritize in merge ???
expect(apisResult['petstore'].styleguide.rules?.['operation-4xx-response']).toEqual('error');
expect(apisResult['petstore'].styleguide.rules?.['local/operation-id-not-test']).toEqual(
'error'
);
//@ts-ignore
expect(apisResult['petstore'].styleguide.plugins.length).toEqual(2);
expect(apisResult['petstore'].styleguide.extendPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/redocly.yaml',
'resolve-config/local-config.yaml',
'resolve-config/redocly.yaml',
]);
expect(apisResult['petstore'].styleguide.pluginPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/plugin.js',
]);
});
});
describe('resolveConfig', () => {
it('should NOT add recommended to top level by default IF there is a config file', async () => {
const rawConfig: RawConfig = {
apis: {
petstore: {
root: 'some/path',
styleguide: {
rules: {
'operation-4xx-response': 'error',
},
},
},
},
styleguide: {
rules: {
'operation-2xx-response': 'warn',
},
},
};
const { apis } = await resolveConfig({ rawConfig, configPath });
//@ts-ignore
expect(apis['petstore'].styleguide.plugins.length).toEqual(1);
//@ts-ignore
expect(apis['petstore'].styleguide.plugins[0].id).toEqual('');
expect(apis['petstore'].styleguide.extendPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/redocly.yaml',
]);
expect(apis['petstore'].styleguide.pluginPaths!.map(removeAbsolutePath)).toEqual([]);
expect(apis['petstore'].styleguide.rules).toEqual({
'operation-2xx-response': 'warn',
'operation-4xx-response': 'error',
});
});
it('should not add recommended to top level by default when apis have extends file', async () => {
const rawConfig: RawConfig = {
apis: {
petstore: {
root: 'some/path',
styleguide: {
extends: ['local-config.yaml'],
rules: {
'operation-4xx-response': 'error',
},
},
},
},
styleguide: {
rules: {
'operation-2xx-response': 'warn',
},
},
};
const { apis } = await resolveConfig({ rawConfig, configPath });
expect(apis['petstore'].styleguide.rules).toBeDefined();
expect(Object.keys(apis['petstore'].styleguide.rules || {}).length).toEqual(7);
expect(apis['petstore'].styleguide.rules?.['operation-2xx-response']).toEqual('warn');
expect(apis['petstore'].styleguide.rules?.['operation-4xx-response']).toEqual('error');
expect(apis['petstore'].styleguide.rules?.['operation-description']).toEqual('error'); // from extends file config
//@ts-ignore
expect(apis['petstore'].styleguide.plugins.length).toEqual(2);
expect(apis['petstore'].styleguide.extendPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/redocly.yaml',
'resolve-config/local-config.yaml',
'resolve-config/redocly.yaml',
]);
expect(apis['petstore'].styleguide.pluginPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/plugin.js',
]);
expect(apis['petstore'].styleguide.recommendedFallback).toBe(false);
});
it('should ignore minimal from the root and read local file', async () => {
const rawConfig: RawConfig = {
apis: {
petstore: {
root: 'some/path',
styleguide: {
extends: ['recommended', 'local-config.yaml'],
rules: {
'operation-4xx-response': 'error',
},
},
},
},
styleguide: {
extends: ['minimal'],
rules: {
'operation-2xx-response': 'warn',
},
},
};
const { apis } = await resolveConfig({ rawConfig, configPath });
expect(apis['petstore'].styleguide.rules).toBeDefined();
expect(apis['petstore'].styleguide.rules?.['operation-2xx-response']).toEqual('warn');
expect(apis['petstore'].styleguide.rules?.['operation-4xx-response']).toEqual('error');
expect(apis['petstore'].styleguide.rules?.['operation-description']).toEqual('error'); // from extends file config
//@ts-ignore
expect(apis['petstore'].styleguide.plugins.length).toEqual(2);
//@ts-ignore
delete apis['petstore'].styleguide.plugins;
expect(apis['petstore'].styleguide.extendPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/redocly.yaml',
'resolve-config/local-config.yaml',
'resolve-config/redocly.yaml',
]);
expect(apis['petstore'].styleguide.pluginPaths!.map(removeAbsolutePath)).toEqual([
'resolve-config/plugin.js',
]);
delete apis['petstore'].styleguide.extendPaths;
delete apis['petstore'].styleguide.pluginPaths;
expect(apis['petstore'].styleguide).toMatchSnapshot();
});
it('should default to the extends from the main config if no extends defined', async () => {
const rawConfig: RawConfig = {
apis: {
petstore: {
root: 'some/path',
styleguide: {
rules: {
'operation-4xx-response': 'error',
},
},
},
},
styleguide: {
extends: ['minimal'],
rules: {
'operation-2xx-response': 'warn',
},
},
};
const { apis } = await resolveConfig({ rawConfig, configPath });
expect(apis['petstore'].styleguide.rules).toBeDefined();
expect(apis['petstore'].styleguide.rules?.['operation-2xx-response']).toEqual('warn'); // from minimal ruleset
});
});