Skip to content

Commit 22ed9bb

Browse files
committed
feat: function argument validation (#773)
BREAKING CHANGE: there's a good chance this will throw exceptions for a few folks who are using the API in an unexpected way.
1 parent ab1fa4b commit 22ed9bb

File tree

6 files changed

+234
-13
lines changed

6 files changed

+234
-13
lines changed

lib/argsert.js

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
const command = require('./command')()
2+
3+
const positionName = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
4+
5+
module.exports = function (expected, callerArguments, length) {
6+
// preface the argument description with "cmd", so
7+
// that we can run it through yargs' command parser.
8+
var position = 0
9+
var parsed = {demanded: [], optional: []}
10+
if (typeof expected === 'object') {
11+
length = callerArguments
12+
callerArguments = expected
13+
} else {
14+
parsed = command.parseCommand('cmd ' + expected)
15+
}
16+
const args = [].slice.call(callerArguments)
17+
18+
while (args.length && args[args.length - 1] === undefined) args.pop()
19+
length = length || args.length
20+
21+
if (length < parsed.demanded.length) {
22+
throw Error('Not enough arguments provided. Expected ' + parsed.demanded.length +
23+
' but received ' + args.length + '.')
24+
}
25+
26+
const totalCommands = parsed.demanded.length + parsed.optional.length
27+
if (length > totalCommands) {
28+
throw Error('Too many arguments provided. Expected max ' + totalCommands +
29+
' but received ' + length + '.')
30+
}
31+
32+
parsed.demanded.forEach(function (demanded) {
33+
const arg = args.shift()
34+
const observedType = guessType(arg)
35+
const matchingTypes = demanded.cmd.filter(function (type) {
36+
return type === observedType || type === '*'
37+
})
38+
if (matchingTypes.length === 0) argumentTypeError(observedType, demanded.cmd, position, false)
39+
position += 1
40+
})
41+
42+
parsed.optional.forEach(function (optional) {
43+
if (args.length === 0) return
44+
const arg = args.shift()
45+
const observedType = guessType(arg)
46+
const matchingTypes = optional.cmd.filter(function (type) {
47+
return type === observedType || type === '*'
48+
})
49+
if (matchingTypes.length === 0) argumentTypeError(observedType, optional.cmd, position, true)
50+
position += 1
51+
})
52+
}
53+
54+
function guessType (arg) {
55+
if (Array.isArray(arg)) {
56+
return 'array'
57+
} else if (arg === null) {
58+
return 'null'
59+
}
60+
return typeof arg
61+
}
62+
63+
function argumentTypeError (observedType, allowedTypes, position, optional) {
64+
throw Error('Invalid ' + (positionName[position] || 'manyith') + ' argument.' +
65+
' Expected ' + allowedTypes.join(' or ') + ' but received ' + observedType + '.')
66+
}

lib/command.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ module.exports = function (yargs, usage, validation) {
2828
return
2929
}
3030

31-
var parsedCommand = parseCommand(cmd)
31+
var parsedCommand = self.parseCommand(cmd)
3232
aliases = aliases.map(function (alias) {
33-
alias = parseCommand(alias).cmd // remove positional args
33+
alias = self.parseCommand(alias).cmd // remove positional args
3434
aliasMap[alias] = parsedCommand.cmd
3535
return alias
3636
})
@@ -94,7 +94,7 @@ module.exports = function (yargs, usage, validation) {
9494
return false
9595
}
9696

97-
function parseCommand (cmd) {
97+
self.parseCommand = function (cmd) {
9898
var extraSpacesStrippedCommand = cmd.replace(/\s{2,}/g, ' ')
9999
var splitCommand = extraSpacesStrippedCommand.split(/\s+(?![^[]*]|[^<]*>)/)
100100
var bregex = /\.*[\][<>]/g

lib/usage.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ module.exports = function (yargs, y18n) {
235235

236236
var extra = [
237237
type,
238-
demandedOptions[key] ? '[' + __('required') + ']' : null,
238+
(key in demandedOptions) ? '[' + __('required') + ']' : null,
239239
options.choices && options.choices[key] ? '[' + __('choices:') + ' ' +
240240
self.stringifiedValues(options.choices[key]) + ']' : null,
241241
defaultString(options.default[key], options.defaultDescription[key])
@@ -330,7 +330,7 @@ module.exports = function (yargs, y18n) {
330330
// copy descriptions.
331331
if (descriptions[alias]) self.describe(key, descriptions[alias])
332332
// copy demanded.
333-
if (demandedOptions[alias]) yargs.demandOption(key, demandedOptions[alias].msg)
333+
if (alias in demandedOptions) yargs.demandOption(key, demandedOptions[alias])
334334
// type messages.
335335
if (~options.boolean.indexOf(alias)) yargs.boolean(key)
336336
if (~options.count.indexOf(alias)) yargs.count(key)

lib/validation.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ module.exports = function (yargs, usage, y18n) {
9898
if (missing) {
9999
const customMsgs = []
100100
Object.keys(missing).forEach(function (key) {
101-
const msg = missing[key].msg
101+
const msg = missing[key]
102102
if (msg && customMsgs.indexOf(msg) < 0) {
103103
customMsgs.push(msg)
104104
}

test/argsert.js

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/* global describe, it */
2+
3+
const argsert = require('../lib/argsert')
4+
const expect = require('chai').expect
5+
6+
require('chai').should()
7+
8+
describe('Argsert', function () {
9+
it('does not throw exception if optional argument is not provided', function () {
10+
argsert('[object]', [].slice.call(arguments))
11+
})
12+
13+
it('throws exception if wrong type is provided for optional argument', function () {
14+
function foo (opts) {
15+
argsert('[object|number]', [].slice.call(arguments))
16+
}
17+
expect(function () {
18+
foo('hello')
19+
}).to.throw(/Invalid first argument. Expected object or number but received string./)
20+
})
21+
22+
it('does not throw exception if optional argument is valid', function () {
23+
function foo (opts) {
24+
argsert('[object]', [].slice.call(arguments))
25+
}
26+
foo({foo: 'bar'})
27+
})
28+
29+
it('throws exception if required argument is not provided', function () {
30+
expect(function () {
31+
argsert('<object>', [].slice.call(arguments))
32+
}).to.throw(/Not enough arguments provided. Expected 1 but received 0./)
33+
})
34+
35+
it('throws exception if required argument is of wrong type', function () {
36+
function foo (opts) {
37+
argsert('<object>', [].slice.call(arguments))
38+
}
39+
expect(function () {
40+
foo('bar')
41+
}).to.throw(/Invalid first argument. Expected object but received string./)
42+
})
43+
44+
it('supports a combination of required and optional arguments', function () {
45+
function foo (opts) {
46+
argsert('<array> <string|object> [string|object]', [].slice.call(arguments))
47+
}
48+
foo([], 'foo', {})
49+
})
50+
51+
it('throws an exception if too many arguments are provided', function () {
52+
function foo (expected) {
53+
argsert('<array> [batman]', [].slice.call(arguments))
54+
}
55+
expect(function () {
56+
foo([], 33, 99)
57+
}).to.throw(/Too many arguments provided. Expected max 2 but received 3./)
58+
})
59+
60+
it('configures function to accept 0 parameters, if only arguments object is provided', function () {
61+
function foo (expected) {
62+
argsert([].slice.call(arguments))
63+
}
64+
expect(function () {
65+
foo(99)
66+
}).to.throw(/Too many arguments provided. Expected max 0 but received 1./)
67+
})
68+
69+
it('allows for any type if * is provided', function () {
70+
function foo (opts) {
71+
argsert('<*>', [].slice.call(arguments))
72+
}
73+
foo('bar')
74+
})
75+
76+
it('should ignore trailing undefined values', function () {
77+
function foo (opts) {
78+
argsert('<*>', [].slice.call(arguments))
79+
}
80+
foo('bar', undefined, undefined)
81+
})
82+
83+
it('should not ignore undefined values that are not trailing', function () {
84+
function foo (opts) {
85+
argsert('<*>', [].slice.call(arguments))
86+
}
87+
expect(function () {
88+
foo('bar', undefined, undefined, 33)
89+
}).to.throw(/Too many arguments provided. Expected max 1 but received 4./)
90+
})
91+
92+
it('supports null as special type', function () {
93+
function foo (arg) {
94+
argsert('<null>', [].slice.call(arguments))
95+
}
96+
foo(null)
97+
})
98+
})

0 commit comments

Comments
 (0)