Skip to content

Commit

Permalink
Refactor and rework http coercion.
Browse files Browse the repository at this point in the history
This fixes a number of subtle bugs and restricts "sloppy"
argument coercion (e.g. 'true' to the bool true) to string-only
HTTP datasources like querystrings and headers.

Fixes #223 (coerced Number past MAX_SAFE_INTEGER)
Possible fix for #208
  • Loading branch information
STRML authored and bajtos committed Dec 14, 2015
1 parent 32efdc4 commit b924a79
Show file tree
Hide file tree
Showing 6 changed files with 441 additions and 198 deletions.
91 changes: 59 additions & 32 deletions lib/dynamic.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function Dynamic(val, ctx) {
* Object containing converter functions.
*/

Dynamic.converters = [];
Dynamic.converters = {};

/**
* Define a named type conversion. The conversion is used when a
Expand All @@ -46,7 +46,7 @@ Dynamic.converters = [];

Dynamic.define = function(name, converter) {
converter.typeName = name;
this.converters.unshift(converter);
this.converters[name] = converter;
};

/**
Expand All @@ -63,19 +63,36 @@ Dynamic.canConvert = function(type) {
/**
* Get converter by type name.
*
* @param {String} type
* If passed an array, will return an array, all coerced to the single
* item in the array. More than one type in an array is not supported.
*
* @param {String|Array} type
* @returns {Function}
*/

Dynamic.getConverter = function(type) {
var converters = this.converters;
var converter;
for (var i = 0; i < converters.length; i++) {
converter = converters[i];
if (converter.typeName === type) {
return converter;
if (Array.isArray(type) && this.converters[type[0]]) {
if (type.length !== 1) {
throw new Error('Coercing to an array type with more than 1 value is unsupported.');
}
return this.getArrayConverter.bind(this, this.converters[type[0]]);
}
return this.converters[type];
};

/**
* If the type passed is an array, get a converter that returns an array.
* Type coercion will be one layer deep: ['a', 2, ['c', 4]] with type
* ['string'] coerces to ['a', '2', '[c,4]'].
* @param {Function} converter Non-array converter fn.
* @param {*} val The value object
* @param {Context} ctx The Remote Context
* @return {Function} Converter
*/
Dynamic.getArrayConverter = function(converter, val, ctx) {
if (!Array.isArray(val)) val = this.converters.array(val, ctx);
return val.map(function(v) {
return converter(v, ctx);
});
};

/**
Expand All @@ -95,29 +112,39 @@ Dynamic.prototype.to = function(type) {
* Built in type converters...
*/

Dynamic.define('boolean', function convertBoolean(val) {
switch (typeof val) {
case 'string':
switch (val) {
case 'false':
case 'undefined':
case 'null':
case '0':
case '':
return false;
default:
return true;
}
break;
case 'number':
return val !== 0;
default:
return Boolean(val);
}
Dynamic.define('boolean', function convertToBoolean(val) {
if (val == null) return val;
return Boolean(val);
});

Dynamic.define('number', function convertNumber(val) {
if (val === 0) return val;
if (!val) return val;
Dynamic.define('number', function convertToNumber(val) {
if (val == null) return val;
return Number(val);
});

Dynamic.define('integer', function convertToInteger(val) {
if (val == null) return val;
return Math.floor(Dynamic.getConverter('number')(val));
});

Dynamic.define('string', function convertToString(val) {
if (val == null) return val;
if (typeof val === 'string') return val;
if (typeof val.toString === 'function' &&
val.toString !== Object.prototype.toString) return val.toString();
if (val && typeof val === 'object') return JSON.stringify(val);
throw new Error('Could not properly convert ' + val + ' to a string.');
});

Dynamic.define('array', function convertToArray(val, ctx) {
if (val == null || val === '') return [];
if (Array.isArray(val)) return val;

// This is not a sloppy conversion, so just wrap in array if it isn't already one.
return [val];
});

// Defined so we can use a type like ['any']
Dynamic.define('any', function noop(val) {
return val;
});
104 changes: 104 additions & 0 deletions lib/http-coerce.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Expose `httpCoerce`
*/

module.exports = httpCoerce;

/**
* Do a sloppy string coercion into a target type.
* Useful for params sent via HTTP params, querystring, headers, or non-JSON body.
*
* @param {*} val Value to coerce. Only works on strings, just returns non-string values.
* @param {string|Array<String>} type Type to coerce to.
* @param {Context} HTTP Context.
*/

function httpCoerce(val, type, ctx) {
// If an array type if defined, regardless of what type it is, try to coerce the string
// into an array.
if (Array.isArray(type)) {
val = coerceArray(val, ctx);
// Members may need to be coerced as well.
val = val.map(function(v) {
return httpCoerce(v, type[0], ctx);
});
} else if (type === 'any' || type === 'object') {
if (val && typeof val === 'object') {
// Objects should have all their members coerced.
var props = Object.keys(val);
for (var i = 0, n = props.length; i < n; i++) {
var key = props[i];
val[key] = httpCoerce(val[key], 'any', ctx);
}
} else {
// If the type specified is 'any', do sloppy string conversion.
val = coerceString(val);
}
}
return val;
}

/*!
* Integer test regexp. Doesn't match if number has a leading zero.
*/

var isInt = /^\-?(?:[0-9]|[1-9][0-9]*)$/;

/*!
* Float test regexp.
*/

var isFloat = /^([0-9]+)?\.[0-9]+$/;
var MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER || Math.pow(2, 53) - 1;

function coerceString(val) {
if (typeof val !== 'string') return val;
if (coerceMap.hasOwnProperty(val)) return coerceMap[val];
if (isFloat.test(val) || isInt.test(val)) {
var out = Number(val);
// Cap at MAX_SAFE_INTEGER so we don't lose precision.
if (out > MAX_SAFE_INTEGER) out = val;
return out;
}
// Nothing matched; return string.
return val;
}

function coerceArray(val, ctx) {
if (val === undefined || val === null || val === '') return [];
if (Array.isArray(val)) return val;
// If it looks like an array, try to parse it.
if (val[0] === '[') {
try {
return JSON.parse(val);
} catch (ex) { /* Do nothing */ }
}

// The user may set delimiters like ',', or ';' to designate array items
// for easier usage.
var delims = ctx.options && ctx.options.arrayItemDelimiters;
if (delims) {
// Construct delimiter regex if input was an array. Overwrite option
// so this only needs to happen once.
if (Array.isArray(delims)) {
delims = new RegExp(delims.map(escapeRegex).join('|'), 'g');
ctx.options.arrayItemDelimiters = delims;
}
return val.split(delims);
}
// Alright, not array-like, just wrap it in an array on the way out.
return [val];
}

// see http://stackoverflow.com/a/6969486/69868
function escapeRegex(d) {
return d.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&');
}

// Map of some values to convert directly to primitives.
var coerceMap = {
'false': false,
'true': true,
'undefined': undefined,
'null': null
};
Loading

0 comments on commit b924a79

Please sign in to comment.