ibm-openapi-validator
Version:
Configurable and extensible validator/linter for OpenAPI documents
227 lines (192 loc) • 7.31 kB
JavaScript
/**
* Copyright 2017 - 2024 IBM Corporation.
* SPDX-License-Identifier: Apache2.0
*/
const { Document, Spectral } = require('@stoplight/spectral-core');
const Parsers = require('@stoplight/spectral-parsers');
const {
getRuleset,
} = require('@stoplight/spectral-cli/dist/services/linter/utils/getRuleset');
const ibmRuleset = require('@ibm-cloud/openapi-ruleset');
const {
checkRulesetVersion,
getFileExtension,
getLocalRulesetVersion,
} = require('../cli-validator/utils');
const { findSpectralRuleset } = require('./utils');
/**
* Creates a Spectral document from the input, runs spectral, converts the results
* from Spectral to IBM format and returns them.
*
* @param {*} opts an object containing the input options
* @param {*} opts.logger the root logger for displaying messages
* @param {*} opts.chalk the chalk object used for logging messages
* @param {*} opts.rulesetFileOverride an optional ruleset filename
* @param {*} opts.validFile
* @param {*} opts.originalFile
* @returns the formatted results
*/
const runSpectral = async function ({ originalFile, validFile }, context) {
const spectral = await setup(context);
const fileExtension = getFileExtension(validFile);
let parser = Parsers.Json;
if (['yaml', 'yml'].includes(fileExtension)) {
parser = Parsers.Yaml;
}
const doc = new Document(originalFile, parser, validFile);
const spectralResults = await spectral.run(doc);
// Save the resolved API definition for use in the scoring tool logic.
context.apiDefinition = doc.data;
return convertResults(spectralResults, context);
};
function convertResults(spectralResults, { config, logger }) {
const { errorsOnly } = config;
// This structure must match the JSON Schema defined for JSON output
const finalResultsObject = {
error: { results: [], summary: { total: 0, entries: [] } },
warning: { results: [], summary: { total: 0, entries: [] } },
info: { results: [], summary: { total: 0, entries: [] } },
hint: { results: [], summary: { total: 0, entries: [] } },
hasResults: false,
};
// use this object to count the occurance of each validation
const summaryHelper = { error: {}, warning: {}, info: {}, hint: {} };
for (const r of spectralResults) {
if (invalidResult(r)) {
logger.debug(
'Spectral validation result does not contain necessary information'
);
continue;
}
const severity = convertSpectralSeverity(r.severity);
// only collect errors when "errors only" is true
if (errorsOnly && r.severity > 0) {
logger.debug(
`Ignoring result with '${severity}' severity due to 'errors-only' option`
);
continue;
}
finalResultsObject.hasResults = true;
finalResultsObject[severity].summary.total++;
finalResultsObject[severity].results.push({
message: r.message,
path: r.path,
rule: r.code,
line: r.range.start.line + 1,
});
// compute a generalized message for the summary
const genMessage = r.message.split(':')[0];
if (!summaryHelper[severity][genMessage]) {
summaryHelper[severity][genMessage] = 0;
}
summaryHelper[severity][genMessage] += 1;
}
// finish putting together the summary object
for (const sev in summaryHelper) {
for (const field in summaryHelper[sev]) {
finalResultsObject[sev].summary.entries.push({
generalizedMessage: field,
count: summaryHelper[sev][field],
percentage: Math.round(
(summaryHelper[sev][field] / finalResultsObject[sev].summary.total) *
100
),
});
}
}
return finalResultsObject;
}
/**
* Creates a new Spectral instance, sets up the ruleset, then returns the spectral instance.
*
* @param {*} context an object containing the options
* @param {*} context.logger the logger to use for logging messages
* @param {*} context.chalk the chalk object for displaying messages
* @param {string} context.config.ruleset an optional ruleset filename
* @returns the new Spectral instance
*/
async function setup({ config, logger }) {
const spectral = new Spectral();
// We'll use the IBM ruleset by default, but also look for a user-provided
// ruleset and use that if one was specified.
let ruleset = ibmRuleset;
const rulesetFileOverride = await findSpectralRuleset(config, logger);
if (rulesetFileOverride) {
try {
ruleset = await getRuleset(rulesetFileOverride);
logger.debug(`Using Spectral ruleset file: ${rulesetFileOverride}`);
// Check the local ruleset version and warn
// the user if they are behind the default.
const rulesetVersion = await getLocalRulesetVersion(
rulesetFileOverride,
logger
);
const versionWarning = checkRulesetVersion(rulesetVersion);
if (versionWarning) {
logger.warn(versionWarning);
}
} catch (e) {
// Check error for common issues but do nothing.
// We get here anytime the user doesn't define a valid Spectral config,
// which is fine. We just use our default in that case.
// In certain cases, we help the user understand what is happening by
// logging informative messages.
checkGetRulesetError(logger, e, rulesetFileOverride);
}
}
spectral.setRuleset(ruleset);
return spectral;
}
module.exports = {
runSpectral,
};
function checkGetRulesetError(logger, error, file) {
const isAggregateError = error instanceof AggregateError;
// Report the error, then check if some additional hints might be needed.
logger.error(
`${
isAggregateError ? 'Problems' : 'Problem'
} reading Spectral ruleset file '${file}': ${error.message}`
);
// Spectral may report an "AggregateError", which could contain multiple errors
// and will hide the "message" fields within the individual errors.
if (isAggregateError) {
error.errors.forEach(err => {
// If a validation path is returned, it should be helpful to the user. This formats the path
// to add it to the message, e.g. "(rules.enum-case-convention.then)".
const errorPathInRuleset = err.path
? ' (' + err.path.join('.') + ')'
: '';
logger.error(`- ${err.message}${errorPathInRuleset}`);
});
logger.debug(
'Spectral returned an `AggregateError`. The ruleset likely did not pass validation.'
);
}
logger.error(`Using the default IBM Cloud OpenAPI Ruleset instead.`);
if (
error.message.startsWith('ENOENT: no such file or directory') &&
error.message.includes('ibm:oas')
) {
logger.warn(
'The IBM ruleset name has changed and the old name is invalid.'
);
logger.warn(
'Change your ruleset to extend `@ibm-cloud/openapi-ruleset` instead of `ibm:oas` to use your custom ruleset.'
);
logger.warn(
'For now, the IBM Spectral rules will run in their default configuration.'
);
}
}
function invalidResult(r) {
return !r || !r.code || !r.message || !r.path || invalidSeverity(r.severity);
}
function invalidSeverity(s) {
return typeof s !== 'number' || s < 0 || s > 3;
}
function convertSpectralSeverity(s) {
// we have already guaranteed s to be a number, 0-3
const mapping = { 0: 'error', 1: 'warning', 2: 'info', 3: 'hint' };
return mapping[s];
}