UNPKG

@redocly/cli

Version:

[@Redocly](https://redocly.com) CLI is your all-in-one OpenAPI utility. It builds, manages, improves, and quality-checks your OpenAPI descriptions, all of which comes in handy for various phases of the API Lifecycle. Create your own rulesets to make API g

564 lines (563 loc) 22.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.HandledError = exports.CircularJSONNotSupportedError = void 0; exports.getFallbackApisOrExit = getFallbackApisOrExit; exports.getExecutionTime = getExecutionTime; exports.printExecutionTime = printExecutionTime; exports.pathToFilename = pathToFilename; exports.escapeLanguageName = escapeLanguageName; exports.langToExt = langToExt; exports.dumpBundle = dumpBundle; exports.saveBundle = saveBundle; exports.promptUser = promptUser; exports.readYaml = readYaml; exports.writeToFileByExtension = writeToFileByExtension; exports.writeYaml = writeYaml; exports.writeJson = writeJson; exports.getAndValidateFileExtension = getAndValidateFileExtension; exports.handleError = handleError; exports.printLintTotals = printLintTotals; exports.printConfigLintTotals = printConfigLintTotals; exports.getOutputFileName = getOutputFileName; exports.printUnusedWarnings = printUnusedWarnings; exports.exitWithError = exitWithError; exports.isSubdir = isSubdir; exports.loadConfigAndHandleErrors = loadConfigAndHandleErrors; exports.sortTopLevelKeysForOas = sortTopLevelKeysForOas; exports.checkIfRulesetExist = checkIfRulesetExist; exports.cleanColors = cleanColors; exports.sendTelemetry = sendTelemetry; exports.cleanArgs = cleanArgs; exports.checkForDeprecatedOptions = checkForDeprecatedOptions; exports.notifyAboutIncompatibleConfigOptions = notifyAboutIncompatibleConfigOptions; exports.formatPath = formatPath; const path_1 = require("path"); const colorette_1 = require("colorette"); const perf_hooks_1 = require("perf_hooks"); const glob = require("glob"); const fs = require("fs"); const os = require("os"); const readline = require("readline"); const stream_1 = require("stream"); const child_process_1 = require("child_process"); const util_1 = require("util"); const openapi_core_1 = require("@redocly/openapi-core"); const utils_1 = require("@redocly/openapi-core/lib/utils"); const config_1 = require("@redocly/openapi-core/lib/config"); const reference_docs_config_schema_1 = require("@redocly/config/lib/reference-docs-config-schema"); const types_1 = require("../types"); const update_version_notifier_1 = require("./update-version-notifier"); const push_1 = require("../commands/push"); const api_1 = require("../reunite/api"); async function getFallbackApisOrExit(argsApis, config) { const { apis } = config; const shouldFallbackToAllDefinitions = !(0, utils_1.isNotEmptyArray)(argsApis) && (0, utils_1.isNotEmptyObject)(apis); const res = shouldFallbackToAllDefinitions ? fallbackToAllDefinitions(apis, config) : await expandGlobsInEntrypoints(argsApis, config); const filteredInvalidEntrypoints = res.filter(({ path }) => !isApiPathValid(path)); if ((0, utils_1.isNotEmptyArray)(filteredInvalidEntrypoints)) { for (const { path } of filteredInvalidEntrypoints) { process.stderr.write((0, colorette_1.yellow)(`\n${formatPath(path)} ${(0, colorette_1.red)(`does not exist or is invalid.\n\n`)}`)); } exitWithError('Please provide a valid path.'); } return res; } function getConfigDirectory(config) { return config.configFile ? (0, path_1.dirname)(config.configFile) : process.cwd(); } function isApiPathValid(apiPath) { if (!apiPath.trim()) { exitWithError('Path cannot be empty.'); return; } return fs.existsSync(apiPath) || (0, openapi_core_1.isAbsoluteUrl)(apiPath) ? apiPath : undefined; } function fallbackToAllDefinitions(apis, config) { return Object.entries(apis).map(([alias, { root, output }]) => ({ path: (0, openapi_core_1.isAbsoluteUrl)(root) ? root : (0, path_1.resolve)(getConfigDirectory(config), root), alias, output: output && (0, path_1.resolve)(getConfigDirectory(config), output), })); } function getAliasOrPath(config, aliasOrPath) { const aliasApi = config.apis[aliasOrPath]; return aliasApi ? { path: (0, openapi_core_1.isAbsoluteUrl)(aliasApi.root) ? aliasApi.root : (0, path_1.resolve)(getConfigDirectory(config), aliasApi.root), alias: aliasOrPath, output: aliasApi.output && (0, path_1.resolve)(getConfigDirectory(config), aliasApi.output), } : { path: aliasOrPath, // find alias by path, take the first match alias: Object.entries(config.apis).find(([_alias, api]) => { return (0, path_1.resolve)(api.root) === (0, path_1.resolve)(aliasOrPath); })?.[0] ?? undefined, }; } async function expandGlobsInEntrypoints(argApis, config) { return (await Promise.all(argApis.map(async (aliasOrPath) => { return glob.hasMagic(aliasOrPath) && !(0, openapi_core_1.isAbsoluteUrl)(aliasOrPath) ? (await (0, util_1.promisify)(glob)(aliasOrPath)).map((g) => getAliasOrPath(config, g)) : getAliasOrPath(config, aliasOrPath); }))).flat(); } function getExecutionTime(startedAt) { return process.env.NODE_ENV === 'test' ? '<test>ms' : `${Math.ceil(perf_hooks_1.performance.now() - startedAt)}ms`; } function printExecutionTime(commandName, startedAt, api) { const elapsed = getExecutionTime(startedAt); process.stderr.write((0, colorette_1.gray)(`\n${api}: ${commandName} processed in ${elapsed}\n\n`)); } function pathToFilename(path, pathSeparator) { return path .replace(/~1/g, '/') .replace(/~0/g, '~') .replace(/^\//, '') .replace(/\//g, pathSeparator); } function escapeLanguageName(lang) { return lang.replace(/#/g, '_sharp').replace(/\//, '_').replace(/\s/g, ''); } function langToExt(lang) { const langObj = { php: '.php', 'c#': '.cs', shell: '.sh', curl: '.sh', bash: '.sh', javascript: '.js', js: '.js', python: '.py', c: '.c', 'c++': '.cpp', coffeescript: '.litcoffee', dart: '.dart', elixir: '.ex', go: '.go', groovy: '.groovy', java: '.java', kotlin: '.kt', 'objective-c': '.m', perl: '.pl', powershell: '.ps1', ruby: '.rb', rust: '.rs', scala: '.sc', swift: '.swift', typescript: '.ts', tsx: '.tsx', }; return langObj[lang.toLowerCase()]; } class CircularJSONNotSupportedError extends Error { constructor(originalError) { super(originalError.message); this.originalError = originalError; // Set the prototype explicitly. Object.setPrototypeOf(this, CircularJSONNotSupportedError.prototype); } } exports.CircularJSONNotSupportedError = CircularJSONNotSupportedError; function dumpBundle(obj, format, dereference) { if (format === 'json') { try { return JSON.stringify(obj, null, 2); } catch (e) { if (e.message.indexOf('circular') > -1) { throw new CircularJSONNotSupportedError(e); } throw e; } } else { return (0, openapi_core_1.stringifyYaml)(obj, { noRefs: !dereference, lineWidth: -1, }); } } function saveBundle(filename, output) { fs.mkdirSync((0, path_1.dirname)(filename), { recursive: true }); fs.writeFileSync(filename, output); } async function promptUser(query, hideUserInput = false) { return new Promise((resolve) => { let output = process.stdout; let isOutputMuted = false; if (hideUserInput) { output = new stream_1.Writable({ write: (chunk, encoding, callback) => { if (!isOutputMuted) { process.stdout.write(chunk, encoding); } callback(); }, }); } const rl = readline.createInterface({ input: process.stdin, output, terminal: true, historySize: hideUserInput ? 0 : 30, }); rl.question(`${query}:\n\n `, (answer) => { rl.close(); resolve(answer); }); isOutputMuted = hideUserInput; }); } function readYaml(filename) { return (0, openapi_core_1.parseYaml)(fs.readFileSync(filename, 'utf-8'), { filename }); } function writeToFileByExtension(data, filePath, noRefs) { const ext = getAndValidateFileExtension(filePath); if (ext === 'json') { writeJson(data, filePath); return; } writeYaml(data, filePath, noRefs); } function writeYaml(data, filename, noRefs = false) { const content = (0, openapi_core_1.stringifyYaml)(data, { noRefs }); if (process.env.NODE_ENV === 'test') { process.stderr.write(content); return; } fs.mkdirSync((0, path_1.dirname)(filename), { recursive: true }); fs.writeFileSync(filename, content); } function writeJson(data, filename) { const content = JSON.stringify(data, null, 2); if (process.env.NODE_ENV === 'test') { process.stderr.write(content); return; } fs.mkdirSync((0, path_1.dirname)(filename), { recursive: true }); fs.writeFileSync(filename, content); } function getAndValidateFileExtension(fileName) { const ext = fileName.split('.').pop(); if (['yaml', 'yml', 'json'].includes(ext)) { return ext; } process.stderr.write((0, colorette_1.yellow)(`Unsupported file extension: ${ext}. Using yaml.\n`)); return 'yaml'; } function handleError(e, ref) { switch (e.constructor) { case HandledError: { throw e; } case openapi_core_1.ResolveError: return exitWithError(`Failed to resolve API description at ${ref}:\n\n - ${e.message}`); case openapi_core_1.YamlParseError: return exitWithError(`Failed to parse API description at ${ref}:\n\n - ${e.message}`); case CircularJSONNotSupportedError: { return exitWithError(`Detected circular reference which can't be converted to JSON.\n` + `Try to use ${(0, colorette_1.blue)('yaml')} output or remove ${(0, colorette_1.blue)('--dereferenced')}.`); } case SyntaxError: return exitWithError(`Syntax error: ${e.message} ${e.stack?.split('\n\n')?.[0]}`); case config_1.ConfigValidationError: return exitWithError(e.message); default: { exitWithError(`Something went wrong when processing ${ref}:\n\n - ${e.message}`); } } } class HandledError extends Error { } exports.HandledError = HandledError; function printLintTotals(totals, definitionsCount) { const ignored = totals.ignored ? (0, colorette_1.yellow)(`${totals.ignored} ${(0, utils_1.pluralize)('problem is', totals.ignored)} explicitly ignored.\n\n`) : ''; if (totals.errors > 0) { process.stderr.write((0, colorette_1.red)(`❌ Validation failed with ${totals.errors} ${(0, utils_1.pluralize)('error', totals.errors)}${totals.warnings > 0 ? ` and ${totals.warnings} ${(0, utils_1.pluralize)('warning', totals.warnings)}` : ''}.\n${ignored}`)); } else if (totals.warnings > 0) { process.stderr.write((0, colorette_1.green)(`Woohoo! Your API ${(0, utils_1.pluralize)('description is', definitionsCount)} valid. 🎉\n`)); process.stderr.write((0, colorette_1.yellow)(`You have ${totals.warnings} ${(0, utils_1.pluralize)('warning', totals.warnings)}.\n${ignored}`)); } else { process.stderr.write((0, colorette_1.green)(`Woohoo! Your API ${(0, utils_1.pluralize)('description is', definitionsCount)} valid. 🎉\n${ignored}`)); } if (totals.errors > 0) { process.stderr.write((0, colorette_1.gray)(`run \`redocly lint --generate-ignore-file\` to add all problems to the ignore file.\n`)); } process.stderr.write('\n'); } function printConfigLintTotals(totals, command) { if (totals.errors > 0) { process.stderr.write((0, colorette_1.red)(`❌ Your config has ${totals.errors} ${(0, utils_1.pluralize)('error', totals.errors)}.`)); } else if (totals.warnings > 0) { process.stderr.write((0, colorette_1.yellow)(`⚠️ Your config has ${totals.warnings} ${(0, utils_1.pluralize)('warning', totals.warnings)}.\n`)); } else if (command === 'check-config') { process.stderr.write((0, colorette_1.green)('✅ Your config is valid.\n')); } } function getOutputFileName({ entrypoint, output, argvOutput, ext, entries, }) { let outputFile = output || argvOutput; if (!outputFile) { return { ext: ext || 'yaml' }; } if (entries > 1 && argvOutput) { ext = ext || (0, path_1.extname)(entrypoint).substring(1); if (!types_1.outputExtensions.includes(ext)) { throw new Error(`Invalid file extension: ${ext}.`); } outputFile = (0, path_1.join)(argvOutput, (0, path_1.basename)(entrypoint, (0, path_1.extname)(entrypoint))) + '.' + ext; } else { ext = ext || (0, path_1.extname)(outputFile).substring(1) || (0, path_1.extname)(entrypoint).substring(1); if (!types_1.outputExtensions.includes(ext)) { throw new Error(`Invalid file extension: ${ext}.`); } outputFile = (0, path_1.join)((0, path_1.dirname)(outputFile), (0, path_1.basename)(outputFile, (0, path_1.extname)(outputFile))) + '.' + ext; } return { outputFile, ext }; } function printUnusedWarnings(config) { const { preprocessors, rules, decorators } = config.getUnusedRules(); if (rules.length) { process.stderr.write((0, colorette_1.yellow)(`[WARNING] Unused rules found in ${(0, colorette_1.blue)(config.configFile || '')}: ${rules.join(', ')}.\n`)); } if (preprocessors.length) { process.stderr.write((0, colorette_1.yellow)(`[WARNING] Unused preprocessors found in ${(0, colorette_1.blue)(config.configFile || '')}: ${preprocessors.join(', ')}.\n`)); } if (decorators.length) { process.stderr.write((0, colorette_1.yellow)(`[WARNING] Unused decorators found in ${(0, colorette_1.blue)(config.configFile || '')}: ${decorators.join(', ')}.\n`)); } if (rules.length || preprocessors.length) { process.stderr.write(`Check the spelling and verify the added plugin prefix.\n`); } } function exitWithError(message) { process.stderr.write((0, colorette_1.red)(message) + '\n\n'); throw new HandledError(message); } /** * Checks if dir is subdir of parent */ function isSubdir(parent, dir) { const relativePath = (0, path_1.relative)(parent, dir); return !!relativePath && !/^..($|\/)/.test(relativePath) && !(0, path_1.isAbsolute)(relativePath); } async function loadConfigAndHandleErrors(options = {}) { try { return await (0, openapi_core_1.loadConfig)(options); } catch (e) { handleError(e, ''); } } function sortTopLevelKeysForOas(document) { if ('swagger' in document) { return sortOas2Keys(document); } return sortOas3Keys(document); } function sortOas2Keys(document) { const orderedKeys = [ 'swagger', 'info', 'host', 'basePath', 'schemes', 'consumes', 'produces', 'security', 'tags', 'externalDocs', 'paths', 'definitions', 'parameters', 'responses', 'securityDefinitions', ]; const result = {}; for (const key of orderedKeys) { if (document.hasOwnProperty(key)) { result[key] = document[key]; } } // merge any other top-level keys (e.g. vendor extensions) return Object.assign(result, document); } function sortOas3Keys(document) { const orderedKeys = [ 'openapi', 'info', 'jsonSchemaDialect', 'servers', 'security', 'tags', 'externalDocs', 'paths', 'webhooks', 'x-webhooks', 'components', ]; const result = {}; for (const key of orderedKeys) { if (document.hasOwnProperty(key)) { result[key] = document[key]; } } // merge any other top-level keys (e.g. vendor extensions) return Object.assign(result, document); } function checkIfRulesetExist(rules) { const ruleset = { ...rules.oas2, ...rules.oas3_0, ...rules.oas3_1, ...rules.async2, ...rules.async3, ...rules.arazzo1, }; if ((0, utils_1.isEmptyObject)(ruleset)) { exitWithError('⚠️ No rules were configured. Learn how to configure rules: https://redocly.com/docs/cli/rules/'); } } function cleanColors(input) { // eslint-disable-next-line no-control-regex return input.replace(/\x1b\[\d+m/g, ''); } async function sendTelemetry(argv, exit_code, has_config, spec_version, spec_keyword, spec_full_version) { try { if (!argv) { return; } const { _: [command], $0: _, ...args } = argv; const event_time = new Date().toISOString(); const redoclyClient = new openapi_core_1.RedoclyClient(); const { RedoclyOAuthClient } = await Promise.resolve().then(() => require('../auth/oauth-client')); const oauthClient = new RedoclyOAuthClient('redocly-cli', update_version_notifier_1.version); const reuniteUrl = (0, api_1.getReuniteUrl)(argv.residency); const logged_in = redoclyClient.hasTokens() || (await oauthClient.isAuthorized(reuniteUrl)); const data = { event: 'cli_command', event_time, logged_in: logged_in ? 'yes' : 'no', command: `${command}`, ...cleanArgs(args, process.argv.slice(2)), node_version: process.version, npm_version: (0, child_process_1.execSync)('npm -v').toString().replace('\n', ''), os_platform: os.platform(), version: update_version_notifier_1.version, exit_code, environment: process.env.REDOCLY_ENVIRONMENT, environment_ci: process.env.CI, has_config: has_config ? 'yes' : 'no', spec_version, spec_keyword, spec_full_version, }; const { otelTelemetry } = await Promise.resolve().then(() => require('../otel')); otelTelemetry.init(); otelTelemetry.send(data.command, data); } catch (err) { // Do nothing. } } function isFile(value) { return fs.existsSync(value) && fs.statSync(value).isFile(); } function isDirectory(value) { return fs.existsSync(value) && fs.statSync(value).isDirectory(); } function cleanString(value) { if (!value) { return value; } if ((0, openapi_core_1.isAbsoluteUrl)(value)) { return value.split('://')[0] + '://url'; } if (isFile(value)) { return value.replace(/.+\.([^.]+)$/, (_, ext) => 'file-' + ext); } if (isDirectory(value)) { return 'folder'; } if (push_1.DESTINATION_REGEX.test(value)) { return value.startsWith('@') ? '@organization/api-name@api-version' : 'api-name@api-version'; } return value; } function replaceArgs(commandInput, targets, replacement) { const targetValues = Array.isArray(targets) ? targets : [targets]; for (const target of targetValues) { commandInput = commandInput.replaceAll(target, replacement); } return commandInput; } function cleanArgs(parsedArgs, rawArgv) { const KEYS_TO_CLEAN = ['organization', 'o', 'input', 'i', 'client-cert', 'client-key', 'ca-cert']; let commandInput = rawArgv.join(' '); const commandArguments = {}; for (const [key, value] of Object.entries(parsedArgs)) { if (KEYS_TO_CLEAN.includes(key)) { commandArguments[key] = '***'; commandInput = replaceArgs(commandInput, value, '***'); } else if (typeof value === 'string') { const cleanedValue = cleanString(value); commandArguments[key] = cleanedValue; commandInput = replaceArgs(commandInput, value, cleanedValue); } else if (Array.isArray(value)) { commandArguments[key] = value.map(cleanString); for (const replacedValue of value) { const newValue = cleanString(replacedValue); if (commandInput.includes(replacedValue)) { commandInput = commandInput.replaceAll(replacedValue, newValue); } } } else { commandArguments[key] = value; } } return { arguments: JSON.stringify(commandArguments), raw_input: commandInput }; } function checkForDeprecatedOptions(argv, deprecatedOptions) { for (const option of deprecatedOptions) { if (argv[option]) { process.stderr.write((0, colorette_1.yellow)(`[WARNING] "${String(option)}" option is deprecated and will be removed in a future release. \n\n`)); } } } function notifyAboutIncompatibleConfigOptions(themeOpenapiOptions) { if ((0, utils_1.isPlainObject)(themeOpenapiOptions)) { const propertiesSet = Object.keys(themeOpenapiOptions); const deprecatedSet = Object.keys(reference_docs_config_schema_1.deprecatedRefDocsSchema.properties); const intersection = propertiesSet.filter((prop) => deprecatedSet.includes(prop)); if (intersection.length > 0) { process.stderr.write((0, colorette_1.yellow)(`\n${(0, utils_1.pluralize)('Property', intersection.length)} ${(0, colorette_1.gray)(intersection.map((prop) => `'${prop}'`).join(', '))} ${(0, utils_1.pluralize)('is', intersection.length)} only used in API Reference Docs and Redoc version 2.x or earlier.\n\n`)); } } } function formatPath(path) { if ((0, openapi_core_1.isAbsoluteUrl)(path)) { return path; } return (0, path_1.relative)(process.cwd(), path); }