Hapi: using pre-route functions for fun and profit
Paul Walker
Posted on April 11, 2021
Well, fun anyway.
Problem
As I added more methods to my latest project I realised that there was an increasing amount of wasted effort.
In every endpoint dealing with an item, we had to fetch the item. That also meant that every endpoint also had to deal with checking the user had the right to access that item. When I started adding things belonging to things, and we then had to check the chain of ownership held, it started getting tedious.
I started to think - Express has middleware, I wonder what Hapi has? There’s bound to be something so I can do the work once and store it in the request object.
To the API docs!
Solutions
Validations
Those looked promising to start with - after all, we were validating the request parameters.
Unfortunately they didn’t help - validations can’t add to the request context, so the validation function would get the items and then the function would have to get the item again. (Or we start doing some caching - possible but overcomplicated.)
Plugins
Next, I looked at plugins. For what I wanted, though, they weren’t a great fit.
Plugins are registered on the whole server, not an individual route. But that raises a problem - how do you know which requests must have a parameter and which don’t? Without that you’re still left checking in the endpoint functions, which wasn’t what I wanted.
Pre-route functions
These looked much more promising. They run after authentication, so you’ve got the user credentials. They can add to the request context - the values they return go into the request.pre
object. And you can add them to individual routes.
Looks like we have a winner!
Trying it out
We’ll need something to start from. Let’s extend the people server from the post on using templates and validation.
We’ll also do the first attempt without using the pre-route function. That lets us check that the basic flow works, since we haven’t used them before, and we can see what kind of difference it makes to the code.
We’ve got a route, /people
, to get a list of all the people we’ve stored. Let’s add a new route to get an individual person. /people/{personId}
would be nicely RESTful.
Test
Firstly - as always - we add a test.
it("can get an individual person", async () => {
const res = await server.inject({
method: "get",
url: "/people/1"
});
expect(res.statusCode).to.equal(200);
expect(res.payload).to.not.be.null;
});
Of course it fails, since the server doesn’t know about that route yet.
Template
Next we’ll add the template that will be used. We’re keeping it really basic - this isn’t about making stuff look pretty, just testing a concept.
<html>
<head>
<title>Purple People Eaters</title>
</head>
<body>
<p><%= person.name %> - <%= person.age %></p>
<a href="/people">Go back to people</a>
</body>
</html>
Code
Now we start adding the actual code. First thing we need to do is extend the route table:
export const peopleRoutes: ServerRoute[] = [
{ method: "GET", path: "/people", handler: showPeople },
{ method: "GET", path: "/people/{personId}", handler: showPerson },
{ method: "GET", path: "/people/add", handler: addPersonGet },
{ method: "POST", path: "/people/add", handler: addPersonPost }
];
Then the handler function. Since we’re not dealing with authentication in this project, it’s fairly simple already.
async function showPerson(request: Request, h: ResponseToolkit): Promise<ResponseObject> {
const person = people.find(person =>
person.id == parseInt(request.params.personId)
);
return h.view("person", { person: person });
}
Note that we’re skipping error checking here, to get something up and running. And it works!
server handles people - positive tests
✓ can see existing people
✓ can show 'add person' page
✓ can add a person and they show in the list
✓ can get an individual person
Using pre
The first thing is to check the function signature needed for the pre-route handlers. It looks like it’s very similar to a standard request handler, but with a different return type.
That makes sense - the request handlers are returning HTTP responses, while the pre-route handlers are potentially returning objects.
It needs to be robust - this is the function which checks the correctness of the incoming data - so we add in all the error checking which would usually be in the HTTP routes. Our design for this is to either return a valid object or throw an exception, so we make our return type Person
.
async function checkPerson(request: Request, h: ResponseToolkit): Promise<Person> {
// Did the user actually give us a person ID?
if (!request.params.personId) {
throw Boom.badRequest("No personId found");
}
try {
const person = people.find(person => person.id == parseInt(request.params.personId));
if (!person) {
throw Boom.notFound("Person not found");
}
return person;
} catch (err) {
console.error("Error", err, "finding person");
throw Boom.badImplementation("Error finding person");
}
}
const checkPersonPre = { method: checkPerson, assign: "person" };
We need to change the routing table to add the new option:
{ method: "GET", path: "/people/{personId}", handler: showPerson, options: { pre: [checkPersonPre] } },
And then update the showPerson
function:
async function showPerson(request: Request, h: ResponseToolkit): Promise<ResponseObject> {
return h.view("person", { person: request.pre.person });
}
Even on our toy project our HTTP handler now looks a lot cleaner.
Usage in a real project
Giving an example on a project I’m developing, you can see it makes even more of a difference.
Prior to the changes, every route had to:
- get site, checking that user was allowed to reference site
- get event, checking it was connected to that site
- handle missing/bad values
Which looked something like this:
async function deleteEventPost(request: Request, h: ResponseToolkit): Promise<ResponseObject> {
try {
if (!request.params.siteId) {
throw Boom.badRequest("No site ID");
}
if (!request.params.eventId) {
throw Boom.badRequest("No event ID");
}
// We don't actually want the site or event, we just
// want to confirm ownership.
const site = await getSite(request.auth.credentials.id, request.params.siteId);
if (!site) {
throw Boom.notFound();
}
const event = await getEvent(site.id, request.params.eventId);
if (!event) {
throw Boom.notFound();
}
await deleteEvent(event.id);
return h.redirect(`/sites/${site.id}/events`);
} catch (err) {
console.error("Error", err);
throw Boom.badImplementation("error deleting event");
}
}
After adding the pre-route handlers, that slimmed down quite a bit:
async function deleteEventPost(request: Request, h: ResponseToolkit): Promise<ResponseObject> {
try {
await deleteEvent(request.pre.event.id);
return h.redirect(`/sites/${request.pre.site.id}/events`);
} catch (err) {
console.error("Error", err);
throw Boom.badImplementation("error deleting event");
}
}
Repeat that for pretty much every single function, and you can see why this is a win!
The work is all taken care of in one place - the actual view functions can just assume the data is there and valid, since if it isn’t then they wouldn’t be running, and they can get on with what they should actually be doing.
End
Well, that’s it. Let me know if it it was helpful. As usual, the code from the post can be found in my Github repo.
Posted on April 11, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.