Joi
by John Vincent
Posted on July 4, 2017
Object schema description language and validator for JavaScript objects.
This stuff ends up sprayed everywhere, so let's create a reference document.
Joi
Trouble
This package is close but has problems with handling errors. Thus I rolled my own based on it.
Notes for when the issues are resolved
npm install express-joi-validation --save
const Joi = require('joi');
const validator = require('express-joi-validation')({});
Validator
function buildErrorString (err) {
let ret = '';
let details = err.error.details;
for (let i = 0; i < details.length; i++) {
ret += ` ${details[i].message}`;
}
return ret;
}
module.exports = function jvfunc(cfg) {
const Joi = cfg.Joi;
const instance = {};
instance.body = function (schema, opts) {
return function jvfunc2 (req, res, next) {
const ret = Joi.validate(req.body, schema, opts.joi);
if (!ret.error) {
req.body = ret.value;
next();
}
else {
const msg = buildErrorString(ret);
const error = new Error(msg);
error.code = 400;
return next(error);
}
};
};
instance.params = function (schema, opts) {
return function jvfunc2 (req, res, next) {
const ret = Joi.validate(req.params, schema, opts.joi);
if (!ret.error) {
req.params = ret.value;
next();
}
else {
const msg = buildErrorString(ret);
const error = new Error(msg);
error.code = 400;
return next(error);
}
};
};
return instance;
};
Installation
npm install joi --save
Usage
const Joi = require('joi');
const validator = require('../../config/validator.js')({Joi});
Define a schema
const schema = Joi.object({
name: Joi.string().required().min(3),
email: Joi.string().email().required(),
message: Joi.string().required().min(10).max(200),
newsletter: Joi.string().required().valid('yes', 'no')
});
Set some options
const joiOpts = {
allowUnknown: false
};
Validate Post
router.route('/message').all(validator.body(schema, {joi: joiOpts})).post(sendMessage);
Validate Get
router.route('/:registerId/:otherId').all(validator.params(confirmSchema, {joi: joiOpts})).get(registerUser);
Route.all
all(validator.body(schema, {joi: joiOpts}))
The router.route()
API makes this possible.
router.route('/users/:user_id')
.all(function(req, res, next) {
// runs for all HTTP verbs first
// think of it as route specific middleware!
next();
})
.get(function(req, res, next) {
res.json(req.user);
})
.put(function(req, res, next) {
// just an example of maybe updating the user
req.user.name = req.params.name;
// save user ... etc
res.json(req.user);
})
.post(function(req, res, next) {
next(new Error('not implemented'));
})
.delete(function(req, res, next) {
next(new Error('not implemented'));
});
This is the final implementation. This worked.
However...
Note
The signature of .all
.all(function(req, res, next)
the following was actually passed
all(validator.body(schema, {joi: joiOpts}))
validator.body
is actually
function (schema, opts) {
return function jvfunc2 (req, res, next) {
}
}
or, it is a function that
- accepts (schema, opts)
- returns
function jvfunc2 (req, res, next) {...}
That is how to 'inject my own function' and still satisfy the requires of the calling function.
More Validation
Label
Always use .label, for example
email: Joi.string().email().required().label('Email Address'),
remember: Joi.string().required().valid('true', 'false').label('Remember Me')
The text in .label() will be used in the error message.
Optional
Field is optional but not empty.
name: Joi.string().optional()
Optional and Empty
Field is optional and could be empty. X-editable passes extra empty parameters.
Joi.string().allow('').optional()
Custom Error Messages
This revolves around the idea of overriding Joi's own messages.
See node_modules/joi/language.js
, which will look something like
'use strict';
// Load modules
// Declare internals
const internals = {};
exports.errors = {
root: 'value',
key: '"{{!key}}" ',
messages: {
wrapArrays: true
},
any: {
unknown: 'is not allowed',
invalid: 'contains an invalid value',
empty: 'is not allowed to be empty',
required: 'is required',
allowOnly: 'must be one of {{valids}}',
default: 'threw an error when running default method'
},
alternatives: {
base: 'not matching any of the allowed alternatives',
child: null
},
array: {
base: 'must be an array',
includes: 'at position {{pos}} does not match any of the allowed types',
includesSingle: 'single value of "{{!key}}" does not match any of the allowed types',
includesOne: 'at position {{pos}} fails because {{reason}}',
includesOneSingle: 'single value of "{{!key}}" fails because {{reason}}',
includesRequiredUnknowns: 'does not contain {{unknownMisses}} required value(s)',
includesRequiredKnowns: 'does not contain {{knownMisses}}',
includesRequiredBoth: 'does not contain {{knownMisses}} and {{unknownMisses}} other required value(s)',
excludes: 'at position {{pos}} contains an excluded value',
excludesSingle: 'single value of "{{!key}}" contains an excluded value',
min: 'must contain at least {{limit}} items',
max: 'must contain less than or equal to {{limit}} items',
length: 'must contain {{limit}} items',
ordered: 'at position {{pos}} fails because {{reason}}',
orderedLength: 'at position {{pos}} fails because array must contain at most {{limit}} items',
ref: 'references "{{ref}}" which is not a positive integer',
sparse: 'must not be a sparse array',
unique: 'position {{pos}} contains a duplicate value'
},
boolean: {
base: 'must be a boolean'
},
binary: {
base: 'must be a buffer or a string',
min: 'must be at least {{limit}} bytes',
max: 'must be less than or equal to {{limit}} bytes',
length: 'must be {{limit}} bytes'
},
date: {
base: 'must be a number of milliseconds or valid date string',
format: 'must be a string with one of the following formats {{format}}',
strict: 'must be a valid date',
min: 'must be larger than or equal to "{{limit}}"',
max: 'must be less than or equal to "{{limit}}"',
isoDate: 'must be a valid ISO 8601 date',
timestamp: {
javascript: 'must be a valid timestamp or number of milliseconds',
unix: 'must be a valid timestamp or number of seconds'
},
ref: 'references "{{ref}}" which is not a date'
},
function: {
base: 'must be a Function',
arity: 'must have an arity of {{n}}',
minArity: 'must have an arity greater or equal to {{n}}',
maxArity: 'must have an arity lesser or equal to {{n}}',
ref: 'must be a Joi reference'
},
lazy: {
base: '!!schema error: lazy schema must be set',
schema: '!!schema error: lazy schema function must return a schema'
},
object: {
base: 'must be an object',
child: '!!child "{{!child}}" fails because {{reason}}',
min: 'must have at least {{limit}} children',
max: 'must have less than or equal to {{limit}} children',
length: 'must have {{limit}} children',
allowUnknown: '!!"{{!child}}" is not allowed',
with: '!!"{{mainWithLabel}}" missing required peer "{{peerWithLabel}}"',
without: '!!"{{mainWithLabel}}" conflict with forbidden peer "{{peerWithLabel}}"',
missing: 'must contain at least one of {{peersWithLabels}}',
xor: 'contains a conflict between exclusive peers {{peersWithLabels}}',
or: 'must contain at least one of {{peersWithLabels}}',
and: 'contains {{presentWithLabels}} without its required peers {{missingWithLabels}}',
nand: '!!"{{mainWithLabel}}" must not exist simultaneously with {{peersWithLabels}}',
assert: '!!"{{ref}}" validation failed because "{{ref}}" failed to {{message}}',
rename: {
multiple: 'cannot rename child "{{from}}" because multiple renames are disabled and another key was already renamed to "{{to}}"',
override: 'cannot rename child "{{from}}" because override is disabled and target "{{to}}" exists'
},
type: 'must be an instance of "{{type}}"',
schema: 'must be a Joi instance'
},
number: {
base: 'must be a number',
min: 'must be larger than or equal to {{limit}}',
max: 'must be less than or equal to {{limit}}',
less: 'must be less than {{limit}}',
greater: 'must be greater than {{limit}}',
float: 'must be a float or double',
integer: 'must be an integer',
negative: 'must be a negative number',
positive: 'must be a positive number',
precision: 'must have no more than {{limit}} decimal places',
ref: 'references "{{ref}}" which is not a number',
multiple: 'must be a multiple of {{multiple}}'
},
string: {
base: 'must be a string',
min: 'length must be at least {{limit}} characters long',
max: 'length must be less than or equal to {{limit}} characters long',
length: 'length must be {{limit}} characters long',
alphanum: 'must only contain alpha-numeric characters',
token: 'must only contain alpha-numeric and underscore characters',
regex: {
base: 'with value "{{!value}}" fails to match the required pattern: {{pattern}}',
name: 'with value "{{!value}}" fails to match the {{name}} pattern',
invert: {
base: 'with value "{{!value}}" matches the inverted pattern: {{pattern}}',
name: 'with value "{{!value}}" matches the inverted {{name}} pattern'
}
},
email: 'must be a valid email',
uri: 'must be a valid uri',
uriRelativeOnly: 'must be a valid relative uri',
uriCustomScheme: 'must be a valid uri with a scheme matching the {{scheme}} pattern',
isoDate: 'must be a valid ISO 8601 date',
guid: 'must be a valid GUID',
hex: 'must only contain hexadecimal characters',
base64: 'must be a valid base64 string',
hostname: 'must be a valid hostname',
lowercase: 'must only contain lowercase characters',
uppercase: 'must only contain uppercase characters',
trim: 'must not have leading or trailing whitespace',
creditCard: 'must be a credit card',
ref: 'references "{{ref}}" which is not a number',
ip: 'must be a valid ip address with a {{cidr}} CIDR',
ipVersion: 'must be a valid ip address of one of the following versions {{version}} with a {{cidr}} CIDR'
}
};
The key is to understand which property you seek to override.
For example
email: Joi.string().email().required().label('Email Address')
.options({ language: { string: { email: '{{key}} must be really good' } } }),
will override language: string: email.
In practice it can take a little playing around to figure which language property to override.
Another example
password: Joi.string().required().regex(/^[a-zA-Z0-9-_]{3,30}$/).label('Password')
.options({ language: { string: { regex: { base: '{{key}} must be only use {{key}} characters a-z A-Z 0-9 -_ only' } } } })
shows the basic idea.
Joi Test Harness
Use this as a starter
'use strict';
const Joi = require('joi');
const internals = {};
const schema = Joi.object().options({ abortEarly: false }).keys({
email: Joi.string().email().required().label('Email Address')
.options({ language: { string: { email: '{{key}} must be really good' } } }),
password: Joi.string().required().regex(/^[a-zA-Z0-9-_]{3,30}$/).label('Password')
.options({ language: { string: { regex: { base: '{{key}} must be only use {{key}} characters a-z, A-Z, 0-9, or -_' } } } })
});
const data = {
email: 'invalid_email_address',
password: '%^$&#*#$%'
};
Joi.assert(data, schema);
An easy way to thoroughly test validations before putting them into real code.
More Examples
const registerSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().required().regex(/^[a-zA-Z0-9]{3,30}$/),
password2: Joi.string().required().regex(/^[a-zA-Z0-9-_]{3,30}$/)
});
const confirmSchema = Joi.object({
registerId: Joi.string().required(),
otherId: Joi.string().required().length(20)
});
const joiOpts = {
allowUnknown: false
};
router.route('/').all(validator.body(registerSchema, {joi: joiOpts})).post(register);
router.route('/:registerId/:otherId').all(validator.params(confirmSchema, {joi: joiOpts})).get(registerUser);