'use strict';
const stringify = require('safe-stable-stringify'),
safeRegex = require('safe-regex'),
jsonpath = require('./jsonpath.js'),
helpers = require('../util/helpers.js'),
xPath = require('./xpath.js'),
combinators = require('../util/combinators.js'),
errors = require('../util/errors.js'),
compatibility = require('./compatibility.js');
/**
* All the predicates that determine whether a stub matches a request
* @module
*/
function sortObjects (a, b) {
const isObject = helpers.isObject;
if (isObject(a) && isObject(b)) {
// Make best effort at sorting arrays of objects to make
// deepEquals order-independent
return sortObjects(stringify(a), stringify(b));
}
else if (a < b) {
return -1;
}
else {
return 1;
}
}
function forceStrings (value) {
const isObject = helpers.isObject;
if (value === null) {
return 'null';
}
else if (Array.isArray(value)) {
return value.map(forceStrings);
}
else if (isObject(value)) {
return Object.keys(value).reduce((accumulator, key) => {
accumulator[key] = forceStrings(value[key]);
return accumulator;
}, {});
}
else if (typeof value.toString === 'function') {
return value.toString();
}
else {
return value;
}
}
function select (type, selectFn, encoding) {
if (encoding === 'base64') {
throw errors.ValidationError(`the ${type} predicate parameter is not allowed in binary mode`);
}
const nodeValues = selectFn();
// Return either a string if one match or array if multiple
// This matches the behavior of node's handling of query parameters,
// which allows us to maintain the same semantics between deepEquals
// (all have to match, passing in an array if necessary) and the other
// predicates (any can match)
if (nodeValues && nodeValues.length === 1) {
return nodeValues[0];
}
else {
return nodeValues;
}
}
function orderIndependent (possibleArray) {
if (Array.isArray(possibleArray)) {
return possibleArray.sort(sortObjects);
}
else {
return possibleArray;
}
}
function transformObject (obj, transform) {
Object.keys(obj).forEach(key => {
obj[key] = transform(obj[key]);
});
return obj;
}
function selectXPath (config, encoding, text) {
const selectFn = combinators.curry(xPath.select, config.selector, config.ns, text);
return orderIndependent(select('xpath', selectFn, encoding));
}
function selectTransform (config, options, logger) {
const cloned = helpers.clone(config);
if (config.jsonpath) {
const stringTransform = options.shouldForceStrings ? forceStrings : combinators.identity;
// use keyCaseSensitive instead of caseSensitive to help "matches" predicates too
// see https://github.com/bbyars/mountebank/issues/361
if (!cloned.keyCaseSensitive) {
cloned.jsonpath.selector = cloned.jsonpath.selector.toLowerCase();
}
return combinators.curry(selectJSONPath, cloned.jsonpath, options.encoding, config, stringTransform, logger);
}
else if (config.xpath) {
if (!cloned.caseSensitive) {
cloned.xpath.ns = transformObject(cloned.xpath.ns || {}, lowercase);
cloned.xpath.selector = cloned.xpath.selector.toLowerCase();
}
return combinators.curry(selectXPath, cloned.xpath, options.encoding);
}
else {
return combinators.identity;
}
}
function lowercase (text) {
return text.toLowerCase();
}
function caseTransform (config) {
return config.caseSensitive ? combinators.identity : lowercase;
}
function exceptTransform (config, logger) {
const exceptRegexOptions = config.caseSensitive ? 'g' : 'gi';
if (config.except) {
if (!safeRegex(config.except)) {
logger.warn(`If mountebank becomes unresponsive, it is because of this unsafe regular expression: ${config.except}`);
}
return text => text.replace(new RegExp(config.except, exceptRegexOptions), '');
}
else {
return combinators.identity;
}
}
function encodingTransform (encoding) {
if (encoding === 'base64') {
return text => Buffer.from(text, 'base64').toString();
}
else {
return combinators.identity;
}
}
function tryJSON (value, predicateConfig, logger) {
try {
const keyCaseTransform = predicateConfig.keyCaseSensitive === false ? lowercase : caseTransform(predicateConfig),
valueTransforms = [exceptTransform(predicateConfig, logger), caseTransform(predicateConfig)];
// We can't call normalize because we want to avoid the array sort transform,
// which will mess up indexed selectors like $..title[1]
return transformAll(JSON.parse(value), [keyCaseTransform], valueTransforms, []);
}
catch (e) {
return value;
}
}
// eslint-disable-next-line max-params
function selectJSONPath (config, encoding, predicateConfig, stringTransform, logger, text) {
const possibleJSON = stringTransform(tryJSON(text, predicateConfig, logger)),
selectFn = combinators.curry(jsonpath.select, config.selector, possibleJSON);
return orderIndependent(select('jsonpath', selectFn, encoding));
}
function transformAll (obj, keyTransforms, valueTransforms, arrayTransforms) {
const apply = fns => combinators.compose.apply(null, fns),
isObject = helpers.isObject;
if (Array.isArray(obj)) {
return apply(arrayTransforms)(obj.map(element => transformAll(element, keyTransforms, valueTransforms, arrayTransforms)));
}
else if (isObject(obj)) {
return Object.keys(obj).reduce((accumulator, key) => {
accumulator[apply(keyTransforms)(key)] = transformAll(obj[key], keyTransforms, valueTransforms, arrayTransforms);
return accumulator;
}, {});
}
else if (typeof obj === 'string') {
return apply(valueTransforms)(obj);
}
else {
return obj;
}
}
function normalize (obj, config, options, logger) {
// Needed to solve a tricky case conversion for "matches" predicates with jsonpath/xpath parameters
if (typeof config.keyCaseSensitive === 'undefined') {
config.keyCaseSensitive = config.caseSensitive;
}
const keyCaseTransform = config.keyCaseSensitive === false ? lowercase : caseTransform(config),
sortTransform = array => array.sort(sortObjects),
transforms = [];
if (options.withSelectors) {
transforms.push(selectTransform(config, options, logger));
}
transforms.push(exceptTransform(config, logger));
transforms.push(caseTransform(config));
transforms.push(encodingTransform(options.encoding));
// sort to provide deterministic comparison for deepEquals,
// where the order in the array for multi-valued querystring keys
// and xpath selections isn't important
return transformAll(obj, [keyCaseTransform], transforms, [sortTransform]);
}
function testPredicate (expected, actual, predicateConfig, predicateFn) {
if (!helpers.defined(actual)) {
actual = '';
}
if (helpers.isObject(expected)) {
return predicateSatisfied(expected, actual, predicateConfig, predicateFn);
}
else {
return predicateFn(expected, actual);
}
}
function bothArrays (expected, actual) {
return Array.isArray(actual) && Array.isArray(expected);
}
function allExpectedArrayValuesMatchActualArray (expectedArray, actualArray, predicateConfig, predicateFn) {
return expectedArray.every(expectedValue =>
actualArray.some(actualValue => testPredicate(expectedValue, actualValue, predicateConfig, predicateFn)));
}
function onlyActualIsArray (expected, actual) {
return Array.isArray(actual) && !Array.isArray(expected);
}
function expectedMatchesAtLeastOneValueInActualArray (expected, actualArray, predicateConfig, predicateFn) {
return actualArray.some(actual => testPredicate(expected, actual, predicateConfig, predicateFn));
}
function expectedLeftOffArraySyntaxButActualIsArrayOfObjects (expected, actual, fieldName) {
return !Array.isArray(expected[fieldName]) && !helpers.defined(actual[fieldName]) && Array.isArray(actual);
}
function predicateSatisfied (expected, actual, predicateConfig, predicateFn) {
if (!actual) {
return false;
}
// Support predicates that reach into fields encoded in JSON strings (e.g. HTTP bodies)
if (typeof actual === 'string') {
actual = tryJSON(actual, predicateConfig);
}
return Object.keys(expected).every(fieldName => {
const isObject = helpers.isObject;
if (bothArrays(expected[fieldName], actual[fieldName])) {
return allExpectedArrayValuesMatchActualArray(
expected[fieldName], actual[fieldName], predicateConfig, predicateFn);
}
else if (onlyActualIsArray(expected[fieldName], actual[fieldName])) {
if (predicateConfig.exists && expected[fieldName]) {
return true;
}
else {
return expectedMatchesAtLeastOneValueInActualArray(
expected[fieldName], actual[fieldName], predicateConfig, predicateFn);
}
}
else if (expectedLeftOffArraySyntaxButActualIsArrayOfObjects(expected, actual, fieldName)) {
// This is a little confusing, but predated the ability for users to specify an
// array for the expected values and is left for backwards compatibility.
// The predicate might be:
// { equals: { examples: { key: 'third' } } }
// and the request might be
// { examples: '[{ "key": "first" }, { "different": true }, { "key": "third" }]' }
// We expect that the "key" field in the predicate definition matches any object key
// in the actual array
return expectedMatchesAtLeastOneValueInActualArray(expected, actual, predicateConfig, predicateFn);
}
else if (isObject(expected[fieldName])) {
return predicateSatisfied(expected[fieldName], actual[fieldName], predicateConfig, predicateFn);
}
else {
return testPredicate(expected[fieldName], actual[fieldName], predicateConfig, predicateFn);
}
});
}
function create (operator, predicateFn) {
return (predicate, request, encoding, logger) => {
const expected = normalize(predicate[operator], predicate, { encoding: encoding }, logger),
actual = normalize(request, predicate, { encoding: encoding, withSelectors: true }, logger);
return predicateSatisfied(expected, actual, predicate, predicateFn);
};
}
function deepEquals (predicate, request, encoding, logger) {
const expected = normalize(forceStrings(predicate.deepEquals), predicate, { encoding: encoding }, logger),
actual = normalize(forceStrings(request), predicate, { encoding: encoding, withSelectors: true, shouldForceStrings: true }, logger),
isObject = helpers.isObject;
return Object.keys(expected).every(fieldName => {
// Support predicates that reach into fields encoded in JSON strings (e.g. HTTP bodies)
if (isObject(expected[fieldName]) && typeof actual[fieldName] === 'string') {
const possibleJSON = tryJSON(actual[fieldName], predicate);
actual[fieldName] = normalize(forceStrings(possibleJSON), predicate, { encoding: encoding }, logger);
}
return stringify(expected[fieldName]) === stringify(actual[fieldName]);
});
}
function matches (predicate, request, encoding, logger) {
// We want to avoid the lowerCase transform on values so we don't accidentally butcher
// a regular expression with upper case metacharacters like \W and \S
// However, we need to maintain the case transform for keys like http header names (issue #169)
// eslint-disable-next-line no-unneeded-ternary
const caseSensitive = predicate.caseSensitive ? true : false, // convert to boolean even if undefined
clone = helpers.merge(predicate, { caseSensitive: true, keyCaseSensitive: caseSensitive }),
noexcept = helpers.merge(clone, { except: '' }),
expected = normalize(predicate.matches, noexcept, { encoding: encoding }, logger),
actual = normalize(request, clone, { encoding: encoding, withSelectors: true }, logger),
options = caseSensitive ? '' : 'i';
if (encoding === 'base64') {
throw errors.ValidationError('the matches predicate is not allowed in binary mode');
}
return predicateSatisfied(expected, actual, clone, (a, b) => {
if (!safeRegex(a)) {
logger.warn(`If mountebank becomes unresponsive, it is because of this unsafe regular expression: ${a}`);
}
return new RegExp(a, options).test(b);
});
}
function not (predicate, request, encoding, logger, imposterState) {
return !evaluate(predicate.not, request, encoding, logger, imposterState);
}
function evaluateFn (request, encoding, logger, imposterState) {
return subPredicate => evaluate(subPredicate, request, encoding, logger, imposterState);
}
function or (predicate, request, encoding, logger, imposterState) {
return predicate.or.some(evaluateFn(request, encoding, logger, imposterState));
}
function and (predicate, request, encoding, logger, imposterState) {
return predicate.and.every(evaluateFn(request, encoding, logger, imposterState));
}
function inject (predicate, request, encoding, logger, imposterState) {
if (request.isDryRun === true) {
return true;
}
const config = {
request: helpers.clone(request),
state: imposterState,
logger: logger
};
compatibility.downcastInjectionConfig(config);
const injected = `(${predicate.inject})(config, logger, imposterState);`;
try {
return eval(injected);
}
catch (error) {
logger.error(`injection X=> ${error}`);
logger.error(` source: ${JSON.stringify(injected)}`);
logger.error(` config.request: ${JSON.stringify(config.request)}`);
logger.error(` config.state: ${JSON.stringify(config.state)}`);
throw errors.InjectionError('invalid predicate injection', { source: injected, data: error.message });
}
}
function toString (value) {
if (value !== null && typeof value !== 'undefined' && typeof value.toString === 'function') {
return value.toString();
}
else {
return value;
}
}
const predicates = {
equals: create('equals', (expected, actual) => toString(expected) === toString(actual)),
deepEquals,
contains: create('contains', (expected, actual) => actual.indexOf(expected) >= 0),
startsWith: create('startsWith', (expected, actual) => actual.indexOf(expected) === 0),
endsWith: create('endsWith', (expected, actual) => actual.indexOf(expected, actual.length - expected.length) >= 0),
matches,
exists: create('exists', function (expected, actual) {
return expected ? (typeof actual !== 'undefined' && actual !== '') : (typeof actual === 'undefined' || actual === '');
}),
not,
or,
and,
inject
};
/**
* Resolves all predicate keys in given predicate
* @param {Object} predicate - The predicate configuration
* @param {Object} request - The protocol request object
* @param {string} encoding - utf8 or base64
* @param {Object} logger - The logger, useful for debugging purposes
* @param {Object} imposterState - The current state for the imposter
* @returns {boolean}
*/
function evaluate (predicate, request, encoding, logger, imposterState) {
const predicateFn = Object.keys(predicate).find(key => Object.keys(predicates).indexOf(key) >= 0),
clone = helpers.clone(predicate);
if (predicateFn) {
return predicates[predicateFn](clone, request, encoding, logger, imposterState);
}
else {
throw errors.ValidationError('missing predicate', { source: predicate });
}
}
module.exports = { evaluate };