koa-bouncer
Version:
A parameter validation library for Koa routes
667 lines (583 loc) • 17.8 kB
JavaScript
'use strict'
// Node
const util = require('util')
// 3rd party
const _ = require('lodash')
const debug = require('debug')('koa-bouncer')
const v = require('validator')
const assert = require('better-assert')
// Number -> Bool
// ES6 introduces {MIN,MAX}_SAFE_INTEGER
//
// Exported only for testing
const isSafeInteger = (exports.isSafeInteger = function(n) {
return Number.MIN_SAFE_INTEGER <= n && n <= Number.MAX_SAFE_INTEGER
})
// String | Number -> Bool
//
// Works on numbers because .toString() is called on them
const isIntString = (function() {
const re = /^(?:[-+]?(?:0|[1-9][0-9]*))$/
return function isIntString(str) {
assert(_.isString(str) || _.isNumber(str))
return re.test(str)
}
})()
// String | Number -> Bool
//
// Use this when you want to use Number.parseFloat to parse numbers
// but do not want to parse things like '5e3' or 'Infinity'.
// Rather just plain ol decimal numbers like '+4.55' and '-6.0001'
//
// Works on numbers because .toString() is called on them
const isDecimalString = (function() {
const re = /^[-+]?([0-9]+|\.[0-9]+|[0-9]+\.[0-9]+)$/
return function isDecimalString(str) {
assert(_.isString(str) || _.isNumber(str))
return re.test(str)
}
})()
function ValidationError(key, message) {
this.name = 'ValidationError'
this.message = message
this.bouncer = {
key: key,
message: message,
}
}
ValidationError.prototype = _.create(Error.prototype)
function Validator(props) {
this.ctx = props.ctx // Koa context
this.key = props.key
this.vals = props.vals
this.throwError = function(tip) {
throw new ValidationError(this.key, tip || 'Invalid value for ' + this.key)
}
// get current contained value
this.val = () => {
return this.vals[this.key]
}
// set contained value
this.set = newVal => {
this.vals[this.key] = newVal
return this
}
this._isOptional = false
this.isOptional = () => {
// TODO: Clean this up
if (this._isOptional) {
if (_.isString(this.val()) && this.val().trim().length === 0) {
//this.set(null);
return this._isOptional
} else if (!_.isUndefined(this.val())) {
this._isOptional = false
}
}
return this._isOptional
}
// set props.val to define an initial val when instantiating a validator
//
// Populate vals on init
// Ex: this.validateBody('foo') will populate this.vals.foo
// with this.request.body.foo (even if undefined)
this.vals[this.key] = props.val
}
////////////////////////////////////////////////////////////
// wrap prototype functions with this if they should noop
// if the Validator is in optional state
function optionalFn(fn) {
return function() {
if (this.isOptional()) {
return this
}
return fn.apply(this, arguments)
}
}
// add prototype methods to the Validator function with this
// if you want the method to noop when Validator is in optional state
Validator.addMethod = function(name, fn) {
Validator.prototype[name] = optionalFn(fn)
}
////////////////////////////////////////////////////////////
// Core methods
//
// Everything is built on top of these basic methods.
////////////////////////////////////////////////////////////
Validator.addMethod('check', function(result, tip) {
if (!Boolean(result)) this.throwError(tip)
return this
})
Validator.addMethod('checkNot', function(result, tip) {
if (Boolean(result)) this.throwError(tip)
return this
})
// Pipes val through predicate function that must return truthy
Validator.addMethod('checkPred', function(pred, tip) {
assert(_.isFunction(pred))
this.check(pred.call(this.ctx, this.val()), tip)
return this
})
Validator.addMethod('checkNotPred', function(pred, tip) {
assert(_.isFunction(pred))
this.checkNot(pred.call(this.ctx, this.val()), tip)
return this
})
// Arbitrarily transform the current value inside a validator.
//
// f is a function that takes one argument: the current value in the validator.
// Whatever value f returns becomes the new value.
Validator.addMethod('tap', function(f, tip) {
assert(_.isFunction(f))
let result
try {
result = f.call(this.ctx, this.val())
} catch (err) {
if (err instanceof ValidationError) this.throwError(tip)
throw err
}
this.set(result)
return this
})
////////////////////////////////////////////////////////////
// General built-in methods
////////////////////////////////////////////////////////////
// cannot be undefined
Validator.prototype.required = function(tip) {
this.checkNotPred(_.isUndefined, tip || this.key + ' is required')
return this
}
Validator.prototype.optional = function() {
// TODO: Clean this up
if (_.isString(this.val()) && this.val().trim().length === 0) {
delete this.vals[this.key]
this._isOptional = true
} else if (_.isUndefined(this.val())) {
this._isOptional = true
}
return this
}
Validator.addMethod('isIn', function(arr, tip) {
assert(_.isArray(arr))
this.checkPred(val => _.includes(arr, val), tip)
return this
})
// Ensures value is not in given array
Validator.addMethod('isNotIn', function(arr, tip) {
assert(_.isArray(arr))
this.checkNotPred(val => _.includes(arr, val), tip)
return this
})
// Ensures value is an array
Validator.addMethod('isArray', function(tip) {
this.checkPred(_.isArray, tip || this.key + ' must be an array')
return this
})
// Ensures value is === equivalent to given value
Validator.addMethod('eq', function(otherVal, tip) {
this.checkPred(val => val === otherVal, tip)
return this
})
// Ensures value > given value
Validator.addMethod('gt', function(otherVal, tip) {
assert(_.isNumber(this.val()))
assert(_.isNumber(otherVal))
this.checkPred(val => val > otherVal, tip)
return this
})
// Ensures value >= given value
Validator.addMethod('gte', function(otherVal, tip) {
assert(_.isNumber(this.val()))
assert(_.isNumber(otherVal))
this.checkPred(val => val >= otherVal, tip)
return this
})
// Ensures value < given value
Validator.addMethod('lt', function(otherVal, tip) {
assert(_.isNumber(this.val()))
assert(_.isNumber(otherVal))
this.checkPred(val => val < otherVal, tip)
return this
})
// Ensures value <= given value
Validator.addMethod('lte', function(otherVal, tip) {
assert(_.isNumber(this.val()))
assert(_.isNumber(otherVal))
this.checkPred(val => val <= otherVal, tip)
return this
})
// Ensures value's length is [min, max] inclusive
Validator.addMethod('isLength', function(min, max, tip) {
assert(!_.isUndefined(this.val().length))
assert(Number.isInteger(min))
assert(Number.isInteger(max))
assert(min <= max)
tip = tip || `${this.key} must be ${min}-${max} characters long`
this.checkPred(val => val.length >= min, tip)
this.checkPred(val => val.length <= max, tip)
return this
})
// If value is undefined, set it to given value or to the value
// returned by a function.
Validator.addMethod('defaultTo', function(valueOrFunction) {
if (!_.isUndefined(this.val())) return this
if (_.isFunction(valueOrFunction)) {
// Run fn with `this` bound to Koa context
this.set(valueOrFunction.call(this.ctx))
} else {
this.set(valueOrFunction)
}
return this
})
Validator.addMethod('isString', function(tip) {
this.checkPred(_.isString, tip || this.key + ' must be a string')
return this
})
// Checks if is already an integer (and type number), throws if its not
Validator.addMethod('isInt', function(tip) {
this.checkPred(Number.isInteger, tip || this.key + ' must be an integer')
this.checkPred(isSafeInteger, tip || this.key + ' is out of integer range')
return this
})
// Converts value to integer, throwing if it fails
Validator.addMethod('toInt', function(tip) {
this.checkPred(isIntString, tip || this.key + ' must be an integer')
const num = Number.parseInt(this.val(), 10)
this.check(isSafeInteger(num), tip || this.key + ' is out of integer range')
this.set(num)
return this
})
// general isNumber check
//
// Note that there are difference betweent he global `isFinite` fn
// and the new ES6 `Number.isFinite` fn.
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isFinite
Validator.addMethod('isFiniteNumber', function(tip) {
this.checkPred(Number.isFinite, tip || this.key + ' must be a number')
return this
})
// If value is not already an array, puts it in a singleton array
Validator.addMethod('toArray', function() {
this.defaultTo([])
this.tap(x => (_.isArray(x) ? x : [x]))
return this
})
// Converts every item in array to an integer.
// Throws if any do not convert or are not within safe integer range.
//
// '5abc' will cause ValidationError even though parseInt would
// parse it into 5. this is because you err on the side of being
// less lenient with user input.
Validator.addMethod('toInts', function(tip) {
this.defaultTo([])
this.checkPred(
val => _.every(val, isIntString),
tip || this.key + ' must be array of integers'
)
const results = this.val().map(val => parseInt(val, 10))
this.check(
_.every(results, isSafeInteger),
tip || this.key + 'must not contain numbers out of integer range'
)
this.set(results)
return this
})
Validator.addMethod('uniq', function() {
assert(_.isArray(this.val()))
this.tap(_.uniq)
return this
})
Validator.addMethod('toBoolean', function() {
this.tap(Boolean)
return this
})
Validator.addMethod('toDecimal', function(tip) {
this.checkPred(isDecimalString, tip || this.key + ' must be a decimal number')
this.set(Number.parseFloat(this.val()))
return this
})
// returns true if v is already a float or if it's a string
// that can parse into a float.
function isFloatIncludingInfinity(x) {
// If it already is a float
if (typeof x === 'number' && !Number.isNaN(x)) {
return true
}
// If it's a string, use v.isFloat which ensures it doesn't have
// illegal float characters like "124abc" even though Number.parseFloat
// could parse it into 124.
if (typeof x === 'string') {
x = x.trim()
if (x === 'Infinity' || x === '-Infinity') {
return true
} else {
return v.isFloat(x)
}
}
return false
}
Validator.addMethod('toFloat', function(tip) {
this.checkPred(isFloatIncludingInfinity, tip || this.key + ' must be a float')
this.set(Number.parseFloat(this.val()))
return this
})
Validator.addMethod('toFiniteFloat', function() {
return this.toFloat().isFiniteNumber()
})
Validator.addMethod('toString', function() {
this.set((this.val() && this.val().toString()) || '')
return this
})
Validator.addMethod('trim', function() {
assert(_.isString(this.val()))
this.tap(x => x.trim())
return this
})
Validator.addMethod('match', function(regexp, tip) {
assert(_.isString(this.val()))
assert(_.isRegExp(regexp))
this.checkPred(val => regexp.test(val), tip)
return this
})
Validator.addMethod('notMatch', function(regexp, tip) {
assert(_.isString(this.val()))
assert(_.isRegExp(regexp))
this.checkNotPred(val => regexp.test(val), tip)
return this
})
Validator.addMethod('fromJson', function(tip) {
assert(_.isString(this.val()))
let parsedObj
try {
parsedObj = JSON.parse(this.val())
} catch (ex) {
this.throwError(tip || 'Invalid JSON for ' + this.key)
}
this.set(parsedObj)
return this
})
////////////////////////////////////////////////////////////
// More specific validator methods
////////////////////////////////////////////////////////////
Validator.addMethod(
'isAlpha',
(function() {
const re = /^[a-z]*$/i
return function isAlpha(tip) {
tip = tip || this.key + ' must only contain chars a-z'
this.isString(tip)
this.checkPred(val => re.test(val), tip)
return this
}
})()
)
Validator.addMethod(
'isAlphanumeric',
(function() {
const re = /^[a-z0-9]*$/i
return function isAlphanumeric(tip) {
tip = tip || this.key + ' must must be alphanumeric (a-z, 0-9)'
this.isString(tip)
this.checkPred(val => re.test(val), tip)
return this
}
})()
)
Validator.addMethod(
'isNumeric',
(function() {
const re = /^[0-9]*$/
return function isNumeric(tip) {
tip = tip || this.key + ' must must only contain numbers'
this.isString(tip)
this.checkPred(val => re.test(val), tip)
return this
}
})()
)
Validator.addMethod(
'isAscii',
(function() {
const re = /^[\x00-\x7F]*$/
return function isAscii(tip) {
tip = tip || this.key + ' must contain only ASCII chars'
this.isString(tip)
this.checkPred(val => re.test(val), tip)
return this
}
})()
)
Validator.addMethod('isBase64', function(tip) {
tip = tip || this.key + ' must be base64 encoded'
this.isString(tip)
if (this.val().length === 0) return this
this.checkPred(v.isBase64, tip)
return this
})
// TODO: Add support to validator's `options` into isEmail
Validator.addMethod('isEmail', function(tip) {
tip = tip || this.key + ' must be a valid email address'
this.isString(tip)
this.checkPred(v.isEmail, tip)
return this
})
Validator.addMethod(
'isHexColor',
(function() {
const re = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i
return function isHexColor(tip) {
tip = tip || this.key + ' must be a hex color'
this.isString(tip)
this.checkPred(val => re.test(val), tip)
return this
}
})()
)
//.isUuid('v4', 'must be uuid');
//.isUuid('must be uuid');
//.isUuid('v4');
//.isUuid();
Validator.addMethod(
'isUuid',
(function() {
const re = {
v3: /^[0-9A-F]{8}-[0-9A-F]{4}-3[0-9A-F]{3}-[0-9A-F]{4}-[0-9A-F]{12}$/i,
v4: /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i,
v5: /^[0-9A-F]{8}-[0-9A-F]{4}-5[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i,
all: /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i,
}
return function isUuid(version, tip) {
if (_.isString(version) && _.isUndefined(tip)) {
// Handle .isUuid('must be uuid') and .isUuid('v4')
if (!_.includes(['v3', 'v4', 'v5', 'all'], version)) {
// Handle: .isUuid('must be uuid')
tip = version
version = 'all'
}
} else if (_.isUndefined(version) && _.isUndefined(tip)) {
// Handle: .isUuid()
version = 'all'
}
tip =
tip || this.key + ' must be a UUID' + (version !== 'all' ? version : '')
this.isString(tip)
this.checkPred(val => re[version].test(val), tip)
return this
}
})()
)
Validator.addMethod('isJson', function(tip) {
tip = tip || this.key + ' must be JSON'
this.isString(tip)
try {
JSON.parse(this.val())
} catch (err) {
this.throwError(tip)
}
return this
})
Validator.addMethod('encodeBase64', function(tip) {
this.isString(tip)
this.tap(val => new Buffer(val).toString('base64'), tip)
return this
})
Validator.addMethod('decodeBase64', function(tip) {
tip = tip || this.key + ' must be base64 encoded'
this.isString(tip)
if (this.val().length === 0) return this
this.isBase64(tip)
this.tap(val => new Buffer(val, 'base64').toString())
return this
})
Validator.addMethod('clamp', function(min, max) {
assert(_.isNumber(this.val()))
assert(_.isNumber(min))
assert(_.isNumber(max))
assert(min <= max)
this.tap(val => (val < min ? min : val))
this.tap(val => (val > max ? max : val))
return this
})
////////////////////////////////////////////////////////////
// API
////////////////////////////////////////////////////////////
exports.ValidationError = ValidationError
exports.Validator = Validator
exports.middleware = function middleware(opts = {}) {
// default ctx getters that user can override
// they should take the Koa context and return an object
opts.getParams = opts.getParams || (ctx => ctx.params)
opts.getQuery = opts.getQuery || (ctx => ctx.query)
opts.getBody = opts.getBody || (ctx => ctx.request.body)
return function(ctx, next) {
debug('Initializing koa-bouncer')
ctx.vals = {}
// we save initialized validators for the duration of the request
// so that multiple calls to, example, this.validateBody('foo')
// will return the same validator
const validators = new Map()
ctx.validateParam = function(key) {
return (
validators.get(key) ||
validators
.set(
key,
new Validator({
ctx,
key: key,
val:
ctx.vals[key] === undefined
? _.get(opts.getParams(ctx), key)
: ctx.vals[key],
vals: ctx.vals,
})
)
.get(key)
)
}
ctx.validateQuery = function(key) {
return (
validators.get(key) ||
validators
.set(
key,
new Validator({
ctx,
key: key,
val:
ctx.vals[key] === undefined
? _.get(opts.getQuery(ctx), key)
: ctx.vals[key],
vals: ctx.vals,
})
)
.get(key)
)
}
ctx.validateBody = function(key) {
return (
validators.get(key) ||
validators
.set(
key,
new Validator({
ctx,
key: key,
val:
ctx.vals[key] === undefined
? _.get(opts.getBody(ctx), key)
: ctx.vals[key],
vals: ctx.vals,
})
)
.get(key)
)
}
ctx.check = function(result, tip) {
if (!result) throw new ValidationError(null, tip)
}
ctx.checkNot = function(result, tip) {
if (result) throw new ValidationError(null, tip)
}
return next()
}
}