Hapi is a Node.js framework in the vein of Express but with a handful of distinguishing ideas, including a no-dependency, high-quality code stack; powerful configuration options; and fine-tuned extensibility that covers many use cases. JavaScript creator Brendan Eich said of Hapi that it “provides the right set of core APIs and extensible plugins to support the requirements of a modern service.” Hapi's clean, capable server API is the best recommendation of all. Let’s get our hands on Hapi, and find out what it brings to the table.
Getting started with Hapi
To start, we’ll create a new project directory called /hapi
and move into it by entering the command in Listing 1.
Listing 1. Starting a new Hapi project
npm init -y
npm install @hapi/hapi
npm
automatically adds a new package.json
file and installs Hapi as a dependency. Now, you can verify that Hapi does, in fact, include only itself as a dependency. Do this by listing the contents of /node_modules
, which has just a single subdirectory, /hapi
.
Add a simple endpoint
Next, let’s add the code to handle a simple endpoint. This endpoint will list a handful of pasta recipes in a RESTful JSON format. Assuming you’re in the /hapi
directory, create a new index.js
file and add the code shown in Listing 2.
Listing 2. index.js
const Hapi = require('@hapi/hapi');
const pastaRecipes = [
{ id: 1, name: 'Spaghetti Bolognese' },
{ id: 2, name: 'Fettuccine Alfredo' },
{ id: 3, name: 'Penne Arrabiata' }
];
const init = async () => {
const server = Hapi.server({
port:5173,
host: '0.0.0.0' // Remove to only bind to localhost
});
server.route({
method: 'GET',
path: '/recipes',
handler: (request, h) => {
return pastaRecipes;
}
});
await server.start();
console.log('Server running at:', server.info.uri);
};
init();
You can test the endpoint with the cURL command: curl http://localhost:5173/recipes
, or by opening the endpoint in a browser. (If you want to connect remotely, you’ll need to add host: 0.0.0.0 to the server config to bind to all network interfaces.)
The code in Listing 2 is self explanatory. We begin by importing the Hapi dependency, and then use it to configure the server itself with Hapi.server({})
, which accepts a JSON object and only requires the port to bind to.
Next, we create an in-memory array to hold the recipes, then we add a route with server.route()
. Again, this is configured with a JSON object which holds the HTTP get()
method, the URL path (/recipes
), and the functional handler, which simply returns the recipe array.
An asynchronous call to server.start()
starts the server.
Add an authenticated POST
Now, let’s add an authenticated POST endpoint that we can use to add a recipe. The post handling is simple, as shown in Listing 3. We can add this endpoint to the next GET
route.
Listing 3. Add a POST endpoint
server.route({
method: 'POST',
path: '/recipes',
handler: (request, h) => {
const recipe = request.payload;
recipe.id=pastaRecipes.length+1;
pastaRecipes.push(recipe);
return recipe;
}
});
Now we can POST
a request to it, as shown in Listing 4.
Listing 4. POSTing with cURL
curl -X POST -d '{"name":"Penne alla Vodka"}' http://localhost:5173/recipes -H 'Content-Type: application/json'
Sending a GET
request to the /recipes
endpoint will now return the recipes with the new addition.
Basic authentication with Hapi
Let’s say we need to allow only admins to access this POST
endpoint. There are many approaches to authentication, but for this, let’s use Hapi to enforce a basic HTTP authentication mechanism. We can then test it with a cURL post, both with and without the credentials set, as shown in Listing 5.
Listing 5. HTTP Basic authentication on an endpoint
const Hapi = require('@hapi/hapi');
const Bcrypt = require('bcrypt');
const users = {
john: {
username: 'john',
password: '$2a$10$iqJSHD.BGr0E2IxQwYgJmeP3NvhPrXAeLSaGCj6IR/XU5QtjVu5Tm', // 'secret'
name: 'John Doe',
id: '2133d32a'
}
};
const validate = async (request, username, password) => {
const user = users[username];
if (!user) {
return { credentials: null, isValid: false };
}
const isValid = await Bcrypt.compare(password, user.password);
const credentials = { id: user.id, name: user.name };
return { isValid, credentials };
};
const init = async () => {
const server = Hapi.server({
port:5173,
host: '0.0.0.0'
});
await server.register(require('@hapi/basic'));
server.auth.strategy('simple', 'basic', { validate });
server.route({
method: 'POST',
path: '/recipes',
options: {
auth: 'simple'
},
handler: (request, h) => {
const recipe = request.payload;
recipe.id=pastaRecipes.length+1;
pastaRecipes.push(recipe);
return recipe;
},
});
await server.start();
console.log('Server running at:', server.info.uri);
};
init();
We've implemented the authentication with the following steps:
- Import the
bcrypt
library (for encryption). - Define a JSON object called
users
to act as our credential “database.” - Register the built-in authentication plugin from Hapi to handle basic authentication:
await server.register(require('@hapi/basic'));
. - Define a
validate
function for checking user credentials against the database. - Add a basic authentication mechanism named
'simple'
to the server (auth.strategy
). Delegate this to thevalidate
function from the previous step. - Apply the
auth
strategy to the/recipes
route with theoptions.auth=’simple’
definition.
Note that Hapi doesn’t have generic middleware like Express. Instead, it uses more targeted plugins, like the auth
plugin we use here.
Before running this code, stop the server and add the bcrypt
dependency: $ npm i bcrypt
. Run the code with: $ node index.js
.
Test the POST endpoint
Now, we can test the secured endpoint, as shown in Listing 6.
Listing 6. Testing the secure POST endpoint with cURL
$ curl -X POST -H "Content-Type: application/json" -d '{"name": "Penne alla Vodka"}' http://localhost:5173/recipes
{"statusCode":401,"error":"Unauthorized","message":"Missing authentication"}
$ curl -X POST -u john:badsecret -H "Content-Type: application/json" -d '{"name": "Penne alla Vodka"}' http://localhost:5173/recipes
{"statusCode":401,"error":"Unauthorized","message":"Bad username or password","attributes":{"error":"Bad username or password"}}
$ curl -X POST -u john:secret -H "Content-Type: application/json" -d '{"name": "Penne alla Vodka"}' http://localhost:5173/recipes
{"name":"Penne alla Vodka","id":5}
Advanced routing with Hapi
Let’s look at a more complex example of routing. In this case, we’ll set up the application to retrieve a pasta dish using its ID.
Listing 7. Get a recipe by ID
server.route({
method: 'GET',
path: '/recipes/{id}',
handler: (request, h) => {
const recipeId = parseInt(request.params.id);
const recipe = pastaRecipes.find(recipe => recipe.id === recipeId);
if (recipe) {
return recipe;
} else {
return h.response({ error: 'Recipe not found' }).code(404);
}
}
});
$ curl http://localhost:5173/recipes/1
{"id":1,"name":"Fettuccine Alfredo"}
Listing 7 shows the code for recovering a recipe by ID, then the cURL command used to test it. The main feature to note is how to identify a URL parameter with curly braces and then recover that value in the handler method. In our case, we use the {id}
value to grab a recipe from the array and return it.
Hapi's built-in memory cache
Hapi includes support for caching out of the box. Let’s take a look at using this with the built-in in-memory cache, called CatBoxMemory
. In a real application, you would swap in something like Redis or Memcached.
Using the cache plugin involves creating the cache with the server.cache()
method, then applying it to the routes. (Note that you can also define the plugins inline when defining the server itself, instead of calling register after the fact.)
Listing 8 has an example of using the cache support. The rest of the code remains the same.
Listing 8. Using the cache
const CatboxMemory = require('@hapi/catbox-memory');
const cache = server.cache({
segment: 'recipes',
expiresIn: 60 * 60 * 1000, // Cache for 1 hour
generateFunc: async (recipeId) => {
const recipe = pastaRecipes.find((recipe) => recipe.id === recipeId);
return recipe ? { ...recipe, cacheHit: true } : null;
},
generateTimeout: 2000
});
server.route({
method: 'GET',
path: '/recipes',
handler: async (request, h) => {
const cachedRecipes = await cache.get('all');
if (cachedRecipes) {
return cachedRecipes;
}
await cache.set('all', { ...pastaRecipes, cacheHit: true });
return pastaRecipes;
}
})
In this example, the GET
endpoint uses cache.get
with the 'all'
key to pull in the recipes. If they are not found, it returns the objects from the database; otherwise, it comes out of the cache. (We add a cacheHit
field just to verify it is coming from the cache.) In Listing 9, you can see the output from testing the cache.
Listing 9. The cache in action
$ curl localhost:5173/recipes
[{"id":0,"name":"Spaghetti Bolognese"},{"id":1,"name":"Fettuccine Alfredo"},{"id":2,"name":"Penne Arrabiata"}]
$ curl localhost:5173/recipes
{"0":{"id":0,"name":"Spaghetti Bolognese"},"1":{"id":1,"name":"Fettuccine Alfredo"},"2":{"id":2,"name":"Penne Arrabiata"},"cacheHit":true}
Hapi's server method—a simplified cache
Hapi also includes what is called a server method, a cleaner way to achieve caching for simple situations. Let’s say we want to add caching to the endpoint we're using to get recipes by ID. We could do this as shown in Listing 10.
Listing 10. Using Hapi's server method for caching
const init = async () => {
const server = Hapi.server({
port:5173,
host: '0.0.0.0'
});
server.method('getRecipe', (id)=>{
const recipe = pastaRecipes.find(recipe => recipe.id === id);
return recipe ? recipe : { error: 'Recipe not found' };
}, {
cache: {
expiresIn: 10 * 1000,
generateTimeout: 2000
}
});
server.route({
method: 'GET',
path: '/recipes/{id}',
handler: async (request, h) => {
return await server.methods.getRecipe(parseInt(request.params.id));
}
});
await server.start();
console.log('Server running at:', server.info.uri);
};
init();
In Listing 10, we see how to create a reusable method with server.method
. With this approach, we use the cache
property to automatically cache the results of the argument method. The cache
property accepts the same object fields as the cache
object. We use the cache
property instead of a normal function in the handler method of the /recipes/{id} GET
route. This is a very simple approach and removes the boilerplate required to create a cached function.
Conclusion
Hapi delivers on the promise of a convenient and extensible server-side API. It’s simple and common-sense enough to pick it up very fast coming from another server, and the approach to adding plugins with a targeted system makes sense and avoids some of the wrangling with middleware that can come with other servers. It’s also nice to know that Hapi requires no dependencies out of the box, meaning its codebase is thoroughly vetted and secure.