Skip to content

Commit 34b0665

Browse files
committed
Fix improper type coercions, especially on array types.
This moves the `arrayItemDelimiters` configuration into http-context.js, as it is an HTTP implementation detail like querystring parsing and is not generalizable to remote methods exposed in other ways. This PR also makes argument coercion more robust for any, object, and array types.
1 parent 29d63b9 commit 34b0665

File tree

4 files changed

+227
-76
lines changed

4 files changed

+227
-76
lines changed

lib/http-context.js

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ function HttpContext(req, res, method, options) {
3434
this.req = req;
3535
this.res = res;
3636
this.method = method;
37+
this.options = options || {};
3738
this.args = this.buildArgs(method);
3839
this.methodString = method.stringName;
39-
this.options = options || {};
4040
this.supportedTypes = this.options.supportedTypes || DEFAULT_SUPPORTED_TYPES;
4141

4242
if (this.supportedTypes === DEFAULT_SUPPORTED_TYPES && !this.options.xml) {
@@ -69,6 +69,13 @@ HttpContext.prototype.buildArgs = function(method) {
6969
var name = o.name || o.arg;
7070
var val;
7171

72+
// Support array types, such as ['string']
73+
var isArrayType = Array.isArray(o.type);
74+
var otype = isArrayType ? o.type[0] : o.type;
75+
otype = (typeof otype === 'string') && otype.toLowerCase();
76+
var isAny = !otype || otype === 'any';
77+
78+
// This is an http method keyword, which requires special parsing.
7279
if (httpFormat) {
7380
switch (typeof httpFormat) {
7481
case 'function':
@@ -112,21 +119,51 @@ HttpContext.prototype.buildArgs = function(method) {
112119
}
113120
} else {
114121
val = ctx.getArgByName(name, o);
122+
// Safe to coerce the contents of this
123+
if (typeof val === 'object' && (!isArrayType || isAny)) {
124+
val = coerceAll(val);
125+
}
115126
}
116127

117-
// cast booleans and numbers
118-
var dynamic;
119-
var otype = (typeof o.type === 'string') && o.type.toLowerCase();
120-
var isAny = !otype || otype === 'any';
128+
// If we expect an array type and we received a string, parse it with JSON.
129+
// If that fails, parse it with the arrayItemDelimiters option.
130+
if (val && typeof val === 'string' && isArrayType) {
131+
var parsed = false;
132+
if (val[0] === '[') {
133+
try {
134+
val = JSON.parse(val);
135+
parsed = true;
136+
} catch (e) {}
137+
}
138+
if (!parsed && ctx.options.arrayItemDelimiters) {
139+
// Construct delimiter regex if input was an array. Overwrite option
140+
// so this only needs to happen once.
141+
var delims = this.options.arrayItemDelimiters;
142+
if (Array.isArray(delims)) {
143+
delims = new RegExp(delims.map(escapeRegex).join('|'), 'g');
144+
this.options.arrayItemDelimiters = delims;
145+
}
146+
147+
val = val.split(delims);
148+
}
149+
}
121150

122-
// try to coerce dynamic args when input is a string
151+
// Coerce dynamic args when input is a string.
123152
if (isAny && typeof val === 'string') {
124-
val = coerce(val);
153+
val = coerceAll(val);
125154
}
126155

156+
// If the input is not an array, but we were expecting one, create
157+
// an array. Create an empty array if input is empty.
158+
if (!Array.isArray(val) && isArrayType) {
159+
if (val !== undefined && val !== '') val = [val];
160+
else val = [];
161+
}
162+
163+
// For boolean and number types, convert certain strings to that type.
164+
// The user can also define new dynamic types.
127165
if (Dynamic.canConvert(otype)) {
128-
dynamic = new Dynamic(val, ctx);
129-
val = dynamic.to(otype);
166+
val = dynamic(val, otype, ctx);
130167
}
131168

132169
// set the argument value
@@ -169,11 +206,6 @@ HttpContext.prototype.getArgByName = function(name, options) {
169206
// req.query
170207
// req.header
171208

172-
// coerce simple types in objects
173-
if (typeof arg === 'object') {
174-
arg = coerceAll(arg);
175-
}
176-
177209
return arg;
178210
};
179211

@@ -189,6 +221,21 @@ var isint = /^[0-9]+$/;
189221

190222
var isfloat = /^([0-9]+)?\.[0-9]+$/;
191223

224+
// see http://stackoverflow.com/a/6969486/69868
225+
function escapeRegex(d) {
226+
return d.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&');
227+
}
228+
229+
// Use dynamic to coerce a value or array of values.
230+
function dynamic(val, toType, ctx) {
231+
if (Array.isArray(val)) {
232+
return val.map(function(v) {
233+
return dynamic(v, toType, ctx);
234+
});
235+
}
236+
return (new Dynamic(val, ctx)).to(toType);
237+
}
238+
192239
function coerce(str) {
193240
if (typeof str !== 'string') return str;
194241
if ('null' === str) return null;

lib/shared-method.js

Lines changed: 63 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -162,68 +162,14 @@ SharedMethod.prototype.invoke = function(scope, args, remotingOptions, cb) {
162162
for (var i = 0; i < accepts.length; i++) {
163163
var desc = accepts[i];
164164
var name = desc.name || desc.arg;
165-
var targetType = desc.type;
166165
var uarg = SharedMethod.convertArg(desc, args[name]);
167-
var actualType = SharedMethod.getType(uarg);
168-
169-
// is the arg optional?
170-
// arg was not provided
171-
if (actualType === 'undefined') {
172-
if (desc.required) {
173-
return cb(badArgumentError(name + ' is a required arg'));
174-
} else {
175-
// Add the argument even if it's undefined to stick with the accepts
176-
formattedArgs.push(undefined);
177-
continue;
178-
}
179-
}
180-
181-
if (actualType === 'number' && Number.isNaN(uarg)) {
182-
return cb(badArgumentError(name + ' must be a number'));
183-
}
184166

185-
// convert strings
186-
if (actualType === 'string' && desc.type !== 'any' && actualType !== targetType) {
187-
// First attempt to parse the argument using the usual method. For arrays,
188-
// this is JSON.parse() so it can throw.
189-
try {
190-
uarg = convertValueToTargetType(uarg, targetType);
191-
} catch (e) {
192-
// If the parsing threw, it's probably an array that failed to parse.
193-
// Try the `arrayItemDelimiters` option to parse it. This gives us support
194-
// for querystring-style `a,b,c`, for example.
195-
var targetIsArray = Array.isArray(targetType) && targetType.length === 1;
196-
if (targetIsArray && remotingOptions.arrayItemDelimiters) {
197-
var delims = remotingOptions.arrayItemDelimiters;
198-
if (Array.isArray(delims)) {
199-
delims = new RegExp(delims.map(escapeRegex).join('|'), 'g');
200-
remotingOptions.arrayItemDelimiters = delims;
201-
}
202-
203-
// If we received the empty string (e.g. &arg=), make it an empty
204-
// array. Otherwise, the split would turn it into ['']
205-
if (uarg.length) {
206-
uarg = uarg.split(delims);
207-
} else {
208-
uarg = [];
209-
}
210-
211-
// use for() instead of .map() so that we don't create fns in a loop
212-
for (var ix in uarg) {
213-
debug('convert %j to %s', uarg[ix], targetType[0]);
214-
uarg[ix] = convertValueToTargetType(uarg[ix], targetType[0]);
215-
}
216-
} else {
217-
// Target isn't an array, or arrayItemDelimiters weren't specified. At this
218-
// point we're out of options so let's throw.
219-
var message = util.format('invalid value for argument \'%s\' of type ' +
220-
'\'%s\': %s', name, desc.type, uarg);
221-
debug('- %s - ' + message, sharedMethod.name);
222-
return cb(badArgumentError(message));
223-
}
224-
}
167+
try {
168+
uarg = coerceAccepts(uarg, desc, name);
169+
} catch (e) {
170+
debug('- %s - ' + e.message, sharedMethod.name);
171+
return cb(e);
225172
}
226-
227173
// Add the argument even if it's undefined to stick with the accepts
228174
formattedArgs.push(uarg);
229175
}
@@ -279,6 +225,64 @@ function escapeRegex(d) {
279225
return d.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&');
280226
}
281227

228+
/**
229+
* Coerce an 'accepts' value into its final type.
230+
* If using HTTP, some coercion is already done in http-context.
231+
*
232+
* This should only do very simple coercion.
233+
*
234+
* @param {*} uarg Argument value.
235+
* @param {Object} desc Argument description.
236+
* @return {*} Coerced argument.
237+
*/
238+
function coerceAccepts(uarg, desc) {
239+
// If array, invoke for each member of the array.
240+
if (Array.isArray(uarg)) {
241+
return uarg.map(function(arg) {
242+
return coerceAccepts(arg, desc);
243+
});
244+
}
245+
246+
var name = desc.name || desc.arg;
247+
var targetType = desc.type;
248+
var actualType = SharedMethod.getType(uarg);
249+
250+
// Target type may be an array. If so, expect the arg to be a member of that array,
251+
// since we map this function over arrays.
252+
var targetIsArray = Array.isArray(targetType) && targetType.length === 1;
253+
if (targetIsArray) {
254+
targetType = targetType[0];
255+
}
256+
257+
// is the arg optional?
258+
// arg was not provided
259+
if (actualType === 'undefined') {
260+
if (desc.required) {
261+
throw new badArgumentError(name + ' is a required arg');
262+
} else {
263+
return undefined;
264+
}
265+
}
266+
267+
if (actualType === 'number' && Number.isNaN(uarg)) {
268+
throw new badArgumentError(name + ' must be a number');
269+
}
270+
271+
// convert strings
272+
if (actualType === 'string' && targetType !== 'any' && actualType !== targetType) {
273+
// JSON.parse can throw, so catch this error.
274+
try {
275+
uarg = convertValueToTargetType(uarg, targetType);
276+
} catch (e) {
277+
var message = util.format('invalid value for argument \'%s\' of type ' +
278+
'\'%s\': %s. Received type was %s. Error: %s',
279+
name, targetType, uarg, typeof uarg, e.message);
280+
throw new badArgumentError(message);
281+
}
282+
}
283+
return uarg;
284+
}
285+
282286
function convertValueToTargetType(value, targetType) {
283287
switch (targetType) {
284288
case 'string':

test/http-context.test.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ describe('HttpContext', function() {
3535
input: 'null',
3636
expectedValue: 'null'
3737
}));
38+
it('should coerce array types properly with non-array input', givenMethodExpectArg({
39+
type: ['string'],
40+
input: 123,
41+
expectedValue: ['123']
42+
}));
43+
it('should not coerce a single string into a number', givenMethodExpectArg({
44+
type: ['string'],
45+
input: '123',
46+
expectedValue: ['123']
47+
}));
3848
});
3949

4050
describe('arguments without a defined type (or any)', function() {
@@ -78,7 +88,7 @@ function givenMethodExpectArg(options) {
7888
app.get('/', function(req, res) {
7989
var ctx = new HttpContext(req, res, method);
8090
try {
81-
expect(ctx.args.testArg).to.equal(options.expectedValue);
91+
expect(ctx.args.testArg).to.eql(options.expectedValue);
8292
} catch (e) {
8393
return done(e);
8494
}

test/rest.test.js

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ var Promise = global.Promise || require('bluebird');
1111
var ACCEPT_XML_OR_ANY = 'application/xml,*/*;q=0.8';
1212
var TEST_ERROR = new Error('expected test error');
1313

14-
1514
describe('strong-remoting-rest', function() {
1615
var app;
1716
var appSupportingJsonOnly;
@@ -901,6 +900,97 @@ describe('strong-remoting-rest', function() {
901900
});
902901
});
903902

903+
it('should coerce strings with type set to "any"', function(done) {
904+
remotes.foo = {
905+
bar: function(a, b, c, fn) {
906+
fn(null, c === true ? a + b : 0);
907+
}
908+
};
909+
910+
var fn = remotes.foo.bar;
911+
912+
fn.shared = true;
913+
fn.accepts = [
914+
{arg: 'a', type: 'any'},
915+
{arg: 'b', type: 'any'},
916+
{arg: 'c', type: 'any'}
917+
];
918+
fn.returns = {root: true};
919+
920+
json('get', '/foo/bar?a=42&b=0.42&c=true')
921+
.expect(200, function(err, res) {
922+
assert.equal(res.body, 42.42);
923+
done();
924+
});
925+
});
926+
927+
it('should coerce contents of array with simple array types', function(done) {
928+
remotes.foo = {
929+
bar: function(a, fn) {
930+
fn(null, a.reduce(function(memo, val) { return memo + val; }, 0));
931+
}
932+
};
933+
934+
var fn = remotes.foo.bar;
935+
936+
fn.shared = true;
937+
fn.accepts = [
938+
{arg: 'a', type: ['number']}
939+
];
940+
fn.returns = {root: true};
941+
942+
json('get', '/foo/bar?a=["1","2","3","4","5"]')
943+
.expect(200, function(err, res) {
944+
assert.equal(res.body, 15);
945+
done();
946+
});
947+
});
948+
949+
it('should pass an array argument even when non-array passed', function(done) {
950+
remotes.foo = {
951+
bar: function(a, fn) {
952+
fn(null, Array.isArray(a));
953+
}
954+
};
955+
956+
var fn = remotes.foo.bar;
957+
958+
fn.shared = true;
959+
fn.accepts = [
960+
{arg: 'a', type: ['number']}
961+
];
962+
fn.returns = {root: true};
963+
964+
json('get',
965+
'/foo/bar?a=1234')
966+
.expect(200, function(err, res) {
967+
assert.equal(res.body, true);
968+
done();
969+
});
970+
});
971+
972+
it('should coerce contents of array with simple array types', function(done) {
973+
remotes.foo = {
974+
bar: function(a, fn) {
975+
fn(null, a.reduce(function(memo, val) { return memo + val; }, 0));
976+
}
977+
};
978+
979+
var fn = remotes.foo.bar;
980+
981+
fn.shared = true;
982+
fn.accepts = [
983+
{arg: 'a', type: ['number']}
984+
];
985+
fn.returns = {root: true};
986+
987+
json('get', '/foo/bar?a=["1","2","3","4","5"]')
988+
.expect(200, function(err, res) {
989+
assert.equal(res.body, 15);
990+
done();
991+
});
992+
});
993+
904994
it('should allow empty body for json request', function(done) {
905995
remotes.foo = {
906996
bar: function(a, b, fn) {
@@ -2183,7 +2273,7 @@ describe('strong-remoting-rest', function() {
21832273

21842274
objects.afterError(method.name, function(ctx, next) {
21852275
if (Array.isArray(hookContext)) {
2186-
hookContext.push(context);
2276+
hookContext.push(ctx);
21872277
} else if (typeof hookContext === 'object') {
21882278
hookContext = [hookContext, ctx];
21892279
} else {

0 commit comments

Comments
 (0)