@redocly/openapi-core
Version:
See https://github.com/Redocly/openapi-cli
718 lines (631 loc) • 23.3 kB
text/typescript
import * as fs from 'fs';
import * as path from 'path';
import { dirname } from 'path';
import { red, blue, yellow, green } from 'colorette';
import { parseYaml, stringifyYaml } from '../js-yaml';
import { notUndefined, slash } from '../utils';
import {
OasVersion,
Oas3PreprocessorsSet,
OasMajorVersion,
Oas3DecoratorsSet,
Oas2RuleSet,
Oas2PreprocessorsSet,
Oas2DecoratorsSet,
Oas3RuleSet,
} from '../oas-types';
import { ProblemSeverity, NormalizedProblem } from '../walk';
import recommended from './recommended';
import { NodeType } from '../types';
export const IGNORE_FILE = '.redocly.lint-ignore.yaml';
const IGNORE_BANNER =
`# This file instructs Redocly's linter to ignore the rules contained for specific parts of your API.\n` +
`# See https://redoc.ly/docs/cli/ for more information.\n`;
export type RuleConfig =
| ProblemSeverity
| 'off'
| ({
severity?: ProblemSeverity;
} & Record<string, any>);
export type PreprocessorConfig =
| ProblemSeverity
| 'off'
| 'on'
| ({
severity?: ProblemSeverity;
} & Record<string, any>);
export type DecoratorConfig = PreprocessorConfig;
export type LintRawConfig = {
plugins?: (string | Plugin)[];
extends?: string[];
doNotResolveExamples?: boolean;
rules?: Record<string, RuleConfig>;
oas2Rules?: Record<string, RuleConfig>;
oas3_0Rules?: Record<string, RuleConfig>;
oas3_1Rules?: Record<string, RuleConfig>;
preprocessors?: Record<string, PreprocessorConfig>;
oas2Preprocessors?: Record<string, PreprocessorConfig>;
oas3_0Preprocessors?: Record<string, PreprocessorConfig>;
oas3_1Preprocessors?: Record<string, PreprocessorConfig>;
decorators?: Record<string, DecoratorConfig>;
oas2Decorators?: Record<string, DecoratorConfig>;
oas3_0Decorators?: Record<string, DecoratorConfig>;
oas3_1Decorators?: Record<string, DecoratorConfig>;
};
export type PreprocessorsConfig = {
oas3?: Oas3PreprocessorsSet;
oas2?: Oas2PreprocessorsSet;
};
export type DecoratorsConfig = {
oas3?: Oas3DecoratorsSet;
oas2?: Oas2DecoratorsSet;
};
export type TypesExtensionFn = (
types: Record<string, NodeType>,
oasVersion: OasVersion,
) => Record<string, NodeType>;
export type TypeExtensionsConfig = Partial<Record<OasMajorVersion, TypesExtensionFn>>;
export type CustomRulesConfig = {
oas3?: Oas3RuleSet;
oas2?: Oas2RuleSet;
};
export type Plugin = {
id: string;
configs?: Record<string, LintRawConfig>;
rules?: CustomRulesConfig;
preprocessors?: PreprocessorsConfig;
decorators?: DecoratorsConfig;
typeExtension?: TypeExtensionsConfig;
};
export type ResolveHeader =
| {
name: string;
envVariable?: undefined;
value: string;
matches: string;
}
| {
name: string;
value?: undefined;
envVariable: string;
matches: string;
};
export type RawResolveConfig = {
http?: Partial<HttpResolveConfig>;
};
export type HttpResolveConfig = {
headers: ResolveHeader[];
customFetch?: Function;
};
export type ResolveConfig = {
http: HttpResolveConfig;
};
export const DEFAULT_REGION = 'us';
export type Region = 'us' | 'eu';
export type AccessTokens = { [region in Region]?: string };
const REDOCLY_DOMAIN = process.env.REDOCLY_DOMAIN;
export const DOMAINS: { [region in Region]: string } = {
us: 'redocly.com',
eu: 'eu.redocly.com',
};
// FIXME: temporary fix for our lab environments
if (REDOCLY_DOMAIN?.endsWith('.redocly.host')) {
DOMAINS[REDOCLY_DOMAIN.split('.')[0] as Region] = REDOCLY_DOMAIN;
}
if (REDOCLY_DOMAIN === 'redoc.online') {
DOMAINS[REDOCLY_DOMAIN as Region] = REDOCLY_DOMAIN;
}
export const AVAILABLE_REGIONS = Object.keys(DOMAINS) as Region[];
export type DeprecatedRawConfig = {
apiDefinitions?: Record<string, string>;
lint?: LintRawConfig;
resolve?: RawResolveConfig;
region?: Region;
referenceDocs?: Record<string, any>;
};
export type Api = {
root: string;
lint?: Omit<LintRawConfig, 'plugins'>;
'features.openapi'?: Record<string, any>;
'features.mockServer'?: Record<string, any>;
};
export type RawConfig = {
apis?: Record<string, Api>;
lint?: LintRawConfig;
resolve?: RawResolveConfig;
region?: Region;
'features.openapi'?: Record<string, any>;
'features.mockServer'?: Record<string, any>;
organization?: string;
};
export class LintConfig {
plugins: Plugin[];
ignore: Record<string, Record<string, Set<string>>> = {};
doNotResolveExamples: boolean;
rules: Record<OasVersion, Record<string, RuleConfig>>;
preprocessors: Record<OasVersion, Record<string, PreprocessorConfig>>;
decorators: Record<OasVersion, Record<string, DecoratorConfig>>;
private _usedRules: Set<string> = new Set();
private _usedVersions: Set<OasVersion> = new Set();
recommendedFallback: boolean = false;
constructor(public rawConfig: LintRawConfig, public configFile?: string) {
this.plugins = rawConfig.plugins ? resolvePlugins(rawConfig.plugins, configFile) : [];
this.doNotResolveExamples = !!rawConfig.doNotResolveExamples;
if (!rawConfig.extends) {
this.recommendedFallback = true;
}
const extendConfigs: LintRawConfig[] = rawConfig.extends
? resolvePresets(rawConfig.extends, this.plugins)
: [recommended];
if (rawConfig.rules || rawConfig.preprocessors || rawConfig.decorators) {
extendConfigs.push({
rules: rawConfig.rules,
preprocessors: rawConfig.preprocessors,
decorators: rawConfig.decorators,
});
}
const merged = mergeExtends(extendConfigs);
this.rules = {
[OasVersion.Version2]: { ...merged.rules, ...merged.oas2Rules },
[OasVersion.Version3_0]: { ...merged.rules, ...merged.oas3_0Rules },
[OasVersion.Version3_1]: { ...merged.rules, ...merged.oas3_1Rules },
};
this.preprocessors = {
[OasVersion.Version2]: { ...merged.preprocessors, ...merged.oas2Preprocessors },
[OasVersion.Version3_0]: { ...merged.preprocessors, ...merged.oas3_0Preprocessors },
[OasVersion.Version3_1]: { ...merged.preprocessors, ...merged.oas3_1Preprocessors },
};
this.decorators = {
[OasVersion.Version2]: { ...merged.decorators, ...merged.oas2Decorators },
[OasVersion.Version3_0]: { ...merged.decorators, ...merged.oas3_0Decorators },
[OasVersion.Version3_1]: { ...merged.decorators, ...merged.oas3_1Decorators },
};
const dir = this.configFile
? path.dirname(this.configFile)
: (typeof process !== 'undefined' && process.cwd()) || '';
const ignoreFile = path.join(dir, IGNORE_FILE);
/* no crash when using it on the client */
if (fs.hasOwnProperty('existsSync') && fs.existsSync(ignoreFile)) {
// TODO: parse errors
this.ignore =
(parseYaml(fs.readFileSync(ignoreFile, 'utf-8')) as Record<
string,
Record<string, Set<string>>
>) || {};
// resolve ignore paths
for (const fileName of Object.keys(this.ignore)) {
this.ignore[path.resolve(dirname(ignoreFile), fileName)] = this.ignore[fileName];
for (const ruleId of Object.keys(this.ignore[fileName])) {
this.ignore[fileName][ruleId] = new Set(this.ignore[fileName][ruleId]);
}
delete this.ignore[fileName];
}
}
}
saveIgnore() {
const dir = this.configFile ? path.dirname(this.configFile) : process.cwd();
const ignoreFile = path.join(dir, IGNORE_FILE);
const mapped: Record<string, any> = {};
for (const absFileName of Object.keys(this.ignore)) {
const ignoredRules = (mapped[slash(path.relative(dir, absFileName))] =
this.ignore[absFileName]);
for (const ruleId of Object.keys(ignoredRules)) {
ignoredRules[ruleId] = Array.from(ignoredRules[ruleId]) as any;
}
}
fs.writeFileSync(ignoreFile, IGNORE_BANNER + stringifyYaml(mapped));
}
addIgnore(problem: NormalizedProblem) {
const ignore = this.ignore;
const loc = problem.location[0];
if (loc.pointer === undefined) return;
const fileIgnore = (ignore[loc.source.absoluteRef] = ignore[loc.source.absoluteRef] || {});
const ruleIgnore = (fileIgnore[problem.ruleId] = fileIgnore[problem.ruleId] || new Set());
ruleIgnore.add(loc.pointer);
}
addProblemToIgnore(problem: NormalizedProblem) {
const loc = problem.location[0];
if (loc.pointer === undefined) return problem;
const fileIgnore = this.ignore[loc.source.absoluteRef] || {};
const ruleIgnore = fileIgnore[problem.ruleId];
const ignored = ruleIgnore && ruleIgnore.has(loc.pointer);
return ignored
? {
...problem,
ignored,
}
: problem;
}
extendTypes(types: Record<string, NodeType>, version: OasVersion) {
let extendedTypes = types;
for (const plugin of this.plugins) {
if (plugin.typeExtension !== undefined) {
switch (version) {
case OasVersion.Version3_0:
case OasVersion.Version3_1:
if (!plugin.typeExtension.oas3) continue;
extendedTypes = plugin.typeExtension.oas3(extendedTypes, version);
case OasVersion.Version2:
if (!plugin.typeExtension.oas2) continue;
extendedTypes = plugin.typeExtension.oas2(extendedTypes, version);
default:
throw new Error('Not implemented');
}
}
}
return extendedTypes;
}
getRuleSettings(ruleId: string, oasVersion: OasVersion) {
this._usedRules.add(ruleId);
this._usedVersions.add(oasVersion);
const settings = this.rules[oasVersion][ruleId] || 'off';
if (typeof settings === 'string') {
return {
severity: settings,
};
} else {
return { severity: 'error' as 'error', ...settings };
}
}
getPreprocessorSettings(ruleId: string, oasVersion: OasVersion) {
this._usedRules.add(ruleId);
this._usedVersions.add(oasVersion);
const settings = this.preprocessors[oasVersion][ruleId] || 'off';
if (typeof settings === 'string') {
return {
severity: settings === 'on' ? ('error' as 'error') : settings,
};
} else {
return { severity: 'error' as 'error', ...settings };
}
}
getDecoratorSettings(ruleId: string, oasVersion: OasVersion) {
this._usedRules.add(ruleId);
this._usedVersions.add(oasVersion);
const settings = this.decorators[oasVersion][ruleId] || 'off';
if (typeof settings === 'string') {
return {
severity: settings === 'on' ? ('error' as 'error') : settings,
};
} else {
return { severity: 'error' as 'error', ...settings };
}
}
getUnusedRules() {
const rules = [];
const decorators = [];
const preprocessors = [];
for (const usedVersion of Array.from(this._usedVersions)) {
rules.push(
...Object.keys(this.rules[usedVersion]).filter((name) => !this._usedRules.has(name)),
);
decorators.push(
...Object.keys(this.decorators[usedVersion]).filter((name) => !this._usedRules.has(name)),
);
preprocessors.push(
...Object.keys(this.preprocessors[usedVersion]).filter(
(name) => !this._usedRules.has(name),
),
);
}
return {
rules,
preprocessors,
decorators,
};
}
getRulesForOasVersion(version: OasMajorVersion) {
switch (version) {
case OasMajorVersion.Version3:
const oas3Rules: Oas3RuleSet[] = []; // default ruleset
this.plugins.forEach((p) => p.preprocessors?.oas3 && oas3Rules.push(p.preprocessors.oas3));
this.plugins.forEach((p) => p.rules?.oas3 && oas3Rules.push(p.rules.oas3));
this.plugins.forEach((p) => p.decorators?.oas3 && oas3Rules.push(p.decorators.oas3));
return oas3Rules;
case OasMajorVersion.Version2:
const oas2Rules: Oas2RuleSet[] = []; // default ruleset
this.plugins.forEach((p) => p.preprocessors?.oas2 && oas2Rules.push(p.preprocessors.oas2));
this.plugins.forEach((p) => p.rules?.oas2 && oas2Rules.push(p.rules.oas2));
this.plugins.forEach((p) => p.decorators?.oas2 && oas2Rules.push(p.decorators.oas2));
return oas2Rules;
}
}
skipRules(rules?: string[]) {
for (const ruleId of rules || []) {
for (const version of Object.values(OasVersion)) {
if (this.rules[version][ruleId]) {
this.rules[version][ruleId] = 'off';
}
}
}
}
skipPreprocessors(preprocessors?: string[]) {
for (const preprocessorId of preprocessors || []) {
for (const version of Object.values(OasVersion)) {
if (this.preprocessors[version][preprocessorId]) {
this.preprocessors[version][preprocessorId] = 'off';
}
}
}
}
skipDecorators(decorators?: string[]) {
for (const decoratorId of decorators || []) {
for (const version of Object.values(OasVersion)) {
if (this.decorators[version][decoratorId]) {
this.decorators[version][decoratorId] = 'off';
}
}
}
}
}
export class Config {
apis: Record<string, Api>;
lint: LintConfig;
resolve: ResolveConfig;
licenseKey?: string;
region?: Region;
'features.openapi': Record<string, any>;
'features.mockServer'?: Record<string, any>;
organization?: string;
constructor(public rawConfig: RawConfig, public configFile?: string) {
this.apis = rawConfig.apis || {};
this.lint = new LintConfig(rawConfig.lint || {}, configFile);
this['features.openapi'] = rawConfig['features.openapi'] || {};
this['features.mockServer'] = rawConfig['features.mockServer'] || {};
this.resolve = {
http: {
headers: rawConfig?.resolve?.http?.headers ?? [],
customFetch: undefined,
},
};
this.region = rawConfig.region;
this.organization = rawConfig.organization;
}
}
function resolvePresets(presets: string[], plugins: Plugin[]) {
return presets.map((presetName) => {
const { pluginId, configName } = parsePresetName(presetName);
const plugin = plugins.find((p) => p.id === pluginId);
if (!plugin) {
throw new Error(`Invalid config ${red(presetName)}: plugin ${pluginId} is not included.`);
}
const preset = plugin.configs?.[configName]!;
if (!preset) {
throw new Error(
pluginId
? `Invalid config ${red(
presetName,
)}: plugin ${pluginId} doesn't export config with name ${configName}.`
: `Invalid config ${red(presetName)}: there is no such built-in config.`,
);
}
return preset;
});
}
function parsePresetName(presetName: string): { pluginId: string; configName: string } {
if (presetName.indexOf('/') > -1) {
const [pluginId, configName] = presetName.split('/');
return { pluginId, configName };
} else {
return { pluginId: '', configName: presetName };
}
}
function resolvePlugins(plugins: (string | Plugin)[] | null, configPath: string = ''): Plugin[] {
if (!plugins) return [];
// @ts-ignore
const requireFunc = typeof __webpack_require__ === 'function' ? __non_webpack_require__ : require;
const seenPluginIds = new Map<string, string>();
return plugins
.map((p) => {
// TODO: resolve npm packages similar to eslint
const pluginModule =
typeof p === 'string'
? (requireFunc(path.resolve(path.dirname(configPath), p)) as Plugin)
: p;
const id = pluginModule.id;
if (typeof id !== 'string') {
throw new Error(red(`Plugin must define \`id\` property in ${blue(p.toString())}.`));
}
if (seenPluginIds.has(id)) {
const pluginPath = seenPluginIds.get(id)!;
throw new Error(
red(
`Plugin "id" must be unique. Plugin ${blue(p.toString())} uses id "${blue(
id,
)}" already seen in ${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) {
throw new Error(`Plugin rules must have \`oas3\` or \`oas2\` 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.preprocessors) {
if (!pluginModule.preprocessors.oas3 && !pluginModule.preprocessors.oas2) {
throw new Error(
`Plugin \`preprocessors\` must have \`oas3\` or \`oas2\` 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.decorators) {
if (!pluginModule.decorators.oas3 && !pluginModule.decorators.oas2) {
throw new Error(`Plugin \`decorators\` must have \`oas3\` or \`oas2\` 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);
}
}
return plugin;
})
.filter(notUndefined);
}
function prefixRules<T extends Record<string, any>>(rules: T, prefix: string) {
if (!prefix) return rules;
const res: any = {};
for (const name of Object.keys(rules)) {
res[`${prefix}/${name}`] = rules[name];
}
return res;
}
type RulesFields =
| 'rules'
| 'oas2Rules'
| 'oas3_0Rules'
| 'oas3_1Rules'
| 'preprocessors'
| 'oas2Preprocessors'
| 'oas3_0Preprocessors'
| 'oas3_1Preprocessors'
| 'decorators'
| 'oas2Decorators'
| 'oas3_0Decorators'
| 'oas3_1Decorators';
function mergeExtends(rulesConfList: LintRawConfig[]) {
const result: Omit<LintRawConfig, RulesFields> & Required<Pick<LintRawConfig, RulesFields>> = {
rules: {},
oas2Rules: {},
oas3_0Rules: {},
oas3_1Rules: {},
preprocessors: {},
oas2Preprocessors: {},
oas3_0Preprocessors: {},
oas3_1Preprocessors: {},
decorators: {},
oas2Decorators: {},
oas3_0Decorators: {},
oas3_1Decorators: {},
};
for (let rulesConf of rulesConfList) {
if (rulesConf.extends) {
throw new Error(
`\`extends\` is not supported in shared configs yet: ${JSON.stringify(
rulesConf,
null,
2,
)}.`,
);
}
Object.assign(result.rules, rulesConf.rules);
Object.assign(result.oas2Rules, rulesConf.oas2Rules);
assignExisting(result.oas2Rules, rulesConf.rules || {});
Object.assign(result.oas3_0Rules, rulesConf.oas3_0Rules);
assignExisting(result.oas3_0Rules, rulesConf.rules || {});
Object.assign(result.oas3_1Rules, rulesConf.oas3_1Rules);
assignExisting(result.oas3_1Rules, rulesConf.rules || {});
Object.assign(result.preprocessors, rulesConf.preprocessors);
Object.assign(result.oas2Preprocessors, rulesConf.oas2Preprocessors);
assignExisting(result.oas2Preprocessors, rulesConf.preprocessors || {});
Object.assign(result.oas3_0Preprocessors, rulesConf.oas3_0Preprocessors);
assignExisting(result.oas3_0Preprocessors, rulesConf.preprocessors || {});
Object.assign(result.oas3_1Preprocessors, rulesConf.oas3_1Preprocessors);
assignExisting(result.oas3_1Preprocessors, rulesConf.preprocessors || {});
Object.assign(result.decorators, rulesConf.decorators);
Object.assign(result.oas2Decorators, rulesConf.oas2Decorators);
assignExisting(result.oas2Decorators, rulesConf.decorators || {});
Object.assign(result.oas3_0Decorators, rulesConf.oas3_0Decorators);
assignExisting(result.oas3_0Decorators, rulesConf.decorators || {});
Object.assign(result.oas3_1Decorators, rulesConf.oas3_1Decorators);
assignExisting(result.oas3_1Decorators, rulesConf.decorators || {});
}
return result;
}
function assignExisting<T>(target: Record<string, T>, obj: Record<string, T>) {
for (let k of Object.keys(obj)) {
if (target.hasOwnProperty(k)) {
target[k] = obj[k];
}
}
}
export function getMergedConfig(config: Config, entrypointAlias?: string): Config {
return entrypointAlias
? new Config({
...config.rawConfig,
lint: getMergedLintConfig(config, entrypointAlias),
'features.openapi': {
...config['features.openapi'],
...config.apis[entrypointAlias]?.['features.openapi'],
},
'features.mockServer': {
...config['features.mockServer'],
...config.apis[entrypointAlias]?.['features.mockServer'],
},
// TODO: merge everything else here
})
: config;
}
export function getMergedLintConfig(config: Config, entrypointAlias?: string) {
const apiLint = entrypointAlias ? config.apis[entrypointAlias]?.lint : {};
const mergedLint = {
...config.rawConfig.lint,
...apiLint,
rules: { ...config.rawConfig.lint?.rules, ...apiLint?.rules },
preprocessors: { ...config.rawConfig.lint?.preprocessors, ...apiLint?.preprocessors },
decorators: { ...config.rawConfig.lint?.decorators, ...apiLint?.decorators },
};
return mergedLint;
}
function transformApiDefinitionsToApis(
apiDefinitions: Record<string, string> = {},
): Record<string, Api> {
let apis: Record<string, Api> = {};
for (const [apiName, apiPath] of Object.entries(apiDefinitions)) {
apis[apiName] = { root: apiPath };
}
return apis;
}
export function transformConfig(rawConfig: DeprecatedRawConfig | RawConfig): RawConfig {
if ((rawConfig as RawConfig).apis && (rawConfig as DeprecatedRawConfig).apiDefinitions) {
throw new Error("Do not use 'apiDefinitions' field. Use 'apis' instead.\n");
}
if (
(rawConfig as RawConfig)['features.openapi'] &&
(rawConfig as DeprecatedRawConfig).referenceDocs
) {
throw new Error("Do not use 'referenceDocs' field. Use 'features.openapi' instead.\n");
}
const { apiDefinitions, referenceDocs, ...rest } = rawConfig as DeprecatedRawConfig & RawConfig;
if (apiDefinitions) {
process.stderr.write(
`The ${yellow('apiDefinitions')} field is deprecated. Use ${green(
'apis',
)} instead. Read more about this change: https://redocly.com/docs/api-registry/guides/migration-guide-config-file/#changed-properties\n`,
);
}
if (referenceDocs) {
process.stderr.write(
`The ${yellow('referenceDocs')} field is deprecated. Use ${green(
'features.openapi',
)} instead. Read more about this change: https://redocly.com/docs/api-registry/guides/migration-guide-config-file/#changed-properties\n`,
);
}
return {
'features.openapi': referenceDocs,
apis: transformApiDefinitionsToApis(apiDefinitions),
...rest,
};
}