— Node.js, JavaScript, RESTful API, Express, intermediate, tutorial, intro-to-node-js — 20 min read
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:
Some basic knowledge of server-side concepts is assumed.
res.json()
src/index.js
express.Router
class to Create Modular Route HandlersGET /api/v1/recipes
POST /api/v1/recipes
GET /api/v1/recipes/:id
PUT /api/v1/recipes/:id
DELETE /api/v1/recipes/:id
/
to /api/v1/recipes
router.route()
recipes
ControllerNode.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.
There's no starter code for this tutorial. You will follow the steps described below to build the project from scratch.
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(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
.
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.
Now, let's set up a new project. Along the way, you will learn about the npm
CLI.
Navigate to the folder where you'll like to store your new project.
Create a new directory called express-recipes
(or use any name that you like) and change into it:
1$ mkdir express-recipes2$ cd express-recipes
package.json
File1$ 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:
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:
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?
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.
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.
To see the latest version of all the npm
packages installed, including their dependencies, you can run:
1$ npm list
.gitignore
FileGenerated 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.
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 src2$ touch src/index.js
Inside src/index.js
, configure and set up an Express server, like this:
1/*2First, use the CommonJS require() function to3import the Express module into the program4*/5const express = require("express");6
7/*8Next, invoke express() to instantiate a new Express9application10*/11const app = express();12
13// Finally, listen on port 8080 for incoming requests14const port = process.env.PORT || 8080;15
16app.listen(port, () => {17 // Log a message when the server is up and running18 console.log(`Server is up on port ${port}.`);19});
That's all it takes to create a basic Node.js server with Express!
To start your server, run:
1$ node src/index.js2Server 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.
Try visiting http://localhost:8080
in the browser.
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
).
Inside src/index.js
, create a route handler after instantiating the Express application:
1const express = require("express");2const app = express();3
4// Route handler that sends a message to someone5// 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./
./
is visited. The callback function accepts two arguments:req
)res
) that an Express app sends to the client.res.send()
in the route handler sends a message back as the response to the client.You should now have a working route!
Restart the server and navigate to http://localhost:8080/
. You should see the Hello Express Student
message.
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});
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.
Create a route handler that can accept a name
parameter:
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!
Restart your server. What do you see when you visit http://localhost:8080/intuit
? You should see the message Welcome to Express Recipes, intuit!
.
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.
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
.
Stop any server that's currently running. Then, start up the Express application with the nodemon
command:
1$ nodemon index.js
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.
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.
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:
Create a public/
folder in your project root and add an index.html
file and a styles.css
file to it.
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>
1h1 {2 color: blue;3}
Configure the Express server to serve content stored in the public/
folder:
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:
Node.js
’s path
module, which is needed to generate the absolute path to the public/
folder.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.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.Navigate to http://localhost:8080
. You should see the index.html
page!
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!
Let's create a simple logging middleware to print information about every incoming request.
Add the following middleware before the route handlers:
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.app.use()
logs information about the incoming request and finally calls next()
.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.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!
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.
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:
GET
request for all the recipes.router
maps the HTTP request to the corresponding controller
for handling.controller
receives the HTTP request and asks the service
to fetch data from storage.service
loads the data from recipes.json
.service
returns data to the controller
.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.
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 Method | CRUD functionality | Database Action |
---|---|---|
GET | read | retrieve data |
POST | create | add data |
PUT | update | modify existing data |
PATCH | update | modify existing data |
DELETE | delete | delete existing data |
DISCUSS:
The Express Recipes
API will support the following RESTful endpoints:
HTTP Method | Path | Action |
---|---|---|
GET | /api/v1/recipes | Read information about all recipes |
POST | /api/v1/recipes | Create a new recipe |
GET | /api/v1/recipes/1 | Read information about the recipe with ID of 1 |
PUT | /api/v1/recipes/1 | Update the existing recipe with ID of 1 with all new content |
DELETE | /api/v1/recipes/1 | Delete 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.
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.
src/index.js
Now that you're ready to start building the API, let's remove any code that is not needed to build the API:
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));1617- app.get("/", (req, res) => {18- res.send("Hello Express Student!");19- });2021- app.get("/:name", (req, res) => {22- res.send(`Welcome to Express Recipes, ${req.params.name}!`);23- });2425const port = process.env.PORT || 8080;26
27app.listen(port, () => {28 console.log(`Server is up on port ${port}.`);29});
express.Router
class to Create Modular Route HandlersRight 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/routers2$ touch src/routers/recipes.js
Inside src/routers/recipes.js
, create a router instance and export the module:
1const express = require("express");2
3const router = express.Router();4
5// You'll add route handlers here in subsequent tasks6
7// Export the router8module.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.
Inside src/index.js
, load the router module at the top and mount the recipes router at the /api/v1/recipes
, like this:
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.
/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.
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
.
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.
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:
1const fs = require("fs").promises;2const path = require("path");3
4const recipesFilePath = path.join(__dirname, "../../db/recipes.json"); // Contruct the path to the recipes data5
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.
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:
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.
Back in the recipes router, import and mount the router handler on the /
path, like this:
1const express = require("express");2const router = express.Router();3
4const { getAll } = require("../controllers/recipes");5
6router.get("/", getAll);7
8module.exports = router;
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!
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:
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.
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});
In the recipes service, implement a save()
method to save a new recipe to the database, like this:
1const fs = require("fs").promises;2const path = require("path");3
4const recipesFilePath = path.join(__dirname, "../../db/recipes.json"); // Contruct the path to the recipes data5
6const getAll = async () => JSON.parse(await fs.readFile(recipesFilePath));7
8const save = async (recipe) => {9 // Get all recipes from the database10 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 recipes15 recipes.push(recipe);16
17 // Save all recipes to the database18 await fs.writeFile(recipesFilePath, JSON.stringify(recipes));19
20 return recipe;21 };22
23module.exports = {24 getAll,25 save26};
In the recipes controller, implement a save()
route handler for POST
requests to api/v1/recipes
:
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 body14 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 database23 const newRecipe = {24 name,25 healthLabels: [...healthLabels], // make a copy of the `healthLabels` array to store in the db26 cookTimeMinutes,27 prepTimeMinutes,28 ingredients: [...ingredients], // make a copy of the `ingredients` array to store in the db29 };30
31 // Respond with a 201 Created status code along with the newly created recipe32 res.status(201).json({ data: await service.save(newRecipe) });33 } catch (error) {34 next(error);35 }36};37
38module.exports = {39 getAll,40 save41};
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.
Back in the recipes router, import and mount the save()
router handler on the /
path, like this:
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;
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:
Select POST
as the HTTP method and enter the url for the POST /api/v1/recipes
endpoint.
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:
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:
Hit Send
.
If all goes well, you should get back a 201 Created
response:
Finally, check the db/recipes.json
file to see if a new recipe has been added (towards the end of the file).
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!
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!
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!
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.
/
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:
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.
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.
Install cors
by running:
1$ npm install cors --save
Inside src/index.js
, call the cors
middleware as part of the server setup:
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});
Congratulations! You now have a fully working API!
router.route()
You can use router.route()
to chain route handlers that share the same route path.
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);1718+ // 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);2324module.exports = router;
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:
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:
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.
Create a directory called utils
with src/
. Inside the utils
directory, create a file called error.js
:
1$ mkdir src/utils2$ touch src/utils/error.js
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:
1class CustomError extends Error {2 constructor({ statusCode, message }) {3 super(); // inherit properties and methods from the built-in Error class4 this.statusCode = statusCode; // define a custom status code for the error5 this.message = message; // define a custom message for the error6 }7}8
9module.exports = {10 CustomError,11};
Inside src/utils/error.js
, define an error-handling middleware to handle errors in the application, right below the CustomError
class:
1const handleError = (err, req, res, next) => {2 let { statusCode, message } = err;3
4 // Log error message in our server's console5 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 message11 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.
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:
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});
Visit the faulty path http://localhost:8080/api/v1/recipes/9999
again. You should see the following:
recipes
ControllerYou 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()
:
1const get = async (req, res, next) => {2 try {3 res.json({ data: res.locals.recipe });4 } catch (error) {5 next(error);6 }7};
update()
:
1const update = async (req, res, next) => {2 try {3- const recipe = await service.get(req.params.id);45- 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;1819 const updated = await service.update(req.params.id, {20 name,21 healthLabels: [...healthLabels],22 cookTimeMinutes,23 prepTimeMinutes,24 ingredients: [...ingredients],25 });2627 res.json({ data: updated });28 } catch (error) {29 next(error);30 }31};
remove()
:
1const remove = async (req, res, next) => {2 try {3- const recipe = await service.get(req.params.id);45- 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.
Take a moment to reflect on what you’ve learned in this tutorial and answer the following questions:
npm
?express.Router
class?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.
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.
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!