Joi

Using Joi

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

npm Joi

Github Joi

Trouble

npm express-joi-validation

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);