dredd
Version:
HTTP API Testing Framework
311 lines (271 loc) • 10.1 kB
JavaScript
const async = require('async');
const clone = require('clone');
const glob = require('glob');
const fs = require('fs');
const path = require('path');
const request = require('request');
const url = require('url');
const dreddTransactions = require('dredd-transactions');
const configureReporters = require('./configureReporters');
const handleRuntimeProblems = require('./handleRuntimeProblems');
const logger = require('./logger');
const Runner = require('./TransactionRunner');
const { applyConfiguration } = require('./configuration');
const options = require('./options.json');
const PROXY_ENV_VARIABLES = ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY'];
const FILE_DOWNLOAD_TIMEOUT = 5000;
function removeDuplicates(arr) {
return arr.reduce((alreadyProcessed, currentItem) => {
if (alreadyProcessed.indexOf(currentItem) === -1) {
return alreadyProcessed.concat(currentItem);
}
return alreadyProcessed;
}, []);
}
class Dredd {
constructor(config) {
this.init(config);
}
// This is here only because there there is no way how to spy a constructor in CoffeScript
init(config) {
this.configuration = applyConfiguration(config);
this.configuration.http = {};
this.tests = [];
this.stats = {
tests: 0,
failures: 0,
errors: 0,
passes: 0,
skipped: 0,
start: 0,
end: 0,
duration: 0,
};
this.transactions = [];
this.runner = new Runner(this.configuration);
configureReporters(this.configuration, this.stats, this.tests, this.runner);
this.logProxySettings();
}
logProxySettings() {
const proxySettings = [];
for (const envVariableName of Object.keys(process.env || {})) {
const envVariableValue = process.env[envVariableName];
if (!Array.from(PROXY_ENV_VARIABLES).includes(envVariableName.toUpperCase())) { continue; }
if (envVariableValue === '') { continue; }
proxySettings.push(`${envVariableName}=${envVariableValue}`);
}
if (proxySettings.length) {
const message = `
HTTP(S) proxy specified by environment variables: \
${proxySettings.join(', ')}. Please read documentation on how
Dredd works with proxies:
https://dredd.org/en/latest/how-it-works/#using-https-proxy
`;
logger.debug(message);
}
}
run(callback) {
this.configDataIsEmpty = true;
if (!this.configuration.files) { this.configuration.files = []; }
if (!this.configuration.data) { this.configuration.data = {}; }
const passedConfigData = {};
const object = this.configuration.data || {};
for (const key of Object.keys(object || {})) {
const val = object[key];
this.configDataIsEmpty = false;
if (typeof val === 'string') {
passedConfigData[key] = {
filename: key,
raw: val,
};
} else if (typeof val === 'object' && val.raw && val.filename) {
passedConfigData[val.filename] = {
filename: val.filename,
raw: val.raw,
};
}
}
if (!this.configDataIsEmpty) {
this.configuration.data = passedConfigData;
}
// Remove duplicate paths
this.configuration.options.path = removeDuplicates(this.configuration.options.path);
if (this.configuration.options.require) {
let mod = this.configuration.options.require;
const abs = fs.existsSync(mod) || fs.existsSync(`${mod}.js`);
if (abs) {
mod = path.resolve(mod);
}
try {
// eslint-disable-next-line global-require, import/no-dynamic-require
require(mod);
} catch (err) {
logger.error(`Error requiring module '${mod}':`, err.message);
callback(err, this.stats);
return;
}
}
// Spin that merry-go-round
logger.debug('Expanding glob patterns.');
this.expandGlobs((globsErr) => {
if (globsErr) { return callback(globsErr, this.stats); }
logger.debug('Reading API description files.');
this.loadFiles((loadErr) => {
if (loadErr) { return callback(loadErr, this.stats); }
logger.debug('Parsing API description files and compiling a list of HTTP transactions to test.');
this.compileTransactions((compileErr) => {
if (compileErr) { return callback(compileErr, this.stats); }
logger.debug('Starting reporters and waiting until all of them are ready.');
this.emitStart((emitStartErr) => {
if (emitStartErr) { return callback(emitStartErr, this.stats); }
logger.debug('Starting transaction runner.');
this.startRunner((runnerErr) => {
if (runnerErr) { return callback(runnerErr, this.stats); }
logger.debug('Wrapping up testing.');
this.transactionsComplete(callback);
});
});
});
});
});
}
// Expand all globs
expandGlobs(callback) {
async.each(this.configuration.options.path, (globToExpand, globCallback) => {
if (/^http(s)?:\/\//.test(globToExpand)) {
this.configuration.files = this.configuration.files.concat(globToExpand);
return globCallback();
}
glob(globToExpand, (err, match) => {
if (err) { return globCallback(err); }
this.configuration.files = this.configuration.files.concat(match);
if (match.length === 0) {
err = new Error(`
API description document(s) not found on path:
'${globToExpand}'
`);
return globCallback(err);
}
globCallback();
});
},
(err) => {
if (err) { return callback(err, this.stats); }
if (this.configDataIsEmpty && this.configuration.files.length === 0) {
err = new Error(`
API description document (or documents) not found on path:
'${this.configuration.options.path}'
`);
return callback(err, this.stats);
}
// Remove duplicate filenames
this.configuration.files = removeDuplicates(this.configuration.files);
callback(null, this.stats);
});
}
// Load all files
loadFiles(callback) {
// 6 parallel connections is a standard limit when connecting to one hostname,
// use the same limit of parallel connections for reading/downloading files
async.eachLimit(this.configuration.files, 6, (fileUrlOrPath, loadCallback) => {
const { protocol, host } = url.parse(fileUrlOrPath);
if (host && ['http:', 'https:'].includes(protocol)) {
logger.debug('Downloading remote file:', fileUrlOrPath);
this.downloadFile(fileUrlOrPath, loadCallback);
} else {
this.readLocalFile(fileUrlOrPath, loadCallback);
}
},
callback);
}
downloadFile(fileUrl, callback) {
const opts = clone(this.configuration.http);
opts.url = fileUrl;
opts.timeout = FILE_DOWNLOAD_TIMEOUT;
request.get(opts, (downloadError, res, body) => {
let err;
if (downloadError) {
logger.debug(`Downloading ${fileUrl} errored:`, `${downloadError}` || downloadError.code);
err = new Error(`
Error when loading file from URL '${fileUrl}'.
Is the provided URL correct?
`);
return callback(err, this.stats);
}
if (!body || (res.statusCode < 200) || (res.statusCode >= 300)) {
err = new Error(`
Unable to load file from URL '${fileUrl}'.
Server did not send any blueprint back and responded with status code ${res.statusCode}.
`);
return callback(err, this.stats);
}
this.configuration.data[fileUrl] = { raw: body, filename: fileUrl };
callback(null, this.stats);
});
}
readLocalFile(filePath, callback) {
fs.readFile(filePath, 'utf8', (readError, data) => {
if (readError) {
const err = new Error(`
Error when reading file '${filePath}' (${readError.message}).
Is the provided path correct?
`);
return callback(err);
}
this.configuration.data[filePath] = { raw: data, filename: filePath };
callback(null, this.stats);
});
}
// Compile transactions from ASTs
compileTransactions(callback) {
this.transactions = [];
// Compile HTTP transactions for each API description
async.each(Object.keys(this.configuration.data), (filename, next) => {
const fileData = this.configuration.data[filename];
if (!fileData.annotations) { fileData.annotations = []; }
logger.debug('Compiling HTTP transactions from API description file:', filename);
dreddTransactions.compile(fileData.raw, filename, (compilationError, compilationResult) => {
if (compilationError) { return next(compilationError); }
fileData.mediaType = compilationResult.mediaType;
fileData.annotations = fileData.annotations.concat(compilationResult.annotations);
this.transactions = this.transactions.concat(compilationResult.transactions);
next();
});
},
(runtimeError) => {
if (!runtimeError) { runtimeError = handleRuntimeProblems(this.configuration.data); }
callback(runtimeError, this.stats);
});
}
// Start the runner
emitStart(callback) {
// More than one reporter is supported
let reporterCount = this.configuration.emitter.listeners('start').length;
// When event 'start' is emitted, function in callback is executed for each
// reporter registered by listeners
this.configuration.emitter.emit('start', this.configuration.data, (reporterError) => {
if (reporterError) { logger.error(reporterError.message); }
// Last called reporter callback function starts the runner
reporterCount--;
if (reporterCount === 0) {
callback(null, this.stats);
}
});
}
startRunner(callback) {
// Run all transactions
this.runner.config(this.configuration);
this.runner.run(this.transactions, callback);
}
transactionsComplete(callback) {
let reporterCount = this.configuration.emitter.listeners('end').length;
this.configuration.emitter.emit('end', () => {
reporterCount--;
if (reporterCount === 0) {
callback(null, this.stats);
}
});
}
}
module.exports = Dredd;
module.exports.options = options;