Skip to content
Hou - Engineer & Tech Educator

Introduction to Node.js Part IV - Building a RESTful API with Express

Node.js, JavaScript, RESTful API, Express, intermediate, tutorial, intro-to-node-js20 min read

Introduction

Welcome to the fourth part of the six-part Introduction to Node.js tutorial series. By the end of this tutorial, you will be able to:

  • Install and use Express to handle HTTP requests
  • Build a RESTful API with Express
  • Utilize the Node Package Manager (npm) to install and manage Node.js package dependencies

Some basic knowledge of server-side concepts is assumed.

Tutorial Outline

  • Express Overview
  • Complete Example
  • The Node Package Manager & Node Community
  • Creating Your First Express Project
    • Task 1: Setup Your Project Folder
    • Task 2: Create a package.json File
    • Task 3: Install Project Dependencies
    • Task 4: Add a .gitignore File
    • Task 5: Create a Basic Express Server
  • Implementing Routing
    • Task 6: Express Route Handler
    • Task 7: Dynamic Routes
    • ASIDE: Installing Nodemon
    • Task 8: Serving Static Assets
  • Implementing Middleware
    • Task 9: Implement Application-Level Middleware
  • Building a RESTful API with Express - res.json()
    • RESTful API Overview
    • JavaScript Object Notation (JSON) Overview
    • Task 10: Remove test code from src/index.js
    • Task 11: Use the express.Router class to Create Modular Route Handlers
    • Task 12: Implement a Route Handler for GET /api/v1/recipes
    • Task 13: Implement a Route Handler for POST /api/v1/recipes
    • Task 14 (CHALLENGE): Implement a Route Handler for GET /api/v1/recipes/:id
    • Task 15 (CHALLENGE): - Implement a Route Handler for PUT /api/v1/recipes/:id
    • Task 16 (CHALLENGE): Implement a Route Handler for DELETE /api/v1/recipes/:id
    • Task 17: Redirect the Base Url / to /api/v1/recipes
    • Task 18: Enable CORS
    • Task 19: Chain Route Handlers with router.route()
    • Task 20: Handle Errors Centrally
    • Task 21: Refactor the recipes Controller
  • Other Node.js Frameworks & Tools
  • Additional Resources
  • Review
  • Key Takeaways
  • What's next

Express Overview

Node.js is commonly used as a web server to serve up websites, JSON, and more. However, its syntax could be verbose. Express is a popular server-side framework for building web applications on Node.js.

Express provides a lightweight abstraction over the Node.js HTTP modules and convenience methods for creating routing, views, and middleware.

In this tutorial, you will build a RESTful API that allows API consumers to retrieve, create, update, and delete a set of recipes.

Along the way, you'll learn about npm and how to use npm to manage Node.js packages. You'll learn how to configure Express to return html templates as well as JSON data to the user. You'll also learn about RESTful design principles.

Starter Code

There's no starter code for this tutorial. You will follow the steps described below to build the project from scratch.

Complete Example

A Github repo with the complete code is available here.

Take a look at a deployed version of the API here.

The Node Package Manager & Node Community

The Node Package Manager(or npm for short) consists of three distinct components:

  • npm is the world's largest (JavaScript) software registry. Open source developers from across the globe use npm to share and borrow packages, and many organizations use npm to manage private development as well.

    The large public database of packages means there's a package for almost everything! You can download and adapt these packages for your applications or incorporate them as they are.

    However, since anybody can publish a package to the npm registry, npm packages might not all be of the same quality. Often, there are dozens or even hundreds of packages with similar names and/or purposes, so it's important to select the most appropriate package for your project.

  • npm also provides a user interface for managing your public and private packages and other aspects of your npm experience, like creating organizations to coordinate package maintenance and restricting code to specific developers.

  • npm also refers to the command-line interface (CLI) tool for downloading and managing Node.js packages. npm came bundled with your Node.js installation and is how most developers interact with the npm registry. While npm is the standard package manager for Node.js, there are other alternatives such as yarn. In this tutorial, you will only be using npm.

What criteria would you apply to select packages for your project?

When you use the search bar on the npm website to find packages, it returns a list of packages which can be ranked by four criteria:

  • Popularity: How popular is the package? If a package has thousands or even millions of downloads, that's probably a strong indicator that others have found the package to be useful. Check out npm trends where you can compare the popularity of different comparable packages.

  • Quality: What steps have the package creator/maintainer taken to ensure high quality? The presence of a README file, package stability, tests, and up-to-date dependencies are characteristics of a high quality package.

  • Maintenance: How well-maintained is the package? Meaning is the package frequently updated to work well with other dependencies or new versions of the npm CLI?

  • Optimality: How optimal is the package? Optimal combines the three criteria mentioned above (ie., popularity, quality, maintenance) into one meaningful score.

The npm search bar is powered by npms and the npms analyzer.

Project Setup

Now, let's set up a new project. Along the way, you will learn about the npm CLI.

Task 1: Setup Your Project Folder

  1. Navigate to the folder where you'll like to store your new project.

  2. Create a new directory called express-recipes (or use any name that you like) and change into it:

1$ mkdir express-recipes
2$ cd express-recipes

Task 2: Create a package.json File

  1. Turn your project into an npm package by running the command:
1$ npm init

What happens when you run the command above?

It loads a questionnaire containing a series of questions about how you would like to configure your package (e.g., project name, licenses, version, etc.).

Press Enter on every question to accept the defaults.

NOTE: If you ran npm init --yes instead, npm will create a default package.json using information extracted from the current directory. This option is seldom used, since developers often want to customize the manifest instead of using default values.

A manifest file called package.json will be created:

package.json
1{
2 "name": "starter-express-recipes",
3 "version": "1.0.0",
4 "description": "",
5 "main": "index.js",
6 "scripts": {
7 "test": "echo \"Error: no test specified\" && exit 1"
8 },
9 "author": "",
10 "license": "ISC"
11}

The package.json file will specify versions of a package that your project can use, based on semantic versioning rules. It also provides centralized configuration for tools (e.g., testing).

A package.json file must include the following fields:

  • The name field which contains your package’s name, which must be lowercase and may contain hyphens and underscores (e.g., my-express-package). Try to choose a name that is unique, descriptive and meets npm policy guidelines. Make sure it is not spelled in a similar way to another package name and will not confuse others about authorship.

  • The version field must be in the x.x.x format and follow semantic versioning guidelines.


What is semantic versioning?

In the npm ecosystem, developers follow npm's semantic versioning guidelines when updating their packages' version numbers.

Every time you make significant updates to an npm package, it is a best practice to publish a new version of the package with an updated version number in the package.json file for a few reasons:

  • Having a consistent system for upticking project dependencies helps other developers understand the scope of changes in a given version and update their codebases as needed.
  • Specifying an explicit version of a library also helps to keep everyone on the same exact version of a package, so that the whole team runs the same version until the package.json file is updated.

Semver (semantic versioning) relies on the x.x.x 3-digit format. The first, second, and third digits represent the major release, the minor release and the patch release, respectively.

Updated packages might contain only backward-compatible bug fixes (i.e., a patch release) or backward-compatible new features (i.e., a minor release), in which case the package could be incorporated presumably without much hassle.

Changes that break backward compatibility, on the other hand, would typically require a higher level of engineering planning and time to incorporate into the project.

Thanks to semver, you can easily specify any version of a package, or require a version higher or lower than what you need by using special symbols (e.g., ~, ^) in your package.json's dependency list.

npm's semver calculator is a useful tool for defining appropriate acceptable package ranges for our project dependencies.

What do the ^(aka carat or hat symbol) and ~(aka tilde symbol) notations mean when defining package versions here?

package.json
1"dependencies": {
2 "awesome_dep": "^0.13.0",
3 "another_awesome_dep": "~5.2.0"
4},

In your package.json, if you write:

  • ~0.13.0, patch releases will be updated when running npm install, meaning 0.13.1 is acceptable, but 0.14.0 is not.
  • ^0.13.0, patch and minor releases will be updated when running npm install, so 0.13.1, 0.14.0 and so on are acceptable.
  • 0.13.0, that exact version will always be used. In other words, the dependency is locked.

What optional fields can be used in package.json?:

Other optional fields include, but are not limited to:

  • author
  • contributors
  • description
  • main
  • private
  • scripts
  • dependencies
  • devDependencies
  • engines
  • browserslist

You just created your first npm package!

When you hear the term package, it just refers to a file or directory that is described by a package.json file. To be published to the npm registry, a package must contain a package.json file.

Task 3: Install Project Dependencies

  1. Install express in your project:
1$ npm install express

What does running the above command create in your folder?

A new folder called node_modules is created.

It is the folder where npm installs the packages the project needs (i.e., dependencies).

The express package is installed in the current file tree, under the node_modules subfolder.

As this happens, npm also adds the express entry in the dependencies property of the package.json file present in the current folder.

Finally, a package-lock.json file is also created.

What do you think is the purpose of the package-lock.json file?Nodejs: The Package Lock JSON File
  1. To see the latest version of all the npm packages installed, including their dependencies, you can run:

    1$ npm list

Task 4: Add a .gitignore File

  1. Generated code (e.g., node_modules) is typically not committed to Git, since they could take up a lot of space unncessarily. Additionally, sensitive environment variables should not be committed to Git. You can a .gitignore file in your root directory to specify which files or folders Git should ignore:

    1$ touch .gitignore

    Add node_modules and .env to your .gitignore file, like this:

    1node_modules/
    2.env

    Since the project dependencies are not committed to the Git repository, you might ask: How would other developers get these dependencies when they clone the project to their local machine?

    Keep in mind package-lock.json makes your build reproducible. Another developer could just run npm install on their local machine to install the dependencies listed in package-lock.json.

    In the next tutorial, you will store your environment variables in a file called .env. The .env file should not be commited to the Git repository because it will contain the authentication secret and other sensitive credentials for the API. So .env should be listed in the .gitignore file.

Task 5: Create a Basic Express Server

  1. Once the express package has been installed in node_modules/ (along with other dependencies), you can now use it in your code. Since you will be creating multiple server-side files, you can organize the code that you write within a folder called src (a.k.a., the source folder). Then inside of the src folder, create a file called index.js:

    1$ mkdir src
    2$ touch src/index.js
  2. Inside src/index.js, configure and set up an Express server, like this:

    src/index.js
    1/*
    2First, use the CommonJS require() function to
    3import the Express module into the program
    4*/
    5const express = require("express");
    6
    7/*
    8Next, invoke express() to instantiate a new Express
    9application
    10*/
    11const app = express();
    12
    13// Finally, listen on port 8080 for incoming requests
    14const port = process.env.PORT || 8080;
    15
    16app.listen(port, () => {
    17 // Log a message when the server is up and running
    18 console.log(`Server is up on port ${port}.`);
    19});

    That's all it takes to create a basic Node.js server with Express!

  3. To start your server, run:

    1$ node src/index.js
    2Server is up on port 8080.

    The Node.js process will stay running until you shut it down. You can always use CTRL + c to terminate the process, stop the server, and regain control of the terminal. If you need to run other CLI commands, then you'd have to start a new terminal window.

  4. Try visiting http://localhost:8080 in the browser.

    What do you see?Cannot GET /

Implementing Routing

Our application is still missing routing!

Routing refers to how an application handles a client request to a particular endpoint. An endpoint consists of a path (e.g., /hello in https://www.helloworld.com/hello) and an HTTP method (i.e., GET, PUT, POST, PATCH,DELETE).

Task 6: Express Route Handler

  1. Inside src/index.js, create a route handler after instantiating the Express application:

    src/index.js
    1const express = require("express");
    2const app = express();
    3
    4// Route handler that sends a message to someone
    5// visiting http://localhost:8080/
    6app.get("/", (req, res) => {
    7 res.send("Hello Express Student!");
    8});
    9
    10const port = process.env.PORT || 8080;
    11
    12app.listen(port, () => {
    13 console.log(`Server is up on port ${port}.`);
    14});

    Let's break down the syntax here:

    • app refers to the instance of the Express server declared earlier at the top of the file.
    • .get() tells our Express server what HTTP method to listen for.
      • The first argument is the path to set up the handler for. This route path will match requests to the root route, /.
      • The second argument is a callback function that is executed when the path / is visited. The callback function accepts two arguments:
    • Calling res.send() in the route handler sends a message back as the response to the client.

    You should now have a working route!

  2. Restart the server and navigate to http://localhost:8080/. You should see the Hello Express Student message.

    Besides `app.get()`, what other HTTP request methods do you think Express supports?Take a look here.

    Keep in mind Express provides a lightweight abstraction over the Node.js HTTP modules. You can compare and contrast the code you just wrote with the corresponding vanilla Node.js implementation below and notice how much less verbose Express makes your code:

    1const http = require("http");
    2const hostname = "127.0.0.1";
    3
    4// http.createServer() creates a new HTTP server and returns it.
    5const server = http.createServer((req, res) => {
    6 // Whenever a new request is received, the request event is called,
    7 // providing two objects: a request (req) and a response (res).
    8 res.statusCode = 200; // Set the statusCode property to 200 to indicate a successful response.
    9 res.setHeader("Content-Type", "text/plain");
    10 res.end("Hello Express Student!"); // close the response, adding the content as an argument to end():
    11});
    12
    13const port = process.env.PORT || 8080;
    14// The server is set to listen on the specified port and hostname.
    15// When the server is ready, the callback function is called,
    16// in this case informing us that the server is running.
    17server.listen(port, hostname, () => {
    18 console.log(`Server running at http://${hostname}:${port}/`);
    19});

Task 7: Dynamic Routes

You can make your routes dynamic, meaning you can add parameters to your routes.

Route parameters are simply placeholders (similar to variables) in a URL. They always start with the : symbol. They allow you to customize your server's responses to an HTTP request.

  1. Create a route handler that can accept a name parameter:

    src/index.js
    1const express = require("express");
    2const app = express();
    3
    4app.get("/", (req, res) => {
    5 res.send("Hello Express Student!");
    6});
    7
    8app.get("/:name", (req, res) => {
    9 res.send(`Welcome to Express Recipes, ${req.params.name}!`);
    10});
    11
    12const port = process.env.PORT || 8080;
    13
    14app.listen(port, () => {
    15 console.log(`Server is up on port ${port}.`);
    16});

    You can now access the :name route parameter as a key inside the req.params object. Our greeting is more personal!

  2. Restart your server. What do you see when you visit http://localhost:8080/intuit? You should see the message Welcome to Express Recipes, intuit!.

Installing Nodemon

Notice that you'd have to manually restart the server every time you make a code change, which is annoying and time-consuming.

Enter Nodemon, a development tool that monitors Node.js applications and automatically restarts the server whenever it detects file changes.

  1. Install the nodemon package globally:

    1$ npm install -g nodemon

    A global installation is performed using the -g flag. Installing a package globally allows a package to be used across all of Node.js applications. The package won't be included in the project dependency, but instead, it will be installed in a global location on your machine.

    The npm root -g command will tell you where that exact location is on your machine.

    On macOS or Linux, this location could be /usr/local/lib/node_modules.

    On Windows, it could be C:\Users\YOU\AppData\Roaming\npm\node_modules.

    If you use nvm to manage Node.js versions, however, note that the location would differ.

    I, for example, use nvm. My packages location was shown as /Users/houchia/.nvm/versions/node/v14.2.0/lib/node_modules.

  2. Stop any server that's currently running. Then, start up the Express application with the nodemon command:

    1$ nodemon index.js
  3. Try making a change inside index.js, for example, changing the greeting. Check the terminal that is running the server - you should see that the server automatically restarted.

  4. But it'd be a hassle to have to remember that long command every time you want to start the server. Let's update the start script in the package.json file to run this command:

1"start": "nodemon src/index.js --ignore ./db"

The --ignore ./db option tells nodemon to ignore changes to the /db folder, which you will add later on. The /db folder will contain the API data. By ignoring changes to the /db folder, nodemon will not restart the server every time data is added, removed or updated inside the /db folder.

Now, whenever you need to start the server, you can just run npm run start, where start is the name of the script you'd like to execute.

Task 8: Serving Static Assets

Express can serve up all the assets needed for your website, including HTML, CSS, JavaScript, images, fonts and more.

You just need to tell Express the path to the directory it should serve. Here are the tasks:

  1. Create a public/ folder in your project root and add an index.html file and a styles.css file to it.

    public/index.html
    1<!DOCTYPE html>
    2<html lang="en">
    3 <head>
    4 <meta charset="UTF-8" />
    5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    6 <link rel="stylesheet" href="styles.css" />
    7 <title>Express Recipes</title>
    8 </head>
    9 <body>
    10 <h1>Welcome to Express Recipes</h1>
    11 </body>
    12</html>
    public/styles.css
    1h1 {
    2 color: blue;
    3}
  2. Configure the Express server to serve content stored in the public/ folder:

    src/index.js
    1const express = require("express");
    2const path = require('path');
    3
    4const app = express();
    5
    6const publicDirectoryPath = path.join(__dirname, './public');
    7app.use(express.static(publicDirectoryPath));
    8
    9app.get("/", (req, res) => {
    10 res.send("Hello Express Student!");
    11});
    12
    13app.get("/:name", (req, res) => {
    14 res.send(`Welcome to Express Recipes, ${req.params.name}!`);
    15});
    16
    17const port = process.env.PORT || 8080;
    18
    19app.listen(port, () => {
    20 console.log(`Server is up on port ${port}.`);
    21});

    Let's break down the syntax above:

    • First, import Node.js’s path module, which is needed to generate the absolute path to the public/ folder.
    • Next, construct the absolute path to the public/ folder. The call to path.join allows you to create an absolute path by combining individual path segments. It combines __dirname, which is the directory path for the current script, with the relative path to the public/ folder. Note that if any site assets are not in the public/ folder, then they're not public and the browser won't be able to load them.
    • Finally, register a middleware function using app.use(). Within app.use(), call express.static(), which is a built-in middleware function that serves static assets included in the public/ directory to the client.
  3. Navigate to http://localhost:8080. You should see the index.html page!

Implementing Middleware

You used a middleware function, express.static(), in the previous section to serve static files to the client. You can use middleware functions for a variety of tasks, such as logging, authentication, parsing form data, etc.

Middleware functions are functions that alter the request (req) and response (res) object(s) in the application's request-response cycle:

New HTTP Request --> Middleware Functions --> Route Handlers

Middleware functions should accept three parameters: req, res, and next. They are executed in a specific order. When the current middleware function is done, it will pass control to the next middleware function in the stack by calling next().

If you don't call next() inside a middleware function, the route handlers that come after the middleware function will NOT run!

Task 9: Implement Application-Level Middleware

Let's create a simple logging middleware to print information about every incoming request.

  1. Add the following middleware before the route handlers:

    src/index.js
    1const express = require("express");
    2const path = require('path');
    3
    4const app = express();
    5
    6app.use((req, res, next) => {
    7 const { method, path } = req;
    8 console.log(
    9 `New request to: ${method} ${path} at ${new Date().toISOString()}`
    10 );
    11 next();
    12});
    13
    14const publicDirectoryPath = path.join(__dirname, './public');
    15app.use(express.static(publicDirectoryPath));
    16
    17app.get("/", (req, res) => {
    18res.send("Hello Express Student!");
    19});
    20
    21app.get("/:name", (req, res) => {
    22 res.send(`Welcome to Express Recipes, ${req.params.name}!`);
    23});
    24
    25const port = process.env.PORT || 8080;
    26
    27app.listen(port, () => {
    28 console.log(`Server is up on port ${port}.`);
    29});

    Let's break down the syntax above:

    • app.use() sets up a middleware function. Because a mount path is not passed as the first argument, the function will be executed every time the app receives a request.
    • The callback function passed to app.use() logs information about the incoming request and finally calls next().
    • Calling next() passes control to the middleware that follows the current middleware, which in this case is the middleware that serves static files from the public/ folder.
  2. Try navigating to http://localhost:8080/. What do you see in the console? You should see the message New request to: GET....

You'll add more middleware functions later on but for now, this is all you need. If you're curious about other ways to incorporate middleware functions, take a read here.

At this point, you have a basic Express server capable of serving static assets and handling a basic HTTP GET request!

Building a RESTful API with Express - res.json()

Let's implement a simplified Express API, Express Recipes, that serves recipes data via JSON over HTTP. The request-response cycle below traces how a user's GET request would flow through the Express Recipes application.

express recipes request response cycle

Note that in the diagram above, model should say service

To keep the API code organization easy to understand, maintain and modify, you will structure and write modular code using three main layers, namely the router, controller, and service.

The router for a given resource is responsible for routing requests to the appropriate controller based on the URL.

The controller for a given resource defines the logic for handling each route and is responsible for manipulating the state of a resource in the API as well as sending back a response (either successful or not) to the client.

The service encapsulates or groups together the functions that perform a specific category of tasks in an application, like making CRUD transactions for a given resource. Services make code DRY because the functions defined in a service can be reused throughout the application.

Overall, structuring and layering your code this way would lead to better modularization, encapsulation, and separation of concerns in your codebase.

Here are the steps involved in the request-response cycle:

  1. The browser sends a GET request for all the recipes.
  2. The router maps the HTTP request to the corresponding controller for handling.
  3. The controller receives the HTTP request and asks the service to fetch data from storage.
  4. The service loads the data from recipes.json.
  5. The service returns data to the controller.
  6. The controller sends JSON data back to the browser.

Keep this mental model in mind for the rest of the tutorial since you will be writing code to implement each part of this diagram, including the router, controller, and service for the recipes resource.

RESTful API Overview

An API allows clients to access data on a server. Representational Transfer State (REST) is a popular architectural convention for structuring and naming APIs using a standardized protocol, such as the Hyper-Text Transfer Protocol (HTTP) standard.

A REST API based on the HTTP standard leverages five main HTTP methods to retrieve and manipulate data. Each method corresponds to a Create, Read, Update, Delete (CRUD) operation.

HTTP MethodCRUD functionalityDatabase Action
GETreadretrieve data
POSTcreateadd data
PUTupdatemodify existing data
PATCHupdatemodify existing data
DELETEdeletedelete existing data

DISCUSS:

What is the difference between `PUT` and `PATCH`?`PATCH` is replacing part of the data and `PUT` is replacing the whole thing. For example, when updating a user profile, `PUT` completely replaces the profile in the database, whereas `PATCH` changes a few fields of the profile.

The Express Recipes API will support the following RESTful endpoints:

HTTP MethodPathAction
GET/api/v1/recipesRead information about all recipes
POST/api/v1/recipesCreate a new recipe
GET/api/v1/recipes/1Read information about the recipe with ID of 1
PUT/api/v1/recipes/1Update the existing recipe with ID of 1 with all new content
DELETE/api/v1/recipes/1Delete the existing recipe with ID of 1

Note that it is a best practice to version your endpoints (i.e., /api/v1) so that you can maintain compatibility with older services while continuing to improve your API.

JavaScript Object Notation (JSON) Overview

Let's review what JSON is.

HTTP sends data as strings.

However, you often want to pass structured data (i.e., arrays and objects) between web applications.

In order to do so, native data structures are serialized: converted from a javascript object into a string representation of the data (aka serialization), using the "JavaScript Object Notation" (JSON) format.

This JSON string can be transmitted over the internet and then parsed back into data (i.e., de-serialized) once it reaches its destination (e.g., the browser).

Because JSON is easy-to-read, light-weight and easy-to-parse, it has become a universal standard for transmitting data across the web.

JSON Example:

1{
2 "owners": [
3 { "name": "Hou", "id": 1 },
4 { "name": "Tim", "id": 2 }
5 ],
6 "restaurantLocation": "This is a an address"
7}

While it is possible to transfer data using other formats (e.g., XML) and there are pros and cons to different approaches, you will only use JSON for this tutorial.

Task 10: Remove test code from src/index.js

  1. Now that you're ready to start building the API, let's remove any code that is not needed to build the API:

    src/index.js
    1const express = require("express");
    2const path = require('path');
    3
    4const app = express();
    5
    6app.use((req, res, next) => {
    7 const { method, path } = req;
    8 console.log(
    9 `New request to: ${method} ${path} at ${new Date().toISOString()}`
    10 );
    11 next();
    12});
    13
    14- const publicDirectoryPath = path.join(\_\_dirname, './public');
    15- app.use(express.static(publicDirectoryPath));
    16
    17- app.get("/", (req, res) => {
    18- res.send("Hello Express Student!");
    19- });
    20
    21- app.get("/:name", (req, res) => {
    22- res.send(`Welcome to Express Recipes, ${req.params.name}!`);
    23- });
    24
    25const port = process.env.PORT || 8080;
    26
    27app.listen(port, () => {
    28 console.log(`Server is up on port ${port}.`);
    29});

Task 11: Use the express.Router class to Create Modular Route Handlers

  1. Right now, the src/index.js file is pretty cluttered, since it includes all the code needed for server setup and initialization, middleware processing and route handling.

    To maintain better separation of concerns and modularity in the codebase, let's move the API routing logic to a separate folder called routers inside the src folder:

    1$ mkdir src/routers
    2$ touch src/routers/recipes.js
  2. Inside src/routers/recipes.js, create a router instance and export the module:

    src/routers/recipes.js
    1const express = require("express");
    2
    3const router = express.Router();
    4
    5// You'll add route handlers here in subsequent tasks
    6
    7// Export the router
    8module.exports = router;

    express.Router() creates a Router instance, which is like a mini Express app that runs its own complete middleware and routing system, and is responsible for routing a single given API resource (e.g., recipes data). This router module can be attached to the main Express app defined in src/index.js. If your API needs to manage multiple resources, then you can create multiple router instances to manage the routing for each resource.

  3. Inside src/index.js, load the router module at the top and mount the recipes router at the /api/v1/recipes, like this:

    src/index.js
    1const express = require("express");
    2const path = require('path');
    3
    4const recipesRouter = require('./routers/recipes');
    5
    6const app = express();
    7
    8app.use((req, res, next) => {
    9 const { method, path } = req;
    10 console.log(
    11 `New request to: ${method} ${path} at ${new Date().toISOString()}`
    12 );
    13 next();
    14});
    15
    16app.use('/api/v1/recipes', recipesRouter);
    17
    18const port = process.env.PORT || 8080;
    19
    20app.listen(port, () => {
    21 console.log(`Server is up on port ${port}.`);
    22});

    The middleware app.use('/api/v1/recipes', recipesRouter) will route any path matching /api/v1/recipes to the recipes router.

Task 12: Implement a Route Handler for GET /api/v1/recipes

Keep in mind after an incoming request has been matched to a controller, the controller will call the appropriate method from the service layer to interact with the database, so you'd need to implement the controller and service layers next.

  1. For simplicity, you will store the API data in a JSON file. Create a folder called db in the project root. Inside the db folder, create a new file called recipes.json.

  2. You will rely on the fs.promises API to perform CRUD operations on the recipes data.

    Copy and paste the contents of recipes.json from this commit into your /db/recipes.json file. This will be the starter data set for the Express Recipes API.

  3. Inside the src folder, create a folder called services and within the services folder, create a file called recipes.js. Then add the following code:

    src/services/recipes.js
    1const fs = require("fs").promises;
    2const path = require("path");
    3
    4const recipesFilePath = path.join(__dirname, "../../db/recipes.json"); // Contruct the path to the recipes data
    5
    6const getAll = async () => JSON.parse(await fs.readFile(recipesFilePath));
    7
    8module.exports = {
    9 getAll,
    10};

    Line 1 imports the fs methods that return Promise objects so that you can use the async/await pattern in your service later. The getAll() method reads, parses, and returns the contents of the db/recipes.json file.

  4. Inside the src folder, create a folder called controllers and within it, create a file called recipes.js. Implement the getAll route handler inside this file:

    src/controllers/recipes.js
    1const service = require("../services/recipes");
    2
    3const getAll = async (req, res, next) => {
    4 try {
    5 res.json({ data: await service.getAll() });
    6 } catch (error) {
    7 next(error);
    8 }
    9};
    10
    11module.exports = {
    12 getAll,
    13};

    Note that the route handler is passed a next argument, which can be used to handle errors caught in the try/catch block. You will handle these errors at the end of the tutorial.

  5. Back in the recipes router, import and mount the router handler on the / path, like this:

    src/routers/recipes.js
    1const express = require("express");
    2const router = express.Router();
    3
    4const { getAll } = require("../controllers/recipes");
    5
    6router.get("/", getAll);
    7
    8module.exports = router;
  6. Navigate to http://localhost:8080/api/v1/recipes. You should be getting back an object that contains all the recipes stored in the data property!

Task 13: Implement a Route Handler for POST /api/v1/recipes

You've already seen an example of a built-in middleware when you used the express.static() method earlier on to serve static assets to the client.

A common server task is to handle form data or JSON submitted via a POST request. However, by default, Express does NOT know how to handle certain types of requests (e.g., a POST request that contains JSON data in the request body) unless we call specific middlewares.

Let's take a look at two widely-used built-in middlewares:

  1. Add the express.json() middleware to parse JSON data and make it accessible in the request object (via req.body).

    Also add the express.urlencoded() middleware to parse incoming requests with URL-encoded payloads.

    src/index.js
    1const express = require("express");
    2const path = require('path');
    3
    4const recipesRouter = require('./routers/recipes');
    5
    6const app = express();
    7
    8app.use((req, res, next) => {
    9 const { method, path } = req;
    10 console.log(
    11 `New request to: ${method} ${path} at ${new Date().toISOString()}`
    12 );
    13 next();
    14});
    15
    16app.use(express.json());
    17app.use(express.urlencoded({ extended: true }));
    18
    19app.use('/api/v1/recipes', recipesRouter);
    20
    21const port = process.env.PORT || 8080;
    22
    23app.listen(port, () => {
    24 console.log(`Server is up on port ${port}.`);
    25});
  2. In the recipes service, implement a save() method to save a new recipe to the database, like this:

    src/services/recipes.js
    1const fs = require("fs").promises;
    2const path = require("path");
    3
    4const recipesFilePath = path.join(__dirname, "../../db/recipes.json"); // Contruct the path to the recipes data
    5
    6const getAll = async () => JSON.parse(await fs.readFile(recipesFilePath));
    7
    8const save = async (recipe) => {
    9 // Get all recipes from the database
    10 const recipes = await getAll();
    11
    12 recipe.id = recipes.length + 1; // Not a robust incrementor mechanism; don't use in production!
    13
    14 // Push the new recipe into the current list of recipes
    15 recipes.push(recipe);
    16
    17 // Save all recipes to the database
    18 await fs.writeFile(recipesFilePath, JSON.stringify(recipes));
    19
    20 return recipe;
    21 };
    22
    23module.exports = {
    24 getAll,
    25 save
    26};
  3. In the recipes controller, implement a save() route handler for POST requests to api/v1/recipes:

    src/controllers/recipes.js
    1const service = require("../services/recipes");
    2
    3const getAll = async (req, res, next) => {
    4 try {
    5 res.json({ data: await service.getAll() });
    6 } catch (error) {
    7 next(error);
    8 }
    9};
    10
    11const save = async (req, res, next) => {
    12 try {
    13 // Extract only the data that is needed from the request body
    14 const {
    15 name,
    16 healthLabels,
    17 cookTimeMinutes,
    18 prepTimeMinutes,
    19 ingredients,
    20 } = req.body;
    21
    22 // Format the new recipe you want to save to the database
    23 const newRecipe = {
    24 name,
    25 healthLabels: [...healthLabels], // make a copy of the `healthLabels` array to store in the db
    26 cookTimeMinutes,
    27 prepTimeMinutes,
    28 ingredients: [...ingredients], // make a copy of the `ingredients` array to store in the db
    29 };
    30
    31 // Respond with a 201 Created status code along with the newly created recipe
    32 res.status(201).json({ data: await service.save(newRecipe) });
    33 } catch (error) {
    34 next(error);
    35 }
    36};
    37
    38module.exports = {
    39 getAll,
    40 save
    41};

    Note that in a real app, you'd probably do more extensive validation of the user-submitted data stored in the req.body to ensure it does not contain unexpected (e.g., missing or invalid parameters), or even malicious input. That is, however, beyond the scope of this tutorial.

  4. Back in the recipes router, import and mount the save() router handler on the / path, like this:

    src/routers/recipes.js
    1const express = require("express");
    2const router = express.Router();
    3
    4const { getAll, save } = require("../controllers/recipes");
    5
    6router.get("/", getAll);
    7router.post("/", save);
    8
    9module.exports = router;
  5. Test out the route with Postman!

    First, download Postman.

    Then, watch this short video on how to make a POST request with Postman.

    To configure and send a HTTP request with Postman:

    1. Select POST as the HTTP method and enter the url for the POST /api/v1/recipes endpoint.

    2. In the Headers tab, set Content-Type to application/json to tell the server that JSON data is being sent over.

      Here's what your configuration should look like:

      Postman POST Configuration

    3. Next, in the Body tab, select raw and add the following data to the request body:

      1{
      2 "name": "Edamame recipes",
      3 "healthLabels": [
      4 "Sugar-Conscious",
      5 "Vegan",
      6 "Vegetarian",
      7 "Peanut-Free",
      8 "Tree-Nut-Free",
      9 "Alcohol-Free"
      10 ],
      11 "cookTimeMinutes": 127,
      12 "prepTimeMinutes": 20,
      13 "ingredients": [
      14 "salt",
      15 "1 one-pound bag edamame",
      16 "1 teaspoon Seasoned Salt"
      17 ]
      18}

      Here's what your configuration for this task should look like:

      Postman POST Request Body

    4. Hit Send.

    5. If all goes well, you should get back a 201 Created response:

      Postman POST Success

    6. Finally, check the db/recipes.json file to see if a new recipe has been added (towards the end of the file).

Task 14 (CHALLENGE): Implement a Route Handler for GET /api/v1/recipes/:id

Let's allow the API consumer to retrieve a recipe by id. Don't forget to test your endpoint with Postman!

SOLUTIONSolution Repo

Task 15 (CHALLENGE): - Implement a Route Handler for PUT /api/v1/recipes/:id

Let's allow the API consumer to update a recipe by id. Don't forget to test your endpoint with Postman!

SOLUTIONSolution Repo

Task 16 (CHALLENGE): Implement a Route Handler for DELETE /api/v1/recipes/:id

Let's allow the API consumer to delete a recipe by id. Don't forget to test your endpoint in Postman!

SOLUTIONSolution Repo

The .sendStatus() method sets the response HTTP status code and sends the response. It's customary to return 204 No Content to indicate successful application of the DELETE method.

Task 17: Redirect the Base Url / to /api/v1/recipes

Let's make the API a bit more user-friendly by redirecting any requests to / to /api/v1/recipes, where all our routes are defined. You can use the res.redirect() method to redirect requests to a different desired path, which is passed as an argument to the method.

In the src/index.js file, add a route handler above the recipes router, like this:

src/index.js
1const express = require("express");
2const path = require('path');
3
4const recipesRouter = require('./routers/recipes');
5
6const app = express();
7
8app.use((req, res, next) => {
9 const { method, path } = req;
10 console.log(
11 `New request to: ${method} ${path} at ${new Date().toISOString()}`
12 );
13 next();
14});
15
16app.use(express.json());
17app.use(express.urlencoded({ extended: true }));
18
19app.get("/", (req, res) => {
20 res.redirect("/api/v1/recipes");
21});
22
23app.use('/api/v1/recipes', recipesRouter);
24
25const port = process.env.PORT || 8080;
26
27app.listen(port, () => {
28 console.log(`Server is up on port ${port}.`);
29});

Once the browser receives a redirect, it performs a GET request to the /api/v1/recipes endpoint instead.

Task 18: Enable CORS

You can use third-party middleware to add functionality to Express apps, including support for Cross Origin Resource Sharing, or CORS for short.

By default, any requests that come from an origin other than localhost:8080 (e.g., mysite.com, facebook.com) will be blocked, as the Express server is currently running on localhost:8080 and so would only allow requests sent from that origin.

So if a website that's hosted on a different origin tries to make GET requests to a server at http://localhost:8080, the requests would be blocked unless the website is added to a CORS whitelist in Express.

The npm package cors is a middleware that allows you to configure CORS in Express applications with various options. By default, the cors package just enables resource sharing and access for ALL origins.

  1. Install cors by running:

    1$ npm install cors --save
  2. Inside src/index.js, call the cors middleware as part of the server setup:

src/index.js
1const express = require("express");
2const path = require('path');
3const cors = require("cors");
4
5const recipesRouter = require('./routers/recipes');
6
7const app = express();
8
9app.use(cors());
10
11app.use((req, res, next) => {
12 const { method, path } = req;
13 console.log(
14 `New request to: ${method} ${path} at ${new Date().toISOString()}`
15 );
16 next();
17});
18
19app.use(express.json());
20app.use(express.urlencoded({ extended: true }));
21
22app.get("/", (req, res) => {
23 res.redirect("/api/v1/recipes");
24});
25
26app.use('/api/v1/recipes', recipesRouter);
27
28const port = process.env.PORT || 8080;
29
30app.listen(port, () => {
31 console.log(`Server is up on port ${port}.`);
32});
  1. BONUS: Create a whitelist of domains that are allowed to access our recipes. See the docs.

Congratulations! You now have a fully working API!

Task 19: Chain Route Handlers with router.route()

  1. You can use router.route() to chain route handlers that share the same route path.

    src/routers/recipe.js
    1const express = require('express');
    2const router = express.Router();
    3
    4const {
    5 deleteRecipe,
    6 getAllRecipes,
    7 getRecipe,
    8 saveRecipe,
    9 updateRecipe,
    10} = require('../controllers/recipe');
    11
    12- router.get('/', getAllRecipes);
    13- router.post('/', saveRecipe);
    14- router.get('/:id', getRecipe);
    15- router.put('/:id', updateRecipe);
    16- router.delete('/:id', deleteRecipe);
    17
    18+ // Route `GET` and `POST` HTTP requests for `/`
    19+ router.route('/').get(getAllRecipes).post(saveRecipe);
    20+
    21+ // Route `GET`, `PUT`, and `DELETE` HTTP requests for `api/v1/recipes/:id`
    22+ router.route('/:id').get(getRecipe).put(updateRecipe).delete(deleteRecipe);
    23
    24module.exports = router;

Task 20: Handle Errors Centrally

Express comes with a default built-in error handler which handles any errors that might arise in the application.

Since you have not written custom error handlers to handle the errors passed to next() in src/controllers/recipe.js, the errors will be handled by the built-in error handler.

Let's see what the default handler does.

Visit a faulty path http://localhost:8080/api/v1/recipes/9999 (i.e., where recipe with id of 9999 does not exist) and take a look at the error displayed in the browser:

Express Error Development

As you can see, the error is written to the client with the stack trace in development mode. For security purposes, the stack trace is not exposed in the production environment. This is what the same error would look like in production:

Express Error Production

As you can see, the error message is neither descriptive nor helpful for the client!

You can create a custom error handler to fix that. To avoid duplicating the code for error handling, you can also create a helper method to handle errors centrally in the application.

  1. Create a directory called utils with src/. Inside the utils directory, create a file called error.js:

    1$ mkdir src/utils
    2$ touch src/utils/error.js
  2. The built-in Node.js Error class contains important information about application errors (e.g., stack traces), so let's define a custom error class that inherits from it:

    src/utils/error.js
    1class CustomError extends Error {
    2 constructor({ statusCode, message }) {
    3 super(); // inherit properties and methods from the built-in Error class
    4 this.statusCode = statusCode; // define a custom status code for the error
    5 this.message = message; // define a custom message for the error
    6 }
    7}
    8
    9module.exports = {
    10 CustomError,
    11};
  3. Inside src/utils/error.js, define an error-handling middleware to handle errors in the application, right below the CustomError class:

    src/utils/error.js
    1const handleError = (err, req, res, next) => {
    2 let { statusCode, message } = err;
    3
    4 // Log error message in our server's console
    5 console.error(message);
    6
    7 // If no status code is specified, set it to 'Internal Server Error (500)'
    8 if (!statusCode) statusCode = 500;
    9
    10 // Send back an error with valid HTTP status code and message
    11 res.status(statusCode).json({
    12 status: "error",
    13 statusCode,
    14 message,
    15 });
    16};
    17
    18module.exports = {
    19 handleError,
    20 CustomError,
    21};

    Note that an error-handling function has four arguments instead of three: (err, req, res, next) and will only get called if an error occurs.

  4. Inside src/index.js, import handleError at the top of the file and call it last, after all other app.use() and route calls, like this:

    src/index.js
    1const express = require("express");
    2const path = require('path');
    3const cors = require("cors");
    4
    5const recipesRouter = require('./routers/recipes');
    6const { handleError } = require('./utils/error');
    7
    8const app = express();
    9
    10app.use(cors());
    11
    12app.use((req, res, next) => {
    13 const { method, path } = req;
    14 console.log(
    15 `New request to: ${method} ${path} at ${new Date().toISOString()}`
    16 );
    17 next();
    18});
    19
    20app.use(express.json());
    21app.use(express.urlencoded({ extended: true }));
    22
    23app.get("/", (req, res) => {
    24 res.redirect("/api/v1/recipes");
    25});
    26
    27app.use('/api/v1/recipes', recipesRouter);
    28
    29app.use(handleError);
    30
    31const port = process.env.PORT || 8080;
    32
    33app.listen(port, () => {
    34 console.log(`Server is up on port ${port}.`);
    35});
  5. Visit the faulty path http://localhost:8080/api/v1/recipes/9999 again. You should see the following: Express Custom Error Handling

Task 21: Refactor the recipes Controller

You can find the commit for this refactoring exercise here.

Currently, get(), update(), and remove() route handlers repeat the following chunk of logic to check that a recipe exists in the database and to throw an error if the recipe does not exist:

1const recipe = await service.get(req.params.id);
2
3if (recipe === undefined) {
4 const err = new Error("Recipe not found");
5 err.statusCode = 404;
6 throw err;
7}

To avoid duplicating the logic across controllers, you can move the validation logic into a middleware function. Inside src/controllers/recipes.js, add a middleware function called recipeExists(), like this:

1const recipeExists = async (req, res, next) => {
2 const recipe = await service.get(req.params.id);
3
4 if (recipe === undefined) {
5 const err = new Error("Recipe not found");
6 err.statusCode = 404;
7 next(err);
8 } else {
9 res.locals.recipe = recipe;
10 next();
11 }
12};

Here, recipeExists() calls next() with an error object if the recipe isn't found. If the recipe is found, it is stored in a special locals property that exists on the res object. The locals property is used to store intermediate data that are only available for the lifetime of the request.

Next, remove the validation logic from the get(), update(), and remove() route handlers, like this:

get():

src/controllers/recipes.js
1const get = async (req, res, next) => {
2 try {
3 res.json({ data: res.locals.recipe });
4 } catch (error) {
5 next(error);
6 }
7};

update():

src/controllers/recipes.js
1const update = async (req, res, next) => {
2 try {
3- const recipe = await service.get(req.params.id);
4
5- if (recipe === undefined) {
6- const err = new Error("Recipe not found");
7- err.statusCode = 404;
8- throw err;
9- }
10-
11 const {
12 name,
13 healthLabels,
14 cookTimeMinutes,
15 prepTimeMinutes,
16 ingredients,
17 } = req.body;
18
19 const updated = await service.update(req.params.id, {
20 name,
21 healthLabels: [...healthLabels],
22 cookTimeMinutes,
23 prepTimeMinutes,
24 ingredients: [...ingredients],
25 });
26
27 res.json({ data: updated });
28 } catch (error) {
29 next(error);
30 }
31};

remove():

src/controllers/recipes.js
1const remove = async (req, res, next) => {
2 try {
3- const recipe = await service.get(req.params.id);
4
5- if (recipe === undefined) {
6- const err = new Error("Recipe not found");
7- err.statusCode = 404;
8- throw err;
9- }
10-
11 await service.remove(req.params.id);
12 res.sendStatus(204);
13 } catch (error) {
14 next(error);
15 }
16};

Next, update the exported route handlers to include the recipeExists() middleware, like this:

1module.exports = {
2 getAll,
3 get: [recipeExists, get],
4 save,
5 update: [recipeExists, update],
6 remove: [recipeExists, remove],
7};

Finally, test your API endpoints in Postman to ensure they are still working as expected. You can write automated tests for your API using a library like Supertest.

Now you know how to build a modular Express API with basic validation and centralized error handling.

Other Node.js Frameworks & Tools

Additional Resources

Review

Take a moment to reflect on what you’ve learned in this tutorial and answer the following questions:

  • What is Express?
  • What is npm?
  • What is the purpose of a middleware? What are some examples of built-in middlewares in Express?
  • What is a RESTful API? What are some data formats supported by a RESTful API?
  • Why would you use the express.Router class?

Key takeaways

  • Express is minimalistic and provides a lightweight abstraction over the Node.js HTTP modules and convenience methods for creating routing, views, and middleware.

  • npm stands for Node Package Manager. It is the world's largest JavaScript software registry. npm also refers to the command-line interface (CLI) tool for downloading and managing Node.js packages.

  • Middleware functions are functions that alter the request (req) and response (res) object(s) in the application's request-response cycle:

    New HTTP Request --> Middleware Functions --> Route Handlers

    They can be used for a variety of tasks, such as logging, authentication, parsing form data, etc.

    Examples of built-in middlewares include express.static(), which serves static assets to the client, and express.json(), which parses JSON data and makes it accessible in the request object (via req.body).

  • REST is a popular architectural convention for structuring and naming APIs using a standardized protocol, such as the HTTP standard. A RESTful API based on the HTTP standard leverages five main HTTP methods (i.e., GET, PUT, POST, PATCH, DELETE) to retrieve and manipulate data. Each method corresponds to a Create, Read, Update, Delete (CRUD) operation. A RESTful API can support various data formats such as XML and JSON.

  • You can use the express.Router class to create modular route handlers, with each router instance being responsible for handling routing for a specific resource (e.g., recipes) in the backend.

What’s next?

In Part 5, you’ll learn how to protect your API endpoints by adding authentication to your Express API using Passport.js and JSON Web Tokens.

Continue to Part 5

Want more content like this? Subscribe to get the latest updates in your inbox

Share your feedback

What did you like or didn't like about this post? Let me know what worked well and what can be improved. Your feedback is much appreciated!