UNPKG

ibm-openapi-validator

Version:

Configurable and extensible validator/linter for OpenAPI documents

396 lines (346 loc) 13.7 kB
#!/usr/bin/env node /** * Copyright 2017 - 2024 IBM Corporation. * SPDX-License-Identifier: Apache2.0 */ const chalk = require('chalk'); const fs = require('fs'); const globby = require('globby'); const isPlainObject = require('lodash/isPlainObject'); const jsonValidator = require('json-dup-key-validator'); const path = require('path'); const readYaml = require('js-yaml'); const util = require('util'); const { getCopyrightString, getFileExtension, preprocessFile, printJson, printResults, printVersions, processArgs, supportedFileExtension, } = require('./utils'); const { runSpectral } = require('../spectral'); const { produceImpactScore, printScoreTables } = require('../scoring-tool'); const { printMarkdownReport } = require('../markdown-report'); let logger; /** * This function is the main entry point to the validator. * It processes the passed-in cli arguments and performs error handling as needed. * @param {*} cliArgs the array of command-line arguments (normally this should be process.argv) * @param {*} parseOptions an optional object containing parse options * @returns an exitCode that indicates success/failure: * 0: The validator ran successfully and passed with no errors * 1: The validator ran successfully but there were errors detected in one or more * of the requested files * 2: The validator encountered an error that prevented it from validating * one or more of the requested files */ async function runValidator(cliArgs, parseOptions = {}) { // Process the CLI args to produce a validator context object. // This context object will contain all the user input as well as some // internal information shared by various components of the validator. let context, command; try { ({ context, command } = await processArgs(cliArgs, parseOptions)); } catch (err) { // console.error(`Caught error: `, err); // "err" will most likely be a CommanderError of some sort ( // help was displayed, version string requested, unknown option, etc.) // and it should have an "exitCode" field. const exitCode = 'exitCode' in err ? err.exitCode : 2; if (exitCode !== 0) { console.error('Command parsing error: ', err.message); } return exitCode === 0 ? Promise.resolve(0) : Promise.reject(2); } // If the version was requested, print that here. Note that we // needed to wait until after the configuration was processed // to try and compute/include the ruleset version. if (command.opts().version) { await printVersions(context); return Promise.resolve(0); } logger = context.logger; logger.debug( `Using validator configuration:\n${JSON.stringify(context.config, null, 2)}` ); // Grab the list of files to validate. let args = context.config.files; // If no arguments are passed in, then display help text and exit. if (args.length === 0) { logger.error('At least one argument must be provided.\n'); console.log(`${getCopyrightString()}\n${command.helpInformation()}`); return Promise.reject(2); } // Turn off coloring if requested. if (!context.config.colorizeOutput) { chalk.level = 0; } context.chalk = chalk; if (!outputIsJSON(context)) { console.log(getCopyrightString()); } // // Run the validator on the files specified via command-line or config file. // // Ignore files listed in the config object's "ignoreFiles" field // by comparing absolute paths. // "filteredArgs" will be "args" minus any ignored files. const filteredArgs = args.filter( file => !context.config.ignoreFiles.includes(path.resolve(file)) ); // Next, display a message for each user-specified file that is being ignored. const ignoredFiles = args.filter(file => !filteredArgs.includes(file)); ignoredFiles.forEach(file => { logger.warn('Ignored ' + path.relative(process.cwd(), file)); }); args = filteredArgs; // At this point, "args" is an array of file names passed in by the user, // but with the ignored files removed. // Nothing in "args" will be a glob type, as glob types are automatically // converted to arrays of matching file names by the shell. const supportedFileTypes = ['json', 'yml', 'yaml']; const filesWithValidExtensions = []; let unsupportedExtensionsFound = false; args.forEach(arg => { if (supportedFileExtension(arg, supportedFileTypes)) { filesWithValidExtensions.push(arg); } else { unsupportedExtensionsFound = true; logger.warn(`Skipping file with unsupported file type: ${arg}`); } }); if (unsupportedExtensionsFound) { logger.warn( 'Supported file types are JSON (.json) and YAML (.yml, .yaml)\n' ); } // Globby is used in an unconventional way here. // We are not passing in globs, but an array of file names. // What globby does is search through the file system looking for files // that match the names in the array. It returns a list of matches (file names). // Therefore, any files that are in filesWithValidExtensions, but are NOT in the // array globby returns, do not actually exist. This is a convenient way of checking for file // existence before iterating through and running the validator on every file. const filesToValidate = await globby(filesWithValidExtensions); const nonExistentFiles = filesWithValidExtensions.filter( file => !filesToValidate.includes(file) ); nonExistentFiles.forEach(file => { logger.warn(`Skipping non-existent file: ${file}`); }); // If no passed in files are valid, exit the program. if (!filesToValidate.length) { logger.error('No files to validate.'); return Promise.reject(2); } // If multiple files were specified and the impact score is requested, exit with an error. // We could change this behavior in the future. if (filesToValidate.length > 1 && context.config.produceImpactScore) { logger.error( 'At most one file can be specified when the impact score is requested.' ); return Promise.reject(2); } // If multiple files were specified and JSON output is requested, exit with an error. if (filesToValidate.length > 1 && outputIsJSON(context)) { logger.error( 'At most one file can be specified when JSON output is requested.' ); return Promise.reject(2); } // Define an exit code to return. This will tell the parent program whether // the validator passed or not. let exitCode = 0; // The "fs" module does not return promises by default. // Create a version of the 'readFile' function that does. const readFile = util.promisify(fs.readFile); // Validate, then process the results for each file being validated. for (const validFile of filesToValidate) { // 'validFile' is the name of the file being processed. Save it for later use. context.currentFilename = validFile; let originalFile; let input; if (!outputIsJSON(context)) { console.log(''); console.log(chalk.underline(`Validation Results for ${validFile}:\n`)); } try { originalFile = await readFile(validFile, 'utf8'); originalFile = preprocessFile(originalFile); const fileExtension = getFileExtension(validFile); if (fileExtension === 'json') { input = JSON.parse(originalFile); } else if (fileExtension === 'yaml' || fileExtension === 'yml') { input = readYaml.load(originalFile); } if (!isPlainObject(input)) { throw `The content of '${validFile}' is not a valid object.`; } // jsonValidator looks through the originalFile for duplicate JSON keys // this is checked for by default in readYaml const duplicateKeysError = jsonValidator.validate(originalFile); if (fileExtension === 'json' && duplicateKeysError) { throw duplicateKeysError; } if ( typeof input.openapi !== 'string' || input.openapi.match(/3\.[0-1]\.[0-9]+/) === null ) { throw 'Only OpenAPI 3.0.x and 3.1.x documents are currently supported.'; } } catch (err) { logError(`Invalid input file: ${validFile}. See below for details.`, err); exitCode = 1; continue; } // Run spectral and collect formatted results let results; try { results = await runSpectral({ validFile, originalFile }, context); } catch (err) { handleSpectralError(err); exitCode = 1; continue; } // Compute scoring information if 1) the user requested the "impact score" // option, 2) if JSON output is requested, or 3) if the markdown report is // requested. The JSON output and markdown report always include all results, // including the standard rule violations and the scoring information. let impactScoreInformation = {}; if ( context.config.produceImpactScore || outputIsJSON(context) || context.config.markdownReport ) { logger.info('Impact scores are being calculated...'); impactScoreInformation = await produceImpactScore(results, context); } else { logger.info( 'Impact scores are not being calculated. Scores are calculated when' + 'requested, or when JSON output or a Markdown report is requested.' ); } // Combine validator and impact score results into one object. results = { ...results, impactScore: { ...impactScoreInformation, }, }; // If the user requested a markdown report of the results, print that here. // Note that we need to do this before we check if the "summaryOnly" option // was provided, because that will filter the results and we want to print // all of the results in the report. let markdownReportLocation; if (context.config.markdownReport) { markdownReportLocation = printMarkdownReport(context, results); } // Check to see if we should be passing back a non-zero exit code. if (results.error.summary.total) { // If we have any errors, then exit code 1 is returned. exitCode = 1; } // If the # of warnings exceeded the warnings limit, then this is an error. const numWarnings = results.warning.summary.total; const warningsLimit = context.config.limits.warnings; if (warningsLimit >= 0 && numWarnings > warningsLimit) { exitCode = 1; logger.error( `Number of warnings (${numWarnings}) exceeds warnings limit (${warningsLimit}).\n` ); } // If summary output is requested, filter out extraneous information here. if (context.config.summaryOnly) { // Remove verbose scoring data. results.impactScore.scoringData = []; // Remove individual rule violation results. ['error', 'warning', 'info', 'hint'].forEach(sev => { results[sev].results = []; }); } // Now print the results, either JSON or text. if (outputIsJSON(context)) { printJson(context, results); } else if (results.hasResults) { printResults(context, results); // If the user requested the "impact score" option, print // the scoring tables in addition to the standard output. if (context.config.produceImpactScore) { printScoreTables(context, results); } } else { // If using textual output but there are no results, // declare that the API "passed" without violations. console.log(context.chalk.green(`${validFile} passed the validator\n`)); } // If the user requested the "markdown report" option and it was // successfully printed, show the user where the file was written // (unless JSON output was requested). if (markdownReportLocation && !outputIsJSON(context)) { console.log( context.chalk.green( `Successfully wrote Markdown report to file: ${markdownReportLocation}\n` ) ); } } return exitCode; } // if the error has a message property (it should), // this is the cleanest way to present it. If not, // print the whole error function getError(err) { return err.message || err; } function logError(description, message = '') { logger.error(`${description}`); if (message) { logger.error(`${message}`); } } function handleSpectralError(error) { logError('There was a problem with spectral.', getError(error)); logger.error('Additional error details:'); // The nimma errors are especially difficult to understand, so we do some parsing // based on the expected structure of the errors to extract the error cause and // the specific file/line number the problem occurs on. if ( error.message && error.message === 'Error running Nimma' && error instanceof AggregateError ) { const errorDedupMap = {}; error.errors.forEach(err => { if (err.cause && err.cause.cause) { const { cause } = err.cause; try { // Look for the filepath/line number at the top of the stack. const topOfStack = cause.stack.split(' at ')[1]; const reason = cause.message; if (errorDedupMap[reason + topOfStack]) { // Don't print duplicates, continue the loop. return; } logger.error(`Cause: ${reason}`); logger.error(`At: ${topOfStack}`); errorDedupMap[reason + topOfStack] = true; } catch (e) { logger.debug('Could not parse Spectral error information'); logger.debug(e.message); logger.error(cause); } } else { logger.error(error); } }); } else { logger.error(error); } } function outputIsJSON(context) { return context.config.outputFormat === 'json'; } module.exports = runValidator;