From b924a79f23a97eaa294bbd60345fb0f0cc77c5a0 Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Tue, 4 Aug 2015 16:38:25 -0500 Subject: [PATCH] Refactor and rework http coercion. 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 --- lib/dynamic.js | 91 +++++++++++++-------- lib/http-coerce.js | 104 ++++++++++++++++++++++++ lib/http-context.js | 161 +++++++------------------------------- lib/http-invocation.js | 14 +--- test/http-context.test.js | 133 +++++++++++++++++++++++++++++-- test/type.test.js | 136 ++++++++++++++++++++++++++++---- 6 files changed, 441 insertions(+), 198 deletions(-) create mode 100644 lib/http-coerce.js diff --git a/lib/dynamic.js b/lib/dynamic.js index 2438da2..d25e063 100644 --- a/lib/dynamic.js +++ b/lib/dynamic.js @@ -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 @@ -46,7 +46,7 @@ Dynamic.converters = []; Dynamic.define = function(name, converter) { converter.typeName = name; - this.converters.unshift(converter); + this.converters[name] = converter; }; /** @@ -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); + }); }; /** @@ -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; +}); diff --git a/lib/http-coerce.js b/lib/http-coerce.js new file mode 100644 index 0000000..e34c094 --- /dev/null +++ b/lib/http-coerce.js @@ -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} 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 +}; diff --git a/lib/http-context.js b/lib/http-context.js index 09c0ba3..15b643a 100644 --- a/lib/http-context.js +++ b/lib/http-context.js @@ -14,6 +14,7 @@ var util = require('util'); var inherits = util.inherits; var assert = require('assert'); var Dynamic = require('./dynamic'); +var httpCoerce = require('./http-coerce'); var js2xmlparser = require('js2xmlparser'); var DEFAULT_SUPPORTED_TYPES = [ 'application/json', 'application/javascript', 'application/xml', @@ -116,12 +117,7 @@ HttpContext.prototype.buildArgs = function(method) { var httpFormat = o.http; var name = o.name || o.arg; var val; - - // Support array types, such as ['string'] - var isArrayType = Array.isArray(o.type); - var otype = isArrayType ? o.type[0] : o.type; - otype = (typeof otype === 'string') && otype; - var isAny = !otype || otype.toLowerCase() === 'any'; + var doSloppyCoerce = true; // This is an http method keyword, which requires special parsing. if (httpFormat) { @@ -151,14 +147,17 @@ HttpContext.prototype.buildArgs = function(method) { val = ctx.req.get(name); break; case 'req': + doSloppyCoerce = false; // complex object // Direct access to http req val = ctx.req; break; case 'res': + doSloppyCoerce = false; // complex object // Direct access to http res val = ctx.res; break; case 'context': + doSloppyCoerce = false; // complex object // Direct access to http context val = ctx; break; @@ -167,54 +166,36 @@ HttpContext.prototype.buildArgs = function(method) { } } else { val = ctx.getArgByName(name, o); - // Safe to coerce the contents of this - if (typeof val === 'object' && (!isArrayType || isAny)) { - val = coerceAll(val); - } } - // If we expect an array type and we received a string, parse it with JSON. - // If that fails, parse it with the arrayItemDelimiters option. - if (val && typeof val === 'string' && isArrayType) { - var parsed = false; - if (val[0] === '[') { - try { - val = JSON.parse(val); - parsed = true; - } catch (e) {} - } - if (!parsed && ctx.options.arrayItemDelimiters) { - // Construct delimiter regex if input was an array. Overwrite option - // so this only needs to happen once. - var delims = this.options.arrayItemDelimiters; - if (Array.isArray(delims)) { - delims = new RegExp(delims.map(escapeRegex).join('|'), 'g'); - this.options.arrayItemDelimiters = delims; - } - - val = val.split(delims); - } - } - - // Coerce dynamic args when input is a string. - if (isAny && typeof val === 'string') { - val = coerceAll(val); + // If this is from the body and we were doing a JSON POST, turn off sloppy coercion. + // This is because JSON, unlike other methods, properly retains types like Numbers, + // Booleans, and null/undefined. + if (ctx.req.body && ctx.req.get('content-type') === 'application/json' && + (ctx.req.body === val || ctx.req.body[name] === val)) { + doSloppyCoerce = false; } - // If the input is not an array, but we were expecting one, create - // an array. Create an empty array if input is empty. - if (!Array.isArray(val) && isArrayType) { - if (val !== undefined && val !== '') val = [val]; - else val = []; + // Most of the time, the data comes through 'sloppy' methods like HTTP headers or a qs + // which don't preserve types. If so, if we are: + // + // * Expecting an array + // * Expecting an object + // * Using the type 'any' + // + // Use some sloppy typing semantics to try to guess what the user meant to send. + if (doSloppyCoerce && + (o.type === 'any' || Array.isArray(o.type) || o.type === 'object')) { + val = httpCoerce(val, o.type, ctx); } - // For boolean and number types, convert certain strings to that type. + // Convert input so it always matches the method definition. // The user can also define new dynamic types. - if (Dynamic.canConvert(otype)) { - val = dynamic(val, otype, ctx); + if (Dynamic.canConvert(o.type)) { + val = new Dynamic(val, ctx).to(o.type); } - // set the argument value + // Set the argument value. args[o.arg] = val; } @@ -230,98 +211,16 @@ HttpContext.prototype.buildArgs = function(method) { HttpContext.prototype.getArgByName = function(name, options) { var req = this.req; - var args = req.params && req.params.args !== undefined ? req.params.args : - req.body && req.body.args !== undefined ? req.body.args : - req.query && req.query.args !== undefined ? req.query.args : - undefined; - - if (args) { - args = JSON.parse(args); - } - - if (typeof args !== 'object' || !args) { - args = {}; - } - var arg = (args && args[name] !== undefined) ? args[name] : - this.req.params[name] !== undefined ? this.req.params[name] : - (this.req.body && this.req.body[name]) !== undefined ? this.req.body[name] : - this.req.query[name] !== undefined ? this.req.query[name] : - this.req.get(name); // search these in order by name - // req.params - // req.body - // req.query - // req.header + var arg = req.params[name] !== undefined ? req.params[name] : // params + (req.body && req.body[name]) !== undefined ? req.body[name] : // body + req.query[name] !== undefined ? req.query[name] : // query + req.get(name); // header return arg; }; -/*! - * Integer test regexp. - */ - -var isint = /^[0-9]+$/; - -/*! - * Float test regexp. - */ - -var isfloat = /^([0-9]+)?\.[0-9]+$/; - -// see http://stackoverflow.com/a/6969486/69868 -function escapeRegex(d) { - return d.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); -} - -// Use dynamic to coerce a value or array of values. -function dynamic(val, toType, ctx) { - if (Array.isArray(val)) { - return val.map(function(v) { - return dynamic(v, toType, ctx); - }); - } - return (new Dynamic(val, ctx)).to(toType); -} - -function coerce(str) { - if (typeof str !== 'string') return str; - if ('null' === str) return null; - if ('true' === str) return true; - if ('false' === str) return false; - if (isfloat.test(str)) return parseFloat(str, 10); - if (isint.test(str) && str.charAt(0) !== '0') return parseInt(str, 10); - return str; -} - -// coerce every string in the given object / array -function coerceAll(obj) { - var type = Array.isArray(obj) ? 'array' : typeof obj; - var i; - var n; - - switch (type) { - case 'string': - return coerce(obj); - case 'object': - if (obj) { - var props = Object.keys(obj); - for (i = 0, n = props.length; i < n; i++) { - var key = props[i]; - obj[key] = coerceAll(obj[key]); - } - } - break; - case 'array': - for (i = 0, n = obj.length; i < n; i++) { - coerceAll(obj[i]); - } - break; - } - - return obj; -} - function buildArgs(ctx, method, fn) { try { return ctx.buildArgs(method); diff --git a/lib/http-invocation.js b/lib/http-invocation.js index d433399..378a34a 100644 --- a/lib/http-invocation.js +++ b/lib/http-invocation.js @@ -50,7 +50,6 @@ function HttpInvocation(method, ctorArgs, args, base, auth) { (method.hasOwnProperty('sharedMethod') && method.sharedMethod.isStatic); var namedArgs = this.namedArgs = {}; var val; - var type; if (!this.isStatic) { method.restClass.ctor.accepts.forEach(function(accept) { @@ -310,7 +309,6 @@ HttpInvocation.prototype.transformResponse = function(res, body, callback) { var ret = returns[i]; var name = ret.name || ret.arg; var val; - var dynamic; var type = ret.type; if (ret.root) { @@ -319,16 +317,8 @@ HttpInvocation.prototype.transformResponse = function(res, body, callback) { val = res.body[name]; } - if (typeof type === 'string' && Dynamic.canConvert(type)) { - dynamic = new Dynamic(val, ctx); - val = dynamic.to(type); - } else if (Array.isArray(type) && Dynamic.canConvert(type[0])) { - type = type[0]; - for (var j = 0, k = val.length; j < k; j++) { - var _val = val[j]; - dynamic = new Dynamic(_val, ctx); - val[j] = dynamic.to(type); - } + if (Dynamic.canConvert(type)) { + val = new Dynamic(val, ctx).to(type); } callbackArgs.push(val); diff --git a/test/http-context.test.js b/test/http-context.test.js index 303b7a7..8ec1310 100644 --- a/test/http-context.test.js +++ b/test/http-context.test.js @@ -26,6 +26,11 @@ describe('HttpContext', function() { input: '0.123456', expectedValue: 0.123456 })); + it('should coerce numbers into strings', givenMethodExpectArg({ + type: 'string', + input: 123456, + expectedValue: '123456' + })); it('should coerce number strings preceded by 0 into numbers', givenMethodExpectArg({ type: 'number', input: '000123', @@ -36,6 +41,16 @@ describe('HttpContext', function() { input: 'null', expectedValue: 'null' })); + it('should not coerce null into the null string', givenMethodExpectArg({ + type: 'string', + input: null, + expectedValue: null + })); + it('should not coerce undefined into the undefined string', givenMethodExpectArg({ + type: 'string', + input: undefined, + expectedValue: undefined + })); it('should coerce array types properly with non-array input', givenMethodExpectArg({ type: ['string'], input: 123, @@ -48,28 +63,79 @@ describe('HttpContext', function() { })); }); - describe('arguments without a defined type (or any)', function() { - it('should coerce boolean strings into actual booleans', givenMethodExpectArg({ + describe('don\'t coerce arguments without a defined type (or any) in JSON', function() { + it('should not coerce boolean strings into actual booleans', givenMethodExpectArg({ + type: 'any', + input: 'true', + expectedValue: 'true' + })); + it('should not coerce integer strings into actual numbers', givenMethodExpectArg({ + type: 'any', + input: '123456', + expectedValue: '123456' + })); + it('should not coerce float strings into actual numbers', givenMethodExpectArg({ + type: 'any', + input: '0.123456', + expectedValue: '0.123456' + })); + it('should not coerce null strings into null', givenMethodExpectArg({ + type: 'any', + input: 'null', + expectedValue: 'null' + })); + }); + + describe('coerce arguments without a defined type (or any) in formdata', function() { + it('should coerce boolean strings into actual booleans', givenFormDataExpectArg({ type: 'any', input: 'true', expectedValue: true })); - it('should coerce integer strings into actual numbers', givenMethodExpectArg({ + it('should coerce integer strings into actual numbers', givenFormDataExpectArg({ type: 'any', input: '123456', expectedValue: 123456 })); - it('should coerce float strings into actual numbers', givenMethodExpectArg({ + it('should coerce float strings into actual numbers', givenFormDataExpectArg({ type: 'any', input: '0.123456', expectedValue: 0.123456 })); - it('should coerce null strings into null', givenMethodExpectArg({ + it('should coerce null strings into null', givenFormDataExpectArg({ type: 'any', input: 'null', expectedValue: null })); - it('should coerce number strings preceded by 0 into strings', givenMethodExpectArg({ + it('should coerce number strings preceded by 0 into strings', givenFormDataExpectArg({ + type: 'any', + input: '000123', + expectedValue: '000123' + })); + }); + + describe('coerce arguments without a defined type (or any) in QS', function() { + it('should coerce boolean strings into actual booleans', givenQSExpectArg({ + type: 'any', + input: 'true', + expectedValue: true + })); + it('should coerce integer strings into actual numbers', givenQSExpectArg({ + type: 'any', + input: '123456', + expectedValue: 123456 + })); + it('should coerce float strings into actual numbers', givenQSExpectArg({ + type: 'any', + input: '0.123456', + expectedValue: 0.123456 + })); + it('should coerce null strings into null', givenQSExpectArg({ + type: 'any', + input: 'null', + expectedValue: null + })); + it('should coerce number strings preceded by 0 into strings', givenQSExpectArg({ type: 'any', input: '000123', expectedValue: '000123' @@ -96,7 +162,62 @@ describe('HttpContext', function() { }); }); +// Tests sending JSON - should be strict conversions function givenMethodExpectArg(options) { + return function(done) { + var method = new SharedMethod(noop, 'testMethod', noop, { + accepts: [{arg: 'testArg', type: options.type}] + }); + + var app = require('express')(); + app.use(require('body-parser').json()); + + app.post('/', function(req, res) { + var ctx = new HttpContext(req, res, method); + try { + expect(ctx.args.testArg).to.eql(options.expectedValue); + } catch (e) { + return done(e); + } + done(); + }); + + request(app).post('/') + .type('json') + .send({testArg: options.input}) + .end(); + }; +} + +// Tests sending via formdata - should be sloppy conversions +function givenFormDataExpectArg(options) { + return function(done) { + var method = new SharedMethod(noop, 'testMethod', noop, { + accepts: [{arg: 'testArg', type: options.type}] + }); + + var app = require('express')(); + app.use(require('body-parser').urlencoded({extended: false})); + + app.post('/', function(req, res) { + var ctx = new HttpContext(req, res, method); + try { + expect(ctx.args.testArg).to.eql(options.expectedValue); + } catch (e) { + return done(e); + } + done(); + }); + + request(app).post('/') + .type('form') + .send({testArg: options.input}) + .end(); + }; +} + +// Tests sending via querystring - should be sloppy conversions +function givenQSExpectArg(options) { return function(done) { var method = new SharedMethod(noop, 'testMethod', noop, { accepts: [{arg: 'testArg', type: options.type}] diff --git a/test/type.test.js b/test/type.test.js index e948626..3072a36 100644 --- a/test/type.test.js +++ b/test/type.test.js @@ -1,5 +1,6 @@ var assert = require('assert'); var Dynamic = require('../lib/dynamic'); +var httpCoerce = require('../lib/http-coerce'); var RemoteObjects = require('../'); describe('types', function() { @@ -24,7 +25,7 @@ describe('types', function() { return 'boop'; }); var dyn = new Dynamic('beep'); - assert.equal(dyn.to('beep'), 'boop'); + assert.strictEqual(dyn.to('beep'), 'boop'); }); }); describe('Dynamic.canConvert(typeName)', function() { @@ -36,28 +37,30 @@ describe('types', function() { }); describe('Built in converters', function() { it('should convert Boolean values', function() { + var shouldConvert = _shouldConvert('boolean'); + shouldConvert(true, true); shouldConvert(false, false); shouldConvert(256, true); shouldConvert(-1, true); shouldConvert(1, true); shouldConvert(0, false); + shouldConvert('', false); + // Expect all these string arguments to return true as we are + // no longer doing sloppy conversion in Dynamic shouldConvert('true', true); - shouldConvert('false', false); - shouldConvert('0', false); + shouldConvert('false', true); + shouldConvert('0', true); shouldConvert('1', true); shouldConvert('-1', true); shouldConvert('256', true); - shouldConvert('null', false); - shouldConvert('undefined', false); - shouldConvert('', false); + shouldConvert('null', true); + shouldConvert('undefined', true); - function shouldConvert(val, expected) { - var dyn = new Dynamic(val); - assert.equal(dyn.to('boolean'), expected); - } }); it('should convert Number values', function() { + var shouldConvert = _shouldConvert('number'); + shouldConvert('-1', -1); shouldConvert('0', 0); shouldConvert('1', 1); @@ -67,17 +70,116 @@ describe('types', function() { shouldConvert(false, 0); shouldConvert({}, 'NaN'); shouldConvert([], 0); + }); - function shouldConvert(val, expected) { - var dyn = new Dynamic(val); + it('should convert Array values', function() { + var shouldConvert = _shouldConvert('array'); - if (expected === 'NaN') { - return assert(Number.isNaN(dyn.to('number'))); - } + shouldConvert(0, [0]); + shouldConvert('a', ['a']); + shouldConvert(false, [false]); + shouldConvert(null, []); + shouldConvert(undefined, []); + shouldConvert('', []); + shouldConvert(['a'], ['a']); + }); - assert.equal(dyn.to('number'), expected); - } + it('should convert Arrays of types', function() { + var shouldConvert = _shouldConvert(['string']); + + shouldConvert(['a', 0, 1, true], ['a', '0', '1', 'true'], ['string']); }); + + it('should only convert Arrays one layer deep', function() { + var shouldConvert = _shouldConvert(['string']); + + shouldConvert(['0', 1, [2, '3']], ['0', '1', '2,3']); + }); + }); + + // Assert builder for Dynamic + function _shouldConvert(type) { + return function(val, expected) { + val = new Dynamic(val).to(type); + + if (expected === 'NaN') return assert(Number.isNaN(val)); + + if (Array.isArray(type) || type === 'array') { + assert.deepEqual(val, expected); + for (var i = 0; i < val.length; i++) { + assert.strictEqual(val[i], expected[i]); + } + } else { + assert.strictEqual(val, expected); + } + }; + } + }); + + describe('Sloppy HTTP converter', function() { + it('should convert strings to primitives', function() { + shouldConvert('true', true); + shouldConvert('false', false); + shouldConvert('0', 0); + shouldConvert('-0', 0); + shouldConvert('1', 1); + shouldConvert('-1', -1); + shouldConvert('256', 256); + shouldConvert('1.022', 1.022); + shouldConvert('0.49', 0.49); + shouldConvert('null', null); + shouldConvert('undefined', undefined); + }); + + // See https://github.com/strongloop/strong-remoting/issues/223 + it('should not convert numbers larger than Number.MAX_SAFE_INTEGER', function() { + shouldConvert('2343546576878989879789', '2343546576878989879789'); + }); + + it('should not convert ints with leading zeroes', function() { + shouldConvert('0291', '0291'); + }); + + it('should not attempt to convert arrays unless given array target type', function() { + shouldConvert('["a","b","c"]', '["a","b","c"]'); }); + + it('should coerce JSON-parsable strings', function() { + shouldConvertArray('["a","b","c"]', ['a', 'b', 'c']); + shouldConvertArray('[1,2,3]', [1, 2, 3]); + shouldConvertArray('["a","b",3]', ['a', 'b', 3]); + // Note lack of 'undefined' which is not valid JSON + shouldConvertArray('[false,true,3,"c",null,"hello"]', + [false, true, 3, 'c', null, 'hello']); + }); + + it('should coerce strings with arrayItemDelimiters', function() { + shouldConvertArrayWithDelims('1,2,3', [1, 2, 3], [',']); + shouldConvertArrayWithDelims('a,b,3', ['a', 'b', 3], [',']); + shouldConvertArrayWithDelims('false,true,3,c,undefined,null,hello', + [false, true, 3, 'c', undefined, null, 'hello'], [',']); + shouldConvertArrayWithDelims('false,true,3,c,undefined;null-hello', + [false, true, 3, 'c', undefined, null, 'hello'], [',', ';', '-']); + }); + + it('should fail to parse invalid json', function() { + // Should still have array wrappter though + shouldConvertArray('[a,b,3]', ['[a,b,3]']); + }); + + function shouldConvert(val, expected, type, ctx) { + val = httpCoerce(val, type || 'any', ctx || {}); + // Believe it or not, deepEqual will actually match '1' and 1, so check types + assert.strictEqual(typeof val, typeof expected); + assert.deepEqual(val, expected); + } + + function shouldConvertArray(val, expected, ctx) { + return shouldConvert(val, expected, ['any'], ctx); + } + + function shouldConvertArrayWithDelims(val, expected, delims) { + return shouldConvertArray(val, expected, {options: {arrayItemDelimiters: delims}}); + } }); });