DEV Community

Cover image for Hapi - templates and validation
Paul Walker
Paul Walker

Posted on • Originally published at solarwinter.net on

Hapi - templates and validation

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.

Oh - sorry about the image, couldn't resist...

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
Enter fullscreen mode Exit fullscreen mode

We’ll also need somewhere to put the templates; not imaginative but I usually use a directory named templates.

$ mkdir templates
Enter fullscreen mode Exit fullscreen mode

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 in development, 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;
  }
  console.log(`Caching templates: ${cached}`);
  server.views({
    engines: {
      ejs: require("ejs")
    },
    relativeTo: __dirname + "/../",
    path: 'templates',
    isCached: cached
  });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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);
    });
})
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 }  
];
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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", ...
Enter fullscreen mode Exit fullscreen mode

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);
    });
})
Enter fullscreen mode Exit fullscreen mode

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()
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 })
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)