Introduction
This tutorial continues from our previous article and implements a simple API service with node.js, mongoDB, mongoose and express. The final product will be a RESTful API that may create, read, update or delete a user within our system.
Background
However, before we dive into the coding, let's try to give some background about the tools that we are going to use:
Node.js: is a cross-platform Javascript runtime environment that allows you to write and execute Javascript code in the backend. Its built on top of Chrome’s V8 Javascript engine and follows an event-driven, non-blocking I/O model, which makes it memory-efficient and easily scalable.
mongoDB: is a database system that belongs to the family of Nosql databases. Isupports an object-oriented database management system (oodbms), which makes it flexible for web development since data is stored in objectssimilar to JSON, in key-value pairs. An object is the smallest logical unit that can be stored, without a predetermined structure, and therefore thitype of database is called schemaless. Since mongoDB uses a document-based model, an object is also referred as a document, which icontained within a collection.
Express: is a minimalist web framework for node.js that allows to build robust web applications and API services.
Mongoose: is an object-relation mapping(ORM) library for mongoDB in node.js. It allows to set a schema, manipulate and validate the model of an entity. In simple terms, it converts an entity that exists in mongoDB into an object that is used in node.js.
Implementation
To access the code, download the repository available in this link, and follow the instructions in the installation section. You can then test the endpoints using development tools such as Postman or curl. The purpose of this tutorial is not to teach you coding, rather than to explain the main components of the API service.
In the following code snippet, we use express to build a simple HTTP server that listens on port 3000 – if an environment variable has been set –. We expect the API to accept data in the form of JSON and therefore, in line 11 we configure a JSON parser for handling the payload of any incoming request. Since we expect incoming requests with GET, POST, DELETE, PUT HTTP verbs, in line 14 we add a middleware that checks the HTTP Headers of a POST or PUT request, ensuring that the MIME type for Accept and Content-Type headers have been set to application/json. Then, in line 17 we add a router with a prefix of /api. This means that the /api prefix will be added to any route available in the router. A router can be thought of as an object that maintains a logical order of the routes/endpoints of our API – we will expand more on that in a later stage –. Finally, we initiate a TCP connection in mongoDB, which is followed by launching the server on port 3000.
import express from 'express'
import routes from './routes'
import mongoConnection from './utils/mongoConnection'
import errorHandling from './utils/errorHandling'
import * as middlewares from './utils/middlewares'
const app = express(),
port = process.env.PORT || 3000;
// includes a parser for json data
app.use(express.json())
// middleware that checks if the incoming requests have set the appropriate headers
app.use(middlewares.checkHeaders)
// routing
app.use('/api', routes)
app.use( errorHandling )
mongoConnection.init({ uri: process.env.MONGO_URI}, db => {
app.listen(port , () => process.stdout.write(`The app listens on port http://localhost:${port}\n`))
})
Using mongoose we implement a model for each user in our system, which will be mapped in mongoDB as soon as we are connected to it. In addition, when we connect for a first time in the database, mongoose creates a collection for the model user, which has the name users – plural of our model User –, and acts as a container of all users within our system. We expect a user to have a unique username and an email and we will store the password of a user, assign a role to it, and record the time that is created or whenever he/she updates its profile account.
import mongoose from 'mongoose'
const ROLES = {
BASIC: 'BASIC',
ADMIN: 'ADMIN',
}
const UserSchema = mongoose.Schema({
username: { type: String, required: true, unique: true, index: true },
email: { type: String, required: true, unique: true, index: true},
password: { type: String, required: true },
role: {type: String, required: true, default: ROLES.BASIC, enum: Object.values(ROLES) }
}, {
timestamp: true
})
export default mongoose.model('User', UserSchema)
Now, let's move on to the essence of our API. In index.js, which can be found in src/routes/index.js of our folder directory, we create an HTTP router that gathers all the routes of our API. Between lines 7 and 11, we implement 5 routes that act as the endpoints of the service, and a client may access them based on the described URIs. For example, /api/users and /api/users/:id are the URIs that a client uses to access the users or an individual user of our system, respectively. Note the use of /api in front of the route URI since we have configured the route to listen on that prefix.
Moving to the next snippet, we implement the route that handles any GET HTTP request for /api/users. Note the use of a wrapped response to provide a client with more details about the execution of a request. In addition, we use hypertext – conforms to the uniform interface constraint – to return the state of the resource in the response body by adding the status, a boolean flag with the name success to indicate whether the request succeeds or fails, and the returned data. Additionally, we wrap the route with a try/catch statement so that any failure to be caught and forwarded to the express error handling mechanism. This mechanism will process the error and return the appropriate response to the client. The file can be found in src/utils/errorHandling.js.
Following, in user.js, we expose an HTTP route that can be accessed in /api/users/:id. The :id acts as a parameter passed from the client to inform the API for the id of the user. For example, a URI in the form /api/users/12 says to the server to search for a user with an id of 12. Additionally, in line 8 we check if the client id is valid, and if not, we raise an error that returns an HTTP response with a status of 400 Bad Request. Next, we search if the user exists in the database, and if not we throw a 404 Not Found error.
The snippet createUser.js focuses on storing a user inside the database, however, before doing that, it needs to perform a series of checks to ensure that the passed data is valid. Since the aim of the project is to demonstrate the design of a REST API, we don’t validate the client inputs, neither check if he/she is authorised to perform such a type of a request. However, in a comment I describe possible checks that may happen to ensure the validity of the request. We only check if actually the client has sent any data in the request body, and if not, we raise a 400 Bad Request error saying that the service was unable to parse the request body. Then, the user is stored in the database and as soon as we do that, we populate the Location header referencing the location of the new user. This header will be available to the client and inform him/her under which URI he/she can access the created user. In our case, the user will be available in /api/users/:id. This step conforms the uniform interface rule – manipulation of resources through representations –. Finally, we return the current state of the application with a status of 201 Created.
The last two code snippets are responsible to update and delete a user within the system. Both routes are exposed in /api/users/:id using the PUT and DELETE HTTP verbs, respectively. In both cases we check if a valid id has been passed to it, and if so, we update/delete the user inside the database. Then, we check if the id could be mapped to an actual user inside the system and if not, a 404 Not Found error is raised. We could also check at the beginning if the user exist and then proceed with the update/deletion, however, this is insignificant at the moment. At the end, we return a response with a 204 No Content status code, without any data to it.
// createUser.js
import User from '../../models/User'
export default async (req,res,next) => {
try {
/*
for demonstration purposes, we don't validate the request body, neither check
if a user is authorized to perform that request.
However we could easily proceed validating the data, returning the appropriate
codes if its invalid
e.g HTTP 422 Unprocessed Entity if the password is less than 8 character
HTTP 409 Conflict if the email already exists
HTTP 422 Unprocessed Entity if the email is invalid
or check the user credentials returning the appropriate codes
HTTP 401 Unauthorized if the user does not pass a jwt token in Authorization header
HTTP 403 Forbidden if a user A attempts to update the profile of user B
*/
// HTTP/1.1 400 Bad Request
if(!Object.values(req.body).length) throw new BadRequest('Unable to parse request body. No data has been passed to it.')
const user = await User.create(req.body)
// HTTP Header Location: /api/users/:id
/*
this allows to reference the URI of the newly created user in the
request response (manipulation of resources through representations)
*/
res.location([ req.originalUrl, user._id ].join('/') )
/*
the state of the resource is delivered by setting a response code
and response body(hypertext as the engine of application state)
*/
res.json({status: 201, success:true, data: user})
} catch( e ){
return next( e )
}
}
// deleteUser.js
import User from '../../models/User'
import NotFound from '../../utils/Errors/NotFound'
import BadRequest from '../../utils/Errors/BadRequest'
export default async (req,res,next) => {
try {
const {id} = req.params
// HTTP/1.1 400 Bad Request
if(!id) throw new BadRequest("Unable to extract user id")
const user = await User.findByIdAndRemove(id)
// HTTP/1.1 404 Not Found
if(!user) throw new NotFound("User not Found")
// an HTTP status code of 204 is returned, indicating that the request was successful, however, no data is returned
res.json({status: 204, success:true})
} catch( e ){
return next( e )
}
}
// updateUser.js
import User from '../../models/User'
import NotFound from '../../utils/Errors/NotFound'
import BadRequest from '../../utils/Errors/BadRequest'
export default async (req,res,next) => {
try {
const {id} = req.params
// HTTP/1.1 400 Bad Request
if(!id) throw new BadRequest("Unable to extract user id")
if(!Object.values(req.body).length) throw new BadRequest('Unable to parse request body. No data has been passed to it.')
// {new: true} -> returns the latest updated record
const user = await User.findOneAndUpdate(id , {"$set" : req.body}, {new: true})
// HTTP/1.1 404 Not Found
if(!user) throw new NotFound("User not Found")
// an HTTP status code of 204 is returned, indicating that the request was successful, however, no data is returned
res.json({status: 204, success:true})
} catch( e ){
return next( e )
}
}
// user.js
import User from '../../models/User'
import NotFound from '../../utils/Errors/NotFound'
export default async (req, res, next) => {
try {
const {id} = req.params
// HTTP/1.1 400 Bad Request
if(!id) throw new BadRequest("Unable to extract user id")
const user = await User.findById(id)
// HTTP/1.1 404 Not Found
if(!user) throw new NotFound("User not found")
/*
the state of the resource is delivered by setting a response code
and response body(hypertext as the engine of application state)
*/
res.json({status: 200, data: user})
} catch( e ){
return next(e)
}
}
// users.js
import User from '../../models/User'
export default async (req, res, next) => {
try {
/*
the state of the resource is delivered by setting a response code
and response body(hypertext as the engine of application state)
*/
res.json({status: 200, success:true, data: await User.find()})
} catch( e ){
return next(e)
}
}
// index.js
import expressPromiseRouter from 'express-promise-router'
import user from './user'
const router = expressPromiseRouter()
// /api/users
router.get('/users', user.findAll )
router.get('/users/:id', user.findOne )
router.post('/users', user.create )
router.delete('/users/:id', user.delete )
router.put('/users/:id', user.update )
export default router
Speculations
Since we only mentioned the uniform interface constraint, you might be wondering where we actually used the rest of the REST style constraints. Using HTTP, we promote a stateless communication and since we do not perform any session-based authentication, our service remains stateless. In addition, HTTP implicitly sets Cache-Control header to no-cache, which means that clients cache a response, but first they proceed by submitting a validation request to our server. Therefore, the Stateless and Cacheable constraints are fulfilled without even noticing it. Knowing that our service does not depend on any other resource, neither the clients needs to perform a request prior to calling our service, we follow a Client-Server model with each component inside the network to act independently.
Summary
In this tutorial we implemented a simple API service that performs CRUD operations of our users' resources using tools such as node.js, mongoDB, mongoose and express. We explained concepts that have been mentioned in our previous article, and I hope that I gave some understanding on how a basic RESTful API service is built.