An Ecommerce project build RESTful APIs using Node.js, Express, and Mongoose.
By running a single command, you will get a production-ready Node.js app installed and fully configured on your machine. The app comes with many built-in features, such as authentication using JWT, request validation, unit and integration tests, continuous integration, docker support, API documentation, pagination, etc. For more details, check the features list below.
If you would still prefer to do the installation manually, follow these steps:
Clone the repo:
git clone --depth 1 https://github.com/VanTrangDinh/BEShop.git
cd BEShop
npx rimraf ./.git
Install the dependencies:
yarn install
Set the environment variables:
cp .env.example .env
# open .env and modify the environment variables (if needed)
-
Authentication and authorization: using passport
-
Validation: request data validation using Joi
-
Logging: using winston, morgan and discord bot
-
Testing: unit and integration tests using Jest
-
Error handling: centralized error handling mechanism
-
API documentation: with swagger-jsdoc and swagger-ui-express
-
Process management: advanced production process management using PM2
-
Dependency management: with Yarn
-
Security: set security HTTP headers using helmet
-
Santizing: sanitize request data against xss and query injection
-
CORS: Cross-Origin Resource-Sharing enabled using cors
-
Compression: gzip compression with compression
-
CI: continuous integration with Travis CI
-
Docker support
-
Code coverage: using coveralls
-
Code quality: with Codacy
-
Git hooks: with husky and lint-staged
-
Editor config: consistent editor configuration using EditorConfig
Running locally:
yarn dev
Running in production:
yarn start
Testing:
# run all tests
yarn test
# run all tests in watch mode
yarn test:watch
# run test coverage
yarn coverage
Docker:
# run docker container in development mode
yarn docker:dev
# run docker container in production mode
yarn docker:prod
# run all tests in a docker container
yarn docker:test
Linting:
# run ESLint
yarn lint
# fix ESLint errors
yarn lint:fix
# run prettier
yarn prettier
# fix prettier errors
yarn prettier:fix
The environment variables can be found and modified in the .env
file. They come with these default values:
# Port number
PORT=3000
# URL of the Mongo DB
MONGODB_URL=mongodb://127.0.0.1:27017/node-boilerplate
# JWT
# JWT secret key
JWT_SECRET=thisisasamplesecret
# Number of minutes after which an access token expires
JWT_ACCESS_EXPIRATION_MINUTES=30
# Number of days after which a refresh token expires
JWT_REFRESH_EXPIRATION_DAYS=30
# SMTP configuration options for the email service
# For testing, you can use a fake SMTP service like Ethereal: https://ethereal.email/create
SMTP_HOST=email-server
SMTP_PORT=587
SMTP_USERNAME=email-server-username
SMTP_PASSWORD=email-server-password
EMAIL_FROM=support@yourapp.com
src\
|--config\ # Environment variables and configuration related things
|--controllers\ # Route controllers (controller layer)
|--docs\ # Swagger files
|--helpers\ # Check connect and async handle file
|--loggers\ # Handle log to server discord
|--middlewares\ # Custom express middlewares
|--models\ # Mongoose models (data layer)
|--routes\ # Routes
|--services\ # Business logic (service layer)
|--utils\ # Utility classes and functions
|--validations\ # Request data validation schemas
|--app.js # Express app
|--index.js # App entry point
To view the list of available APIs and their specifications, run the server and go to http://localhost:3000/v1/docs
in your browser. This documentation page is automatically generated using the swagger definitions written as comments in the route files.
List of available routes:
Shop routes:\
POST /v1/api/register
- register\
POST /v1/api/login
- login\
POST /v1/api/forgot-password
refresh auth tokens\
POST /v1/api/reset-password
- send reset password email\
POST /v1/api/verify-email
- reset password\
POST /v1/api/logout
- log out\
POST /v1/api/refresh-tokens
- send verification email\
POST /v1/api/send-verification-email
- verify email
Cart routes:\
POST /v1/api/cart
- add product\
GET /v1/api/cart
- get list of cart items\
DELETE /v1/api/cart
- delete cart\
PATCH /v1/api/cart/update
- update\
Checkout routes:\
POST /v1/api/checkout/review
- checkout\
POST /v1/api/checkout/order
- order\
GET /v1/api/checkout/order
- get order\
PATCH /v1/api/checkout/order/:orderId
- cancel order\
PATCH /v1/api/checkout/order/accept
- update status of order\
Discount routes:\
POST /v1/api/discount
- create discount code\
GET /v1/api/discount
- get discount\
DELETE /v1/api/discount
- delete discount\
GET /v1/api/discout/
- cancel order\
GET /v1/api/discount/list-product-code
- get all discount\
POST /v1/api/discount/amount
- apply discount code\
POST /v1/api/discount/cancel
- cancel discount code\
Product routes:\
GET /v1/api/product/search/:keysearch
- search fulltex product\
GET /v1/api/product
- get all product\
POST /v1/api/product
- create product\
PATCH /v1/api/product/:productId
- update product\
GET /v1/api/product/:productId
- get product\
POST /v1/api/product/publish/:id
- publish product\
POST /v1/api/product/unpublish/:id
- un publish product\
GET /v1/api/product/drafts/all
- get drafts\
GET /v1/api/product/published/all
- get all pushlished\
Inventory routes:\
POST /v1/api/inventory
- add stock to inventory\
GET /v1/api/inventory/:inventoryId
- get inventory\
User routes:\
POST /v1/users
- create a user\
GET /v1/users
- get all users\
GET /v1/users/:userId
- get user\
PATCH /v1/users/:userId
- update user\
DELETE /v1/users/:userId
- delete user
The app has a centralized error handling mechanism.
When designing routes, it is recommended for controllers to make an effort to capture errors and pass them to the error handling middleware by invoking next(error)
. To simplify this process, you can also enclose the controller within the asyncHandler
utility wrapper, which takes care of forwarding any errors.
'use strict';
const express = require('express');
const accessController = require('../../controllers/access.controller');
const asyncHandler = require('../../helpers/asyncHandle');
const router = express.Router();
router.post('/register', asyncHandler(accessController.signUp));
The error handling middleware sends an error response, which has the following format:
{
"code": 401,
"message": "Please authenticate"
}
In development mode, the error response includes the error stack trace.
To facilitate error handling, the application provides an AuthFailureError utility class. You can assign a response code and a message to it, and then throw it from any location (asyncHandler will catch it).
For instance, if you are attempting to retrieve a user from the database but cannot find them, and you wish to send a 404 error, the code should resemble the following:
'use strict'
const { AuthFailureError } = require('../core/error.response');
const { findByEmail, getShopById, verifyShop } = require('./shop.service');
static verifyEmail = async (verifyEmailToken) => {
const keyStore = await findByRefreshToken(verifyEmailToken);
if (!keyStore) throw new AuthFailureError('Verify email failed');
};
Request data is validated using Joi. Check the documentation for more details on how to write Joi validation schemas.
The validation schemas are defined in the src/validations
directory and are used in the routes by providing them as parameters to the validate
middleware.
const express = require('express');
const validate = require('../../middlewares/validate');
const userValidation = require('../../validations/user.validation');
const userController = require('../../controllers/user.controller');
const router = express.Router();
router.post('/users', validate(userValidation.createUser), userController.createUser);
To require authentication for certain routes, you can use the auth
middleware.
const express = require('express');
const auth = require('../../middlewares/auth');
const userController = require('../../controllers/user.controller');
const router = express.Router();
router.post('/users', auth(), userController.createUser);
These routes require a valid JWT access token in the Authorization request header using the Bearer schema. If the request does not contain a valid access token, an Unauthorized (401) error is thrown.
Generating Access Tokens:
An access token can be generated by making a successful call to the register (POST /v1/api/user/register
) or login (POST /v1/api/user/login
) endpoints. The response of these endpoints also contains refresh tokens (explained below).
An access token is valid for 30 minutes. You can modify this expiration time by changing the JWT_ACCESS_EXPIRATION_MINUTES
environment variable in the .env file.
Refreshing Access Tokens:
After the access token expires, a new access token can be generated, by making a call to the refresh token endpoint (POST /v1/api/user/refresh-tokens
) and sending along a valid refresh token in the request body. This call returns a new access token and a new refresh token.
A refresh token is valid for 30 days. You can modify this expiration time by changing the JWT_REFRESH_EXPIRATION_DAYS
environment variable in the .env file.
To require authentication for certain routes using an API key, you can implement the following approach:
- Generate and assign a unique API key to each user or client that needs access to protected routes.
//Admin creates and assigns an API key to each API user.
const apikeyModel = require('../models/apikey.model');
const crypto = require('crypto');
const newKey = await apikeyModel.create({
key: crypto.randomBytes(64).toString('hex'),
permissions: ['0000'],
});
- When making requests to the protected routes, include the API key as part of the request headers or query parameters.
'use strict';
const express = require('express');
const accessController = require('../../controllers/access.controller');
const asyncHandler = require('../../helpers/asyncHandle');
const { apikey } = require('../../middlewares/authUtils');
const router = express.Router();
router.use(apikey);
router.post('/register', asyncHandler(accessController.signUp));
To require authentication for certain routes, you can use the authenticationV2
middleware.
'use strict';
const express = require('express');
const accessController = require('../../controllers/access.controller');
const asyncHandler = require('../../helpers/asyncHandle');
const { authenticationV2 } = require('../../middlewares/authUtils');
const router = express.Router();
router.use(authenticationV2);
router.post('/logout', asyncHandler(accessController.logOut));
router.post('/refresh-tokens', asyncHandler(accessController.handlerRefreshToken));
router.post('/send-verification-email', asyncHandler(accessController.sendVerificationEmail));
These routes require a valid JWT access token in the Authorization request header using the Bearer schema. If the request does not contain a valid access token, an Unauthorized (401) error is thrown.
Generating Access Tokens:
An access token can be generated by making a successful call to the register (POST /v1/api/shop/register
) or login (POST /v1/api/shop/login
) endpoints. The response of these endpoints also contains refresh tokens (explained below).
An access token is valid for 30 minutes. You can modify this expiration time by changing the JWT_ACCESS_EXPIRATION_MINUTES
environment variable in the .env file.
Refreshing Access Tokens:
After the access token expires, a new access token can be generated, by making a call to the refresh token endpoint (POST /v1/api/shop/refresh-tokens
) and sending along a valid refresh token in the request body. This call returns a new access token and a new refresh token.
A refresh token is valid for 30 days. You can modify this expiration time by changing the JWT_REFRESH_EXPIRATION_DAYS
environment variable in the .env file.
The auth
middleware can also be used to require certain rights/permissions to access a route.
const express = require('express');
const auth = require('../../middlewares/auth');
const userController = require('../../controllers/user.controller');
const router = express.Router();
router.post('/users', auth('manageUsers'), userController.createUser);
In the example above, an authenticated user can access this route only if that user has the manageUsers
permission.
The permissions are role-based. You can view the permissions/rights of each role in the src/config/roles.js
file.
If the user making the request does not have the required permissions to access this route, a Forbidden (403) error is thrown.
The repository pattern is used in this project to handle data access and retrieval for the cart
collection.
The cart
repository provides an interface to interact with the cart
collection in the database. It encapsulates the logic for finding a cart document by its ID.
'use strict';
const { cart } = require('../cart.model');
const { converToObjectInMongodb } = require('../../utils');
const findCartById = async (cartId) => {
return cart.findOne({ _id: converToObjectInMongodb(cartId), cart_state: 'active' }).lean();
};
module.exports = { findCartById };
The ProductFactory
class combines the Factory and Strategy patterns to provide flexible object creation and behavior selection for different types of products.
Factory Pattern:
The Factory pattern is utilized in the ProductFactory
class to encapsulate the creation logic of products. It provides a centralized factory that creates objects based on the provided parameters or configuration. This helps to abstract away the object creation details from the client code.
Usage:
To create products using the Factory pattern, follow these steps:
- Import the
ProductFactory
module:
const ProductFactory = require('./path/to/product.factory');
- Use the factory to create products:
const product = await ProductFactory.createProduct(type, payload);
Replace type
with the desired product type (e.g., 'Electronics'
, 'Clothing'
, 'Furniture'
) and provide the necessary payload
object to configure the product.
- The factory will create an instance of the appropriate product class based on the provided type and invoke the
createProduct
method of that class.
Strategy Pattern
The Strategy pattern is utilized in the Product
, Clothing
, Electronics
, and Furniture
classes to define different behaviors for creating and updating products. Each class represents a specific type of product and extends the Product
class.
Usage
To use the Strategy pattern for different product types, follow these steps:
-
Import the relevant modules:
const ProductFactory = require('./path/to/product.factory'); const { BadRequestError } = require('../core/error.response'); const { insertInventory } = require('../models/repository/inventory.repo'); const { updateProductById } = require('../models/repository/product.repo'); const { updateNestedObjectPraser } = require('../utils');
-
Register the product types with the
ProductFactory
:ProductFactory.registerProductType('Electronics', Electronics); ProductFactory.registerProductType('Clothing', Clothing); ProductFactory.registerProductType('Furniture', Furniture);
-
Use the
ProductFactory
to create and update products:const product = await ProductFactory.createProduct(type, payload); const updatedProduct = await ProductFactory.updateProduct(type, productId, payload);
Replace type
with the desired product type (e.g., 'Electronics'
, 'Clothing'
, 'Furniture'
) and provide the necessary payload
object to configure the product. The updateProduct
method allows updating an existing product based on its productId
The Redis Lock module provides functionality for acquiring and releasing locks using Redis. It ensures that only one process or thread can access a particular resource at a time, preventing concurrency issues. Installation
Install the required dependencies using npm
:
The Control Order module provides functionality for managing order requests when a product has limited availability and multiple users are trying to order it simultaneously. It helps prevent overselling and ensures fair distribution of the available stock.
Installation
Install the required dependencies using npm
:
npm install redis
Usage
To use the Redis Lock module, follow these steps:
-
Import the necessary modules:
const redis = require('redis'); const redisClient = redis.createClient(); const { promisify } = require('util'); const { reservationInventory } = require('../models/repository/inventory.repo');
-
Create a Redis client and promisify Redis commands:
const pexpire = promisify(redisClient.pExpire).bind(redisClient); const setnxAsync = promisify(redisClient.setNX).bind(redisClient);
-
Implement the
acquireLock
function:const acquireLock = async (productId, quantity, cartId) => { const key = `lock_v2023_${productId}`; const retryTime = 10; const expireTime = '3000'; for (let i = 0; i < retryTime; i++) { const result = await setnxAsync(key, expireTime); if (result === 1) { const isReservation = await reservationInventory({ productId, quantity, cartId, }); if (isReservation.modifiedCount) { await pexpire(key, expireTime); return key; } return null; } else { await new Promise((resolve) => setTimeout(resolve, 50)); } } return null; };
The
acquireLock
function attempts to acquire a lock for a specificproductId
andquantity
by setting a Redis key usingsetnxAsync
. It retries a certain number of times and returns the acquired lock key ornull
if the lock acquisition fails. -
Implement the
releaseLock
function:const releaseLock = async (keyLock) => { const delAsyncKey = promisify(redisClient.del).bind(redisClient); return await delAsyncKey(keyLock); };
The
releaseLock
function releases the lock associated with the providedkeyLock
. -
Export the functions:
```javascript module.exports = {acquireLock,releaseLock}; ```
API
- acquireLock(productId, quantity, cartId)
Attempts to acquire a lock for the specified productId
and quantity
. The cartId
parameter is used for reservation purposes. If the lock is successfully acquired and the inventory is updated, the lock key is returned. Otherwise, null
is returned.
- releaseLock(keyLock)
Releases the lock associated with the provided keyLock
.
Dependencies
The Control Order module depends on the following packages:
redis
: Redis client for Node.js.util
: Utility module for working with Promises.
Make sure to install these dependencies using npm
or any other package manager before using the Control Order module.