Jalu Pujo Rumekso
Posted on February 22, 2021
Today Joe wants to enhance his app with a layer of validation. He thinks validation is a basic requirement to prevent nonsensical inputs.
Joi
Thankfully, there is a wonderful library that can help him achieve this goal easily. The library is Joi (funny enough, it has a similar name to Joe). On its site, Joi describes himself as "the most powerful schema description language and data validator for JavaScript". Awesome!
Without further ado, Joe begins adding Joi to his project by running npm install joi
. Then as usual, before he can do amazing things with the library, he needs to require it and store it in a variable: const Joi = require("joi")
.
The documentation says that he can start using Joi by defining his data first. Then that data definition will be used to validate the incoming input. Defining data is done by calling the available methods on Joi instance. At Joi's official documentation he finds a comprehensive list of the available methods. Here are some that picks Joe's interest:
- string() which means it must be a string,
- min() chained after string() to define the minimum string characters,
- max() chained after string() to define the maximum string characters,
- required() which means it is required,
- integer() which means it must be an integer, etc.
Joe finds the methods are self-explanatory so he thought it must be easy to learn the others as well for a more complex use case later.
Joe reviews his store data. It has three properties: id, name, and address. The id will be generated automatically by the database so he doesn't need to worry about it. For the name, it's obviously must be a string. And since it's the main descriptor of a store, he wants it to be classified as required. Also, he wants its length to be 30 characters at max. For the address, he only wants it to be a string. So here is his code:
const storeSchema = Joi.object({
name: Joi.string()
.max(30)
.required(),
address: Joi.string(),
});
Using Joi with Hapi
Now the question is how to use this schema to validate the request payload in Hapi? Fortunately, Joi integrates very well with Hapi. All Joe needs to do is assigning the schema to options.validate.payload
or options.validate.query
or options.validate.params
of a route, depends on what input he wants to validate. In this case, Joe wants to validate the payload at the create and update store route.
Here is how he implements it at the create store route:
server.route({
method: "POST",
path: "/api/stores",
handler(req) {
const newStore = {
id: stores.length + 1,
name: req.payload.name,
address: req.payload.address ?? null,
};
stores.push(newStore);
return newStore;
},
options: {
validate: {
payload: storeSchema,
},
},
});
And here is how he implements it at the update store route (which is identical):
server.route({
method: "PUT",
path: "/api/stores/{id}",
handler(req) {
const { id } = req.params;
const theStore = stores.find((store) => store.id === parseInt(id));
theStore.name = req.payload.name ?? null;
theStore.address = req.payload.address ?? null;
return theStore;
},
options: {
validate: {
payload: storeSchema,
},
},
});
Then he tests the create store route by sending this request:
POST http://localhost:3000/api/stores HTTP/1.1
content-type: application/json
{
"name": 1
}
He intentionally assigns an integer to the name property. So how does the app respond?
Here is what he gets back from the app:
HTTP/1.1 400 Bad Request
content-type: application/json; charset=utf-8
cache-control: no-cache
content-length: 82
Date: Sun, 21 Feb 2021 06:44:56 GMT
Connection: close
{
"statusCode": 400,
"error": "Bad Request",
"message": "Invalid request payload input"
}
He was greeted by Bad Request error with a message of invalid request payload input. The message clearly indicates that his code works. But he isn't satisfied with the generic error message. Yes, it's true that the payload is invalid, but what's the reason? He wants to know it too.
Throwing Joi's Original Error
As usual, then he goes to his friend asking about "joi hapi validation error". And as usual, his friend does a good job finding the information that he needs.
So here is what he finds. It turned out that since version 17, the Hapi team decided to not send Joi's input validation errors to the client. Hapi will send a generic 400 error instead such as what he saw above. They explain that it's a security consideration. He finds the answer from this interesting discussion.
Fortunately, Hapi provides a workaround for people like Joe who want to get the original Joi's validation error. It's done by configuring the routes.validate.failAction()
on the server configuration object.
The failAction()
method is an async function. It has three parameters: req, h, and err. The last parameter is where Joi's original error resides. So throwing it will send the error back to the user when the validation fails.
Now here is how Joe's server object looks:
const server = Hapi.server({
port: 3000,
host: "localhost",
routes: {
validate: {
async failAction(req, h, err) {
console.error(err);
throw err;
},
},
},
});
He throws the error back to the requester also console.log() it so he can inspect it from the terminal as well.
Then when he sends the same request as before, he gets this:
HTTP/1.1 400 Bad Request
content-type: application/json; charset=utf-8
cache-control: no-cache
content-length: 128
Date: Sun, 21 Feb 2021 07:04:48 GMT
Connection: close
{
"statusCode": 400,
"error": "Bad Request",
"message": "\"name\" must be a string",
"validation": {
"source": "payload",
"keys": [
"name"
]
}
}
And when he sends an empty name, he gets this:
HTTP/1.1 400 Bad Request
content-type: application/json; charset=utf-8
cache-control: no-cache
content-length: 123
Date: Sun, 21 Feb 2021 10:31:52 GMT
Connection: close
{
"statusCode": 400,
"error": "Bad Request",
"message": "\"name\" is required",
"validation": {
"source": "payload",
"keys": [
"name"
]
}
}
Now Joe feels happier because he receives a more meaningful message. Although Hapi suggests to him not to throw the detailed error, he wants to keep it this way for the development purpose.
Then he realizes something that's not quite right.
Since he tells Joi that the required property is only the name property, then there'll be a case where the user doesn't send the address. If the user doesn't send the address then Javascript will assign undefined
to the address property. Joe doesn't want that. He wants Javascript to assign null
instead. So he modifies his code to implement that functionality. Here is how his code looks:
server.route({
method: "POST",
path: "/api/stores",
handler(req) {
const newStore = {
id: stores.length + 1,
name: req.payload.name,
address: req.payload.address ?? null,
};
stores.push(newStore);
return newStore;
},
options: {
validate: {
payload: storeSchema,
},
},
});
server.route({
method: "PUT",
path: "/api/stores/{id}",
handler(req) {
const { id } = req.params;
const theStore = stores.find((store) => store.id === parseInt(id));
theStore.name = req.payload.name;
theStore.address = req.payload.address ?? null;
return theStore;
},
options: {
validate: {
payload: storeSchema,
},
},
});
Joe uses the nullish coalescing operator which basically says: is req.payload.address
has a value other than undefined
or null
? If it has, then uses that value, otherwise assign null
.
With this new modification, then his create/update store route will always return three properties: id, name, and address that can be a string of address or null
.
The modification also concludes Joe's learning session today. He feels satisfied with the new enhancement on his app. Now he doesn't have to worry about his user sending a number or even an empty string to name his store.
Next time he wants to learn about the response toolkit.
Posted on February 22, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.