NodeJS Rest API with Express, Passport, JWT and MongoDB

Héla Ben Khalfallah
13 min readMay 9, 2020

How to build a simple Rest API with NodeJS and Express (JSON) ?

A word about express

Express is a popular unopinionated web framework, written in JavaScript and hosted within the Node.js runtime environment.

The best things about express are modularity and flexibility : easy to plug middlewares via “use” (composition).

Express is basically a routing layer composed of many modular processing units called middleware.

https://github.com/rohit120582sharma/Documentation/wiki/Express-JS
Express middlewares
// Create an express instance
const app = express();

// init cookie parser
app.use(cookieParser());

// configure app
app.use('*', cors());
app.use(morgan('dev', {'stream': AppLogger.stream}));

// parse application/x-www-form-urlencoded
app.use(bodyParser.urlencoded({extended: false}));

// parse application/json
app.use(bodyParser.json());

// init and configure passport
app.use(passport.initialize());

// authentication routes
app.use(process.env.AUTH_BASE_PATH, AuthRouter);
Express compositions — https://medium.com/@viral_shah/express-middlewares-demystified-f0c2c37ea6a1

A word about mongodb

MongoDB is a cross-platform document-oriented database program. Classified as a NoSQL database program, and uses JSON-like documents with schema.

Mongo Json Object
Mongo query

NoSQL Pros and Cons :

Configure the project

  1. Create a node project
mkdir node-express-mongoose
cd node-express-mongoose
npm init

2. Configure Eslint

npm install eslint --save-dev 
./node_modules/.bin/eslint --init

For backend projects I prefer google eslint, for frontend projects I use airbnb configuration.

Using Google’s configuration leads to have good naming and good JSDOC coverage.

Project architecture

Important modules :

  • app : the API.
  • core : commons modules (logger, messages provider, …)
  • passport : handle authentication strategy (OAuth, token, …).
  • server : express http server’s configuration.
Project Architecture

For our example, we have mongo and postgresql that’s why I splitted by database type and not by feature or functionality. If it was only one database type, app’ structure could be : authentication, users, photos, comments, …

mongo module contains :

mongo modules
mongo components

env file

  1. Create .env file which will contains all application’s environment variables.
// server const
DEFAULT_LANGUAGE = en
SERVER_APP_PORT = 3304

//log vars
LOG_DIR_NAME = ../../log
LOG_FILE_NAME = app.log
LOG_MAX_SIZE = 5242880
LOG_MAX_FILE = 25
LOG_DATE_PATTERN = .dd-MM-yyyy

// enable mongoose
MONGOOSE_ENABLED = true

// mongoose const
MONGOOSE_DB_HOST = 127.0.0.1
MONGOOSE_DB_PORT = 27017
MONGOOSE_DB_NAME = local

// jwt secret
JWT_SCHEME = jwt
JWT_TOKEN_PREFIX = JWT
JWT_SECRET_OR_KEY = XXXXXXXXXXXX
JWT_TOKEN_EXPIRATION = 18000000
JWT_TOKEN_HASH_ALGO = XXXX

// base urls
AUTH_BASE_PATH = /app/auth
MONGO_USER_BASE_PATH = /app/mongo/users

// urls
AUTH_REGISTER_PATH = /register
AUTH_LOGIN_PATH = /login
USER_LIST_PATH = /users-list
USER_ADD_PATH = /add-user
USER_UPDATE_PATH = /update-user
USER_DELETE_PATH = /delete-user
USER_PROFILE_PATH = /profile
USER_PROFILE_ID_PATH = /profile-id
USER_PROFILE_EMAIL_PATH = /profile-email

2. add dotenv :

npm install dotenv

3. Init dotenv (index.js)

//dot env configuration
import dotenv from 'dotenv'

// load env
dotenv.config()

4. Production mode :

You shouldn’t commit .env to the source code repository. You don’t want outsiders gaining access to secrets, like API keys. If you are using dotenv to help manage your environment variables, be sure to include the .env file in your .gitignore.

If your app is running on a physical machine or a virtual machine, then you can create a .env while logged into the server and it would run just as it is done on your local machine.

server.js

This file is used to : initialize, configure and launch a http express server.

Initialisation :

import express from 'express';// Create an express instance
const app = express();

Plug different accessories :

cookieParser :

import cookieParser from 'cookie-parser';
app.use(cookieParser());

cors :

import cors from 'cors';
app.use('*', cors());

logger :

import * as winston from 'winston';
import * as fs from 'fs';
import path from 'path';

// create log file if not exist
const logDirectory = path.join(__dirname, process.env.LOG_DIR_NAME);
if (!fs.existsSync(logDirectory)) {
fs.mkdirSync(logDirectory);
}

// app loger config
const AppLogger = winston.createLogger({
transports: [
new winston.transports.File({
level: 'info',
filename: process.env.LOG_FILE_NAME,
dirname: logDirectory,
handleExceptions: true,
json: true,
maxsize: process.env.LOG_MAX_SIZE,
maxFiles: process.env.LOG_MAX_FILE,
colorize: false,
}),
new winston.transports.Console({
name: 'error',
level: 'error',
handleExceptions: true,
json: false,
colorize: true,
}),
new winston.transports.Console({
name: 'debug',
level: 'debug',
handleExceptions: true,
json: false,
colorize: true,
}),
],
exitOnError: false,
});

export default AppLogger;
import {
AppLogger,
} from '../core';
import morgan from 'morgan';
AppLogger.stream = {
write: function(message, encoding) {
AppLogger.info(message, encoding);
},
};
app.use(morgan('dev', {'stream': AppLogger.stream}));

bodyParser :

import bodyParser from 'body-parser';// parse application/x-www-form-urlencoded
app.use(bodyParser.urlencoded({extended: false}));

// parse application/json
app.use(bodyParser.json());

passport :

import passport from 'passport';// init and configure passport
app.use(passport.initialize());

High level routing :

import MongoDBConnect from '../app/mongo/db/db/MongoDBConnect';if (process.env.MONGOOSE_ENABLED === 'true') {
AppLogger.debug('server MONGOOSE_ENABLED');
new MongoDBConnect();
}
// authentication routes
app.use(process.env.AUTH_BASE_PATH, AuthRouter);
// app routes
if (process.env.MONGOOSE_ENABLED === 'true') {
// mongo routes
AppLogger.debug('server MONGOOSE_ENABLED');
app.use(process.env.MONGO_USER_BASE_PATH, MgUserRouter);
}
// others routes
app.get('/', (req, res) => {
res.send('Invalid endpoint!');
});
// base urls
AUTH_BASE_PATH = /app/auth
MONGO_USER_BASE_PATH = /app/mongo/users

Delegate each route to its manager :

  • path containing /app/auth are delegated to AuthRouter.
  • path containing /app/mongo/users are delegated to MgUserRouter.
https://expressjs.com/en/guide/writing-middleware.html

Start server :

// Start server
const portNumber = process.env.SERVER_APP_PORT || 3300;
AppLogger.debug('AppRouter portNumber : ' + portNumber);
app.listen(portNumber, () => {
AppLogger.debug('server started - ' + portNumber);
});

passport.js

Passport is Express-compatible authentication middleware for Node.js. Passport’s sole purpose is to authenticate requests, which it does through an extensible set of plugins known as strategies.

For our case we will use JWT authentication strategy.

For this reason we need to install :

passport-jwt
jsonwebtoken

This module lets you authenticate endpoints using a JSON web token. It is intended to be used to secure RESTful endpoints without sessions.

To use JWT with passport, we should override default passport’s configuration.

passport.js :

import passportJWT from 'passport-jwt';
import MongoModels from '../app/mongo/db/models/index';

// passport & jwt config
const {
Strategy: JWTStrategy,
ExtractJwt: ExtractJWT,
} = passportJWT;

// import User model
const User = MongoModels.UserModel;

// define passport jwt strategy
const opts = {};
opts.jwtFromRequest = ExtractJWT.fromAuthHeaderWithScheme(process.env.JWT_SCHEME);
opts.secretOrKey = process.env.JWT_SECRET_OR_KEY;
const passportJWTStrategy = new JWTStrategy(opts, function(jwtPayload, done) {
// retrieve mail from jwt payload
const email = jwtPayload.email;

// if mail exist in database then authentication succeed
User.findOne({email: email}, (error, user) => {
if (error) {
return done(error, false);
} else {
if (user) {
done(null, user);
} else {
done(null, false);
}
}
});
});

// config passport
module.exports = function(passport) {
// token strategy
passport.use(passportJWTStrategy);

// return configured passport
return passport;
};

mongodb connection

mongoose is mongodb object modeling for node.js.

To connect to mongodb :

import mongoose from 'mongoose';
import {
AppLogger,
} from '../../../../core';

// promise
mongoose.Promise = Promise;

/**
* Mongodb connect
*/
const MongoDBConnect = async () => {
const dbHost = process.env.MONGOOSE_DB_HOST;
const dbPort = process.env.MONGOOSE_DB_PORT;
const dbName = process.env.MONGOOSE_DB_NAME;
try {
await mongoose.connect(`mongodb://${dbHost}:${dbPort}/${dbName}`, {
useCreateIndex: true,
useNewUrlParser: true,
useUnifiedTopology: true,
});
AppLogger.debug('Connected to mongo!!!');
} catch (error) {
AppLogger.error('Could not connect to MongoDB', error);
}
};

export default MongoDBConnect;
// .env : mongoose const
MONGOOSE_DB_HOST = 127.0.0.1
MONGOOSE_DB_PORT = 27017
MONGOOSE_DB_NAME = local

User mongo schema

Before saving a user (modification or new), we hash password. We use bcrypt for that :

// import mongoose
import mongoose from 'mongoose';
import passportLocalMongoose from 'passport-local-mongoose';
import bcrypt from 'bcrypt';

const {
Schema,
} = mongoose;

// prepare mongoose user schema
const userSchema = Schema(
{
firstName: {
type: String,
required: true, // required
},
lastName: {
type: String,
required: true, // required
},
password: {
type: String,
required: true, // required
},
email: {
type: String,
required: true, // required
unique: true, // unique email
trim: true,
},
username: {
type: String,
unique: true, // unique username
required: true, // required
trim: true,
},
birthday: String,
job: String,
},
{
collection: 'User',
});

// pre saving user
userSchema.pre('save', function(next) {
const user = this;

// only hash the password if it has been modified (or is new)
if (this.isModified('password') || this.isNew) {
bcrypt.genSalt(10, function(error, salt) {
// handle error
if (error) return next(error);

// hash the password using our new salt
bcrypt.hash(user.password, salt, function(error, hash) {
// handle error
if (error) return next(error);

// override the cleartext password with the hashed one
user.password = hash;
next();
});
});
} else {
return next();
}
});

// post saving user
userSchema.post('save', function(user, next) {
next();
});

// compare password
userSchema.methods.comparePassword = function(passw, cb) {
bcrypt.compare(passw, this.password, function(err, isMatch) {
if (err) {
return cb(err);
}
cb(null, isMatch);
});
};

// pass passport-local-mongoose plugin
// in order to handle password hashing
userSchema.plugin(passportLocalMongoose);

// export mongoose user schema module
const UserModel = mongoose.model('User', userSchema);
export default UserModel;

MessageProvider

This module contains all backend status messages that will been used in all controllers.

All messages :

// all language keys
const KEYS = {
VERIFY_REQUIRED_INFORMATION: 'VERIFY_REQUIRED_INFORMATION',
WRONG_PASSWORD: 'WRONG_PASSWORD',
WRONG_SESSION: 'WRONG_SESSION',
USER_NOT_EXIST: 'USER_NOT_EXIST',
USER_ALREADY_EXIST: 'USER_ALREADY_EXIST',
USER_ID_NOT_FOUND: 'USER_ID_NOT_FOUND',
USER_EMAIL_NOT_FOUND: 'USER_EMAIL_NOT_FOUND',
USER_ADD_ERROR: 'USER_ADD_ERROR',
USER_UPDATE_ERROR: 'USER_UPDATE_ERROR',
USER_DELETE_ERROR: 'USER_DELETE_ERROR',
USER_LIST_ERROR: 'USER_LIST_ERROR',
USER_LIST_DELETE_SUCCESS: 'USER_LIST_DELETE_SUCCESS',
};

// all messages
const DATA = [
{
key: KEYS.USER_ALREADY_EXIST,
value: 'User already exist.',
language: 'en',
status: 401,
},
{
key: KEYS.VERIFY_REQUIRED_INFORMATION,
value: 'Please verify required information.',
language: 'en',
status: 401,
},
{
key: KEYS.USER_NOT_EXIST,
value: 'User not exist.',
language: 'en',
status: 401,
},
{
key: KEYS.WRONG_PASSWORD,
value: 'Authentication failed. Wrong password.',
language: 'en',
status: 401,
},
{
key: KEYS.WRONG_SESSION,
value: 'An error was occured, please logout and authenticate again !',
language: 'en',
status: 401,
},
{
key: KEYS.USER_ID_NOT_FOUND,
value: 'Cannot find user with this id !',
language: 'en',
status: 401,
},
{
key: KEYS.USER_EMAIL_NOT_FOUND,
value: 'Cannot find user with this email !',
language: 'en',
status: 401,
},
{
key: KEYS.USER_ADD_ERROR,
value: 'An error occured when inserting user!',
language: 'en',
status: 401,
},
{
key: KEYS.USER_UPDATE_ERROR,
value: 'An error occured when updating user!',
language: 'en',
status: 401,
},
{
key: KEYS.USER_DELETE_ERROR,
value: 'An error occured when deleting user!',
language: 'en',
status: 401,
},
{
key: KEYS.USER_LIST_ERROR,
value: 'An error occured when getting user list !',
language: 'en',
status: 401,
},
{
key: KEYS.USER_LIST_DELETE_SUCCESS,
value: 'User list deleted with success !',
language: 'en',
status: 200,
},
];

const Messages = {
KEYS,
DATA,
};

export default Messages;

Message :

/**
* Backend common error messages
*/
class Message {
/**
* New message
* @param {*} key
* @param {*} value
* @param {*} language
* @param {*} status
*/
constructor(key, value, language, status) {
this._key = key;
this._value = value;
this._language = language;
this._status = status;
}

get key() {
return this._key;
}
set key(key) {
this._key = key;
}
get value() {
return this._value;
}
set value(value) {
this._value = value;
}
get language() {
return this._language;
}
set language(language) {
this._language = language;
}
get status() {
return this._status;
}
set status(status) {
this._status = status;
}

toString() {
return `(
key : ${this._key},
value : ${this._value} ,
language : ${this._language},
status : ${this._status}
)`;
}
}

export default Message;

MessageProvider :

// message manager
import Messages from './Messages';
import {find} from 'lodash';

// check if key exist
const isKeyExist = (key) => {
return (Messages.KEYS && key) ? (key in Messages.KEYS) : false;
};

// get message object by key
const messageObjectByKey = (key) => {
const language = process.env.DEFAULT_LANGUAGE;
if (key) {
const message = find(
Messages.DATA,
{
'key': key,
'language': language,
});
return message;
}
return null;
};

// get message by key
const messageByKey = (key) => {
if (isKeyExist(key)) {
const {value} = messageObjectByKey(key);
return value ? value : '';
}
return '';
};

// get status by key
const statusByKey = (key) => {
if (isKeyExist(key)) {
const {status} = messageObjectByKey(key);
return status ? status : '';
}
return 500;
};

const MesssageProvider = {
messageByKey,
statusByKey,
};

export default MesssageProvider;

AuthController : register new user

import jwt from 'jsonwebtoken';
import MongoModels from '../../mongo/db/models/index';
import UserController from '../../mongo/controllers/UserController';
import {
MesssageProvider,
Messages,
} from '../../../core';

const User = MongoModels.UserModel;

/**
* Register a new user
* @param {*} request
* @param {*} response
* @return {*} created user or error
*/
const register = (request, response) => {
if (UserController.isValidUser(request)) {
// insert only if we have required data
// we can find by username or email
// because they are unique
// insert only if user not exist
const email = request.body.email || '';
User.findOne({email: email}, (error, user) => {
// insert only if user not exist
if (error) {
response
.status(401)
.send({
success: false,
message: error.message,
});
} else {
if (!user) {
const userModel = UserController.userFromRequest(request);
userModel.save((error) => {
if (error) {
response
.status(401)
.send({
success: false,
message: error.message,
});
} else {
response
.status(200)
.send({
success: true,
user: userModel,
});
}
});
} else {
response
.status(401)
.send({
success: false,
message: MesssageProvider
.messageByKey(Messages.KEYS.USER_ALREADY_EXIST),
});
}
}
});
} else {
return response
.status(401)
.send({
success: false,
message: MesssageProvider
.messageByKey(Messages.KEYS.VERIFY_REQUIRED_INFORMATION),
});
}
};

AuthController : login an existing user

/**
* User login
* @param {*} request
* @param {*} response
* @return {*} logged existant user or error
*/
const login = async (request, response) => {
const email = request.body.email || '';
const password = request.body.password || '';
if (email && password) {
User.findOne({email: email}, (error, user) => {
// check if user exist
if (error) {
response.status(401).send({
success: false,
message: error.message,
});
} else {
if (!user) {
response.status(401).send({
success: false,
message: MesssageProvider
.messageByKey(Messages.KEYS.USER_NOT_EXIST),
});
} else {
// check if password matches
user.comparePassword(password, (error, isMatch) => {
if (isMatch && !error) {
// if user is found and password is right create a token
// algorithm: process.env.JWT_TOKEN_HASH_ALGO
const token = jwt.sign(
user.toJSON(),
process.env.JWT_SECRET_OR_KEY, {
expiresIn: process.env.JWT_TOKEN_EXPIRATION,
});

// return the information including token as JSON
response
.status(200)
.send({
success: true,
user: user,
token: `${process.env.JWT_TOKEN_PREFIX} ${token}`,
});
} else {
response
.status(401)
.send({
success: false,
message: MesssageProvider
.messageByKey(Messages.KEYS.WRONG_PASSWORD),
});
}
});
}
}
});
} else {
return response
.status(401)
.send({
success: false,
message: MesssageProvider
.messageByKey(Messages.KEYS.VERIFY_REQUIRED_INFORMATION),
});
}
};

Authentication Router

import express from 'express';
import AuthController from '../controllers/AuthController';

const {
Router,
} = express;

// router instance
const AuthRouter = Router();

/**
* Register new user route
*/
AuthRouter.post(process.env.AUTH_REGISTER_PATH, (req, res) => {
AuthController.register(req, res);
});

/**
* Login an existant user
*/
AuthRouter.post(process.env.AUTH_LOGIN_PATH, (req, res, next) => {
AuthController.login(req, res, next);
});

export default AuthRouter;

Authentication utils

import jwt from 'jsonwebtoken';/**
* verify if token is valid
* @param {*} token
* @return {boolean}
*/
const isValidToken = (token) => {
try {
jwt.verify(token, process.env.JWT_SECRET_OR_KEY);
return true;
} catch (error) {
// error
return false;
}
};

/**
* retrieve token from header
* @param {*} headers
* @return {string} token or null
*/
const retrieveToken = (headers) => {
if (headers && headers.authorization) {
const tokens = headers.authorization.split(' ');
if (tokens && tokens.length === 2) {
return tokens[1];
} else {
return null;
}
} else {
return null;
}
};

Check if user is valid

/**
* Check for required params
* @param {*} request
* @return {boolean}
*/
const isValidUser = (request) => {
if (request) {
const email = request.body.email || '';
const username = request.body.username || '';
const password = request.body.password || '';
const firstName = request.body.firstName || '';
const lastName = request.body.lastName || '';
if (email && username && password && firstName && lastName) {
return true;
}
}
return false;
};

Retrieve user from request

/**
* Retrieve user from request
* @param {*} request
* @return {object} user or null
*/
const userFromRequest = (request) => {
if (isValidUser(request)) {
return new User(request.body);
}
return null;
};

Retrieve all user from mongodb

/**
* Retrieve all user
* @param {*} request
* @param {*} response
*/
const find = (request, response) => {
User.find((error, users) => {
if (!error) {
response
.status(200)
.send({
success: true,
users: users,
});
} else {
response
.status(401)
.send({
success: false,
message: error.message,
});
}
});
};

Add user if not exist

/**
* Add user if not exist
* @param {*} request
* @param {*} response
* @return {*}
*/
const addIfNotExist = (request, response) => {
// insert only if we have required data
if (isValidUser(request)) {
// we can find by username or email
// because they are unique
// insert only if user not exist
const email = request.body.email || '';
User.findOne({email: email}, (error, user) => {
// insert only if user not exist
if (error) {
response
.status(401)
.send({
success: false,
message: error.message,
});
} else {
if (!user) {
const userModel = userFromRequest(request);
userModel.save((error) => {
if (error) {
response
.status(401)
.send({
success: false,
message: error.message,
});
} else {
response
.status(200)
.send({
success: true,
user: userModel,
});
}
});
} else {
response
.status(401)
.send({
success: false,
message: MesssageProvider
.messageByKey(Messages.KEYS.USER_ALREADY_EXIST),
});
}
}
});
} else {
return response
.status(401)
.send({
success: false,
message: MesssageProvider
.messageByKey(Messages.KEYS.VERIFY_REQUIRED_INFORMATION),
});
}
};

Complete code

How to test ?

Login — to have an access token
Use token to access to different ressources

Thank you for reading my story.

You can find me at :

Twitter : https://twitter.com/b_k_hela

Github : https://github.com/helabenkhalfallah

--

--

Héla Ben Khalfallah

I love coding whatever the language and trying new programming tendencies. I have a special love to JS (ES6+), functional programming, clean code & tech-books.