dredd
Version:
HTTP API Testing Framework
610 lines (529 loc) • 24.2 kB
JavaScript
const async = require('async');
const chai = require('chai');
const clone = require('clone');
const gavel = require('gavel');
const os = require('os');
const url = require('url');
const addHooks = require('./addHooks');
const logger = require('./logger');
const reporterOutputLogger = require('./reporters/reporterOutputLogger');
const packageData = require('../package.json');
const sortTransactions = require('./sortTransactions');
const performRequest = require('./performRequest');
function headersArrayToObject(arr) {
return Array.from(arr).reduce((result, currentItem) => {
result[currentItem.name] = currentItem.value;
return result;
}, {});
}
function eventCallback(reporterError) {
if (reporterError) { logger.error(reporterError.message); }
}
class TransactionRunner {
constructor(configuration) {
this.configureTransaction = this.configureTransaction.bind(this);
this.executeTransaction = this.executeTransaction.bind(this);
this.configuration = configuration;
this.logs = [];
this.hookStash = {};
this.error = null;
this.hookHandlerError = null;
}
config(config) {
this.configuration = config;
this.multiBlueprint = Object.keys(this.configuration.data).length > 1;
}
run(transactions, callback) {
logger.debug('Sorting HTTP transactions');
transactions = this.configuration.options.sorted ? sortTransactions(transactions) : transactions;
logger.debug('Configuring HTTP transactions');
transactions = transactions.map(this.configureTransaction.bind(this));
// Remainings of functional approach, probs to be eradicated
logger.debug('Reading hook files and registering hooks');
addHooks(this, transactions, (addHooksError) => {
if (addHooksError) { return callback(addHooksError); }
logger.debug('Executing HTTP transactions');
this.executeAllTransactions(transactions, this.hooks, callback);
});
}
executeAllTransactions(transactions, hooks, callback) {
// Warning: Following lines is "differently" performed by 'addHooks'
// in TransactionRunner.run call. Because addHooks creates hooks.transactions
// as an object `{}` with transaction.name keys and value is every
// transaction, we do not fill transactions from executeAllTransactions here.
// Transactions is supposed to be an Array here!
let transaction;
if (!hooks.transactions) {
hooks.transactions = {};
for (transaction of transactions) {
hooks.transactions[transaction.name] = transaction;
}
}
// End of warning
if (this.hookHandlerError) { return callback(this.hookHandlerError); }
logger.debug('Running \'beforeAll\' hooks');
this.runHooksForData(hooks.beforeAllHooks, transactions, () => {
if (this.hookHandlerError) { return callback(this.hookHandlerError); }
// Iterate over transactions' transaction
// Because async changes the way referencing of properties work,
// we need to work with indexes (keys) here, no other way of access.
return async.timesSeries(transactions.length, (transactionIndex, iterationCallback) => {
transaction = transactions[transactionIndex];
logger.debug(`Processing transaction #${transactionIndex + 1}:`, transaction.name);
logger.debug('Running \'beforeEach\' hooks');
this.runHooksForData(hooks.beforeEachHooks, transaction, () => {
if (this.hookHandlerError) { return iterationCallback(this.hookHandlerError); }
logger.debug('Running \'before\' hooks');
this.runHooksForData(hooks.beforeHooks[transaction.name], transaction, () => {
if (this.hookHandlerError) { return iterationCallback(this.hookHandlerError); }
// This method:
// - skips and fails based on hooks or options
// - executes a request
// - recieves a response
// - runs beforeEachValidation hooks
// - runs beforeValidation hooks
// - runs Gavel validation
this.executeTransaction(transaction, hooks, () => {
if (this.hookHandlerError) { return iterationCallback(this.hookHandlerError); }
logger.debug('Running \'afterEach\' hooks');
this.runHooksForData(hooks.afterEachHooks, transaction, () => {
if (this.hookHandlerError) { return iterationCallback(this.hookHandlerError); }
logger.debug('Running \'after\' hooks');
this.runHooksForData(hooks.afterHooks[transaction.name], transaction, () => {
if (this.hookHandlerError) { return iterationCallback(this.hookHandlerError); }
logger.debug(`Evaluating results of transaction execution #${transactionIndex + 1}:`, transaction.name);
this.emitResult(transaction, iterationCallback);
});
});
});
});
});
},
(iterationError) => {
if (iterationError) { return callback(iterationError); }
logger.debug('Running \'afterAll\' hooks');
this.runHooksForData(hooks.afterAllHooks, transactions, () => {
if (this.hookHandlerError) { return callback(this.hookHandlerError); }
callback();
});
});
});
}
// The 'data' argument can be 'transactions' array or 'transaction' object
runHooksForData(hooks, data, callback) {
if (hooks && hooks.length) {
logger.debug('Running hooks...');
// Capture outer this
const runHookWithData = (hookFnIndex, runHookCallback) => {
const hookFn = hooks[hookFnIndex];
try {
this.runHook(hookFn, data, (err) => {
if (err) {
logger.debug('Hook errored:', err);
this.emitHookError(err, data);
}
runHookCallback();
});
} catch (error) {
// Beware! This is very problematic part of code. This try/catch block
// catches also errors thrown in 'runHookCallback', i.e. in all
// subsequent flow! Then also 'callback' is called twice and
// all the flow can be executed twice. We need to reimplement this.
if (error instanceof chai.AssertionError) {
const transactions = Array.isArray(data) ? data : [data];
for (const transaction of transactions) { this.failTransaction(transaction, `Failed assertion in hooks: ${error.message}`); }
} else {
logger.debug('Hook errored:', error);
this.emitHookError(error, data);
}
runHookCallback();
}
};
async.timesSeries(hooks.length, runHookWithData, () => callback());
} else {
callback();
}
}
// The 'data' argument can be 'transactions' array or 'transaction' object.
//
// If it's 'transactions', it is treated as single 'transaction' anyway in this
// function. That probably isn't correct and should be fixed eventually
// (beware, tests count with the current behavior).
emitHookError(error, data) {
if (!(error instanceof Error)) { error = new Error(error); }
const test = this.createTest(data);
test.request = data.request;
this.emitError(error, test);
}
runHook(hook, data, callback) {
if (hook.length === 1) {
// Sync api
hook(data);
callback();
} else if (hook.length === 2) {
// Async api
hook(data, () => callback());
}
}
configureTransaction(transaction) {
const { configuration } = this;
const { origin, request, response } = transaction;
const mediaType = (
configuration.data[origin.filename]
? configuration.data[origin.filename].mediaType
: undefined
) || 'text/vnd.apiblueprint';
// Parse the server URL (just once, caching it in @parsedUrl)
if (!this.parsedUrl) { this.parsedUrl = this.parseServerUrl(configuration.server); }
const fullPath = this.getFullPath(this.parsedUrl.path, request.uri);
const headers = headersArrayToObject(request.headers);
// Add Dredd User-Agent (if no User-Agent is already present)
const hasUserAgent = Object.keys(headers)
.map(name => name.toLowerCase())
.includes('user-agent');
if (!hasUserAgent) {
const system = `${os.type()} ${os.release()}; ${os.arch()}`;
headers['User-Agent'] = `Dredd/${packageData.version} (${system})`;
}
// Parse and add headers from the config to the transaction
if (configuration.options.header.length > 0) {
for (const header of configuration.options.header) {
const splitIndex = header.indexOf(':');
const headerKey = header.substring(0, splitIndex);
const headerValue = header.substring(splitIndex + 1);
headers[headerKey] = headerValue;
}
}
request.headers = headers;
// The data models as used here must conform to Gavel.js
// as defined in `http-response.coffee`
const expected = { headers: headersArrayToObject(response.headers) };
if (response.body) { expected.body = response.body; }
if (response.status) { expected.statusCode = response.status; }
if (response.schema) { expected.bodySchema = response.schema; }
// Backward compatible transaction name hack. Transaction names will be
// replaced by Canonical Transaction Paths: https://github.com/apiaryio/dredd/issues/227
if (!this.multiBlueprint) {
transaction.name = transaction.name.replace(`${transaction.origin.apiName} > `, '');
}
// Transaction skipping (can be modified in hooks). If the input format
// is OpenAPI 2, non-2xx transactions should be skipped by default.
let skip = false;
if (mediaType.indexOf('swagger') !== -1) {
const status = parseInt(response.status, 10);
if ((status < 200) || (status >= 300)) {
skip = true;
}
}
const configuredTransaction = {
name: transaction.name,
id: `${request.method} (${expected.statusCode}) ${request.uri}`,
host: this.parsedUrl.hostname,
port: this.parsedUrl.port,
request,
expected,
origin,
fullPath,
protocol: this.parsedUrl.protocol,
skip,
};
return configuredTransaction;
}
parseServerUrl(serverUrl) {
if (!serverUrl.match(/^https?:\/\//i)) {
// Protocol is missing. Remove any : or / at the beginning of the URL
// and prepend the URL with 'http://' (assumed as default fallback).
serverUrl = `http://${serverUrl.replace(/^[:/]*/, '')}`;
}
return url.parse(serverUrl);
}
getFullPath(serverPath, requestPath) {
if (serverPath === '/') { return requestPath; }
if (!requestPath) { return serverPath; }
// Join two paths
//
// How:
// Removes all slashes from the beginning and from the end of each segment.
// Then joins them together with a single slash. Then prepends the whole
// string with a single slash.
//
// Why:
// Note that 'path.join' won't work on Windows and 'url.resolve' can have
// undesirable behavior depending on slashes.
// See also https://github.com/joyent/node/issues/2216
let segments = [serverPath, requestPath];
segments = (Array.from(segments).map(segment => segment.replace(/^\/|\/$/g, '')));
// Keep trailing slash at the end if specified in requestPath
// and if requestPath isn't only '/'
const trailingSlash = (requestPath !== '/') && (requestPath.slice(-1) === '/') ? '/' : '';
return `/${segments.join('/')}${trailingSlash}`;
}
// Factory for 'transaction.test' object creation
createTest(transaction) {
return {
status: '',
title: transaction.id,
message: transaction.name,
origin: transaction.origin,
startedAt: transaction.startedAt,
};
}
// Marks the transaction as failed and makes sure everything in the transaction
// object is set accordingly. Typically this would be invoked when transaction
// runner decides to force a transaction to behave as failed.
failTransaction(transaction, reason) {
transaction.fail = true;
this.ensureTransactionResultsGeneralSection(transaction);
if (reason) { transaction.results.general.results.push({ severity: 'error', message: reason }); }
if (!transaction.test) { transaction.test = this.createTest(transaction); }
transaction.test.status = 'fail';
if (reason) { transaction.test.message = reason; }
let results;
if (transaction.test.results) {
results = transaction.test.results;
} else {
results = transaction.test.results = transaction.results;
}
return results;
}
// Marks the transaction as skipped and makes sure everything in the transaction
// object is set accordingly.
skipTransaction(transaction, reason) {
transaction.skip = true;
this.ensureTransactionResultsGeneralSection(transaction);
if (reason) { transaction.results.general.results.push({ severity: 'warning', message: reason }); }
if (!transaction.test) { transaction.test = this.createTest(transaction); }
transaction.test.status = 'skip';
if (reason) { transaction.test.message = reason; }
let results;
if (transaction.test.results) {
results = transaction.test.results;
} else {
results = transaction.test.results = transaction.results;
}
return results;
}
// Ensures that given transaction object has 'results' with 'general' section
// where custom Gavel-like errors or warnings can be inserted.
ensureTransactionResultsGeneralSection(transaction) {
if (!transaction.results) { transaction.results = {}; }
if (!transaction.results.general) { transaction.results.general = {}; }
let results;
if (transaction.results.general.results) {
results = transaction.results.general.results;
} else {
results = transaction.results.general.results = [];
}
return results;
}
// Inspects given transaction and emits 'test *' events with 'transaction.test'
// according to the test's status
emitResult(transaction, callback) {
if (this.error || !transaction.test) {
logger.debug('No emission of test data to reporters', this.error, transaction.test);
this.error = null; // Reset the error indicator
return callback();
}
if (transaction.skip) {
logger.debug('Emitting to reporters: test skip');
this.configuration.emitter.emit('test skip', transaction.test, eventCallback);
return callback();
}
if (transaction.test.valid) {
if (transaction.fail) {
this.failTransaction(transaction, `Failed in after hook: ${transaction.fail}`);
logger.debug('Emitting to reporters: test fail');
this.configuration.emitter.emit('test fail', transaction.test, eventCallback);
} else {
logger.debug('Emitting to reporters: test pass');
this.configuration.emitter.emit('test pass', transaction.test, eventCallback);
}
return callback();
}
logger.debug('Emitting to reporters: test fail');
this.configuration.emitter.emit('test fail', transaction.test, eventCallback);
callback();
}
// Emits 'test error' with given test data. Halts the transaction runner.
emitError(error, test) {
logger.debug('Emitting to reporters: test error');
this.configuration.emitter.emit('test error', error, test, eventCallback);
// Record the error to halt the transaction runner. Do not overwrite
// the first recorded error if more of them occured.
this.error = this.error || error;
}
// This is actually doing more some pre-flight and conditional skipping of
// the transcation based on the configuration or hooks. TODO rename
executeTransaction(transaction, hooks, callback) {
if (!callback) { [callback, hooks] = Array.from([hooks, undefined]); }
// Number in miliseconds (UNIX-like timestamp * 1000 precision)
transaction.startedAt = Date.now();
const test = this.createTest(transaction);
logger.debug('Emitting to reporters: test start');
this.configuration.emitter.emit('test start', test, eventCallback);
this.ensureTransactionResultsGeneralSection(transaction);
if (transaction.skip) {
logger.debug('HTTP transaction was marked in hooks as to be skipped. Skipping');
transaction.test = test;
this.skipTransaction(transaction, 'Skipped in before hook');
return callback();
} if (transaction.fail) {
logger.debug('HTTP transaction was marked in hooks as to be failed. Reporting as failed');
transaction.test = test;
this.failTransaction(transaction, `Failed in before hook: ${transaction.fail}`);
return callback();
} if (this.configuration.options['dry-run']) {
reporterOutputLogger.info('Dry run. Not performing HTTP request');
transaction.test = test;
this.skipTransaction(transaction);
return callback();
} if (this.configuration.options.names) {
reporterOutputLogger.info(transaction.name);
transaction.test = test;
this.skipTransaction(transaction);
return callback();
} if ((this.configuration.options.method.length > 0) && !(Array.from(this.configuration.options.method).includes(transaction.request.method))) {
logger.debug(`\
Only ${(Array.from(this.configuration.options.method).map(m => m.toUpperCase())).join(', ')}\
requests are set to be executed. \
Not performing HTTP ${transaction.request.method.toUpperCase()} request.\
`);
transaction.test = test;
this.skipTransaction(transaction);
return callback();
} if ((this.configuration.options.only.length > 0) && !(Array.from(this.configuration.options.only).includes(transaction.name))) {
logger.debug(`\
Only '${this.configuration.options.only}' transaction is set to be executed. \
Not performing HTTP request for '${transaction.name}'.\
`);
transaction.test = test;
this.skipTransaction(transaction);
return callback();
}
this.performRequestAndValidate(test, transaction, hooks, callback);
}
// An actual HTTP request, before validation hooks triggering
// and the response validation is invoked here
performRequestAndValidate(test, transaction, hooks, callback) {
const uri = url.format({
protocol: transaction.protocol,
hostname: transaction.host,
port: transaction.port,
}) + transaction.fullPath;
const options = { http: this.configuration.http };
performRequest(uri, transaction.request, options, (error, real) => {
if (error) {
logger.debug('Requesting tested server errored:', error);
test.title = transaction.id;
test.expected = transaction.expected;
test.request = transaction.request;
this.emitError(error, test);
return callback();
}
transaction.real = real;
if (!transaction.real.body && transaction.expected.body) {
// Leaving body as undefined skips its validation completely. In case
// there is no real body, but there is one expected, the empty string
// ensures Gavel does the validation.
transaction.real.body = '';
}
logger.debug('Running \'beforeEachValidation\' hooks');
this.runHooksForData(hooks && hooks.beforeEachValidationHooks, transaction, () => {
if (this.hookHandlerError) { return callback(this.hookHandlerError); }
logger.debug('Running \'beforeValidation\' hooks');
this.runHooksForData(hooks && hooks.beforeValidationHooks[transaction.name], transaction, () => {
if (this.hookHandlerError) { return callback(this.hookHandlerError); }
this.validateTransaction(test, transaction, callback);
});
});
});
}
validateTransaction(test, transaction, callback) {
logger.debug('Validating HTTP transaction by Gavel.js');
logger.debug('Determining whether HTTP transaction is valid (getting boolean verdict)');
gavel.isValid(transaction.real, transaction.expected, 'response', (isValidError, isValid) => {
if (isValidError) {
logger.debug('Gavel.js validation errored:', isValidError);
this.emitError(isValidError, test);
}
test.title = transaction.id;
test.actual = transaction.real;
test.expected = transaction.expected;
test.request = transaction.request;
if (isValid) {
test.status = 'pass';
} else {
test.status = 'fail';
}
logger.debug('Validating HTTP transaction (getting verbose validation result)');
gavel.validate(transaction.real, transaction.expected, 'response', (validateError, gavelResult) => {
if (!isValidError && validateError) {
logger.debug('Gavel.js validation errored:', validateError);
this.emitError(validateError, test);
}
// Warn about empty responses
// Expected is as string, actual is as integer :facepalm:
const isExpectedResponseStatusCodeEmpty = ['204', '205'].includes(
test.expected.statusCode ? test.expected.statusCode.toString() : undefined
);
const isActualResponseStatusCodeEmpty = ['204', '205'].includes(
test.actual.statusCode ? test.actual.statusCode.toString() : undefined
);
const hasBody = (test.expected.body || test.actual.body);
if ((isExpectedResponseStatusCodeEmpty || isActualResponseStatusCodeEmpty) && hasBody) {
logger.warn(`\
${test.title} HTTP 204 and 205 responses must not \
include a message body: https://tools.ietf.org/html/rfc7231#section-6.3\
`);
}
// Create test message from messages of all validation errors
let message = '';
const object = gavelResult || {};
let validatorOutput;
for (const sectionName of Object.keys(object || {})) {
// Section names are 'statusCode', 'headers', 'body' (and 'version', which is irrelevant)
validatorOutput = object[sectionName];
if (sectionName !== 'version') {
for (const gavelError of validatorOutput.results || []) {
message += `${sectionName}: ${gavelError.message}\n`;
}
}
}
test.message = message;
// Record raw validation output to transaction results object
//
// It looks like the transaction object can already contain 'results'.
// (Needs to be prooved, the assumption is based just on previous
// version of the code.) In that case, we want to save the new validation
// output, but we want to keep at least the original array of Gavel errors.
const results = transaction.results || {};
for (const sectionName of Object.keys(gavelResult || {})) {
// Section names are 'statusCode', 'headers', 'body' (and 'version', which is irrelevant)
const rawValidatorOutput = gavelResult[sectionName];
if (sectionName !== 'version') {
if (!results[sectionName]) { results[sectionName] = {}; }
// We don't want to modify the object and we want to get rid of some
// custom Gavel.js types ('clone' will keep just plain JS objects).
validatorOutput = clone(rawValidatorOutput);
// If transaction already has the 'results' object, ...
if (results[sectionName].results) {
// ...then take all Gavel errors it contains and add them to the array
// of Gavel errors in the new validator output object...
validatorOutput.results = validatorOutput.results.concat(results[sectionName].results);
}
// ...and replace the original validator object with the new one.
results[sectionName] = validatorOutput;
}
}
transaction.results = results;
// Set the validation results and the boolean verdict to the test object
test.results = transaction.results;
test.valid = isValid;
// Propagate test object so 'after' hooks can modify it
transaction.test = test;
callback();
});
});
}
}
module.exports = TransactionRunner;