NodeJS Boilerplate (Express, Typescript, TypeORM, MySQL)
In this blog, we’re going to create a nodejs boilerplate project that connects to MySQL database via TypeORM library. Here is the reference project structure:
We will go through each directory and see the intended use.
- configs: setup the configuration for the project (express, logger,…).
- constants: define the constants that have used in the project.
- controllers: handle logic for all controller layers.
- entities: define the types & the relation of the tables.
- enums: define the enums that have used in the project.
- errors: build logic to custom handling errors.
- interfaces: define the interfaces that have used in the project.
- middlewares: create the middlewares for pre-processing the requests.
- migrations: define the migrations to process the tables structure & seed data.
- mocks: for testing.
- routes: define all endpoints.
- seeds: create sample data when running the migrations.
- services: handle logic for actions to the database via TypeORM library.
- utilities: build utility functions.
- validations: define the schemas that have been used to validate the requests.
// tsconfig.json
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"target": "es6",
"noImplicitAny": true,
"moduleResolution": "node",
"sourceMap": false,
"outDir": "dist",
"baseUrl": ".",
"rootDir": "src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"paths": {
"*": [
"node_modules/*",
"src/interfaces/*"
]
},
"lib": [
"es2015", "es5", "es6", "dom"
]
},
"include": [
"src/**/*"
]
}
// ormconfig.js
require('dotenv/config');
module.exports = {
type: 'mysql',
host: process.env.DB_HOST || '127.0.0.1',
username: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'nodejs-sample',
port: process.env.DB_PORT || 3306,
charset: 'utf8',
driver: 'mysql',
synchronize: false,
entities: process.env.NODE_ENV !== 'production' ? ['**/**.entity.ts'] : ['dist/**/*.entity.js'],
logging: process.env.NODE_ENV !== 'production' ? 'all' : 'error',
migrations: process.env.NODE_ENV !== 'production' ? ['src/migrations/*.ts'] : ['dist/migrations/*.js'],
cli: {
migrationsDir: 'src/migrations'
},
connectTimeout: 30000,
acquireTimeout: 30000
};
// .env
NODE_ENV=development
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=12345678
DB_NAME=nodejs-sample
PORT=5555
TOKEN_SECRET_KEY=test
// nodemon.json
{
"watch": ["src"],
"ext": "ts",
"ignore": ["src/**/*.spec.ts"],
"exec": "ts-node ./src/index.ts"
}
In the package.json file, we will define some commands to run & build the project.
// package.json
...
"scripts": {
"test": "jest --collectCoverage",
"prebuild": "rimraf dist/",
"build": "tsc",
"start": "yarn serve",
"serve": "node dist/index.js",
"dev": "nodemon",
"watch-ts": "tsc -w",
"migration:generate": "typeorm migration:create -n",
"migration:run": "ts-node ./node_modules/typeorm/cli.js migration:run",
"migration:revert": "ts-node ./node_modules/typeorm/cli.js migration:revert"
},
...
// index.ts
require('dotenv').config();
import 'reflect-metadata';
import { createConnection } from 'typeorm';
import logger from './configs/logger.config';
import app from './configs/express.config';
const PORT = process.env.PORT || 5000;
const connect = async () => {
try {
const connection = await createConnection(); // Connect to the DB that is setup in the ormconfig.js
await connection.runMigrations(); // Run all migrations
logger.info('Connect to database successfully');
app.listen(PORT, () => {
logger.info(`Server running at ${PORT}`);
});
} catch (e) {
logger.info(`The connection to database was failed with error: ${e}`);
}
}
connect();
1. Migrations
In the ormconfig.js file, we define two properties: migrations & cli. It means that typeorm will handle all ts files inside the src/migrations folder.
In the package.json file, we define 3 commands:
- migration:generate to create a migration file.
-
migration:run to run all migration files.
-
migration:revert to revert to the previous migration.
Run two commands: yarn migration:generate CreateUserTable & yarn migration:generate SeedUserTable to create user table & sample data.
// 1651270421471-CreateUserTable.ts
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
export class CreateUserTable1651270421471 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'user',
columns: [
{
name: 'id',
type: 'int',
isPrimary: true,
isGenerated: true,
generationStrategy: 'increment',
},
{
name: 'createdAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updatedAt',
type: 'datetime',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'email',
type: 'varchar',
length: '100',
isNullable: false,
},
{
name: 'password',
type: 'varchar',
length: '100',
isNullable: false,
},
{
name: 'firstName',
type: 'varchar',
length: '255',
isNullable: false,
},
{
name: 'lastName',
type: 'varchar',
length: '255',
isNullable: false,
},
{
name: 'isDeleted',
type: 'tinyint',
default: '0',
},
],
uniques: [
{
columnNames: ['email'],
},
],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('user');
}
}
// 1651270465906-SeedUserTable.ts
import { MigrationInterface, QueryRunner, getRepository } from 'typeorm';
import { User } from '../entities/user/user.entity';
import { userSeed } from '../seeds/user.seed';
export class SeedUserTable1651270465906 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await getRepository(User).save(userSeed);
}
public async down(queryRunner: QueryRunner): Promise<void> {
}
}
2. Entities
Inside entities folder, we create two files:
- base.entity.ts is extended by other entities.
- user.entity.ts an entity for user table.
// base.entity.ts
import { CreateDateColumn, UpdateDateColumn } from 'typeorm';
export class BaseEntity {
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
// user.entity.ts
import { Column, Entity, PrimaryGeneratedColumn, Unique, OneToMany } from 'typeorm';
// Entities
import { BaseEntity } from '../base/base.entity';
@Entity('user', { orderBy: { id: 'DESC' } })
export class User extends BaseEntity {
@PrimaryGeneratedColumn({ type: 'int' })
id: number;
@Column({ length: 100, nullable: false })
@Unique(['email'])
email: string;
@Column({ length: 100, nullable: false, select: false })
password: string;
@Column({ length: 255, nullable: false })
firstName: string;
@Column({ length: 255, nullable: false })
lastName: string;
@Column({ default: false })
isDeleted: boolean;
toJSON() {
delete this.isDeleted;
return this;
}
}
3. Services
We create user.service.ts to handle CRUD logic for users.
// user.service.ts
import { getRepository } from 'typeorm';
// Entities
import { User } from '../../entities/user/user.entity';
// Utilities
import Encryption from '../../utilities/encryption.utility';
import ApiUtility from '../../utilities/api.utility';
import DateTimeUtility from '../../utilities/date-time.utility';
// Interfaces
import {
ICreateUser,
ILoginUser,
IUpdateUser,
IUserQueryParams,
} from '../../interfaces/user.interface';
import { IDeleteById, IDetailById } from '../../interfaces/common.interface';
// Errors
import { StringError } from '../../errors/string.error';
const where = { isDeleted: false };
const create = async (params: ICreateUser) => {
const item = new User();
item.email = params.email;
item.password = await Encryption.generateHash(params.password, 10);
item.firstName = params.firstName;
item.lastName = params.lastName;
const userData = await getRepository(User).save(item);
return ApiUtility.sanitizeUser(userData);
};
const login = async (params: ILoginUser) => {
const user = await getRepository(User)
.createQueryBuilder('user')
.where('user.email = :email', { email: params.email })
.select([
'user.createdAt',
'user.updatedAt',
'user.id',
'user.email',
'user.password',
'user.firstName',
'user.lastName',
'user.isDeleted',
])
.getOne();
if (!user) {
throw new StringError('Your email has not been registered');
}
if (await Encryption.verifyHash(params.password, user.password)) {
return ApiUtility.sanitizeUser(user);
}
throw new StringError('Your password is not correct');
};
const getById = async (params: IDetailById) => {
try {
const data = await getRepository(User).findOne({ id: params.id });
return ApiUtility.sanitizeUser(data);
} catch (e) {
return null;
}
};
const detail = async (params: IDetailById) => {
const query = {
where: { ...where, id: params.id },
}
const user = await getRepository(User).findOne(query);
if (!user) {
throw new StringError('User is not existed');
}
return ApiUtility.sanitizeUser(user);
}
const update = async (params: IUpdateUser) => {
const query = { ...where, id: params.id };
const user = await getRepository(User).findOne(query);
if (!user) {
throw new StringError('User is not existed');
}
return await getRepository(User).update(query, {
firstName: params.firstName,
lastName: params.lastName,
updatedAt: DateTimeUtility.getCurrentTimeStamp(),
});
}
const list = async (params: IUserQueryParams) => {
let userRepo = getRepository(User).createQueryBuilder('user');
userRepo = userRepo.where('user.isDeleted = :isDeleted', { isDeleted: false });
if (params.keyword) {
userRepo = userRepo.andWhere(
'(LOWER(user.firstName) LIKE LOWER(:keyword) OR LOWER(user.lastName) LIKE LOWER(:keyword))',
{ keyword: `%${params.keyword}%` },
);
}
// Pagination
const paginationRepo = userRepo;
const total = await paginationRepo.getMany();
const pagRes = ApiUtility.getPagination(total.length, params.limit, params.page);
userRepo = userRepo.limit(params.limit).offset(ApiUtility.getOffset(params.limit, params.page));
const users = await userRepo.getMany();
const response = [];
if (users && users.length) {
for (const item of users) {
response.push(ApiUtility.sanitizeUser(item));
}
}
return { response, pagination: pagRes.pagination };
};
const remove = async (params: IDeleteById) => {
const query = { ...where, id: params.id };
const user = await getRepository(User).findOne(query);
if (!user) {
throw new StringError('User is not existed');
}
return await getRepository(User).update(query, {
isDeleted: true,
updatedAt: DateTimeUtility.getCurrentTimeStamp(),
});
}
export default {
create,
login,
getById,
detail,
update,
list,
remove,
}
4. Controllers
We create user.controller.ts to handle CRUD logic for users.
// user.controller.ts
import httpStatusCodes from 'http-status-codes';
// Interfaces
import IController from '../../interfaces/IController';
import {
ICreateUser,
ILoginUser,
IUpdateUser,
IUserQueryParams,
} from '../../interfaces/user.interface';
import { IDeleteById, IDetailById } from '../../interfaces/common.interface';
// Errors
import { StringError } from '../../errors/string.error';
// Services
import userService from '../../services/user/user.service';
// Utilities
import ApiResponse from '../../utilities/api-response.utility';
import Encryption from '../../utilities/encryption.utility';
import ApiUtility from '../../utilities/api.utility';
// Constants
import constants from '../../constants';
const create: IController = async (req, res) => {
try {
const params: ICreateUser = {
email: req.body.email,
password: req.body.password,
firstName: req.body.firstName,
lastName: req.body.lastName,
}
const user = await userService.create(params);
return ApiResponse.result(res, user, httpStatusCodes.CREATED);
} catch (e) {
if (e.code === constants.ERROR_CODE.DUPLICATED) {
return ApiResponse.error(res, httpStatusCodes.CONFLICT, 'Email already exists.');
}
return ApiResponse.error(res, httpStatusCodes.BAD_REQUEST);
}
};
const login: IController = async (req, res) => {
try {
const params: ILoginUser = {
email: req.body.email,
password: req.body.password,
}
const user = await userService.login(params);
const cookie = await generateUserCookie(user.id);
return ApiResponse.result(res, user, httpStatusCodes.OK, cookie);
} catch (e) {
if (e instanceof StringError) {
return ApiResponse.error(res, httpStatusCodes.BAD_REQUEST, e.message);
}
return ApiResponse.error(res, httpStatusCodes.BAD_REQUEST, 'Something went wrong');
}
};
const me: IController = async (req, res) => {
const cookie = await generateUserCookie(req.user.id);
return ApiResponse.result(res, req.user, httpStatusCodes.OK, cookie);
};
const detail: IController = async (req, res) => {
try {
const params: IDetailById = {
id: parseInt(req.params.id, 10),
}
const data = await userService.detail(params);
return ApiResponse.result(res, data, httpStatusCodes.OK);
} catch (e) {
ApiResponse.exception(res, e);
}
};
const update: IController = async (req, res) => {
try {
const params: IUpdateUser = {
firstName: req.body.firstName,
lastName: req.body.lastName,
id: parseInt(req.params.id, 10),
}
await userService.update(params);
return ApiResponse.result(res, params, httpStatusCodes.OK);
} catch (e) {
ApiResponse.exception(res, e);
}
};
const updateMe: IController = async (req, res) => {
try {
const params: IUpdateUser = {
firstName: req.body.firstName,
lastName: req.body.lastName,
id: req.user.id,
}
await userService.update(params);
return ApiResponse.result(res, params, httpStatusCodes.OK);
} catch (e) {
ApiResponse.exception(res, e);
}
};
const list: IController = async (req, res) => {
try {
const limit = ApiUtility.getQueryParam(req, 'limit');
const page = ApiUtility.getQueryParam(req, 'page');
const keyword = ApiUtility.getQueryParam(req, 'keyword');
const params: IUserQueryParams = { limit, page, keyword };
const data = await userService.list(params);
return ApiResponse.result(res, data.response, httpStatusCodes.OK, null, data.pagination);
} catch (e) {
ApiResponse.exception(res, e);
}
};
const remove: IController = async (req, res) => {
try {
const params: IDeleteById = {
id: parseInt(req.params.id, 10),
}
await userService.remove(params);
return ApiResponse.result(res, params, httpStatusCodes.OK);
} catch (e) {
ApiResponse.exception(res, e);
}
};
const generateUserCookie = async (userId: number) => {
return {
key: constants.COOKIE.COOKIE_USER,
value: await Encryption.generateCookie(constants.COOKIE.KEY_USER_ID, userId.toString()),
};
};
export default {
create,
login,
me,
detail,
update,
updateMe,
list,
remove,
};
5. Routes
We define the endpoints for authentication, user
// index.route.ts
import * as express from 'express';
import defaultRouter from './default/default.route';
import authRouter from './auth/auth.route';
import meRouter from './me/me.route';
import userRouter from './user/user.route';
const router = express.Router();
router.use('/', defaultRouter);
router.use('/auth', authRouter);
router.use('/me', meRouter);
router.use('/user', userRouter);
export default router;
// auth.route.ts
import express from 'express';
const schemaValidator = require('express-joi-validator');
// Controller
import userController from '../../controllers/user/user.controller';
// Schema
import userSchema from '../../validations/schemas/user.schema';
const router = express.Router();
router.post(
'/register',
schemaValidator(userSchema.register),
userController.create,
);
router.post(
'/login',
schemaValidator(userSchema.login),
userController.login,
);
export default router;
// me.route.ts
import express from 'express';
const schemaValidator = require('express-joi-validator');
// Controller
import userController from '../../controllers/user/user.controller';
// Schema
import userSchema from '../../validations/schemas/user.schema';
const router = express.Router();
router.get(
'/',
userController.me,
);
router.put(
'/',
schemaValidator(userSchema.updateMe),
userController.updateMe,
);
export default router;
// user.route.ts
import express from 'express';
const schemaValidator = require('express-joi-validator');
// Controller
import userController from '../../controllers/user/user.controller';
// Schema
import userSchema from '../../validations/schemas/user.schema';
// Middleware
import { isAdmin } from '../../middlewares/permission-handler.middleware';
const router = express.Router();
router.get(
'/',
userController.list,
);
router.delete(
'/:id',
isAdmin(),
userController.remove,
);
export default router;
6. Middlewares
Inside middlewares folder, we create 4 middlewares to handle the requests & the responses.
- api-error-handler.middleware.ts handle all error responses.
- authenticate.middleware.ts verify cookies to make sure that user is logged.
- joi-error-handler.middleware.ts handle all error validations that use joi library.
- permission-handler.middleware.ts to check the permission of the current user.
// api-error-handler.middleware.ts
import HttpStatus from 'http-status-codes';
import express from 'express';
export interface IError {
status?: number;
code?: number;
message?: string;
}
export const notFoundErrorHandler = (
req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
res.status(HttpStatus.NOT_FOUND).json({
success: false,
error: {
code: HttpStatus.NOT_FOUND,
message: HttpStatus.getStatusText(HttpStatus.NOT_FOUND),
},
});
}
export const errorHandler = (
err: IError,
req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
res.status(err.status || HttpStatus.INTERNAL_SERVER_ERROR).json({
success: false,
error: {
code: err.code || HttpStatus.INTERNAL_SERVER_ERROR,
message: err.message || HttpStatus.getStatusText(HttpStatus.INTERNAL_SERVER_ERROR),
},
});
}
// authenticate.middleware.ts
import express from 'express';
import httpStatusCodes from 'http-status-codes';
// Services
import userService from '../services/user/user.service';
// Interfaces
import IRequest from '../interfaces/IRequest';
// Utilities
import ApiResponse from '../utilities/api-response.utility';
import Encryption from '../utilities/encryption.utility';
import ApiUtility from '../utilities/api.utility';
// Constants
import constants from '../constants';
export default async (
req: IRequest,
res: express.Response,
next: express.NextFunction,
) => {
if (constants.APPLICATION.authorizationIgnorePath.indexOf(`${req.originalUrl}`) === -1) {
const authorizationHeader = ApiUtility.getCookieFromRequest(req, constants.COOKIE.COOKIE_USER);
if (authorizationHeader) {
const decoded = await Encryption.verifyCookie(authorizationHeader);
if (decoded) {
const user = await userService.getById({ id: decoded.data[constants.COOKIE.KEY_USER_ID] });
if (user) {
// @ts-ignore
req.user = user;
} else {
return ApiResponse.error(res, httpStatusCodes.UNAUTHORIZED);
}
} else {
return ApiResponse.error(res, httpStatusCodes.UNAUTHORIZED);
}
} else {
return ApiResponse.error(res, httpStatusCodes.FORBIDDEN);
}
}
next();
};
// joi-error-handler.middleware.ts
import express from 'express';
import * as HttpStatus from 'http-status-codes';
import { IError } from './api-error-handler.middleware';
interface IJoiErrorDetail {
message?: string;
path?: string;
}
interface IJoiError extends IError {
isJoi?: boolean;
// tslint:disable-next-line: prefer-array-literal
details?: Array<IJoiErrorDetail>;
}
export default (
err: IJoiError,
req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
if (err.isJoi) {
const error = {
code: HttpStatus.BAD_REQUEST,
message: HttpStatus.getStatusText(HttpStatus.BAD_REQUEST),
details:
err.details &&
err.details.map(err => ({
message: err.message,
param: err.path,
})),
};
return res.status(HttpStatus.BAD_REQUEST).json(error);
}
return next(err);
};
// permission-handler.middleware.ts
import express from 'express';
import httpStatusCodes from 'http-status-codes';
// Interfaces
import IRequest from '../interfaces/IRequest';
// Utilities
import ApiResponse from '../utilities/api-response.utility';
export const isAdmin = () => {
return async (req: IRequest, res: express.Response, next: express.NextFunction) => {
if (req.user.email !== 'admin@gmail.com') {
return ApiResponse.error(res, httpStatusCodes.UNAUTHORIZED);
}
next();
};
};