The Jamstack has a nice way of separating the front end from the back end to where the entire solution doesn’t have to ship in a single monolith — and all at the exact same time. When the Jamstack is paired with a REST API, the client and the API can evolve independently. This means both front and back ends are not tightly coupled, and changing one doesn’t necessarily mean changing the other.
In this article, I’ll take a look at a REST API from the perspective of the Jamstack. I’ll show how to evolve the API without breaking existing clients and adhere to REST standards. I’ll pick Hapi as the tool of choice to build the API, and Joi for endpoint validations. The database persistence layer will go in MongoDB via Mongoose to access the data. Test-driven development will help me iterate through changes and provide a quick way to get feedback with less cognitive load. At the end, the goal is for you to see how REST, and the Jamstack, can provide a solution with high cohesion and low coupling between software modules. This type of architecture is best for distributed systems with lots of microservices each on their own separate domains. I’ll assume a working knowledge of NPM, ES6+, and a basic familiarity with API endpoints.
The API will work with author data, with a name, email, and an optional 1:N (one-to-few via document embedding) relationship on favorite topics. I’ll write a GET, PUT (with an upsert), and DELETE endpoints. To test the API, any client that supports
fetch() will do, so I’ll pick Hoppscotch and CURL.
I’ll keep the reading flow of this piece like a tutorial where you can follow along from top to bottom. For those who’d rather skip to the code, it is available on GitHub for your viewing pleasure. This tutorial assumes a working version of Node (preferably the latest LTS) and MongoDB already installed.
To start the project up from scratch, create a folder and
cd into it:
mkdir hapi-authors-rest-api cd hapi-authors-rest-api
Once inside the project folder, fire up
npm init and follow the prompt. This creates a
package.json at the root of the folder.
Every Node project has dependencies. I’ll need Hapi, Joi, and Mongoose to get started:
npm i @hapi/hapi joi mongoose --save-exact
- @hapi/hapi: HTTP REST server framework
- Joi: powerful object schema validator
- Mongoose: MongoDB object document modeling
package.json to make sure all dependencies and project settings are in place. Then, add an entry point to this project:
"scripts": "start": "node index.js" ,
MVC Folder Structure with Versioning
For this REST API, I’ll use a typical MVC folder structure with controllers, routes, and a database model. The controller will have a version like
AuthorV1Controller to allow the API to evolve when there are breaking changes to the model. Hapi will have a
index.js to make this project testable via test-driven development. The
test folder will contain the unit tests.
Below is the overall folder structure:
┳ ┣━┓ config ┃ ┣━━ dev.json ┃ ┗━━ index.js ┣━┓ controllers ┃ ┗━━ AuthorV1Controller.js ┣━┓ model ┃ ┣━━ Author.js ┃ ┗━━ index.js ┣━┓ routes ┃ ┣━━ authors.js ┃ ┗━━ index.js ┣━┓ test ┃ ┗━━ Author.js ┣━━ index.js ┣━━ package.json ┗━━ server.js
For now, go ahead and create the folders and respective files inside each folder.
mkdir config controllers model routes test touch config/dev.json config/index.js controllers/AuthorV1Controller.js model/Author.js model/index.js routes/authors.js routes/index.js test/Authors.js index.js server.js
This is what each folder is intended for:
config: configuration info to plug into the Mongoose connection and the Hapi server.
controllers: these are Hapi handlers that deal with the Request/Response objects. Versioning allows multiple endpoints per version number — that is,
model: connects to the MongoDB database and defines the Mongoose schema.
routes: defines the endpoints with Joi validation for REST purists.
test: unit tests via Hapi’s lab tool. (More on this later.)
In a real project, you may find it useful to abstract common business logic into a separate folder, say
utils. I recommend creating a
AuthorUtil.js module with purely functional code to make this reusable across endpoints and easy to unit test. Because this solution doesn’t have any complex business logic, I’ll choose to skip this folder.
One gotcha to adding more folders is having more layers of abstraction and more cognitive load while making changes. With exceptionally large code bases, it’s easy to get lost in the chaos of layers of misdirection. Sometimes it’s better to keep the folder structure as simple and as flat as possible.
This is what IntelliSense looks like in VS Code:
In WebStorm, this is called code completion, but it’s essentially the same thing. Feel free to pick whichever IDE you prefer to write the code. I use Vim and WebStorm, but you may choose differently.
To enable TypeScript type declarations in this project, fire up NPM and save these developer dependencies:
npm i @types/hapi @types/mongoose --save-dev
I recommend keeping developer dependencies separate from app dependencies. This way, it’s clear to other devs in the organization what the packages are meant for. When a build server pulls down the repo, it also has the option to skip packages the project doesn’t need at runtime.
With all the developer niceties in place, it’s now time to start writing code. Open the Hapi
server.js file and put in place the main server:
const config = require('./config') const routes = require('./routes') const db = require('./model') const Hapi = require('@hapi/hapi') const server = Hapi.server( port: config.APP_PORT, host: config.APP_HOST, routes: cors: true ) server.route(routes) exports.init = async () => await server.initialize() await db.connect() return server exports.start = async () => await server.start() await db.connect() console.log(`Server running at: $server.info.uri`) return server process.on('unhandledRejection', (err) => console.error(err) process.exit(1) )
I’ve enabled CORS by setting
cors to true so this REST API can work with Hoppscotch.
To keep it simple, I’ll forgo semicolons in this project. It’s somewhat freeing to skip a TypeScript build in this project and typing that extra character. This follows the Hapi mantra, because it’s all about the developer happiness anyway.
config/index.js, be sure to export the
module.exports = require('./dev')
To flesh out configuring the server, put this in
"APP_PORT": 3000, "APP_HOST": "127.0.0.1"
To keep the REST endpoints following the HTTP standards, I’ll add Joi validations. These validations help to decouple the API from the client, because they enforce resource integrity. For the Jamstack, this means the client no longer cares about implementation details behind each resource. It’s free to treat each endpoint independently, because the validation will ensure a valid request to the resource. Adhering to a strict HTTP standard makes the client evolve based on a target resource that sits behind an HTTP boundary, which enforces the decoupling. Really, the goal is to use versioning and validations to keep a clean boundary in the Jamstack.
With REST, the main goal is to maintain idempotency with the GET, PUT, and DELETE methods. These are safe request methods because subsequent requests to same resource don’t have any side effects. The same intended effect gets repeated even if the client fails to establish a connection.
I’ll choose to skip POST and PATCH, since these aren’t safe methods. This is for the sake of brevity and idempotency, but not because these methods tight couple the client in any way. The same strict HTTP standards can apply to these methods, except that they don’t guarantee idempotency.
routes/authors.js, add the following Joi validations:
const Joi = require('joi') const authorV1Params = Joi.object( id: Joi.string().required() ) const authorV1Schema = Joi.object( name: Joi.string().required(), email: Joi.string().email().required(), topics: Joi.array().items(Joi.string()), // optional createdAt: Joi.date().required() )
Note that any changes to the versioned model will likely need a new version, like a
v2. This guarantees backwards compatibility for existing clients and allows the API to evolve independently. Required fields will fail the request with a 400 (Bad Request) response when there are fields missing.
With the params and schema validations in place, add the actual routes to this resource:
// routes/authors.js const v1Endpoint = require('../controllers/AuthorV1Controller') module.exports = [ method: 'GET', path: '/v1/authors/id', handler: v1Endpoint.details, options: validate: params: authorV1Params , response: schema: authorV1Schema , method: 'PUT', path: '/v1/authors/id', handler: v1Endpoint.upsert, options: validate: params: authorV1Params, payload: authorV1Schema , response: schema: authorV1Schema , method: 'DELETE', path: '/v1/authors/id', handler: v1Endpoint.delete, options: validate: params: authorV1Params ]
To make these routes available to the
server.js, add this in
module.exports = [ ...require('./authors') ]
The Joi validations go in the
options field of the routes array. Each request path takes in a string ID param that matches the
ObjectId in MongoDB. This
id is part of the versioned route because it’s the target resource the client needs to work with. For a PUT, there’s a payload validation that matches the response from the GET. This is to adhere to REST standards where the PUT response must match a subsequent GET.
This is what it says in the standard:
A successful PUT of a given representation would suggest that a subsequent GET on that same target resource will result in an equivalent representation being sent in a 200 (OK) response.
This makes it inappropriate for a PUT to support partial updates since a subsequent GET would not match the PUT. For the Jamstack, it’s important to adhere to HTTP standards to ensure predictability for clients and decoupling.
AuthorV1Controller handles the request via a method handler in
v1Endpoint. It’s a good idea to have one controller for each version, because this is what sends the response back to the client. This makes it easier to evolve the API via a new versioned controller without breaking existing clients.
Build a Rest API for the Jamstack with Hapi and TypeScript