Skip to content

Commit

Permalink
feat: function argument validation (#773)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: there's a good chance this will throw exceptions for a few folks who are using the API in an unexpected way.
  • Loading branch information
bcoe committed Feb 26, 2017
1 parent ab1fa4b commit 22ed9bb
Show file tree
Hide file tree
Showing 6 changed files with 234 additions and 13 deletions.
66 changes: 66 additions & 0 deletions lib/argsert.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
const command = require('./command')()

const positionName = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']

module.exports = function (expected, callerArguments, length) {
// preface the argument description with "cmd", so
// that we can run it through yargs' command parser.
var position = 0
var parsed = {demanded: [], optional: []}
if (typeof expected === 'object') {
length = callerArguments
callerArguments = expected
} else {
parsed = command.parseCommand('cmd ' + expected)
}
const args = [].slice.call(callerArguments)

while (args.length && args[args.length - 1] === undefined) args.pop()
length = length || args.length

if (length < parsed.demanded.length) {
throw Error('Not enough arguments provided. Expected ' + parsed.demanded.length +
' but received ' + args.length + '.')
}

const totalCommands = parsed.demanded.length + parsed.optional.length
if (length > totalCommands) {
throw Error('Too many arguments provided. Expected max ' + totalCommands +
' but received ' + length + '.')
}

parsed.demanded.forEach(function (demanded) {
const arg = args.shift()
const observedType = guessType(arg)
const matchingTypes = demanded.cmd.filter(function (type) {
return type === observedType || type === '*'
})
if (matchingTypes.length === 0) argumentTypeError(observedType, demanded.cmd, position, false)
position += 1
})

parsed.optional.forEach(function (optional) {
if (args.length === 0) return
const arg = args.shift()
const observedType = guessType(arg)
const matchingTypes = optional.cmd.filter(function (type) {
return type === observedType || type === '*'
})
if (matchingTypes.length === 0) argumentTypeError(observedType, optional.cmd, position, true)
position += 1
})
}

function guessType (arg) {
if (Array.isArray(arg)) {
return 'array'
} else if (arg === null) {
return 'null'
}
return typeof arg
}

function argumentTypeError (observedType, allowedTypes, position, optional) {
throw Error('Invalid ' + (positionName[position] || 'manyith') + ' argument.' +
' Expected ' + allowedTypes.join(' or ') + ' but received ' + observedType + '.')
}
6 changes: 3 additions & 3 deletions lib/command.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ module.exports = function (yargs, usage, validation) {
return
}

var parsedCommand = parseCommand(cmd)
var parsedCommand = self.parseCommand(cmd)
aliases = aliases.map(function (alias) {
alias = parseCommand(alias).cmd // remove positional args
alias = self.parseCommand(alias).cmd // remove positional args
aliasMap[alias] = parsedCommand.cmd
return alias
})
Expand Down Expand Up @@ -94,7 +94,7 @@ module.exports = function (yargs, usage, validation) {
return false
}

function parseCommand (cmd) {
self.parseCommand = function (cmd) {
var extraSpacesStrippedCommand = cmd.replace(/\s{2,}/g, ' ')
var splitCommand = extraSpacesStrippedCommand.split(/\s+(?![^[]*]|[^<]*>)/)
var bregex = /\.*[\][<>]/g
Expand Down
4 changes: 2 additions & 2 deletions lib/usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ module.exports = function (yargs, y18n) {

var extra = [
type,
demandedOptions[key] ? '[' + __('required') + ']' : null,
(key in demandedOptions) ? '[' + __('required') + ']' : null,
options.choices && options.choices[key] ? '[' + __('choices:') + ' ' +
self.stringifiedValues(options.choices[key]) + ']' : null,
defaultString(options.default[key], options.defaultDescription[key])
Expand Down Expand Up @@ -330,7 +330,7 @@ module.exports = function (yargs, y18n) {
// copy descriptions.
if (descriptions[alias]) self.describe(key, descriptions[alias])
// copy demanded.
if (demandedOptions[alias]) yargs.demandOption(key, demandedOptions[alias].msg)
if (alias in demandedOptions) yargs.demandOption(key, demandedOptions[alias])
// type messages.
if (~options.boolean.indexOf(alias)) yargs.boolean(key)
if (~options.count.indexOf(alias)) yargs.count(key)
Expand Down
2 changes: 1 addition & 1 deletion lib/validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ module.exports = function (yargs, usage, y18n) {
if (missing) {
const customMsgs = []
Object.keys(missing).forEach(function (key) {
const msg = missing[key].msg
const msg = missing[key]
if (msg && customMsgs.indexOf(msg) < 0) {
customMsgs.push(msg)
}
Expand Down
98 changes: 98 additions & 0 deletions test/argsert.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/* global describe, it */

const argsert = require('../lib/argsert')
const expect = require('chai').expect

require('chai').should()

describe('Argsert', function () {
it('does not throw exception if optional argument is not provided', function () {
argsert('[object]', [].slice.call(arguments))
})

it('throws exception if wrong type is provided for optional argument', function () {
function foo (opts) {
argsert('[object|number]', [].slice.call(arguments))
}
expect(function () {
foo('hello')
}).to.throw(/Invalid first argument. Expected object or number but received string./)
})

it('does not throw exception if optional argument is valid', function () {
function foo (opts) {
argsert('[object]', [].slice.call(arguments))
}
foo({foo: 'bar'})
})

it('throws exception if required argument is not provided', function () {
expect(function () {
argsert('<object>', [].slice.call(arguments))
}).to.throw(/Not enough arguments provided. Expected 1 but received 0./)
})

it('throws exception if required argument is of wrong type', function () {
function foo (opts) {
argsert('<object>', [].slice.call(arguments))
}
expect(function () {
foo('bar')
}).to.throw(/Invalid first argument. Expected object but received string./)
})

it('supports a combination of required and optional arguments', function () {
function foo (opts) {
argsert('<array> <string|object> [string|object]', [].slice.call(arguments))
}
foo([], 'foo', {})
})

it('throws an exception if too many arguments are provided', function () {
function foo (expected) {
argsert('<array> [batman]', [].slice.call(arguments))
}
expect(function () {
foo([], 33, 99)
}).to.throw(/Too many arguments provided. Expected max 2 but received 3./)
})

it('configures function to accept 0 parameters, if only arguments object is provided', function () {
function foo (expected) {
argsert([].slice.call(arguments))
}
expect(function () {
foo(99)
}).to.throw(/Too many arguments provided. Expected max 0 but received 1./)
})

it('allows for any type if * is provided', function () {
function foo (opts) {
argsert('<*>', [].slice.call(arguments))
}
foo('bar')
})

it('should ignore trailing undefined values', function () {
function foo (opts) {
argsert('<*>', [].slice.call(arguments))
}
foo('bar', undefined, undefined)
})

it('should not ignore undefined values that are not trailing', function () {
function foo (opts) {
argsert('<*>', [].slice.call(arguments))
}
expect(function () {
foo('bar', undefined, undefined, 33)
}).to.throw(/Too many arguments provided. Expected max 1 but received 4./)
})

it('supports null as special type', function () {
function foo (arg) {
argsert('<null>', [].slice.call(arguments))
}
foo(null)
})
})
Loading

0 comments on commit 22ed9bb

Please sign in to comment.