UNPKG

@redocly/openapi-core

Version:

See https://github.com/Redocly/openapi-cli

718 lines (631 loc) 23.3 kB
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, }; }