├── .DS_Store ├── .env.example ├── routes ├── v1 │ ├── index.js │ ├── analytic.js │ └── income.js └── index.js ├── repositories ├── analytic.js └── income.js ├── services ├── analytic.js └── income.js ├── controllers ├── analytic.js └── income.js ├── utils ├── paginate.js └── paseto.js ├── config └── config.js ├── models ├── income.js └── index.js ├── middleware ├── errorHandler.js └── auth.js ├── package.json ├── server.js ├── README.md ├── .gitignore └── test └── income.js /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Faeshal/nodejs-layered-architecture/HEAD/.DS_Store -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PORT= 2 | 3 | DB_USERNAME= 4 | DB_PASSWORD= 5 | DB_HOST= 6 | DB_NAME_DEV= 7 | DB_NAME_TEST= 8 | 9 | PASETO_SECRET_KEY= -------------------------------------------------------------------------------- /routes/v1/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | router.use("/", require("./income")); 5 | router.use("/", require("./analytic")); 6 | 7 | module.exports = router; 8 | -------------------------------------------------------------------------------- /routes/v1/analytic.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | const analyticController = require("../../controllers/analytic"); 4 | 5 | router.get("/analytics", analyticController.getAnalytics); 6 | 7 | module.exports = router; 8 | -------------------------------------------------------------------------------- /repositories/analytic.js: -------------------------------------------------------------------------------- 1 | const Income = require("../models").income; 2 | const log = require("log4js").getLogger("repository:analytic"); 3 | log.level = "info"; 4 | 5 | exports.getAnaytics = async () => { 6 | let data = await Income.count(); 7 | return data; 8 | }; 9 | -------------------------------------------------------------------------------- /services/analytic.js: -------------------------------------------------------------------------------- 1 | const analyticRepo = require("../repositories/analytic"); 2 | const log = require("log4js").getLogger("service:analytic"); 3 | log.level = "debug"; 4 | 5 | exports.getAnalytic = async (body) => { 6 | log.info("body:", body); 7 | const data = await analyticRepo.getAnaytics(); 8 | return data; 9 | }; 10 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | 4 | router.use("/api/v1", require("./v1")); 5 | 6 | router.get("/", (req, res, next) => { 7 | res 8 | .status(200) 9 | .json({ success: true, message: "welcome to the Express API" }); 10 | }); 11 | 12 | module.exports = router; 13 | -------------------------------------------------------------------------------- /controllers/analytic.js: -------------------------------------------------------------------------------- 1 | require("pretty-error").start(); 2 | const asyncHandler = require("express-async-handler"); 3 | const analyticService = require("../services/analytic"); 4 | const log = require("log4js").getLogger("controllers:analytic"); 5 | log.level = "debug"; 6 | 7 | // * @route GET /api/v1/analytics 8 | // @desc get analaytics 9 | // @access public 10 | exports.getAnalytics = asyncHandler(async (req, res, next) => { 11 | const data = await analyticService.getAnalytic(); 12 | res.status(200).json({ success: true, data }); 13 | }); 14 | -------------------------------------------------------------------------------- /utils/paginate.js: -------------------------------------------------------------------------------- 1 | const pagination = require("express-paginate"); 2 | const log = require("log4js").getLogger("utils:paginate"); 3 | log.level = "info"; 4 | 5 | async function paginate(options) { 6 | try { 7 | const totalPage = Math.ceil(options.length / options.limit); 8 | let currentPage = parseInt(options.page) || 1; 9 | if (currentPage > totalPage) { 10 | currentPage = totalPage; 11 | } 12 | const nextPage = pagination.hasNextPages(options.req)(totalPage); 13 | return { 14 | totalPage, 15 | currentPage, 16 | nextPage, 17 | }; 18 | } catch (err) { 19 | log.error(err); 20 | return; 21 | } 22 | } 23 | 24 | module.exports = { paginate }; 25 | -------------------------------------------------------------------------------- /config/config.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | module.exports = { 3 | development: { 4 | username: process.env.DB_USERNAME, 5 | password: process.env.DB_PASSWORD, 6 | database: process.env.DB_NAME_DEV, 7 | host: process.env.DB_HOST, 8 | dialect: "mysql", 9 | logging: false, 10 | }, 11 | test: { 12 | username: process.env.DB_USERNAME, 13 | password: process.env.DB_PASSWORD, 14 | database: process.env.DB_NAME_TEST, 15 | host: process.env.DB_HOST, 16 | dialect: "mysql", 17 | }, 18 | production: { 19 | username: "root", 20 | password: null, 21 | database: "database_production", 22 | host: "127.0.0.1", 23 | dialect: "mysql", 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /models/income.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const { Model } = require("sequelize"); 3 | module.exports = (sequelize, DataTypes) => { 4 | class income extends Model { 5 | /** 6 | * Helper method for defining associations. 7 | * This method is not a part of Sequelize lifecycle. 8 | * The `models/index` file will call this method automatically. 9 | */ 10 | static associate(models) { 11 | // define association here 12 | } 13 | } 14 | income.init( 15 | { 16 | name: { 17 | type: DataTypes.STRING, 18 | allowNull: false, 19 | }, 20 | value: { 21 | type: DataTypes.INTEGER, 22 | allowNull: false, 23 | }, 24 | }, 25 | { 26 | sequelize, 27 | modelName: "income", 28 | } 29 | ); 30 | return income; 31 | }; 32 | -------------------------------------------------------------------------------- /services/income.js: -------------------------------------------------------------------------------- 1 | const incomeRepo = require("../repositories/income"); 2 | const log = require("log4js").getLogger("service:income"); 3 | log.level = "debug"; 4 | 5 | exports.add = async (body) => { 6 | log.info("body:", body); 7 | const data = await incomeRepo.add(body); 8 | return data; 9 | }; 10 | 11 | exports.getAll = async (body) => { 12 | log.info("body:", body); 13 | let data = await incomeRepo.getAll(body); 14 | return data; 15 | }; 16 | 17 | exports.getById = async (id) => { 18 | log.info("id:", id); 19 | const data = await incomeRepo.getById(id); 20 | return data; 21 | }; 22 | 23 | exports.update = async (body, id) => { 24 | log.info("body:", body, "- id:", id); 25 | const data = await incomeRepo.update(body, id); 26 | return data; 27 | }; 28 | 29 | exports.delete = async (id) => { 30 | log.info("id:", id); 31 | const data = await incomeRepo.delete(id); 32 | return data; 33 | }; 34 | -------------------------------------------------------------------------------- /middleware/errorHandler.js: -------------------------------------------------------------------------------- 1 | const log = require("log4js").getLogger("middleware:errorHandler"); 2 | log.level = "error"; 3 | 4 | class ErrorResponse extends Error { 5 | constructor(message, statusCode) { 6 | super(message); 7 | this.statusCode = statusCode; 8 | 9 | Error.captureStackTrace(this, this.constructor); 10 | } 11 | } 12 | 13 | const errorHandler = (err, req, res, next) => { 14 | let error = { ...err }; 15 | error.message = err.message; 16 | error.statusCode = err.statusCode; 17 | log.warn(err); 18 | if (err.response) { 19 | if (err.response.data) { 20 | log.warn("axios error detail:", err.response.data); 21 | return res.status(error.statusCode || 500).json({ 22 | success: false, 23 | message: err.response.data.message || "Server Error", 24 | }); 25 | } 26 | } 27 | 28 | res.status(error.statusCode || 500).json({ 29 | success: false, 30 | message: error.message || "Server Error", 31 | }); 32 | }; 33 | 34 | module.exports = { ErrorResponse, errorHandler }; 35 | -------------------------------------------------------------------------------- /repositories/income.js: -------------------------------------------------------------------------------- 1 | const Income = require("../models").income; 2 | const { paginate } = require("../utils/paginate"); 3 | const log = require("log4js").getLogger("repository:income"); 4 | log.level = "info"; 5 | 6 | exports.add = async (body) => { 7 | const data = await Income.create(body); 8 | return data; 9 | }; 10 | 11 | exports.getAll = async (body) => { 12 | const { limit, page, req } = body; 13 | let data = await Income.findAndCountAll(body); 14 | 15 | // * pagination 16 | const pagin = await paginate({ 17 | length: data.count, 18 | limit, 19 | page, 20 | req, 21 | }); 22 | let result = { pagin, data }; 23 | return result; 24 | }; 25 | 26 | exports.getById = async (id) => { 27 | const data = await Income.findOne({ where: { id } }); 28 | return data; 29 | }; 30 | 31 | exports.update = async (body, id) => { 32 | const data = await Income.update(body, { where: { id } }); 33 | return data; 34 | }; 35 | 36 | exports.delete = async (id) => { 37 | const data = await Income.destroy({ where: { id } }); 38 | return data; 39 | }; 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodejs-bulletproof", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha", 8 | "dev": "nodemon server.js" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "compression": "^1.7.4", 15 | "connect-session-sequelize": "^7.1.4", 16 | "cors": "^2.8.5", 17 | "dayjs": "^1.11.4", 18 | "dotenv": "^16.0.1", 19 | "express": "^4.18.1", 20 | "express-async-handler": "^1.2.0", 21 | "express-paginate": "^1.0.2", 22 | "express-session": "^1.17.3", 23 | "express-validator": "^6.14.2", 24 | "helmet": "^5.1.0", 25 | "hpp": "^0.2.3", 26 | "log4js": "^6.6.0", 27 | "morgan": "^1.10.0", 28 | "mysql2": "^2.3.3", 29 | "paseto": "^3.1.4", 30 | "pretty-error": "^4.0.0", 31 | "sequelize": "^6.24.0", 32 | "underscore": "^1.13.4" 33 | }, 34 | "devDependencies": { 35 | "chai": "^4.3.6", 36 | "chai-http": "^4.3.0", 37 | "mocha": "^10.0.0", 38 | "nodemon": "^3.0.1", 39 | "sequelize-cli": "^6.5.1" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /routes/v1/income.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | const incomeController = require("../../controllers/income"); 4 | const { body, param } = require("express-validator"); 5 | const { protect, authorize } = require("../../middleware/auth"); 6 | 7 | router.get("/incomes", incomeController.getIncomes); 8 | router.post( 9 | "/incomes", 10 | [ 11 | body("name", "name is required").not().isEmpty().trim(), 12 | body("value", "value is required & must be an integer") 13 | .not() 14 | .isEmpty() 15 | .isInt(), 16 | ], 17 | incomeController.addIncomes 18 | ); 19 | router.get( 20 | "/incomes/:id", 21 | [param("id", "param must be integer").exists().isNumeric()], 22 | incomeController.getIncome 23 | ); 24 | router.put( 25 | "/incomes/:id", 26 | [ 27 | param("id", "param must be integer").exists().isNumeric(), 28 | body("name", "name is required").not().isEmpty().trim(), 29 | body("value", "value is required & must be an integer") 30 | .not() 31 | .isEmpty() 32 | .isInt(), 33 | ], 34 | incomeController.updateIncome 35 | ); 36 | router.delete( 37 | "/incomes/:id", 38 | [param("id", "param must be integer").exists().isNumeric()], 39 | incomeController.deleteIncome 40 | ); 41 | 42 | module.exports = router; 43 | -------------------------------------------------------------------------------- /middleware/auth.js: -------------------------------------------------------------------------------- 1 | require("pretty-error").start(); 2 | const { ErrorResponse } = require("../middleware/errorHandler"); 3 | const { verifyToken } = require("../utils/paseto"); 4 | const log = require("log4js").getLogger("middleware:auth"); 5 | log.level = "info"; 6 | 7 | exports.protect = async (req, res, next) => { 8 | try { 9 | let token; 10 | if ( 11 | req.headers.authorization && 12 | req.headers.authorization.startsWith("Bearer") 13 | ) { 14 | token = req.headers.authorization.split(" ")[1]; 15 | } 16 | if (!token) { 17 | return next(new ErrorResponse("unauthorized, token is empty", 401)); 18 | } 19 | 20 | // * Verify Paseto Token 21 | const decoded = await verifyToken(token); 22 | if (decoded.success == false) { 23 | return next(new ErrorResponse("unauthorized or expired token", 401)); 24 | } 25 | 26 | req.user = decoded.data; 27 | next(); 28 | } catch (err) { 29 | log.error(err); 30 | return res 31 | .status(401) 32 | .json({ success: false, message: "unauthorized or expired token" }); 33 | } 34 | }; 35 | 36 | exports.authorize = (...roles) => { 37 | return (req, res, next) => { 38 | if (!roles.includes(req.user.role)) { 39 | return res 40 | .status(401) 41 | .json({ success: false, message: "role not authorize" }); 42 | } 43 | next(); 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /utils/paseto.js: -------------------------------------------------------------------------------- 1 | const { V3 } = require("paseto"); 2 | const log = require("log4js").getLogger("utils:paseto"); 3 | log.level = "info"; 4 | 5 | const generateToken = async (payload) => { 6 | try { 7 | log.info("payload", payload); 8 | 9 | // generate secret key 10 | // const genSecret = await V3.generateKey("local", { format: "paserk" }); 11 | // log.warn("secret key :", genSecret); 12 | 13 | // local paseto strategy 14 | const token = await V3.encrypt(payload, process.env.PASETO_SECRET_KEY, { 15 | expiresIn: "24h", 16 | }); 17 | 18 | return { 19 | success: true, 20 | statusCode: 200, 21 | message: "ok", 22 | data: token, 23 | }; 24 | } catch (err) { 25 | log.error(err); 26 | return { 27 | success: false, 28 | statusCode: 500, 29 | message: "generate token failed", 30 | }; 31 | } 32 | }; 33 | 34 | const verifyToken = async (token) => { 35 | try { 36 | const decoded = await V3.decrypt(token, process.env.PASETO_SECRET_KEY); 37 | log.warn("decoded:", decoded); 38 | return { 39 | success: true, 40 | statusCode: 200, 41 | message: "token valid", 42 | data: decoded, 43 | }; 44 | } catch (err) { 45 | log.error(err); 46 | return { 47 | success: false, 48 | statusCode: 500, 49 | message: "invalid / expired token", 50 | }; 51 | } 52 | }; 53 | 54 | module.exports = { generateToken, verifyToken }; 55 | -------------------------------------------------------------------------------- /models/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const fs = require("fs"); 4 | const path = require("path"); 5 | const Sequelize = require("sequelize"); 6 | const basename = path.basename(__filename); 7 | const env = process.env.NODE_ENV || "development"; 8 | const config = require(__dirname + "/../config/config.js")[env]; 9 | const db = {}; 10 | const log = require("log4js").getLogger("models:index"); 11 | log.level = "info"; 12 | 13 | let sequelize; 14 | if (config.use_env_variable) { 15 | sequelize = new Sequelize(process.env[config.use_env_variable], config); 16 | } else { 17 | sequelize = new Sequelize( 18 | config.database, 19 | config.username, 20 | config.password, 21 | config 22 | ); 23 | } 24 | 25 | (async () => { 26 | try { 27 | await sequelize.sync(); 28 | // await sequelize.authenticate(); 29 | // await sequelize.sync({ alter: true }); 30 | log.info("Maria Connected ✅"); 31 | } catch (error) { 32 | log.error("Maria Connection Failure 🔥", error); 33 | process.exit(1); 34 | } 35 | })(); 36 | 37 | fs.readdirSync(__dirname) 38 | .filter((file) => { 39 | return ( 40 | file.indexOf(".") !== 0 && file !== basename && file.slice(-3) === ".js" 41 | ); 42 | }) 43 | .forEach((file) => { 44 | const model = require(path.join(__dirname, file))( 45 | sequelize, 46 | Sequelize.DataTypes 47 | ); 48 | db[model.name] = model; 49 | }); 50 | 51 | Object.keys(db).forEach((modelName) => { 52 | if (db[modelName].associate) { 53 | db[modelName].associate(db); 54 | } 55 | }); 56 | 57 | db.sequelize = sequelize; 58 | db.Sequelize = Sequelize; 59 | 60 | module.exports = db; 61 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | require("dotenv").config(); 3 | require("pretty-error").start(); 4 | const express = require("express"); 5 | const app = express(); 6 | const PORT = process.env.PORT || 3000; 7 | const morgan = require("morgan"); 8 | const cors = require("cors"); 9 | const compression = require("compression"); 10 | const hpp = require("hpp"); 11 | const helmet = require("helmet"); 12 | const log4js = require("log4js"); 13 | const paginate = require("express-paginate"); 14 | const dayjs = require("dayjs"); 15 | const { errorHandler } = require("./middleware/errorHandler"); 16 | const log = log4js.getLogger("entrypoint"); 17 | log.level = "info"; 18 | 19 | // * Security, Compression & Parser 20 | app.use(helmet()); 21 | app.use(hpp()); 22 | app.use(cors()); 23 | app.use(compression()); 24 | app.use(express.json()); 25 | app.use(express.urlencoded({ extended: true })); 26 | 27 | // * Http Logger 28 | morgan.token("time", (req) => { 29 | let user = "anonym"; 30 | if (req.user) { 31 | if (req.user.name) { 32 | user = req.user.name || "anonym"; 33 | } 34 | } 35 | const time = dayjs().format("h:mm:ss A") + " - " + user; 36 | return time; 37 | }); 38 | app.use(morgan("morgan: [:time] :method :url - :status")); 39 | 40 | // * Paginate 41 | app.use(paginate.middleware(10, 30)); 42 | 43 | // * Route 44 | app.use(require("./routes")); 45 | 46 | // * Custom Error Handler 47 | app.use(errorHandler); 48 | 49 | // * Rolling Log 50 | let layoutConfig = { 51 | type: "pattern", 52 | pattern: "%x{id}: [%x{info}] %p %c - %[%m%]", 53 | tokens: { 54 | id: () => { 55 | return Date.now(); 56 | }, 57 | info: (req) => { 58 | const info = dayjs().format("D/M/YYYY h:mm:ss A"); 59 | return info; 60 | }, 61 | }, 62 | }; 63 | log4js.configure({ 64 | appenders: { 65 | app: { 66 | type: "dateFile", 67 | filename: "./logs/app.log", 68 | numBackups: 3, 69 | layout: layoutConfig, 70 | maxLogSize: 7000000, // byte == 3mb 71 | }, 72 | console: { 73 | type: "console", 74 | layout: layoutConfig, 75 | }, 76 | }, 77 | categories: { 78 | default: { appenders: ["app", "console"], level: "debug" }, 79 | }, 80 | }); 81 | 82 | // * Server Listen 83 | app.listen(PORT, (err) => { 84 | if (err) { 85 | log.error(`Error : ${err}`); 86 | process.exit(1); 87 | } 88 | log.info(`Server is Running On Port : ${PORT}`); 89 | }); 90 | 91 | module.exports = app; 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 🥇 Service Layer Architecture for Node.js Boilerplate REST API with **[Sequelize](https://sequelize.org/)** ORM 2 | 3 | ![img](https://cdn.buttercms.com/MeGKGWTZRZmCh0pNgSNP) 4 | 5 | 🌴 Looking for **Typescript** implementation ? **[nodets-layered-architecture](https://github.com/Faeshal/nodets-layered-architecture)** 6 | 7 | ❓ As you already know Express.js is an **unopinionated** framework, this means that developer free to determine how to structure the project. in Contrast with **opinionated** framework like Laravel or SpringBoot where developers are forced to follow their existing standard rules. However, one of the drawbacks of unopinionated framework is to finding best practices. There are no definite rules on how the project should be structure, each developer has own style to determining it. So i created this template as a backend boilerplate project that i usually use. I call this structure Service Layer Architecture & i will continuously update it when needed. 8 | 9 | 💡 There are 3 main layers: 10 | 11 | 1. Controller layer (for request handler) 🌐 12 | 13 | This is the module of your code where the API routes are defined. Here you define only, and only your API routes. In the route handler functions, you can deconstruct the request object, pick the important data pieces and pass them to the service layer for processing. 14 | 15 | 2. Service layer (for business logic) 🚀 16 | 17 | This is where your business logic lives, even the secret sauce of your application. It contains a bunch of classes and methods that take up singular responsibility and are reusable (and also follow other S.O.L.I.D programming principles). This layer allows you to effectively decouple the processing logic from where the routes are defined. 18 | 19 | 3. Data Access Layer / Repository (for interacting with the database)🛡️ 20 | 21 | The Data Access layer can take up the responsibility of talking to the database - fetching from, writing to, and updating it. All your SQL queries, database connections, models, ORM (object-relational mappers), etc. are supposed to be defined here. In this version i use an sql database with Sequelize ORM. So if you use NoSQL database or other ORM you can customize it, basically the concept is the same, you must create a repository layer. 22 | 23 | This three-layer setup serves as a reliable scaffolding for most Node.js applications, making your applications easier to code, maintain, debug and test. 24 | 25 | 🗡 **July 2022 - [faeshal.com](https://faeshal.com)** 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* -------------------------------------------------------------------------------- /controllers/income.js: -------------------------------------------------------------------------------- 1 | require("pretty-error").start(); 2 | const asyncHandler = require("express-async-handler"); 3 | const incomeService = require("../services/income"); 4 | const { ErrorResponse } = require("../middleware/errorHandler"); 5 | const { validationResult } = require("express-validator"); 6 | const log = require("log4js").getLogger("controllers:income"); 7 | log.level = "debug"; 8 | 9 | // * @route GET /api/v1/incomes 10 | // @desc get incomes 11 | // @access public 12 | exports.getIncomes = asyncHandler(async (req, res, next) => { 13 | const data = await incomeService.getAll({ 14 | limit: req.query.limit, 15 | offset: req.skip, 16 | order: [["createdAt", "DESC"]], 17 | req, 18 | }); 19 | res.status(200).json({ 20 | success: true, 21 | totalData: data.data.count, 22 | totalPage: data.pagin.totalPage, 23 | currentPage: data.pagin.currentPage, 24 | nextPage: data.pagin.nextPage, 25 | data: data.data.rows || [], 26 | }); 27 | }); 28 | 29 | // * @route POST /api/v1/incomes 30 | // @desc add new incomes 31 | // @access public 32 | exports.addIncomes = asyncHandler(async (req, res, next) => { 33 | log.info("body:", req.body); 34 | // * Validator 35 | const errors = validationResult(req); 36 | if (!errors.isEmpty()) { 37 | return next( 38 | new ErrorResponse(errors.array({ onlyFirstError: true })[0].msg, 400) 39 | ); 40 | } 41 | await incomeService.add(req.body); 42 | res.status(201).json({ success: true, message: "income create" }); 43 | }); 44 | 45 | // * @route GET /api/v1/incomes/:id 46 | // @desc get income by id 47 | // @access public 48 | exports.getIncome = asyncHandler(async (req, res, next) => { 49 | const { id } = req.params; 50 | // *Express Validator 51 | const errors = validationResult(req); 52 | if (!errors.isEmpty()) { 53 | return next( 54 | new ErrorResponse(errors.array({ onlyFirstError: true })[0].msg, 400) 55 | ); 56 | } 57 | const data = await incomeService.getById(id); 58 | res.status(200).json({ success: true, data: data || {} }); 59 | }); 60 | 61 | // * @route PUT /api/v1/incomes/:id 62 | // @desc update income by id 63 | // @access public 64 | exports.updateIncome = asyncHandler(async (req, res, next) => { 65 | log.info("body:", req.body); 66 | const { id } = req.params; 67 | // *Express Validator 68 | const errors = validationResult(req); 69 | if (!errors.isEmpty()) { 70 | return next( 71 | new ErrorResponse(errors.array({ onlyFirstError: true })[0].msg, 400) 72 | ); 73 | } 74 | 75 | // * check valid id 76 | const isValid = await incomeService.getById(id); 77 | if (!isValid) { 78 | return next(new ErrorResponse("invalid id", 400)); 79 | } 80 | 81 | // * call update service 82 | await incomeService.update(req.body, id); 83 | 84 | res.status(200).json({ success: true, message: "update success" }); 85 | }); 86 | 87 | // * @route DELETE /api/v1/incomes/:id 88 | // @desc delete income by id 89 | // @access public 90 | exports.deleteIncome = asyncHandler(async (req, res, next) => { 91 | const { id } = req.params; 92 | // *Express Validator 93 | const errors = validationResult(req); 94 | if (!errors.isEmpty()) { 95 | return next( 96 | new ErrorResponse(errors.array({ onlyFirstError: true })[0].msg, 400) 97 | ); 98 | } 99 | 100 | // * check valid id 101 | const isValid = await incomeService.getById(id); 102 | log.info("isvalid", isValid); 103 | if (!isValid) { 104 | return next(new ErrorResponse("invalid id", 400)); 105 | } 106 | 107 | // * call delete service 108 | await incomeService.delete(id); 109 | 110 | res.status(200).json({ success: true, message: "delete success" }); 111 | }); 112 | -------------------------------------------------------------------------------- /test/income.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | process.env.NODE_ENV = "test"; 3 | const server = require("../server"); 4 | const incomeService = require("../services/income"); 5 | const chai = require("chai"); 6 | const chaiHttp = require("chai-http"); 7 | const log = require("log4js").getLogger("test:income"); 8 | log.level = "debug"; 9 | chai.should(); 10 | chai.expect(); 11 | chai.use(chaiHttp); 12 | 13 | describe("Income API", () => { 14 | // ** GET /api/v1/incomes 15 | describe("GET /api/v1/incomes", () => { 16 | it("It should GET all the income", (done) => { 17 | chai 18 | .request(server) 19 | .get("/api/v1/incomes") 20 | .end((err, res) => { 21 | res.should.have.status(200); 22 | res.body.should.be.a("object"); 23 | res.body.should.have.property("success").eq(true); 24 | res.body.should.have.property("data").to.be.an("array"); 25 | done(); 26 | }); 27 | }); 28 | 29 | it("It should NOT GET all the income", (done) => { 30 | chai 31 | .request(server) 32 | .get("/api/v1/income") 33 | .end((err, res) => { 34 | res.should.have.status(404); 35 | done(); 36 | }); 37 | }); 38 | }); 39 | 40 | // ** POST /api/v1/incomes 41 | describe("POST /api/v1/incomes", () => { 42 | it("it should POST an income ", async () => { 43 | let body = { 44 | name: "sell cocacola", 45 | value: Math.floor(Math.random() * 101), 46 | }; 47 | const res = await chai.request(server).post("/api/v1/incomes").send(body); 48 | res.should.have.status(201); 49 | res.body.should.be.a("object"); 50 | res.body.should.have.property("success").eq(true); 51 | res.body.should.have.property("message"); 52 | }); 53 | 54 | it("it should not POST an income without value field", async () => { 55 | let body = { 56 | name: "buy cocacola", 57 | }; 58 | const res = await chai.request(server).post("/api/v1/incomes").send(body); 59 | res.should.have.status(400); 60 | res.body.should.be.a("object"); 61 | res.body.should.have.property("success").eq(false); 62 | res.body.should.have.property("message"); 63 | }); 64 | }); 65 | 66 | // ** GET /api/v1/incomes/:id 67 | describe("GET /api/v1/incomes/:id", () => { 68 | it("it should GET an income by id", async () => { 69 | const id = 6; 70 | const res = await chai.request(server).get(`/api/v1/incomes/${id}`); 71 | res.should.have.status(200); 72 | res.body.should.be.a("object"); 73 | res.body.should.have.property("success").eq(true); 74 | res.body.should.have.property("data").to.be.an("object"); 75 | }); 76 | 77 | it("it should not GET an income by id without numeric params", async () => { 78 | const id = "x"; 79 | const res = await chai.request(server).get(`/api/v1/incomes/${id}`); 80 | res.should.have.status(400); 81 | res.body.should.be.a("object"); 82 | res.body.should.have.property("success").eq(false); 83 | res.body.should.have.property("message"); 84 | }); 85 | }); 86 | 87 | // ** PUT /api/v1/incomes/:id 88 | describe("PUT /api/v1/incomes/:id", () => { 89 | it("it should PUT an income", async () => { 90 | // create the data first 91 | const result = await incomeService.add({ 92 | name: "income property", 93 | value: Math.floor(Math.random() * 101), 94 | }); 95 | const { id } = result.dataValues; 96 | // then update with new data 97 | const body = { 98 | name: "passive income property", 99 | value: Math.floor(Math.random() * 101), 100 | }; 101 | const res = await chai 102 | .request(server) 103 | .put(`/api/v1/incomes/${id}`) 104 | .send(body); 105 | res.should.have.status(200); 106 | res.body.should.be.a("object"); 107 | res.body.should.have.property("success").eq(true); 108 | res.body.should.have.property("message"); 109 | }); 110 | 111 | it("it should NOT PUT an income with non numeric value field", async () => { 112 | // create the data first 113 | const result = await incomeService.add({ 114 | name: "sell ticket", 115 | value: Math.floor(Math.random() * 101), 116 | }); 117 | const { id } = result.dataValues; 118 | // update with wrong data value 119 | const body = { 120 | name: "buy cocacola", 121 | value: "$900", 122 | }; 123 | const res = await chai 124 | .request(server) 125 | .put(`/api/v1/incomes/${id}`) 126 | .send(body); 127 | res.should.have.status(400); 128 | res.body.should.be.a("object"); 129 | res.body.should.have.property("success").eq(false); 130 | res.body.should.have.property("message"); 131 | }); 132 | }); 133 | 134 | // ** DELETE /api/v1/incomes/:id 135 | describe("DELETE /api/v1/incomes/:id", () => { 136 | it("it should DElETE an income", async function () { 137 | // create the data first 138 | const result = await incomeService.add({ 139 | name: "passive income property", 140 | value: Math.floor(Math.random() * 101), 141 | }); 142 | const { id } = result.dataValues; 143 | // than delete 144 | const res = await chai.request(server).delete(`/api/v1/incomes/${id}`); 145 | res.should.have.status(200); 146 | res.body.should.be.a("object"); 147 | res.body.should.have.property("success").eq(true); 148 | res.body.should.have.property("message"); 149 | }); 150 | 151 | it("it should NOT DELETE an income with invalid id", async () => { 152 | const id = 0; 153 | const res = await chai.request(server).delete(`/api/v1/incomes/${id}`); 154 | res.should.have.status(400); 155 | res.body.should.be.a("object"); 156 | res.body.should.have.property("success").eq(false); 157 | res.body.should.have.property("message"); 158 | }); 159 | }); 160 | }); 161 | --------------------------------------------------------------------------------