Using templates and validation in Hapi
In this post we’ll add template rendering (with Vision) and input validation (with Joi) into our application from the previous set of posts.
In this post we’ll add to our application from the previous set of posts. We'll be using Vision for template rendering and Joi for input validation.
Vision
Vision adds methods to the Server
, Request
, and ResponseToolkit
interfaces so you can use view engines to render templates. It’s agnostic of the particular engine used; we’ll be using EJS in this post, simply because I’m familiar with it.
Setup
The usual deal with packages:
$ yarn add @hapi/vision ejs
$ yarn add -D @types/hapi__vision @types/ejs
We’ll also need somewhere to put the templates; not imaginative but I usually use a directory named templates
.
$ mkdir templates
registerVision
below registers and configures the plugin. The template directory is referenced in the server.views
call, with path
option; we use the relativeTo
option to indicate it lives in the directory above src
(or lib
after compilation).
We disable caching unless we’re in production, so we don’t have to restart the server every time we change the template.
src/server.ts
:
async function registerVision(server: Server) {
let cached: boolean;
await server.register(hapiVision);
if (!process.env.NODE_ENV || process.env.NODE_ENV === "development") {
cached = false;
} else {
cached = true;
}
server.log(["debug"], `Caching templates: ${cached}`);
server.views({
engines: {
ejs: require("ejs")
},
relativeTo: __dirname + "/../",
path: 'templates',
isCached: cached
});
}
Note that we don’t have to change the existing /
route at all; we can use Vision in parallel with other response methods.
Test
For testing we’re going to add another package, node-html-parser
. We want to be able to extract some details from the webpages the application sends back, to confirm it looks like we expect.
(So this doesn’t turn into a really long post we’re going to keep the HTML testing to a minimum.)
$ yarn add -D node-html-parser
import { Server } from "@hapi/hapi";
import { describe, it, beforeEach, afterEach } from "mocha";
import { expect } from "chai";
import { parse } from "node-html-parser";
import { init } from "../src/server";
const personData = { name: "Sherlock Holmes", age: 32 }
describe.only("server handles people", async () => {
let server: Server;
beforeEach((done) => {
init().then(s => { server = s; done(); });
})
afterEach((done) => {
server.stop().then(() => done());
});
it("can see existing people", async () => {
const res = await server.inject({
method: "get",
url: "/people"
});
expect(res.statusCode).to.equal(200);
expect(res.payload).to.not.be.null;
const html = parse(res.payload);
const people = html.querySelectorAll("li.person-entry");
expect(people.length).to.equal(2);
});
it("can show 'add person' page", async () => {
const res = await server.inject({
method: "get",
url: "/people/add"
});
expect(res.statusCode).to.equal(200);
});
it("can add a person and they show in the list", async () => {
let res = await server.inject({
method: "post",
url: "/people/add",
payload: personData
});
expect(res.statusCode).to.equal(302);
expect(res.headers.location).to.equal("/people");
res = await server.inject({
method: "get",
url: "/people"
});
expect(res.statusCode).to.equal(200);
expect(res.payload).to.not.be.null;
const html = parse(res.payload);
const people = html.querySelectorAll("li.person-entry");
expect(people.length).to.equal(3);
});
})
So this looks pretty much like the tests we wrote last time, which is one of the advantages of using tools like mocha and chai. The only difference you’ll notice is that we parse the HTML to see how many people are in the list. That can obviously be taken further - checking the page title, checking the right buttons are available, stuff like that.
If we run this it’ll fail; this post is going to be long enough already, so I won’t paste it in.
Templates
templates/people.ejs
- this is a very bare-bones page, with an un-ordered list.
The EJS template logic expects people
to be an array of person
structures; the loop then builds an <li>
element for each entry.
<html>
<head>
<title>Purple People Eaters</title>
</head>
<body>
<ul>
<% people.forEach(person => { %>
<li class="person-entry">
<%= person.name %> - <%= person.age %>
</li>
<% }) %>
</ul>
<a href="/people/add">Add person</a>
</body>
</html>
templates/addPerson.ejs
is pretty much just a standard form; the only EJS-ness is using the supplied person
structure to set the initial values of the input fields. This helps if the user makes an error and the page has to be re-rendered - they won’t lose their data.
<html>
<head>
<title>Purple People Eaters</title>
</head>
<body>
<h1>Add person</h1>
<form method="POST">
<label for="name">Name</label>
<input type="text" name="name" value="<%= person.name %>">
<label for="age">Age</label>
<input type="text" name="age" value="<%= person.age %>">
<button type="submit">Add</button>
</form>
</body>
</html>
We’ve got the tests and we’ve got the templates - we just need to add the code behind it all now.
Code
Using Vision is surprisingly simple - the view
method will render a given template with the data in the given context.
You’ll notice that in addPersonGet
we declare an empty structure (as Person
type) and pass it to view - otherwise we’d have to check if the variable exists before using it in an expression. In addPersonPost
, if an exception is thrown for whatever reason then the page is re-rendered with the data
structure passed back in.
import { Request, ResponseToolkit, ResponseObject, ServerRoute } from "@hapi/hapi";
type Person = {
name: string;
age: number;
}
const people: Person[] = [
{ name: "Sophie", age: 37 },
{ name: "Dan", age: 42 }
];
async function showPeople(request: Request, h: ResponseToolkit): Promise<ResponseObject> {
return h.view("people", { people: people });
}
async function addPersonGet(request: Request, h: ResponseToolkit): Promise<ResponseObject> {
let data = ({} as Person);
return h.view("addPerson", { person: data });
}
async function addPersonPost(request: Request, h: ResponseToolkit): Promise<ResponseObject> {
let data = ({} as Person);
try {
data = (request.payload as Person);
people.push(data);
return h.redirect("/people");
} catch (err) {
console.error("Caught error", err);
return h.view("addPerson", { person: data })
}
}
export const peopleRoutes: ServerRoute[] = [
{ method: "GET", path: "/people", handler: showPeople },
{ method: "GET", path: "/people/add", handler: addPersonGet },
{ method: "POST", path: "/people/add", handler: addPersonPost }
];
The obligatory check:
yarn run v1.22.10
$ NODE_ENV=test mocha -r ts-node/register test/**/*.test.ts
server handles people
✓ can see existing people
✓ can show 'add person' page
✓ can add a person and they show in the list
3 passing (113ms)
✨ Done in 2.69s.
Done!
Validating with Joi
What we’ve just added works, but it doesn’t do any checking on the input. It’s easy to make the server crash. That’s where Joi comes in.
Setup
It’s really easy to set up, just add the package! It contains it’s own definitions, so we don’t need a type package.
$ yarn add joi
Adding tests
Just to define the terms briefly - positive tests check that things work if given valid inputs. Negative tests check that things don’t crash and burn if given bad input. What we’ve written so far are just positive tests - those are good, but bugs (or nasty users) will give your API bad parameters are some point.
Positive tests
For this we’ll just use the existing tests and check that they still pass. It would be clearer to alter the describe
clause to be clear, though:
describe("server handles people - positive tests", ...
Negative tests
These try to break the API by passing in bad data - no name, no age, non-number age, and so on. I usually find the negative tests outnumber the positive tests by the time you’ve covered everything…
describe("server handles people - negative tests", async () => {
let server: Server;
beforeEach(async () => {
server = await init();
})
afterEach(async () => {
await server.stop();
});
it("can't add a person with no name", async () => {
let res = await server.inject({
method: "post",
url: "/people/add",
payload: { ...personData, name: null }
});
expect(res.statusCode).to.equal(200);
});
it("can't add a person with no age", async () => {
let res = await server.inject({
method: "post",
url: "/people/add",
payload: { ...personData, age: null }
});
expect(res.statusCode).to.equal(200);
});
it("can't add a person with non-number age", async () => {
let res = await server.inject({
method: "post",
url: "/people/add",
payload: { ...personData, age: "Watson" }
});
expect(res.statusCode).to.equal(200);
});
})
We check for status code 200, since on error the page should be re-rendered.
Using Joi
To use Joi you need to describe the allowed shape of the data. One of the advantages of using Joi is that the schema description is very English-like.
import Joi from "joi";
const ValidationError = Joi.ValidationError;
const schema = Joi.object({
name: Joi.string().required(),
age: Joi.number().required()
});
When addPersonPost
receives new data, the schema object can be used to validate it. The stripUnknown
option removes any elements that aren’t listed in the schema, which is another layer of protection.
data = (request.payload as Person);
const o = schema.validate(data, { stripUnknown: true });
if (o.error) {
throw o.error;
}
data = (o.value as Person);
people.push(data);
The extra code added to the catch
clause will process any errors found by Joi and add them to the context given to rendering the addPerson
page.
NB: Joi declares the context
and key
elements as optional. It’s not clear why that is - I’ve chosen to override those with the !
decorator, to tell Typescript those elements will be present.
const errors: { [key: string]: string } = {};
if (err instanceof ValidationError && err.isJoi) {
for (const detail of err.details) {
errors[detail.context!.key!] = detail.message;
}
} else {
console.error("error", err, "adding person");
}
return h.view("addPerson", { person: data, errors: errors })
To report the errors back to the user, we add these span
sections to the template, if you’ve got a very uniform template you could generate the HTML with Javascript, but this was the simplest way for the small template we have here.
<label for="name">Name</label>
<input type="text" name="name" value="<%= person.name %>">
<span id="name-error" class="error text-red-500"></span>
<label for="age">Age</label>
<input type="text" name="age" value="<%= person.age %>">
<span id="age-error" class="error text-red-500"></span>
Lastly, we add this script to the page. If errors
is defined, we loop through, try to find the matching HTML element, and (if found) set it to the error message. Doing it this way saves lots of fiddly adjustments in the template to conditionally render error messages.
<script>
const errors = <%- JSON.stringify(locals.errors || null) %>;
if (errors) {
Object.keys(errors).forEach(error => {
const el = document.getElementById(error + "-error");
if (el) {
el.innerText = errors[error];
}
})
}
</script>
Of course the proof is in the testing.
yarn run v1.22.10
$ NODE_ENV=test mocha -r ts-node/register test/**/*.test.ts
server greets people
✓ says hello world
✓ says hello to a person
smoke test
✓ index responds
server handles people - positive tests
✓ can see existing people
✓ can show 'add person' page
✓ can add a person and they show in the list
server handles people - negative tests
✓ can't add a person with no name
✓ can't add a person with no age
✓ can't add a person with non-number age
9 passing (195ms)
✨ Done in 4.51s.
We’ve just touched the surface of what Joi can do. We can add more restrictions on name - for example, that it must be more than 1 character. We could add some restrictions on age to make it positive only (which would be reasonable!). If we did that we’d then add a test for negative age, and so on.
I hope this has been useful; if you’ve got any questions or suggestions please do let me know.
All of the code is available in this Github repo, if you want to clone it and play.