From 3aadca1bf2a2b1579431cdc1232262f2a7abbc68 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 | 84 ++++++++++++++++++-- 6 files changed, 399 insertions(+), 188 deletions(-) create mode 100644 lib/http-coerce.js diff --git a/lib/dynamic.js b/lib/dynamic.js index fa86bc1..9795463 100644 --- a/lib/dynamic.js +++ b/lib/dynamic.js @@ -32,7 +32,7 @@ function Dynamic(val, ctx) { * Object containing converter functions. */ -Dynamic.converters = []; +Dynamic.converters = {}; /** * Define a named type conversion. The conversion is used when a @@ -51,7 +51,11 @@ Dynamic.converters = []; Dynamic.define = function(name, converter) { converter.typeName = name; - this.converters.unshift(converter); + this.converters[name] = function(val, ctx) { + // Ignore null/undefined + if (name !== 'array' && val === null || val === undefined) return val; + return converter(val, ctx); + }; }; /** @@ -68,19 +72,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); + }); }; /** @@ -100,29 +121,35 @@ 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) { + return Boolean(val); }); -Dynamic.define('number', function convertNumber(val) { - if (val === 0) return val; - if (!val) return val; +Dynamic.define('number', function convertToNumber(val) { return Number(val); }); + +Dynamic.define('integer', function convertToInteger(val) { + return Math.floor(Dynamic.getConverter('number')(val)); +}); + +Dynamic.define('string', function convertToString(val) { + if (typeof val === 'string') return val; + if (val && 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 === undefined || 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 fa6cb4c..ddf6351 100644 --- a/lib/http-context.js +++ b/lib/http-context.js @@ -18,6 +18,7 @@ var inherits = util.inherits; var assert = require('assert'); var ContextBase = require('./context-base'); var Dynamic = require('./dynamic'); +var httpCoerce = require('./http-coerce'); var js2xmlparser = require('js2xmlparser'); var SharedMethod = require('./shared-method'); @@ -120,12 +121,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) { @@ -156,14 +152,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; @@ -172,54 +171,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; } @@ -235,98 +216,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 0839dcf..c3227cd 100644 --- a/lib/http-invocation.js +++ b/lib/http-invocation.js @@ -53,7 +53,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) { @@ -315,7 +314,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) { @@ -324,16 +322,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 14bc67d..edd82a7 100644 --- a/test/http-context.test.js +++ b/test/http-context.test.js @@ -31,6 +31,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', @@ -41,6 +46,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, @@ -53,28 +68,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' @@ -101,7 +167,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 0e7d857..bfd3204 100644 --- a/test/type.test.js +++ b/test/type.test.js @@ -5,6 +5,7 @@ var assert = require('assert'); var Dynamic = require('../lib/dynamic'); +var httpCoerce = require('../lib/http-coerce'); var RemoteObjects = require('../'); describe('types', function() { @@ -29,7 +30,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() { @@ -47,15 +48,17 @@ describe('types', function() { 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); @@ -80,9 +83,76 @@ describe('types', function() { return assert(Number.isNaN(dyn.to('number'))); } - assert.equal(dyn.to('number'), expected); + assert.strictEqual(dyn.to('number'), 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}}); + } + }); });