├── .gitignore ├── _config.yml ├── utils ├── appError.js └── apiFeatures.js ├── config.env ├── controllers ├── errorController.js ├── userController.js ├── baseController.js └── authController.js ├── package.json ├── routes └── userRoutes.js ├── server.js ├── README.md ├── app.js └── models └── userModel.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /utils/appError.js: -------------------------------------------------------------------------------- 1 | class AppError extends Error { 2 | constructor(statusCode, status, message) { 3 | super(message); 4 | this.statusCode = statusCode; 5 | this.status = status; 6 | this.message = message; 7 | } 8 | } 9 | 10 | module.exports = AppError; -------------------------------------------------------------------------------- /config.env: -------------------------------------------------------------------------------- 1 | NODE_ENVIROMMENT=development 2 | PORT=Your port for example 3000 3 | DATABASE= for example: mongodb+srv://test:@cluster1-fsrf5.mongodb.net/example?retryWrites=true&w=majority 4 | DATABASE_PASSWORD= Your password 5 | 6 | JWT_SECRET=Your secret 7 | JWT_EXPIRES_IN=30d 8 | -------------------------------------------------------------------------------- /controllers/errorController.js: -------------------------------------------------------------------------------- 1 | // Express automatically knows that this entire function is an error handling middleware by specifying 4 parameters 2 | module.exports = (err, req, res, next) => { 3 | err.statusCode = err.statusCode || 500; 4 | err.status = err.status || 'error'; 5 | 6 | res.status(err.statusCode).json({ 7 | status: err.status, 8 | error: err, 9 | message: err.message, 10 | stack: err.stack 11 | }); 12 | 13 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "app.js", 6 | "scripts": { 7 | "debug": "ndb server.js", 8 | "start": "nodemon server.js" 9 | }, 10 | "author": "Moath Shreim", 11 | "license": "ISC", 12 | "dependencies": { 13 | "bcryptjs": "^2.4.3", 14 | "cors": "^2.8.5", 15 | "dotenv": "^8.2.0", 16 | "express": "^4.17.1", 17 | "express-mongo-sanitize": "^1.3.2", 18 | "express-rate-limit": "^5.0.0", 19 | "helmet": "^3.21.2", 20 | "hpp": "^0.2.2", 21 | "jsonwebtoken": "^8.5.1", 22 | "mongoose": "^5.7.7", 23 | "validator": "^12.0.0", 24 | "xss-clean": "^0.1.1" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /controllers/userController.js: -------------------------------------------------------------------------------- 1 | const User = require('../models/userModel'); 2 | const base = require('./baseController'); 3 | 4 | exports.deleteMe = async (req, res, next) => { 5 | try { 6 | await User.findByIdAndUpdate(req.user.id, { 7 | active: false 8 | }); 9 | 10 | res.status(204).json({ 11 | status: 'success', 12 | data: null 13 | }); 14 | 15 | 16 | } catch (error) { 17 | next(error); 18 | } 19 | }; 20 | 21 | exports.getAllUsers = base.getAll(User); 22 | exports.getUser = base.getOne(User); 23 | 24 | // Don't update password on this 25 | exports.updateUser = base.updateOne(User); 26 | exports.deleteUser = base.deleteOne(User); -------------------------------------------------------------------------------- /routes/userRoutes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const userController = require('../controllers/userController'); 4 | const authController = require('./../controllers/authController'); 5 | 6 | 7 | router.post('/login', authController.login); 8 | router.post('/signup', authController.signup); 9 | 10 | // Protect all routes after this middleware 11 | router.use(authController.protect); 12 | 13 | router.delete('/deleteMe', userController.deleteMe); 14 | 15 | // Only admin have permission to access for the below APIs 16 | router.use(authController.restrictTo('admin')); 17 | 18 | router 19 | .route('/') 20 | .get(userController.getAllUsers); 21 | 22 | 23 | router 24 | .route('/:id') 25 | .get(userController.getUser) 26 | .patch(userController.updateUser) 27 | .delete(userController.deleteUser); 28 | 29 | module.exports = router; -------------------------------------------------------------------------------- /utils/apiFeatures.js: -------------------------------------------------------------------------------- 1 | class APIFeatures { 2 | constructor(query, queryString) { 3 | this.query = query; 4 | this.queryString = queryString; 5 | } 6 | 7 | sort() { 8 | if (this.queryString.sort) { 9 | const sortBy = this.queryString.sort.split(",").join(" "); 10 | this.query = this.query.sort(sortBy); 11 | } 12 | return this; 13 | } 14 | 15 | paginate() { 16 | const page = this.queryString.page * 1 || 1; 17 | const limit = this.queryString.limit * 1 || 10; 18 | const skip = (page - 1) * limit; 19 | 20 | this.query = this.query.skip(skip).limit(limit); 21 | return this; 22 | } 23 | 24 | // Field Limiting ex: -----/user?fields=name,email,address 25 | limitFields() { 26 | if (this.queryString.fields) { 27 | const fields = this.queryString.fields.split(",").join(" "); 28 | this.query = this.query.select(fields); 29 | } 30 | return this; 31 | } 32 | } 33 | 34 | module.exports = APIFeatures; 35 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const dotenv = require('dotenv'); 3 | dotenv.config({ 4 | path: './config.env' 5 | }); 6 | 7 | process.on('uncaughtException', err => { 8 | console.log('UNCAUGHT EXCEPTION!!! shutting down...'); 9 | console.log(err.name, err.message); 10 | process.exit(1); 11 | }); 12 | 13 | const app = require('./app'); 14 | 15 | const database = process.env.DATABASE.replace('', process.env.DATABASE_PASSWORD); 16 | 17 | // Connect the database 18 | mongoose.connect(database, { 19 | useNewUrlParser: true, 20 | useCreateIndex: true, 21 | useFindAndModify: false 22 | }).then(con => { 23 | console.log('DB connection Successfully!'); 24 | }); 25 | 26 | // Start the server 27 | const port = process.env.PORT; 28 | app.listen(port, () => { 29 | console.log(`Application is running on port ${port}`); 30 | }); 31 | 32 | process.on('unhandledRejection', err => { 33 | console.log('UNHANDLED REJECTION!!! shutting down ...'); 34 | console.log(err.name, err.message); 35 | server.close(() => { 36 | process.exit(1); 37 | }); 38 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node.js, Express and MongoDB Project Structure 2 | This is a basic project structure to help you to start building your own RESTful web APIs (for Android, IOS, or JavaScript framworks) using Express framework and MongoDB with a good structure practices based on clean MVC Architecture. 3 | 4 | 5 | # Features 6 | - Fundamental of Express: routing, middleware, sending response and more 7 | - Fundamental of Mongoose: Data models, data validation and middleware 8 | - RESTful API including pagination,sorting and limiting fields 9 | - CRUD operations with MongoDB 10 | - Security: encyption, sanitization and more 11 | - Authentication with JWT : login and signup 12 | - Authorization (User roles and permissions) 13 | - Error handling 14 | - Enviroment Varaibles 15 | - handling error outside Express 16 | - Catching Uncaught Exception 17 | 18 | # Project Structure 19 | - server.js : Responsible for connecting the MongoDB and starting the server. 20 | - app.js : Configure everything that has to do with Express application. 21 | - config.env: for Enviroment Varaiables 22 | - routes -> userRoutes.js: The goal of the route is to guide the request to the correct handler function which will be in one of the controllers 23 | - controllers -> userController.js: Handle the application request, interact with models and send back the response to the client 24 | - models -> userModel.js: (Business logic) related to business rules, how the business works and business needs ( Creating new user in the database, checking if the user password is correct, validating user input data) 25 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const rateLimit = require('express-rate-limit'); 3 | const helmet = require('helmet'); 4 | const mongoSanitize = require('express-mongo-sanitize'); 5 | const xss = require('xss-clean'); 6 | const hpp = require('hpp'); 7 | const cors = require('cors'); 8 | 9 | 10 | const userRoutes = require('./routes/userRoutes'); 11 | const globalErrHandler = require('./controllers/errorController'); 12 | const AppError = require('./utils/appError'); 13 | const app = express(); 14 | 15 | // Allow Cross-Origin requests 16 | app.use(cors()); 17 | 18 | // Set security HTTP headers 19 | app.use(helmet()); 20 | 21 | // Limit request from the same API 22 | const limiter = rateLimit({ 23 | max: 150, 24 | windowMs: 60 * 60 * 1000, 25 | message: 'Too Many Request from this IP, please try again in an hour' 26 | }); 27 | app.use('/api', limiter); 28 | 29 | // Body parser, reading data from body into req.body 30 | app.use(express.json({ 31 | limit: '15kb' 32 | })); 33 | 34 | // Data sanitization against Nosql query injection 35 | app.use(mongoSanitize()); 36 | 37 | // Data sanitization against XSS(clean user input from malicious HTML code) 38 | app.use(xss()); 39 | 40 | // Prevent parameter pollution 41 | app.use(hpp()); 42 | 43 | 44 | // Routes 45 | app.use('/api/v1/users', userRoutes); 46 | 47 | // handle undefined Routes 48 | app.use('*', (req, res, next) => { 49 | const err = new AppError(404, 'fail', 'undefined route'); 50 | next(err, req, res, next); 51 | }); 52 | 53 | app.use(globalErrHandler); 54 | 55 | module.exports = app; -------------------------------------------------------------------------------- /models/userModel.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const validator = require("validator"); 3 | const bcrypt = require("bcryptjs"); 4 | 5 | const userSchema = new mongoose.Schema({ 6 | name: { 7 | type: String, 8 | required: [true, "Please fill your name"], 9 | }, 10 | email: { 11 | type: String, 12 | required: [true, "Please fill your email"], 13 | unique: true, 14 | lowercase: true, 15 | validate: [validator.isEmail, " Please provide a valid email"], 16 | }, 17 | address: { 18 | type: String, 19 | trim: true, 20 | }, 21 | password: { 22 | type: String, 23 | required: [true, "Please fill your password"], 24 | minLength: 6, 25 | select: false, 26 | }, 27 | passwordConfirm: { 28 | type: String, 29 | required: [true, "Please fill your password confirm"], 30 | validate: { 31 | validator: function(el) { 32 | // "this" works only on create and save 33 | return el === this.password; 34 | }, 35 | message: "Your password and confirmation password are not the same", 36 | }, 37 | }, 38 | role: { 39 | type: String, 40 | enum: ["admin", "teacher", "student"], 41 | default: "student", 42 | }, 43 | active: { 44 | type: Boolean, 45 | default: true, 46 | select: false, 47 | }, 48 | }); 49 | 50 | // encrypt the password using 'bcryptjs' 51 | // Mongoose -> Document Middleware 52 | userSchema.pre("save", async function(next) { 53 | // check the password if it is modified 54 | if (!this.isModified("password")) { 55 | return next(); 56 | } 57 | 58 | // Hashing the password 59 | this.password = await bcrypt.hash(this.password, 12); 60 | 61 | // Delete passwordConfirm field 62 | this.passwordConfirm = undefined; 63 | next(); 64 | }); 65 | 66 | // This is Instance Method that is gonna be available on all documents in a certain collection 67 | userSchema.methods.correctPassword = async function( 68 | typedPassword, 69 | originalPassword, 70 | ) { 71 | return await bcrypt.compare(typedPassword, originalPassword); 72 | }; 73 | 74 | const User = mongoose.model("User", userSchema); 75 | module.exports = User; 76 | -------------------------------------------------------------------------------- /controllers/baseController.js: -------------------------------------------------------------------------------- 1 | const AppError = require('../utils/appError'); 2 | const APIFeatures = require('../utils/apiFeatures'); 3 | 4 | exports.deleteOne = Model => async (req, res, next) => { 5 | try { 6 | const doc = await Model.findByIdAndDelete(req.params.id); 7 | 8 | if (!doc) { 9 | return next(new AppError(404, 'fail', 'No document found with that id'), req, res, next); 10 | } 11 | 12 | res.status(204).json({ 13 | status: 'success', 14 | data: null 15 | }); 16 | } catch (error) { 17 | next(error); 18 | } 19 | }; 20 | 21 | exports.updateOne = Model => async (req, res, next) => { 22 | try { 23 | const doc = await Model.findByIdAndUpdate(req.params.id, req.body, { 24 | new: true, 25 | runValidators: true 26 | }); 27 | 28 | if (!doc) { 29 | return next(new AppError(404, 'fail', 'No document found with that id'), req, res, next); 30 | } 31 | 32 | res.status(200).json({ 33 | status: 'success', 34 | data: { 35 | doc 36 | } 37 | }); 38 | 39 | } catch (error) { 40 | next(error); 41 | } 42 | }; 43 | 44 | exports.createOne = Model => async (req, res, next) => { 45 | try { 46 | const doc = await Model.create(req.body); 47 | 48 | res.status(201).json({ 49 | status: 'success', 50 | data: { 51 | doc 52 | } 53 | }); 54 | 55 | } catch (error) { 56 | next(error); 57 | } 58 | }; 59 | 60 | exports.getOne = Model => async (req, res, next) => { 61 | try { 62 | const doc = await Model.findById(req.params.id); 63 | 64 | if (!doc) { 65 | return next(new AppError(404, 'fail', 'No document found with that id'), req, res, next); 66 | } 67 | 68 | res.status(200).json({ 69 | status: 'success', 70 | data: { 71 | doc 72 | } 73 | }); 74 | } catch (error) { 75 | next(error); 76 | } 77 | }; 78 | 79 | exports.getAll = Model => async (req, res, next) => { 80 | try { 81 | const features = new APIFeatures(Model.find(), req.query) 82 | .sort() 83 | .paginate(); 84 | 85 | const doc = await features.query; 86 | 87 | res.status(200).json({ 88 | status: 'success', 89 | results: doc.length, 90 | data: { 91 | data: doc 92 | } 93 | }); 94 | 95 | } catch (error) { 96 | next(error); 97 | } 98 | 99 | }; -------------------------------------------------------------------------------- /controllers/authController.js: -------------------------------------------------------------------------------- 1 | const { promisify } = require("util"); 2 | const jwt = require("jsonwebtoken"); 3 | const User = require("../models/userModel"); 4 | const AppError = require("../utils/appError"); 5 | 6 | const createToken = id => { 7 | return jwt.sign( 8 | { 9 | id, 10 | }, 11 | process.env.JWT_SECRET, 12 | { 13 | expiresIn: process.env.JWT_EXPIRES_IN, 14 | }, 15 | ); 16 | }; 17 | 18 | exports.login = async (req, res, next) => { 19 | try { 20 | const { email, password } = req.body; 21 | 22 | // 1) check if email and password exist 23 | if (!email || !password) { 24 | return next( 25 | new AppError(404, "fail", "Please provide email or password"), 26 | req, 27 | res, 28 | next, 29 | ); 30 | } 31 | 32 | // 2) check if user exist and password is correct 33 | const user = await User.findOne({ 34 | email, 35 | }).select("+password"); 36 | 37 | if (!user || !(await user.correctPassword(password, user.password))) { 38 | return next( 39 | new AppError(401, "fail", "Email or Password is wrong"), 40 | req, 41 | res, 42 | next, 43 | ); 44 | } 45 | 46 | // 3) All correct, send jwt to client 47 | const token = createToken(user.id); 48 | 49 | // Remove the password from the output 50 | user.password = undefined; 51 | 52 | res.status(200).json({ 53 | status: "success", 54 | token, 55 | data: { 56 | user, 57 | }, 58 | }); 59 | } catch (err) { 60 | next(err); 61 | } 62 | }; 63 | 64 | exports.signup = async (req, res, next) => { 65 | try { 66 | const user = await User.create({ 67 | name: req.body.name, 68 | email: req.body.email, 69 | password: req.body.password, 70 | passwordConfirm: req.body.passwordConfirm, 71 | role: req.body.role, 72 | }); 73 | 74 | const token = createToken(user.id); 75 | 76 | user.password = undefined; 77 | 78 | res.status(201).json({ 79 | status: "success", 80 | token, 81 | data: { 82 | user, 83 | }, 84 | }); 85 | } catch (err) { 86 | next(err); 87 | } 88 | }; 89 | 90 | exports.protect = async (req, res, next) => { 91 | try { 92 | // 1) check if the token is there 93 | let token; 94 | if ( 95 | req.headers.authorization && 96 | req.headers.authorization.startsWith("Bearer") 97 | ) { 98 | token = req.headers.authorization.split(" ")[1]; 99 | } 100 | if (!token) { 101 | return next( 102 | new AppError( 103 | 401, 104 | "fail", 105 | "You are not logged in! Please login in to continue", 106 | ), 107 | req, 108 | res, 109 | next, 110 | ); 111 | } 112 | 113 | // 2) Verify token 114 | const decode = await promisify(jwt.verify)(token, process.env.JWT_SECRET); 115 | 116 | // 3) check if the user is exist (not deleted) 117 | const user = await User.findById(decode.id); 118 | if (!user) { 119 | return next( 120 | new AppError(401, "fail", "This user is no longer exist"), 121 | req, 122 | res, 123 | next, 124 | ); 125 | } 126 | 127 | req.user = user; 128 | next(); 129 | } catch (err) { 130 | next(err); 131 | } 132 | }; 133 | 134 | // Authorization check if the user have rights to do this action 135 | exports.restrictTo = (...roles) => { 136 | return (req, res, next) => { 137 | if (!roles.includes(req.user.role)) { 138 | return next( 139 | new AppError(403, "fail", "You are not allowed to do this action"), 140 | req, 141 | res, 142 | next, 143 | ); 144 | } 145 | next(); 146 | }; 147 | }; 148 | --------------------------------------------------------------------------------