For the last few days, I've been working on an API for Kids In Touch using Hapi.js from the folks at Walmart Labs. I'm pretty impressed with it so far. I have found it to be much easier to adapt that Express. Hapi some great default configurations that make it very easy to build an API.
One thing that I wasn't too happy about was the default output from validations on a route. I felt the output was overly verbose and hard to parse on the client.
Fortunately, Hapi's validation system provides a "failAction" property. So, you can just take the original validation output and format it according to your API's specs.
Here is an example of the default output:
{
"code": 400,
"error": "Bad Request",
"message": "the length of familyName must be at least 2 characters long, the value of givenName is not allowed to be undefined, the value of mobile is not allowed to be undefined",
"validation": {
"source": "payload",
"keys": [
"familyName",
"givenName",
"mobile"
]
}
}
Whew! A client would need to do a lot of parsing just to figure out what fields to mark as invalid and for what reason. It would be even more difficult when a single field causes more than one validation error.
To pretty this all up a bit, here is a validation formatter that will modify the output. Just save this as a file in the application root and then use it for the fail action on every route.
var _ = require("lodash");
/**
* A mapping of errors produced by Hapi/Joi to errors desired in API
* @type {Array}
*/
var errorMessageMaps = [
{ newMessage : "maximum-length", matches : "less than" },
{ newMessage : "minimum-length", matches : "at least" },
{ newMessage : "true-required", matches : "true" },
{ newMessage : "false-required", matches : "false" },
{ newMessage : "invalid-email", matches : "email must be a valid email" },
{ newMessage : "required", matches : "undefined" },
{ newMessage : "not-allowed", matches : "the key" }
];
/**
* Find all the messages that are related to a given field
* @param messages
* @param field
* @returns {Array}
*/
var findMessagesForField = function( messages, field ) {
var matchingErrors = _.filter( messages, function( message ) {
return message.indexOf( field ) > 0 ? true : false ;
});
return matchingErrors
};
/**
* Associate a list of errors (Hapi/Joi versions) to those desired for the API
* @param errors
* @returns {Array}
*/
var associateErrorMessages = function( errors ) {
var associatedErrors = [];
// For each error find the associated error.
_.map( errors, function( error ) {
var tempErrors = _.filter( errorMessageMaps, function( errorMap ) {
return error.indexOf( errorMap.matches ) > 0 ? true : false ;
});
_.map( tempErrors, function( anError ) {
associatedErrors.push( anError.newMessage);
})
});
// If there are more than one set of errors for a field that is required,
// don't need to show any other than "required". Example if email was not provided and
// is required, might have ["invalid-email", "required"]. Don't need "invalid-email" error
// because we know it was not provided.
if( associatedErrors.indexOf( 'required') !== -1 ) {
associatedErrors = ["required"];
}
return associatedErrors;
};
/**
* Return array object that contain the invalid field and any related errors for that field.
* @param errorInfo
* @returns {Array}
*
* @example
* [
{
"attr": "familyName",
"msgs": [
"required"
]
},
{
"attr": "givenName",
"msgs": [
"required"
]
}
]
*/
module.exports = function( errorInfo ) {
var formatted = [];
var messages = errorInfo.message.split(",");
var fieldsWithErrors = errorInfo.validation.keys;
fieldsWithErrors = _.uniq(fieldsWithErrors);
_.map( fieldsWithErrors, function( currentField ) {
var matchingErrors = findMessagesForField( messages, currentField );
var associatedMessages = associateErrorMessages( matchingErrors );
formatted.push( { "attr" : currentField , "msgs" : associatedMessages } );
});
return formatted;
}
In your route, do the following:
validate : {
payload : schema,
// Override the default fail action for validation issues.
// Format properly
failAction: function( source, error, next) {
var formattedErrors = FormatErrors( error.response.payload);
var error = Hapi.error.badRequest();
error.reformat();
error.response.payload.status = 'fail';
error.response.payload.data = formattedErrors;
return next(error);
}
}
Then, you'll get a nice pretty output such as :
{
"code": 400,
"error": "Bad Request",
"status": "fail",
"data": [
{
"attr": "familyName",
"msgs": [
"minimum-length"
]
},
{
"attr": "givenName",
"msgs": [
"required"
]
},
{
"attr": "mobile",
"msgs": [
"required"
]
}
]
}