Intro to Hapi: The Node.js framework

Hapi is a Node.js framework that features a high-quality code stack, powerful configuration, and dialed-in extensibility—all without added dependencies. Let's take Hapi for a spin.

Stacking. Stacked building blocks.
oatawa/Shutterstock

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 the validate function from the previous step.
  • Apply the auth strategy to the /recipes route with the options.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.

Copyright © 2023 IDG Communications, Inc.