@redocly/openapi-core
Version:
See https://github.com/Redocly/redocly-cli
494 lines (445 loc) • 15.2 kB
text/typescript
import * as path from 'path';
import { isAbsoluteUrl } from '../ref-utils';
import { pickDefined } from '../utils';
import { BaseResolver } from '../resolve';
import { defaultPlugin } from './builtIn';
import {
getResolveConfig,
getUniquePlugins,
mergeExtends,
parsePresetName,
prefixRules,
transformConfig,
} from './utils';
import type {
StyleguideRawConfig,
ApiStyleguideRawConfig,
Plugin,
RawConfig,
ResolvedApi,
ResolvedStyleguideConfig,
RuleConfig,
DeprecatedInRawConfig,
} from './types';
import { isBrowser } from '../env';
import { isNotString, isString, isDefined, parseYaml, keysOf } from '../utils';
import { Config } from './config';
import { colorize, logger } from '../logger';
import {
Asserts,
AssertionFn,
asserts,
buildAssertCustomFunction,
} from '../rules/common/assertions/asserts';
import type { Assertion, AssertionDefinition, RawAssertion } from '../rules/common/assertions';
export async function resolveConfig(rawConfig: RawConfig, configPath?: string): Promise<Config> {
if (rawConfig.styleguide?.extends?.some(isNotString)) {
throw new Error(
`Error configuration format not detected in extends value must contain strings`
);
}
const resolver = new BaseResolver(getResolveConfig(rawConfig.resolve));
const apis = await resolveApis({
rawConfig,
configPath,
resolver,
});
const styleguide = await resolveStyleguideConfig({
styleguideConfig: rawConfig.styleguide,
configPath,
resolver,
});
return new Config(
{
...rawConfig,
apis,
styleguide,
},
configPath
);
}
export function resolvePlugins(
plugins: (string | Plugin)[] | null,
configPath: string = ''
): Plugin[] {
if (!plugins) return [];
// TODO: implement or reuse Resolver approach so it will work in node and browser envs
const requireFunc = (plugin: string | Plugin): Plugin | undefined => {
if (isBrowser && isString(plugin)) {
logger.error(`Cannot load ${plugin}. Plugins aren't supported in browser yet.`);
return undefined;
}
if (isString(plugin)) {
try {
const absoltePluginPath = path.resolve(path.dirname(configPath), plugin);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return typeof __webpack_require__ === 'function'
? // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
__non_webpack_require__(absoltePluginPath)
: require(absoltePluginPath);
} catch (e) {
if (e instanceof SyntaxError) {
throw e;
}
throw new Error(`Failed to load plugin "${plugin}". Please provide a valid path`);
}
}
return plugin;
};
const seenPluginIds = new Map<string, string>();
return plugins
.map((p) => {
if (isString(p) && isAbsoluteUrl(p)) {
throw new Error(colorize.red(`We don't support remote plugins yet.`));
}
// TODO: resolve npm packages similar to eslint
const pluginModule = requireFunc(p);
if (!pluginModule) {
return;
}
const id = pluginModule.id;
if (typeof id !== 'string') {
throw new Error(
colorize.red(`Plugin must define \`id\` property in ${colorize.blue(p.toString())}.`)
);
}
if (seenPluginIds.has(id)) {
const pluginPath = seenPluginIds.get(id)!;
throw new Error(
colorize.red(
`Plugin "id" must be unique. Plugin ${colorize.blue(
p.toString()
)} uses id "${colorize.blue(id)}" already seen in ${colorize.blue(pluginPath)}`
)
);
}
seenPluginIds.set(id, p.toString());
const plugin: Plugin = {
id,
...(pluginModule.configs ? { configs: pluginModule.configs } : {}),
...(pluginModule.typeExtension ? { typeExtension: pluginModule.typeExtension } : {}),
};
if (pluginModule.rules) {
if (!pluginModule.rules.oas3 && !pluginModule.rules.oas2 && !pluginModule.rules.async2) {
throw new Error(`Plugin rules must have \`oas3\`, \`oas2\` or \`async2\` rules "${p}.`);
}
plugin.rules = {};
if (pluginModule.rules.oas3) {
plugin.rules.oas3 = prefixRules(pluginModule.rules.oas3, id);
}
if (pluginModule.rules.oas2) {
plugin.rules.oas2 = prefixRules(pluginModule.rules.oas2, id);
}
if (pluginModule.rules.async2) {
plugin.rules.async2 = prefixRules(pluginModule.rules.async2, id);
}
}
if (pluginModule.preprocessors) {
if (
!pluginModule.preprocessors.oas3 &&
!pluginModule.preprocessors.oas2 &&
!pluginModule.preprocessors.async2
) {
throw new Error(
`Plugin \`preprocessors\` must have \`oas3\`, \`oas2\` or \`async2\` preprocessors "${p}.`
);
}
plugin.preprocessors = {};
if (pluginModule.preprocessors.oas3) {
plugin.preprocessors.oas3 = prefixRules(pluginModule.preprocessors.oas3, id);
}
if (pluginModule.preprocessors.oas2) {
plugin.preprocessors.oas2 = prefixRules(pluginModule.preprocessors.oas2, id);
}
if (pluginModule.preprocessors.async2) {
plugin.preprocessors.async2 = prefixRules(pluginModule.preprocessors.async2, id);
}
}
if (pluginModule.decorators) {
if (
!pluginModule.decorators.oas3 &&
!pluginModule.decorators.oas2 &&
!pluginModule.decorators.async2
) {
throw new Error(
`Plugin \`decorators\` must have \`oas3\`, \`oas2\` or \`async2\` decorators "${p}.`
);
}
plugin.decorators = {};
if (pluginModule.decorators.oas3) {
plugin.decorators.oas3 = prefixRules(pluginModule.decorators.oas3, id);
}
if (pluginModule.decorators.oas2) {
plugin.decorators.oas2 = prefixRules(pluginModule.decorators.oas2, id);
}
if (pluginModule.decorators.async2) {
plugin.decorators.async2 = prefixRules(pluginModule.decorators.async2, id);
}
}
if (pluginModule.assertions) {
plugin.assertions = pluginModule.assertions;
}
return plugin;
})
.filter(isDefined);
}
export async function resolveApis({
rawConfig,
configPath = '',
resolver,
}: {
rawConfig: RawConfig;
configPath?: string;
resolver?: BaseResolver;
}): Promise<Record<string, ResolvedApi>> {
const { apis = {}, styleguide: styleguideConfig = {} } = rawConfig;
const resolvedApis: Record<string, ResolvedApi> = {};
for (const [apiName, apiContent] of Object.entries(apis || {})) {
if (apiContent.styleguide?.extends?.some(isNotString)) {
throw new Error(
`Error configuration format not detected in extends value must contain strings`
);
}
const rawStyleguideConfig = getMergedRawStyleguideConfig(
styleguideConfig,
apiContent.styleguide
);
const resolvedApiConfig = await resolveStyleguideConfig({
styleguideConfig: rawStyleguideConfig,
configPath,
resolver,
});
resolvedApis[apiName] = { ...apiContent, styleguide: resolvedApiConfig };
}
return resolvedApis;
}
async function resolveAndMergeNestedStyleguideConfig(
{
styleguideConfig,
configPath = '',
resolver = new BaseResolver(),
}: {
styleguideConfig?: StyleguideRawConfig;
configPath?: string;
resolver?: BaseResolver;
},
parentConfigPaths: string[] = [],
extendPaths: string[] = []
): Promise<ResolvedStyleguideConfig> {
if (parentConfigPaths.includes(configPath)) {
throw new Error(`Circular dependency in config file: "${configPath}"`);
}
const plugins = getUniquePlugins(
resolvePlugins([...(styleguideConfig?.plugins || []), defaultPlugin], configPath)
);
const pluginPaths = styleguideConfig?.plugins
?.filter(isString)
.map((p) => path.resolve(path.dirname(configPath), p));
const resolvedConfigPath = isAbsoluteUrl(configPath)
? configPath
: configPath && path.resolve(configPath);
const extendConfigs: ResolvedStyleguideConfig[] = await Promise.all(
styleguideConfig?.extends?.map(async (presetItem) => {
if (!isAbsoluteUrl(presetItem) && !path.extname(presetItem)) {
return resolvePreset(presetItem, plugins);
}
const pathItem = isAbsoluteUrl(presetItem)
? presetItem
: isAbsoluteUrl(configPath)
? new URL(presetItem, configPath).href
: path.resolve(path.dirname(configPath), presetItem);
const extendedStyleguideConfig = await loadExtendStyleguideConfig(pathItem, resolver);
return await resolveAndMergeNestedStyleguideConfig(
{
styleguideConfig: extendedStyleguideConfig,
configPath: pathItem,
resolver: resolver,
},
[...parentConfigPaths, resolvedConfigPath],
extendPaths
);
}) || []
);
const { plugins: mergedPlugins = [], ...styleguide } = mergeExtends([
...extendConfigs,
{
...styleguideConfig,
plugins,
extends: undefined,
extendPaths: [...parentConfigPaths, resolvedConfigPath],
pluginPaths,
},
]);
return {
...styleguide,
extendPaths: styleguide.extendPaths?.filter((path) => path && !isAbsoluteUrl(path)),
plugins: getUniquePlugins(mergedPlugins),
recommendedFallback: styleguideConfig?.recommendedFallback,
doNotResolveExamples: styleguideConfig?.doNotResolveExamples,
};
}
export async function resolveStyleguideConfig(
opts: {
styleguideConfig?: StyleguideRawConfig;
configPath?: string;
resolver?: BaseResolver;
},
parentConfigPaths: string[] = [],
extendPaths: string[] = []
): Promise<ResolvedStyleguideConfig> {
const resolvedStyleguideConfig = await resolveAndMergeNestedStyleguideConfig(
opts,
parentConfigPaths,
extendPaths
);
return {
...resolvedStyleguideConfig,
rules:
resolvedStyleguideConfig.rules && groupStyleguideAssertionRules(resolvedStyleguideConfig),
};
}
export function resolvePreset(presetName: string, plugins: Plugin[]): ResolvedStyleguideConfig {
const { pluginId, configName } = parsePresetName(presetName);
const plugin = plugins.find((p) => p.id === pluginId);
if (!plugin) {
throw new Error(
`Invalid config ${colorize.red(presetName)}: plugin ${pluginId} is not included.`
);
}
const preset = plugin.configs?.[configName];
if (!preset) {
throw new Error(
pluginId
? `Invalid config ${colorize.red(
presetName
)}: plugin ${pluginId} doesn't export config with name ${configName}.`
: `Invalid config ${colorize.red(presetName)}: there is no such built-in config.`
);
}
return preset;
}
async function loadExtendStyleguideConfig(
filePath: string,
resolver: BaseResolver
): Promise<StyleguideRawConfig> {
try {
const fileSource = await resolver.loadExternalRef(filePath);
const rawConfig = transformConfig(
parseYaml(fileSource.body) as RawConfig & DeprecatedInRawConfig
);
if (!rawConfig.styleguide) {
throw new Error(`Styleguide configuration format not detected: "${filePath}"`);
}
return rawConfig.styleguide;
} catch (error) {
throw new Error(`Failed to load "${filePath}": ${error.message}`);
}
}
function getMergedRawStyleguideConfig(
rootStyleguideConfig: StyleguideRawConfig,
apiStyleguideConfig?: ApiStyleguideRawConfig
) {
const resultLint = {
...rootStyleguideConfig,
...pickDefined(apiStyleguideConfig),
rules: { ...rootStyleguideConfig?.rules, ...apiStyleguideConfig?.rules },
oas2Rules: { ...rootStyleguideConfig?.oas2Rules, ...apiStyleguideConfig?.oas2Rules },
oas3_0Rules: { ...rootStyleguideConfig?.oas3_0Rules, ...apiStyleguideConfig?.oas3_0Rules },
oas3_1Rules: { ...rootStyleguideConfig?.oas3_1Rules, ...apiStyleguideConfig?.oas3_1Rules },
preprocessors: {
...rootStyleguideConfig?.preprocessors,
...apiStyleguideConfig?.preprocessors,
},
oas2Preprocessors: {
...rootStyleguideConfig?.oas2Preprocessors,
...apiStyleguideConfig?.oas2Preprocessors,
},
oas3_0Preprocessors: {
...rootStyleguideConfig?.oas3_0Preprocessors,
...apiStyleguideConfig?.oas3_0Preprocessors,
},
oas3_1Preprocessors: {
...rootStyleguideConfig?.oas3_1Preprocessors,
...apiStyleguideConfig?.oas3_1Preprocessors,
},
decorators: { ...rootStyleguideConfig?.decorators, ...apiStyleguideConfig?.decorators },
oas2Decorators: {
...rootStyleguideConfig?.oas2Decorators,
...apiStyleguideConfig?.oas2Decorators,
},
oas3_0Decorators: {
...rootStyleguideConfig?.oas3_0Decorators,
...apiStyleguideConfig?.oas3_0Decorators,
},
oas3_1Decorators: {
...rootStyleguideConfig?.oas3_1Decorators,
...apiStyleguideConfig?.oas3_1Decorators,
},
recommendedFallback: apiStyleguideConfig?.extends
? false
: rootStyleguideConfig.recommendedFallback,
};
return resultLint;
}
function groupStyleguideAssertionRules({
rules,
plugins,
}: ResolvedStyleguideConfig): Record<string, RuleConfig> | undefined {
if (!rules) {
return rules;
}
// Create a new record to avoid mutating original
const transformedRules: Record<string, RuleConfig> = {};
// Collect assertion rules
const assertions: Assertion[] = [];
for (const [ruleKey, rule] of Object.entries(rules)) {
// keep the old assert/ syntax as an alias
if (
(ruleKey.startsWith('rule/') || ruleKey.startsWith('assert/')) &&
typeof rule === 'object' &&
rule !== null
) {
const assertion = rule as RawAssertion;
if (plugins) {
registerCustomAssertions(plugins, assertion);
// We may have custom assertion inside where block
for (const context of assertion.where || []) {
registerCustomAssertions(plugins, context);
}
}
assertions.push({
...assertion,
assertionId: ruleKey,
});
} else {
// If it's not an assertion, keep it as is
transformedRules[ruleKey] = rule;
}
}
if (assertions.length > 0) {
transformedRules.assertions = assertions;
}
return transformedRules;
}
function registerCustomAssertions(plugins: Plugin[], assertion: AssertionDefinition) {
for (const field of keysOf(assertion.assertions)) {
const [pluginId, fn] = field.split('/');
if (!pluginId || !fn) continue;
const plugin = plugins.find((plugin) => plugin.id === pluginId);
if (!plugin) {
throw Error(colorize.red(`Plugin ${colorize.blue(pluginId)} isn't found.`));
}
if (!plugin.assertions || !plugin.assertions[fn]) {
throw Error(
`Plugin ${colorize.red(
pluginId
)} doesn't export assertions function with name ${colorize.red(fn)}.`
);
}
(asserts as Asserts & { [name: string]: AssertionFn })[field] = buildAssertCustomFunction(
plugin.assertions[fn]
);
}
}