├── .dockerignore ├── .eslintignore ├── .eslintrc.cjs ├── .github ├── FUNDING.yml └── workflows │ ├── push-image.yaml │ └── snyk-scan.yaml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc.js ├── .sequelizerc ├── .vscode └── settings.json ├── Dockerfile ├── README.md ├── cli ├── .necarc ├── assets │ ├── controller.js │ ├── module.js │ ├── routes.js │ ├── service.js │ └── validator.js ├── commands │ └── component.js ├── core │ └── yargs.js ├── helpers │ ├── asset-helper.js │ ├── index.js │ ├── init-helper.js │ ├── path-helper.js │ ├── template-helper.js │ └── view-helper.js └── index.js ├── config ├── custom-environment-variables.yaml ├── dbConfig.js ├── default.yaml ├── development.yaml ├── keys │ ├── private.key │ └── public.key ├── production.yaml └── test.yaml ├── healthcheck.js ├── jest.config.js ├── package.json ├── pnpm-lock.yaml ├── src ├── app.js ├── db │ └── models │ │ ├── User.js │ │ └── index.js ├── jsconfig.json ├── loaders │ ├── config.js │ ├── index.js │ └── routes.js ├── middlewares │ ├── auth.js │ ├── authorize.js │ ├── error-handler.js │ ├── express-callback.js │ ├── index.js │ ├── logRoutes.js │ ├── not-found-error.js │ ├── validate-json.js │ └── validator-callback.js ├── modules │ ├── app-health │ │ ├── app-health.controller.js │ │ ├── app-health.module.js │ │ ├── app-health.routes.js │ │ └── app-health.service.js │ ├── auth │ │ ├── auth.controller.js │ │ ├── auth.module.js │ │ ├── auth.routes.js │ │ ├── auth.service.js │ │ ├── auth.types.js │ │ ├── auth.validator.js │ │ └── jwt.service.js │ ├── typedefs.js │ └── user │ │ └── user.module.js ├── server.js ├── support │ └── logger.js └── utils │ ├── api-errors.js │ ├── graceful-shutdown.js │ ├── helper.js │ ├── httpStatus.js │ └── normalize-port.js └── tests ├── fixtures └── user.fixture.js ├── middlewares ├── auth.test.js ├── authorize.test.js ├── error-handler.test.js ├── express-callback.test.js ├── not-found.error.test.js ├── validate-json.test.js └── validator-callback.test.js ├── modules └── auth │ ├── auth.controller.test.js │ ├── auth.service.test.js │ └── jwt.service.test.js ├── support └── logger.test.js └── utils ├── api-error.test.js ├── graceful-shutdown.test.js ├── helper.test.js └── normalize-port.test.js /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | *.log 3 | .idea 4 | .nyc_output 5 | *Dockerfile* 6 | node_modules 7 | *docker-compose* 8 | 9 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | cli/**/*.js -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true 5 | }, 6 | extends: ['standard', 'eslint:recommended', 'plugin:jest/recommended'], 7 | overrides: [ 8 | { 9 | env: { 10 | node: true, 11 | 'jest/globals': true 12 | }, 13 | files: [ 14 | '.eslintrc.{js,cjs}' 15 | ], 16 | parserOptions: { 17 | sourceType: 'script' 18 | } 19 | } 20 | ], 21 | parserOptions: { 22 | ecmaVersion: 'latest', 23 | sourceType: 'module' 24 | }, 25 | plugins: ['jest'] 26 | } 27 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/push-image.yaml: -------------------------------------------------------------------------------- 1 | name: Create and publish a Docker image 2 | 3 | on: 4 | push: 5 | branches: ['master'] 6 | 7 | jobs: 8 | build-and-push-image: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | packages: write 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | - name: Get Node v20 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: 20 20 | - name: Install pnpm 21 | run: npm install -g pnpm 22 | - name: Install dependencies 23 | run: pnpm install 24 | - name: Run unit tests 25 | run: pnpm test 26 | - name: Log in to GitHub Docker Registry 27 | uses: docker/login-action@v2 28 | with: 29 | registry: docker.pkg.github.com 30 | username: ${{ github.actor }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | - name: Build and push Docker image 33 | uses: docker/build-push-action@v4 34 | with: 35 | push: true 36 | tags: | 37 | docker.pkg.github.com/${{ github.repository }}/node-express-modular-architecture:${{ github.sha }} 38 | -------------------------------------------------------------------------------- /.github/workflows/snyk-scan.yaml: -------------------------------------------------------------------------------- 1 | name: Snyk Scan 2 | on: 3 | pull_request: 4 | types: [opened, synchronize, reopened] 5 | jobs: 6 | security: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v3 11 | - name: Get Node v20 12 | uses: actions/setup-node@v3 13 | with: 14 | node-version: 20 15 | - name: Install dependencies 16 | run: npm install 17 | - name: Run Snyk to check for vulnerabilities 18 | uses: snyk/actions/node@master 19 | continue-on-error: true 20 | env: 21 | SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} 22 | with: 23 | command: code test --sarif 24 | args: --sarif-file-output=snyk.sarif 25 | 26 | # Push the Snyk Code results into GitHub Code Scanning tab 27 | - name: Upload result to GitHub Code Scanning 28 | uses: github/codeql-action/upload-sarif@v2 29 | with: 30 | sarif_file: snyk.sarif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nyc_output 3 | .idea 4 | logs 5 | coverage 6 | .DS_Store 7 | ### VisualStudioCode ### 8 | .vscode/* 9 | !.vscode/settings.json 10 | !.vscode/tasks.json 11 | !.vscode/launch.json 12 | !.vscode/extensions.json -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm test 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | singleQuote: true, 5 | trailingComma: 'none', 6 | bracketSpacing: true, 7 | jsxBracketSameLine: false, 8 | printWidth: 120, 9 | tabWidth: 2, 10 | useTabs: false, 11 | semi: true, 12 | parser: 'flow' 13 | }; 14 | -------------------------------------------------------------------------------- /.sequelizerc: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | config: path.resolve('src/config', 'config.js'), 5 | 'models-path': path.resolve('src/db', 'models'), 6 | 'seeders-path': path.resolve('src/db', 'seeders'), 7 | 'migrations-path': path.resolve('src/db', 'migrations') 8 | }; 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "docwriter.style": "Auto-detect" 3 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG HUSKY=0 2 | 3 | FROM node:20-alpine AS base 4 | ENV NODE_ENV=production 5 | EXPOSE 3000 6 | RUN npm install -g pnpm 7 | RUN mkdir /app && chown -R node:node /app 8 | WORKDIR /app 9 | USER node 10 | COPY --chown=node:node package.json package-lock*.json ./ 11 | RUN pnpm install 12 | ENV PATH /app/node_modules/.bin:$PATH 13 | # check every 30s to ensure this service returns HTTP 200 14 | HEALTHCHECK --interval=30s CMD node healthcheck.js 15 | 16 | FROM base as source 17 | COPY --chown=node:node . . 18 | 19 | FROM source as dev 20 | ENV NODE_ENV=development 21 | RUN pnpm install --only=development 22 | CMD ["nodemon", "server.js"] 23 | 24 | FROM source as prod 25 | CMD ["node", "src/server.js"] 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Node Express Modular Architecture 3 | A boilerplate/starter project for quickly building RESTful APIs using Node.js, Express, and sequelize. 4 | 5 | It incorporates the latest technologies, such as `pnpm` for package management, `jest` for test cases, and `eslint` for enforcing coding guidelines and best practices. 6 | 7 | 8 | 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. 9 | 10 | ## Deployment and Production Readiness 11 | 12 | This repository is designed to be easily deployed to production environments using Docker. The following steps are performed automatically by the Docker actions configured in this repository: 13 | 14 | - **Building Docker Image:** The Docker action builds a Docker image for this application using the provided Dockerfile. 15 | - **Running Unit Tests:** Before the Docker image is built and pushed, the Docker action runs the unit tests to ensure the codebase is functioning as expected. 16 | - **Pushing Docker Image:** Once the unit tests pass, the Docker image is pushed to a container registry, making it ready for deployment. 17 | - **Production Ready:** With the Docker image available in the container registry, this repository is production-ready, and the image can be deployed to production environments at any time. 18 | 19 | By leveraging Docker and the automated workflows defined in this repository, you can ensure consistent and reliable deployments of this application, along with the confidence of passing unit tests before moving to production. 20 | 21 | ### Prerequisites 22 | 23 | Before deploying this application using Docker, make sure you have the following prerequisites installed: 24 | 25 | - Docker: [Installation Guide](https://docs.docker.com/get-docker/) 26 | - Container Registry Credentials: Ensure you have the necessary credentials to push Docker images to your container registry. 27 | 28 | ### Deployment Steps 29 | 30 | To deploy this application to a production environment using Docker, follow these steps: 31 | 32 | 1. Clone this repository: `git clone ` 33 | 2. Navigate to the repository's root directory: `cd ` 34 | 3. Build the Docker image: `docker build -t .` 35 | 4. Push the Docker image to your container registry: `docker push ` 36 | 5. Deploy the Docker image to your production environment using the container registry and deployment tooling of your choice. 37 | 38 | Make sure to replace ``, ``, and `` with the appropriate values for your setup. 39 | 40 | ### Continuous Integration and Delivery (CI/CD) 41 | 42 | This repository is equipped with continuous integration and delivery capabilities using Docker actions. With every code change and pull request, the defined workflows will automatically run the unit tests and perform the necessary steps for building and pushing the Docker image. This ensures that the application is always production-ready and provides a streamlined process for deploying updates. 43 | 44 | To configure or customize the CI/CD workflows, refer to the `.github/workflows` directory in this repository and modify the workflow files according to your requirements. 45 | 46 | ## Manual Installation 47 | 48 | If you would still prefer to do the installation manually, follow these steps: 49 | 50 | Clone the repo: 51 | 52 | ```bash 53 | git clone --depth 1 https://github.com/sujeet-agrahari/node-express-modular-architecture.git 54 | cd node-express-modular-architecture 55 | npx rimraf ./.git 56 | ``` 57 | 58 | Install the dependencies: 59 | 60 | ```bash 61 | pnpm install 62 | ``` 63 | 64 | Set the environment variables: 65 | 66 | ```bash 67 | # open .env and modify the environment variables (if needed) 68 | ``` 69 | 70 | ## Table of Contents 71 | 72 | - [Node Express Modular Architecture](#node-express-modular-architecture) 73 | - [Deployment and Production Readiness](#deployment-and-production-readiness) 74 | - [Prerequisites](#prerequisites) 75 | - [Deployment Steps](#deployment-steps) 76 | - [Continuous Integration and Delivery (CI/CD)](#continuous-integration-and-delivery-cicd) 77 | - [Manual Installation](#manual-installation) 78 | - [Table of Contents](#table-of-contents) 79 | - [Features](#features) 80 | - [Commands](#commands) 81 | - [Environment Variables](#environment-variables) 82 | - [Project Structure](#project-structure) 83 | - [CLI Support](#cli-support) 84 | - [API Documentation](#api-documentation) 85 | - [API Endpoints](#api-endpoints) 86 | - [Error Handling](#error-handling) 87 | - [Validation](#validation) 88 | - [Authentication](#authentication) 89 | - [Authorization](#authorization) 90 | - [Logging](#logging) 91 | - [Linting](#linting) 92 | - [Contributing](#contributing) 93 | - [Inspirations](#inspirations) 94 | - [License](#license) 95 | 96 | ## Features 97 | 98 | - **Postgresql**: [Sequelize](https://sequelize.org/) 99 | - **Authentication and authorization**: using [jsonwebtoken](https://www.npmjs.com/package/jsonwebtoken) 100 | - **Validation**: request data validation using [Joi](https://github.com/hapijs/joi) 101 | - **Logging**: using [winston](https://github.com/winstonjs/winston) 102 | - **Testing**: unit and integration tests using [ava](https://jestjs.io) 103 | - **Error handling**: centralized error handling mechanism [express-async-errors](https://www.npmjs.com/package/express-async-errors) 104 | - **API documentation**: with [swagger-jsdoc](https://github.com/Surnet/swagger-jsdoc) and [swagger-ui-express](https://github.com/scottie1984/swagger-ui-express) 105 | - **Process management**: advanced production process management using [PM2](https://pm2.keymetrics.io) 106 | - **Dependency management**: with [npm](https://www.npmjs.com/) 107 | - **Environment variables**: using [dotenv](https://github.com/motdotla/dotenv) and [node-config](https://www.npmjs.com/package/config) 108 | - **CORS**: Cross-Origin Resource-Sharing enabled using [cors](https://github.com/expressjs/cors) 109 | - **Graceful Shutdown**: with [stoppable](https://www.npmjs.com/package/stoppable) releases database connection and other resource before closing the server also _listens_ for signals like `SIGINT` and `SIGTERM` 110 | 111 | 112 | ## Commands 113 | 114 | Running locally: 115 | 116 | ```bash 117 | pnpm start 118 | ``` 119 | 120 | Running in production: 121 | 122 | ```bash 123 | # set NODE_ENV=production in .env 124 | pnpm start 125 | ``` 126 | Docker: 127 | 128 | ```bash 129 | # development 130 | docker build ---target dev -t sujeet-agrahari/your-project:latest 131 | 132 | #production 133 | docker build --target prod -t sujeet-agrahari/your-project:latest 134 | ``` 135 | 136 | Linting: 137 | 138 | ```bash 139 | # run ESLint 140 | pnpm run lint 141 | 142 | # fix ESLint errors 143 | pnpm run lint:fix 144 | ``` 145 | 146 | ## Environment Variables 147 | 148 | The environment variables can be found and modified in the `.env` file. They come with these default values: 149 | 150 | ```bash 151 | # Port number 152 | PORT=3000 153 | 154 | # Set environment 155 | NODE_ENV=development 156 | 157 | # node-config directory path 158 | NODE_CONFIG_DIR=./src/config 159 | ``` 160 | 161 | ## Project Structure 162 | 163 | ``` 164 | src/ 165 | |--config/ # Environment variables and configuration-related files. 166 | |--components/ # Contains individual components. 167 | |--component.module.js # Entry file for the component. 168 | |--component.controller.js # Controller for the component. 169 | |--component.service.js # Service for the component. 170 | |--component.routes.js # Routes for the component. 171 | |--component.validator.js # Validators for the component. 172 | |--docs/ # Swagger files for API documentation. 173 | |--middlewares/ # Custom Express middlewares. 174 | |--db/ # Sequelize ORM files for the data layer. 175 | |--models/ # Sequelize models. 176 | |--seeders/ # Sequelize seeders. 177 | |--migrations/ # Sequelize migrations. 178 | |--utils/ # Utility classes and functions. 179 | |--loaders/ # Lodash routes and configurations; also validates configurations. 180 | |--support/ # Wrapper for used packages, enabling easy package replacement. 181 | |--app.js # Express app setup. 182 | |--server.js # Entry point for the application. 183 | ``` 184 | ## CLI Support 185 | node-express-modular-architecture comes with `cli` support. Instead of creating components and files manully you can use command line tool to automate the process. 186 | 187 | To create a new component simply run, 188 | ``` 189 | npm run create:component -- --name="ComponentNameHere" 190 | ``` 191 | 192 | ## API Documentation 193 | 194 | 195 | ### API Endpoints 196 | 197 | List of available routes: 198 | 199 | **Auth routes**:\ 200 | `POST /v1/auth/register` - register 201 | `POST /v1/auth/login` - login 202 | 203 | ## Error Handling 204 | 205 | The app has a centralized error handling mechanism, no `try{..} catch() {..}` 206 | 207 | All errors are captured, logged and managed through one file: `src/middlewares/error.js` 208 | 209 | It will log errors, debugs, and infos on console in `development` mode, and in production mode it also logs to files: `logs/errors.log` and `logs/combined.log` 210 | 211 | 212 | The error handling middleware sends an error response, which has the following format: 213 | 214 | ``` 215 | { 216 | error: { 217 | "code": 404, 218 | "message": "Not found" 219 | } 220 | } 221 | ``` 222 | 223 | When running in development mode, the error response also contains the error stack. 224 | 225 | The app has a utility ApiError class to which you can attach a response code and a message, and then throw it from anywhere 226 | 227 | For example, if you are trying to get a user from the DB who is not found, and you want to send a 404 error, the code should look something like: 228 | 229 | ```javascript 230 | const { NodeFoundError } = require('../utils/api-errors'); 231 | const { User } = require('../db/models'); 232 | 233 | const getUser = async (userId) => { 234 | const user = await User.findByPk(userId); 235 | if (!user) { 236 | throw new NotFoundError('User not found'); 237 | } 238 | }; 239 | ``` 240 | 241 | ## Validation 242 | 243 | Request data is validated using [Joi](https://joi.dev/). Check the [documentation](https://joi.dev/api/) for more details on how to write Joi validation schemas. 244 | 245 | The validation schemas are defined in the `src/components/{eachComponent}/validations` directory and are used in the routes by providing them as parameters to the `validate` middleware. 246 | 247 | ```javascript 248 | router.post( 249 | '/login', 250 | makeValidatorCallback(AuthValidator.validateLogin), 251 | makeExpressCallback(AuthController.login) 252 | ); 253 | ``` 254 | 255 | ## Authentication 256 | 257 | To require authentication for certain routes, you can use the `auth` middleware. 258 | 259 | ```javascript 260 | // Routes 261 | const { AuthRoutes } = require('../components/Auth/auth.module'); 262 | module.exports = function getRoutes(app) { 263 | app.use('/api/v1/auth', AuthRoutes); 264 | }; 265 | ``` 266 | 267 | 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. 268 | 269 | **Generating Access Tokens**: 270 | 271 | An access token can be generated by making a successful call to the register (`POST /v1/auth/register`) or login (`POST /v1/auth/login`) endpoints. The response of these endpoints also contains refresh tokens (explained below). 272 | 273 | An access token is valid for one day. You can modify this expiration time by changing the config variable in the `config/default.yaml` file. 274 | ``` 275 | JWT_SIGN_OPTIONS: 276 | issuer: My Compny Pvt Ltd 277 | audience: https://example.in 278 | expiresIn: 1d 279 | ``` 280 | 281 | **Refreshing Access Tokens**: 282 | 283 | After the access token expires, a new access token can be generated, by making a call to the refresh token endpoint (`POST /v1/auth/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. 284 | 285 | An access token is valid for one day. You can modify this expiration time by changing the config variable in the `config/development.yaml` file. 286 | 287 | 288 | ## Authorization 289 | 290 | The `auth` middleware can also be used to require certain rights/permissions to access a route. 291 | 292 | ```javascript 293 | const express = require('express'); 294 | const auth = require('../../middlewares/auth'); 295 | const userController = require('../../controllers/user.controller'); 296 | 297 | const router = express.Router(); 298 | 299 | router.post('/users', auth('manageUsers'), userController.createUser); 300 | ``` 301 | 302 | In the example above, an authenticated user can access this route only if that user has the `manageUsers` permission. 303 | 304 | The permissions are role-based. You can view the permissions/rights of each role in the `src/config/roles.js` file. 305 | 306 | If the user making the request does not have the required permissions to access this route, a Forbidden (403) error is thrown. 307 | 308 | ## Logging 309 | 310 | Import the logger from `src/config/logger.js`. It is using the [Winston](https://github.com/winstonjs/winston) logging library. 311 | 312 | Logging should be done according to the following severity levels (ascending order from most important to least important): 313 | 314 | ```javascript 315 | const logger = require('/config/logger'); 316 | 317 | logger.error('message'); // level 0 318 | logger.warn('message'); // level 1 319 | logger.info('message'); // level 2 320 | logger.http('message'); // level 3 321 | logger.verbose('message'); // level 4 322 | logger.debug('message'); // level 5 323 | ``` 324 | 325 | In development mode, log messages of all severity levels will be printed to the console. 326 | 327 | In production mode, only `info`, `warn`, and `error` logs will be printed to the console.\ 328 | It is up to the server (or process manager) to actually read them from the console and store them in log files.\ 329 | This app uses pm2 in production mode, which is already configured to store the logs in log files. 330 | 331 | Note: API request information (request url, response code, timestamp, etc.) are also automatically logged (using [morgan](https://github.com/expressjs/morgan)). 332 | 333 | 334 | ## Linting 335 | 336 | Linting is done using [ESLint](https://eslint.org/) and [Prettier](https://prettier.io). 337 | 338 | In this app, ESLint is configured to follow the [Airbnb JavaScript style guide](https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb-base) with some modifications. It also extends [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) to turn off all rules that are unnecessary or might conflict with Prettier. 339 | 340 | To modify the ESLint configuration, update the `.eslintrc.json` file. To modify the Prettier configuration, update the `.prettierrc.json` file. 341 | 342 | To prevent a certain file or directory from being linted, add it to `.eslintignore` and `.prettierignore`. 343 | 344 | To maintain a consistent coding style across different IDEs, the project contains `.editorconfig` 345 | 346 | ## Contributing 347 | 348 | Contributions are more than welcome! Please check out the [contributing guide](CONTRIBUTING.md). 349 | 350 | ## Inspirations 351 | 352 | - [danielfsousa/express-rest-es2017-boilerplate](https://github.com/danielfsousa/express-rest-es2017-boilerplate) 353 | - [madhums/node-express-mongoose](https://github.com/madhums/node-express-mongoose) 354 | - [kunalkapadia/express-mongoose-es6-rest-api](https://github.com/kunalkapadia/express-mongoose-es6-rest-api) 355 | 356 | ## License 357 | 358 | [MIT](LICENSE) 359 | -------------------------------------------------------------------------------- /cli/.necarc: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | componentsPath: '../src/components' 3 | }; 4 | -------------------------------------------------------------------------------- /cli/assets/controller.js: -------------------------------------------------------------------------------- 1 | const <%= name %>Service = require('./<%= nameLower %>.service'); 2 | 3 | const <%= name %>Controller = { 4 | getResource: async (httpRequest) => { 5 | const resource = await <%= name %>Service.doGetResource(httpRequest); 6 | return { 7 | statusCode: 200, 8 | data: resource 9 | }; 10 | } 11 | }; 12 | 13 | module.exports = <%= name %>Controller; 14 | -------------------------------------------------------------------------------- /cli/assets/module.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | 3 | const { makeExpressCallback, makeValidatorCallback } = require('../../middlewares'); 4 | 5 | // validator 6 | const <%= name %>Validator = require('./<%= nameLower %>.validator'); 7 | 8 | // service 9 | const <%= name %>Service = require('./<%= nameLower %>.service'); 10 | 11 | // controller 12 | const <%= name %>Controller = require('./<%= nameLower %>.controller'); 13 | 14 | // routes 15 | const routes = require('./<%= nameLower %>.routes')({ 16 | router, 17 | <%= name %>Controller, 18 | <%= name %>Validator, 19 | makeValidatorCallback, 20 | makeExpressCallback 21 | }); 22 | 23 | module.exports = { 24 | <%= name %>Controller, 25 | <%= name %>Service, 26 | <%= name %>Routes: routes 27 | }; 28 | -------------------------------------------------------------------------------- /cli/assets/routes.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ router, <%= name %>Controller, <%= name %>Validator, makeValidatorCallback, makeExpressCallback }) => { 2 | router.get( 3 | '/', 4 | makeValidatorCallback(<%= name %>Validator.validateLogin), 5 | makeExpressCallback(<%= name %>Controller.getResource) 6 | ); 7 | return router; 8 | }; 9 | -------------------------------------------------------------------------------- /cli/assets/service.js: -------------------------------------------------------------------------------- 1 | const <%= name %>Service = { 2 | doGetResource: async (requestBody) => {} 3 | }; 4 | 5 | module.exports = <%= name %>Service; 6 | -------------------------------------------------------------------------------- /cli/assets/validator.js: -------------------------------------------------------------------------------- 1 | const Joi = require('@hapi/joi').extend(require('@hapi/joi-date')); 2 | 3 | const options = { 4 | errors: { 5 | wrap: { 6 | label: '', 7 | }, 8 | }, 9 | }; 10 | 11 | /** 12 | * 13 | * @param httpRequest 14 | */ 15 | const validateSample = (httpRequest) => { 16 | const schema = Joi.object({ 17 | phone: Joi.string() 18 | .pattern(/^[6-9]\d{9}$/) 19 | .required() 20 | .messages({ 21 | 'string.pattern.base': 'Provide valid phone number!', 22 | }), 23 | password: Joi.string().min(8).max(20).alphanum().required(), 24 | }); 25 | return schema.validate(httpRequest.body, options); 26 | }; 27 | 28 | module.exports = { 29 | validateSample, 30 | }; 31 | -------------------------------------------------------------------------------- /cli/commands/component.js: -------------------------------------------------------------------------------- 1 | const helpers = require('../helpers'); 2 | const { baseOptions } = require('../core/yargs'); 3 | 4 | /** 5 | * 6 | * @param args 7 | */ 8 | function initComponent(args) { 9 | helpers.init.createComponentFolder(args.name, !!args.force); 10 | helpers.init.createModuleFile(args.name, !!args.force); 11 | helpers.init.createControllerFile(args.name, !!args.force); 12 | helpers.init.createServiceFile(args.name, !!args.force); 13 | helpers.init.createValidatorFile(args.name, !!args.force); 14 | helpers.init.createRoutesFile(args.name, !!args.force); 15 | } 16 | 17 | module.exports = { 18 | /** 19 | * 20 | * @param yargs 21 | */ 22 | builder: (yargs) => 23 | baseOptions(yargs).option('force', { 24 | describe: 'Will drop the existing component and re-create it', 25 | type: 'boolean', 26 | default: false, 27 | }).argv, 28 | 29 | /** 30 | * 31 | * @param argv 32 | */ 33 | handler: async (argv) => { 34 | switch (argv._[0]) { 35 | case 'init:component': 36 | await initComponent(argv); 37 | break; 38 | default: 39 | break; 40 | } 41 | process.exit(0); 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /cli/core/yargs.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const yargs = require('yargs'); 3 | 4 | const path = require('path'); 5 | 6 | /** 7 | * 8 | * @param componentsPath 9 | */ 10 | function loadRCFile(componentsPath) { 11 | const rcFile = componentsPath || path.resolve(process.cwd(), '.necarc'); 12 | const rcFileResolved = path.resolve(rcFile); 13 | return fs.existsSync(rcFileResolved) 14 | ? JSON.parse(JSON.stringify(require(rcFileResolved))) 15 | : {}; 16 | } 17 | 18 | const args = yargs 19 | .help(false) 20 | .version(false) 21 | .config(loadRCFile(yargs.argv.componentsPath)); 22 | 23 | /** 24 | * 25 | */ 26 | module.exports = function getYArgs() { 27 | return args; 28 | }; 29 | 30 | /** 31 | * 32 | * @param yargs 33 | */ 34 | module.exports.baseOptions = (yargs) => { 35 | return yargs 36 | .option('name', { 37 | describe: 'The name of the component', 38 | default: 'Dummy', 39 | type: 'string', 40 | }) 41 | .option('components-path', { 42 | describe: 'The path to a JSON file containing components path', 43 | type: 'string', 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /cli/helpers/asset-helper.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const path = require('path'); 3 | 4 | const assets = { 5 | /** 6 | * 7 | * @param from 8 | * @param to 9 | */ 10 | copy: (from, to) => { 11 | fs.copySync(path.resolve(__dirname, '..', 'assets', from), to); 12 | }, 13 | 14 | /** 15 | * 16 | * @param assetPath 17 | */ 18 | read: (assetPath) => { 19 | return fs 20 | .readFileSync(path.resolve(__dirname, '..', 'assets', assetPath)) 21 | .toString(); 22 | }, 23 | 24 | /** 25 | * 26 | * @param targetPath 27 | * @param content 28 | */ 29 | write: (targetPath, content) => { 30 | fs.writeFileSync(targetPath, content); 31 | }, 32 | 33 | /** 34 | * 35 | * @param filePath 36 | * @param token 37 | * @param content 38 | */ 39 | inject: (filePath, token, content) => { 40 | const fileContent = fs.readFileSync(filePath).toString(); 41 | fs.writeFileSync(filePath, fileContent.replace(token, content)); 42 | }, 43 | 44 | /** 45 | * 46 | * @param filePath 47 | * @param configPath 48 | */ 49 | injectConfigFilePath: (filePath, configPath) => { 50 | this.inject(filePath, '__CONFIG_FILE__', configPath); 51 | }, 52 | 53 | /** 54 | * 55 | * @param pathToCreate 56 | */ 57 | mkdirp: (pathToCreate) => { 58 | fs.mkdirpSync(pathToCreate); 59 | }, 60 | }; 61 | 62 | module.exports = assets; 63 | -------------------------------------------------------------------------------- /cli/helpers/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | 4 | module.exports = {}; 5 | 6 | fs.readdirSync(__dirname) 7 | .filter((file) => file.indexOf('.') !== 0 && file.indexOf('index.js') === -1) 8 | .forEach((file) => { 9 | module.exports[file.replace('-helper.js', '')] = require(path.resolve( 10 | __dirname, 11 | file 12 | )); 13 | }); 14 | 15 | module.exports.default = module.exports; 16 | -------------------------------------------------------------------------------- /cli/helpers/init-helper.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const helpers = require('./index'); 4 | 5 | /** 6 | * 7 | * @param folderName 8 | * @param folder 9 | * @param force 10 | */ 11 | function createFolder(folderName, folder, force) { 12 | if (force && fs.existsSync(folder) === true) { 13 | helpers.view.log(`Deleting the ${folderName} folder. (--force)`); 14 | 15 | try { 16 | fs.readdirSync(folder).forEach((filename) => { 17 | fs.unlinkSync(path.resolve(folder, filename)); 18 | }); 19 | } catch (e) { 20 | helpers.view.error(e); 21 | } 22 | 23 | try { 24 | fs.rmdirSync(folder); 25 | helpers.view.log(`Successfully deleted the ${folderName} folder.`); 26 | } catch (e) { 27 | helpers.view.error(e); 28 | } 29 | } 30 | 31 | try { 32 | if (fs.existsSync(folder) === false) { 33 | helpers.asset.mkdirp(folder); 34 | helpers.view.log( 35 | `Successfully created ${folderName} folder at "${folder}".` 36 | ); 37 | } else { 38 | helpers.view.log(`${folderName} folder at "${folder}" already exists.`); 39 | } 40 | } catch (e) { 41 | helpers.view.error(e); 42 | } 43 | } 44 | 45 | const init = { 46 | /** 47 | * 48 | * @param componentName 49 | * @param force 50 | */ 51 | createComponentFolder: (componentName, force) => { 52 | createFolder( 53 | componentName, 54 | helpers.path.getComponentPath(componentName), 55 | force 56 | ); 57 | }, 58 | /** 59 | * 60 | * @param componentName 61 | * @param force 62 | */ 63 | createModuleFile: (componentName, force) => { 64 | const modulePath = helpers.path.getComponentPath(componentName); 65 | const moduleFilePath = path.resolve( 66 | modulePath, 67 | helpers.path.addFileExtension(`${componentName.toLowerCase()}.module`) 68 | ); 69 | 70 | if (!helpers.path.existsSync(modulePath)) { 71 | helpers.view.log('Models folder not available.'); 72 | } else if (helpers.path.existsSync(moduleFilePath) && !force) { 73 | helpers.view.notifyAboutExistingFile(moduleFilePath); 74 | } else { 75 | helpers.asset.write( 76 | moduleFilePath, 77 | helpers.template.render( 78 | 'module.js', 79 | { 80 | name: componentName, 81 | nameLower: componentName.toLowerCase(), 82 | }, 83 | { 84 | beautify: false, 85 | } 86 | ) 87 | ); 88 | } 89 | }, 90 | /** 91 | * 92 | * @param componentName 93 | * @param force 94 | */ 95 | createControllerFile: (componentName, force) => { 96 | const modulePath = helpers.path.getComponentPath(componentName); 97 | const moduleFilePath = path.resolve( 98 | modulePath, 99 | helpers.path.addFileExtension(`${componentName.toLowerCase()}.controller`) 100 | ); 101 | 102 | if (!helpers.path.existsSync(modulePath)) { 103 | helpers.view.log('Models folder not available.'); 104 | } else if (helpers.path.existsSync(moduleFilePath) && !force) { 105 | helpers.view.notifyAboutExistingFile(moduleFilePath); 106 | } else { 107 | helpers.asset.write( 108 | moduleFilePath, 109 | helpers.template.render( 110 | 'controller.js', 111 | { 112 | name: componentName, 113 | nameLower: componentName.toLowerCase(), 114 | }, 115 | { 116 | beautify: false, 117 | } 118 | ) 119 | ); 120 | } 121 | }, 122 | /** 123 | * 124 | * @param componentName 125 | * @param force 126 | */ 127 | createServiceFile: (componentName, force) => { 128 | const modulePath = helpers.path.getComponentPath(componentName); 129 | const moduleFilePath = path.resolve( 130 | modulePath, 131 | helpers.path.addFileExtension(`${componentName.toLowerCase()}.service`) 132 | ); 133 | 134 | if (!helpers.path.existsSync(modulePath)) { 135 | helpers.view.log('Models folder not available.'); 136 | } else if (helpers.path.existsSync(moduleFilePath) && !force) { 137 | helpers.view.notifyAboutExistingFile(moduleFilePath); 138 | } else { 139 | helpers.asset.write( 140 | moduleFilePath, 141 | helpers.template.render( 142 | 'service.js', 143 | { 144 | name: componentName, 145 | nameLower: componentName.toLowerCase(), 146 | }, 147 | { 148 | beautify: false, 149 | } 150 | ) 151 | ); 152 | } 153 | }, 154 | /** 155 | * 156 | * @param componentName 157 | * @param force 158 | */ 159 | createRoutesFile: (componentName, force) => { 160 | const modulePath = helpers.path.getComponentPath(componentName); 161 | const moduleFilePath = path.resolve( 162 | modulePath, 163 | helpers.path.addFileExtension(`${componentName.toLowerCase()}.routes`) 164 | ); 165 | 166 | if (!helpers.path.existsSync(modulePath)) { 167 | helpers.view.log('Models folder not available.'); 168 | } else if (helpers.path.existsSync(moduleFilePath) && !force) { 169 | helpers.view.notifyAboutExistingFile(moduleFilePath); 170 | } else { 171 | helpers.asset.write( 172 | moduleFilePath, 173 | helpers.template.render( 174 | 'routes.js', 175 | { 176 | name: componentName, 177 | nameLower: componentName.toLowerCase(), 178 | }, 179 | { 180 | beautify: false, 181 | } 182 | ) 183 | ); 184 | } 185 | }, 186 | /** 187 | * 188 | * @param componentName 189 | * @param force 190 | */ 191 | createValidatorFile: (componentName, force) => { 192 | const modulePath = helpers.path.getComponentPath(componentName); 193 | const moduleFilePath = path.resolve( 194 | modulePath, 195 | helpers.path.addFileExtension(`${componentName.toLowerCase()}.validator`) 196 | ); 197 | 198 | if (!helpers.path.existsSync(modulePath)) { 199 | helpers.view.log('Models folder not available.'); 200 | } else if (helpers.path.existsSync(moduleFilePath) && !force) { 201 | helpers.view.notifyAboutExistingFile(moduleFilePath); 202 | } else { 203 | helpers.asset.write( 204 | moduleFilePath, 205 | helpers.template.render( 206 | 'validator.js', 207 | { 208 | name: componentName, 209 | nameLower: componentName.toLowerCase(), 210 | }, 211 | { 212 | beautify: false, 213 | } 214 | ) 215 | ); 216 | } 217 | }, 218 | }; 219 | 220 | module.exports = init; 221 | module.exports.default = init; 222 | -------------------------------------------------------------------------------- /cli/helpers/path-helper.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | const getYArgs = require('../core/yargs'); 4 | 5 | const args = getYArgs().argv; 6 | 7 | module.exports = { 8 | /** 9 | * 10 | * @param type 11 | * @param name 12 | * @param options 13 | */ 14 | getFileName(type, name, options) { 15 | return this.addFileExtension( 16 | [getCurrentYYYYMMDDHHmms(), name || `unnamed-${type}`].join('-'), 17 | options 18 | ); 19 | }, 20 | 21 | /** 22 | * 23 | */ 24 | getFileExtension() { 25 | return 'js'; 26 | }, 27 | 28 | /** 29 | * 30 | * @param basename 31 | * @param options 32 | */ 33 | addFileExtension(basename, options) { 34 | return [basename, this.getFileExtension(options)].join('.'); 35 | }, 36 | 37 | /** 38 | * 39 | * @param componentName 40 | */ 41 | getComponentPath(componentName) { 42 | return ( 43 | args.componentPath || 44 | path.resolve(process.cwd(), `src/modules/${componentName}`) 45 | ); 46 | }, 47 | 48 | /** 49 | * 50 | * @param pathToCheck 51 | */ 52 | existsSync(pathToCheck) { 53 | if (fs.accessSync) { 54 | try { 55 | fs.accessSync(pathToCheck, fs.R_OK); 56 | return true; 57 | } catch (e) { 58 | return false; 59 | } 60 | } else { 61 | return fs.existsSync(pathToCheck); 62 | } 63 | }, 64 | }; 65 | -------------------------------------------------------------------------------- /cli/helpers/template-helper.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | const _ = require('lodash'); 3 | const beautify = require('js-beautify'); 4 | const helpers = require('./index'); 5 | 6 | module.exports = { 7 | /** 8 | * 9 | * @param path 10 | * @param locals 11 | * @param options 12 | */ 13 | render(path, locals, options) { 14 | options = _.assign( 15 | { 16 | beautify: true, 17 | indent_size: 2, 18 | preserve_newlines: false, 19 | }, 20 | options || {} 21 | ); 22 | 23 | const template = helpers.asset.read(path); 24 | let content = _.template(template)(locals || {}); 25 | 26 | if (options.beautify) { 27 | content = beautify(content, options); 28 | } 29 | 30 | return content; 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /cli/helpers/view-helper.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const clc = require('cli-color'); 3 | const getYArgs = require('../core/yargs'); 4 | 5 | const args = getYArgs().argv; 6 | 7 | module.exports = { 8 | /** 9 | * 10 | */ 11 | teaser() { 12 | this.log(); 13 | this.log(clc.underline('Created By - Sujeet Agrahari')); 14 | this.log(); 15 | }, 16 | 17 | /** 18 | * 19 | */ 20 | log() { 21 | console.log.apply(this, arguments); 22 | }, 23 | 24 | /** 25 | * 26 | * @param error 27 | */ 28 | error(error) { 29 | let message = error; 30 | const extraMessages = []; 31 | 32 | if (error instanceof Error) { 33 | message = !args.debug ? error.message : error.stack; 34 | } 35 | 36 | if (args.debug && error.original) { 37 | extraMessages.push(error.original.message); 38 | } 39 | 40 | this.log(); 41 | console.error(`${clc.red('ERROR:')} ${message}`); 42 | extraMessages.forEach((message) => 43 | console.error(`${clc.red('EXTRA MESSAGE:')} ${message}`) 44 | ); 45 | this.log(); 46 | 47 | process.exit(1); 48 | }, 49 | 50 | /** 51 | * 52 | * @param message 53 | */ 54 | warn(message) { 55 | this.log(`${clc.yellow('WARNING:')} ${message}`); 56 | }, 57 | 58 | /** 59 | * 60 | * @param file 61 | */ 62 | notifyAboutExistingFile(file) { 63 | this.error( 64 | `The file ${clc.blueBright(file)} already exists. ` + 65 | 'Run command with --force to overwrite it.' 66 | ); 67 | }, 68 | }; 69 | -------------------------------------------------------------------------------- /cli/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const yargs = require('yargs'); 4 | const helpers = require('./helpers'); 5 | const component = require('./commands/component'); 6 | 7 | helpers.view.teaser(); 8 | 9 | yargs 10 | .help() 11 | .version() 12 | .command('init:component', 'Create a new component', component) 13 | .wrap(yargs.terminalWidth()) 14 | .demandCommand(1, 'Please specify a command') 15 | .help() 16 | .strict() 17 | .recommendCommands().argv; 18 | -------------------------------------------------------------------------------- /config/custom-environment-variables.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | JWT_ACCESS_TOKEN_SECRET: JWT_ACCESS_TOKEN_SECRET 3 | JWT_REFRESH_TOKEN_SECRET: JWT_REFRESH_TOKEN_SECRET 4 | ACCESS_TOKEN_EXPIRES_IN: ACCESS_TOKEN_EXPIRES_IN 5 | REFRESH_TOKEN_EXPIRES_IN: REFRESH_TOKEN_EXPIRES_IN 6 | NODE_ENV: NODE_ENV 7 | DB_PASSWORD: DB_PASSWORD 8 | 9 | -------------------------------------------------------------------------------- /config/dbConfig.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | development: { 3 | username: 'mealmate_dev', 4 | database: 'mealmate', 5 | host: '127.0.0.1', 6 | dialect: 'postgres', 7 | operatorsAliases: 0 8 | }, 9 | test: { 10 | username: 'root', 11 | database: 'database_test', 12 | host: '127.0.0.1', 13 | dialect: 'postgres', 14 | operatorsAliases: 0 15 | }, 16 | production: { 17 | username: 'root', 18 | database: 'database_production', 19 | host: '127.0.0.1', 20 | dialect: 'postgres', 21 | operatorsAliases: 0 22 | } 23 | } 24 | 25 | export default config 26 | -------------------------------------------------------------------------------- /config/default.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | JWT_SIGN_OPTIONS: 3 | issuer: My Compny Pvt Ltd 4 | audience: https://example.in 5 | expiresIn: 15d 6 | API_PREFIX: api/v1 -------------------------------------------------------------------------------- /config/development.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | ACCESS_TOKEN_EXPIRES_IN: 1h 3 | REFRESH_TOKEN_EXPIRES_IN: 7d 4 | JWT_ACCESS_TOKEN_SECRET: "----- YOUR KEY -----" 5 | JWT_REFRESH_TOKEN_SECRET: "----- YOUR KEY -----" 6 | -------------------------------------------------------------------------------- /config/keys/private.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIBOwIBAAJBAKmNkLKSn6sISsNeXGlm8PIUN7/nKH99f4LigbswMPJbXWpuTu1h 3 | HteAXHOLkLdTUmgJWxxG6+E8WygkCh4AlR8CAwEAAQJBAKENOhOVGJsR/koDGI55 4 | 3IZlU+sxvBMVdwgw9P+EMAoT1cPScB/zClYbFaN25R9jFoAomXp870dZTv8wnnrb 5 | 4eECIQDtaickdkmBg7Qv536QKKvcKjVwixKVKEuX7htqx4yizwIhALbTcrQzHjOQ 6 | f6whq2FHg14yBU64R1ObseUqBYobjLyxAiBrZypuBFUckkhho4hODxgwcafbUg8G 7 | C0SnZsBgfgXRYQIgD41nOYsLq6lEIxluIcVamH2609p7PtKEzJDJjdkQHNECIQCK 8 | H/YBFwmFZu58dsA1q72YkBOuVN83d2u8vGry0tGbIg== 9 | -----END RSA PRIVATE KEY----- -------------------------------------------------------------------------------- /config/keys/public.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKmNkLKSn6sISsNeXGlm8PIUN7/nKH99 3 | f4LigbswMPJbXWpuTu1hHteAXHOLkLdTUmgJWxxG6+E8WygkCh4AlR8CAwEAAQ== 4 | -----END PUBLIC KEY----- -------------------------------------------------------------------------------- /config/production.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | ACCESS_TOKEN_EXPIRES_IN: 1h 3 | REFRESH_TOKEN_EXPIRES_IN: 7d 4 | JWT_ACCESS_TOKEN_SECRET: "----- YOUR KEY -----" 5 | JWT_REFRESH_TOKEN_SECRET: "----- YOUR KEY -----" 6 | -------------------------------------------------------------------------------- /config/test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | ACCESS_TOKEN_EXPIRES_IN: 1h 3 | REFRESH_TOKEN_EXPIRES_IN: 7d 4 | JWT_ACCESS_TOKEN_SECRET: "----- YOUR KEY -----" 5 | JWT_REFRESH_TOKEN_SECRET: "----- YOUR KEY -----" 6 | -------------------------------------------------------------------------------- /healthcheck.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | /** 4 | * Checks the health status of the application. 5 | * It sends a GET request to the health endpoint and validates the response. 6 | * Logs the health status of the application. 7 | */ 8 | export const checkAppHealth = async () => { 9 | try { 10 | const response = await fetch(`http://localhost:${process.env.PORT}/health`) 11 | const appHealthStatus = await response.json() 12 | 13 | // Validate app health status 14 | if ( 15 | appHealthStatus.database.status === 'up' && 16 | appHealthStatus.app.status === 'up' 17 | ) { 18 | console.log('App is up and running') 19 | // Additional actions if the app is healthy 20 | } else { 21 | console.error('App is not healthy') 22 | // Additional actions if the app is not healthy 23 | } 24 | } catch (error) { 25 | console.error('Error occurred while checking app health:', error) 26 | // Additional actions if an error occurred while checking app health 27 | } 28 | } 29 | 30 | checkAppHealth() 31 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const jestConfig = { 2 | testEnvironment: 'node', 3 | testMatch: ['**/tests/**/*.test.js'] 4 | } 5 | 6 | export default jestConfig 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-express-modular-architecture", 3 | "version": "1.0.0", 4 | "description": "A modular project architecture for node express apis.", 5 | "main": "./src/server.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "jest --config ./jest.config.js --coverage", 9 | "start:dev": "NODE_ENV=development nodemon -L ./src/server.js", 10 | "lint": "./node_modules/.bin/eslint .", 11 | "migrate": "sequelize db:migrate", 12 | "migrate:undo": "sequelize db:migrate:undo", 13 | "lint:fix": "./node_modules/.bin/eslint . --fix", 14 | "create:component": "node ./cli init:component" 15 | }, 16 | "husky": { 17 | "hooks": { 18 | "pre-commit": "npm run lint && npm run test" 19 | } 20 | }, 21 | "babel": { 22 | "env": { 23 | "test": { 24 | "plugins": [ 25 | "@babel/plugin-transform-modules-commonjs" 26 | ] 27 | } 28 | } 29 | }, 30 | "author": "Sujeet Agrahari", 31 | "license": "ISC", 32 | "dependencies": { 33 | "@babel/eslint-parser": "^7.25.8", 34 | "aws-sdk": "^2.1691.0", 35 | "bcryptjs": "^2.4.3", 36 | "config": "^3.3.12", 37 | "cors": "^2.8.5", 38 | "csurf": "^1.11.0", 39 | "express": "5.0.1", 40 | "express-winston": "^4.2.0", 41 | "helmet": "^8.0.0", 42 | "joi": "^17.13.3", 43 | "js-yaml": "^4.1.0", 44 | "jsonwebtoken": "^9.0.2", 45 | "lodash": "^4.17.21", 46 | "moment": "2.29.4", 47 | "nodemailer": "6.9.9", 48 | "pg": "^8.13.0", 49 | "pg-hstore": "^2.3.4", 50 | "sequelize": "^6.37.4", 51 | "stoppable": "^1.1.0", 52 | "uuid": "^8.3.2", 53 | "winston": "^3.15.0" 54 | }, 55 | "devDependencies": { 56 | "@babel/plugin-transform-modules-commonjs": "^7.25.7", 57 | "@faker-js/faker": "^6.3.1", 58 | "cli-color": "^2.0.4", 59 | "eslint": "^8.57.1", 60 | "eslint-config-airbnb-base": "^15.0.0", 61 | "eslint-config-prettier": "^8.10.0", 62 | "eslint-config-standard": "^17.1.0", 63 | "eslint-plugin-import": "^2.31.0", 64 | "eslint-plugin-jest": "^27.9.0", 65 | "eslint-plugin-jsdoc": "^44.2.7", 66 | "eslint-plugin-n": "^16.6.2", 67 | "eslint-plugin-prettier": "^4.2.1", 68 | "eslint-plugin-promise": "^6.6.0", 69 | "fs-extra": "^10.1.0", 70 | "husky": "^8.0.3", 71 | "jest": "^29.7.0", 72 | "js-beautify": "^1.15.1", 73 | "jscodeshift": "^17.0.0", 74 | "jsdoc": "^4.0.3", 75 | "nodemon": "^3.1.7", 76 | "prettier": "^2.8.8", 77 | "sequelize-cli": "^6.6.2", 78 | "sequelize-mock": "^0.10.2", 79 | "yargs": "^17.7.2" 80 | }, 81 | "pnpm": { 82 | "overrides": { 83 | "sequelize@<6.28.1": ">=6.28.1", 84 | "sequelize@<6.29.0": ">=6.29.0", 85 | "hoek@<4.2.1": ">=4.2.1", 86 | "semver@>=7.0.0 <7.5.2": ">=7.5.2", 87 | "jsonwebtoken@<9.0.0": ">=9.0.0", 88 | "jsonwebtoken@<=8.5.1": ">=9.0.0" 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import cors from 'cors' 3 | import { requestLogger } from './support/logger.js' 4 | import { errorHandler, badJsonHandler, notFoundHandler } from './middlewares/index.js' 5 | import loadRoutes from './loaders/routes.js' 6 | import './loaders/config.js' 7 | import helmet from 'helmet' 8 | import csurf from 'csurf' 9 | 10 | const app = express() 11 | 12 | /** 13 | * Enable CORS 14 | */ 15 | app.use(cors()) 16 | 17 | /** 18 | * Set up security headers. 19 | */ 20 | app.use(helmet()) 21 | 22 | /** 23 | * Set up CSRF protection. 24 | */ 25 | app.use(csurf()) 26 | 27 | /** 28 | * Log requests 29 | */ 30 | app.use(requestLogger) 31 | 32 | /** 33 | * Parse JSON body 34 | */ 35 | app.use(express.json()) 36 | 37 | /** 38 | * Handle bad JSON format 39 | */ 40 | app.use(badJsonHandler) 41 | 42 | /** 43 | * Load routes 44 | */ 45 | loadRoutes(app) 46 | 47 | /** 48 | * Handle 404 not found error 49 | */ 50 | app.use(notFoundHandler) 51 | 52 | /** 53 | * Catch all errors 54 | */ 55 | app.use(errorHandler) 56 | 57 | export default app 58 | -------------------------------------------------------------------------------- /src/db/models/User.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import { Model } from 'sequelize' 3 | import bcrypt from 'bcryptjs' 4 | 5 | /** 6 | * User model definition. 7 | * 8 | * @param {import('sequelize').Sequelize} sequelize - The Sequelize instance. 9 | * @param {import('sequelize/types')} DataTypes - The Sequelize DataTypes. 10 | * @returns {typeof Model} - The User model. 11 | */ 12 | const UserModel = (sequelize, DataTypes) => { 13 | /** 14 | * User class extending Sequelize Model. 15 | */ 16 | class User extends Model { 17 | /** 18 | * Associate models. 19 | * 20 | * @param {object} models - The models to associate. 21 | */ 22 | static associate (models) { 23 | // define association here 24 | if (models.Student) { 25 | models.User.hasOne(models.Student, { 26 | foreignKey: 'userId' 27 | }) 28 | } 29 | } 30 | 31 | /** 32 | * Override toJSON method to exclude password. 33 | * 34 | * @returns {object} - The user object without the password. 35 | */ 36 | toJSON () { 37 | const user = { ...this.dataValues } 38 | delete user.password 39 | return user 40 | } 41 | } 42 | 43 | User.init( 44 | { 45 | phone: { 46 | type: DataTypes.STRING, 47 | allowNull: false, 48 | validate: { 49 | notNull: true, 50 | notEmpty: true, 51 | is: /^[6-9]\d{9}$/ 52 | } 53 | }, 54 | password: { 55 | type: DataTypes.STRING, 56 | allowNull: false, 57 | validate: { 58 | notNull: true, 59 | notEmpty: true 60 | } 61 | }, 62 | isDeleted: { 63 | type: DataTypes.BOOLEAN, 64 | defaultValue: false 65 | }, 66 | role: { 67 | type: DataTypes.STRING, 68 | defaultValue: 'Student', 69 | allowNull: false, 70 | validate: { 71 | notNull: true, 72 | notEmpty: true, 73 | isIn: [['Student', 'Teacher']] 74 | } 75 | } 76 | }, 77 | { 78 | sequelize, 79 | schema: 'NITTI', 80 | modelName: 'User', 81 | hooks: { 82 | /** 83 | * Hash the password before validating the user. 84 | * 85 | * @param {User} user - The user instance. 86 | */ 87 | beforeValidate: async (user) => { 88 | if (user.password) { 89 | user.password = await bcrypt.hash(user.password, 8) 90 | } 91 | } 92 | } 93 | } 94 | ) 95 | 96 | return User 97 | } 98 | 99 | export default UserModel 100 | -------------------------------------------------------------------------------- /src/db/models/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | /* eslint-disable import/no-dynamic-require */ 3 | 4 | import { Sequelize } from 'sequelize' 5 | import configFile from '../../../config/dbConfig.js' 6 | import UserModel from './User.js' 7 | 8 | /** 9 | * Initializes and configures the Sequelize instance and models. 10 | * @module db/models 11 | */ 12 | 13 | const env = process.env.NODE_ENV || 'development' 14 | const config = configFile[env] 15 | const db = {} 16 | 17 | let sequelize 18 | if (config.use_env_variable) { 19 | sequelize = new Sequelize(process.env[config.use_env_variable], config) 20 | } else { 21 | sequelize = new Sequelize( 22 | config.database, 23 | config.username, 24 | process.env.DB_PASSWORD, 25 | config 26 | ) 27 | } 28 | 29 | db.User = UserModel(sequelize, Sequelize.DataTypes) 30 | 31 | Object.keys(db).forEach((modelName) => { 32 | if (db[modelName].associate) { 33 | db[modelName].associate(db) 34 | } 35 | }) 36 | 37 | db.sequelize = sequelize 38 | db.Sequelize = Sequelize 39 | 40 | export { sequelize, Sequelize } 41 | 42 | export default db 43 | -------------------------------------------------------------------------------- /src/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./**/*.js" 4 | ], 5 | "exclude": ["node_modules"] 6 | } -------------------------------------------------------------------------------- /src/loaders/config.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sujeet-agrahari/node-express-modular-architecture/90c50ac3b3cfcba5fb61c2ffcb539accab6ea1b0/src/loaders/config.js -------------------------------------------------------------------------------- /src/loaders/index.js: -------------------------------------------------------------------------------- 1 | import Config from './config' 2 | import Routes from './routes' 3 | 4 | export default { 5 | Config, 6 | Routes 7 | } 8 | -------------------------------------------------------------------------------- /src/loaders/routes.js: -------------------------------------------------------------------------------- 1 | // Routes 2 | import config from 'config' 3 | import { AuthRoutes } from '../modules/auth/auth.module.js' 4 | import { AppHealthRoutes } from '../modules/app-health/app-health.module.js' 5 | 6 | const routes = [ 7 | { 8 | path: '/auth', 9 | route: AuthRoutes 10 | }, 11 | { 12 | excludeAPIPrefix: true, 13 | path: '/health', 14 | route: AppHealthRoutes 15 | } 16 | ] 17 | 18 | /** 19 | * Register routes with the app 20 | * @param {object} app - The Express app object 21 | */ 22 | const registerRoutes = (app) => { 23 | routes.forEach(({ path, route, excludeAPIPrefix }) => { 24 | // If excludeAPIPrefix is true, use the path as is. 25 | // Otherwise, prepend the API_PREFIX to the path. 26 | const routePath = excludeAPIPrefix ? path : config.API_PREFIX + path 27 | // Mount the route on the app using the determined route path. 28 | app.use(routePath, route) 29 | }) 30 | } 31 | 32 | export default registerRoutes 33 | -------------------------------------------------------------------------------- /src/middlewares/auth.js: -------------------------------------------------------------------------------- 1 | import { verifyJWT } from '../modules/auth/jwt.service.js' 2 | import { UnauthorizedError } from '../utils/api-errors.js' 3 | 4 | /** 5 | * Decodes a JWT token from the provided authorization header. 6 | * 7 | * @param {string} header - The authorization header containing the JWT token. 8 | * @returns {Promise} The decoded payload of the JWT token. 9 | * @throws {UnauthorizedError} If the authorization header is missing or invalid. 10 | */ 11 | const decodeToken = async (header) => { 12 | if (!header) { 13 | throw new UnauthorizedError('Authorization header missing') 14 | } 15 | const token = header.replace('Bearer ', '') 16 | const payload = await verifyJWT({ token }) 17 | return payload 18 | } 19 | 20 | /** 21 | * Middleware to handle authentication. 22 | * 23 | * This middleware checks the HTTP method and path of the request. If the method is 'OPTIONS' 24 | * or the path is '/api/v1/auth/login', it allows the request to proceed without authentication. 25 | * For other requests, it attempts to decode the token from the 'Authorization' header and 26 | * attaches the decoded token to the request context. 27 | * 28 | * @param {Object} req - The request object. 29 | * @param {Object} res - The response object. 30 | * @param {Function} next - The next middleware function. 31 | * @returns {Promise} - A promise that resolves when the middleware is complete. 32 | */ 33 | const authMiddleware = async (req, res, next) => { 34 | const { method, path } = req 35 | if (method === 'OPTIONS' || ['/api/v1/auth/login'].includes(path)) { 36 | return next() 37 | } 38 | req.context = await decodeToken( 39 | req.header('Authorization') || req.header('authorization') 40 | ) 41 | return next() 42 | } 43 | 44 | export default authMiddleware 45 | -------------------------------------------------------------------------------- /src/middlewares/authorize.js: -------------------------------------------------------------------------------- 1 | import { UnauthorizedError } from '../utils/api-errors.js' 2 | 3 | /** 4 | * Middleware to authorize based on user roles. 5 | * 6 | * @param {Array} roles - Array of roles that are allowed to access the route. 7 | * @returns {Function} Middleware function to check user role. 8 | */ 9 | const authorize = (roles) => (req, res, next) => { 10 | if (!req.user.role || !roles.includes(req.user.role)) { 11 | throw new UnauthorizedError() 12 | } 13 | return next() 14 | } 15 | 16 | export default authorize 17 | -------------------------------------------------------------------------------- /src/middlewares/error-handler.js: -------------------------------------------------------------------------------- 1 | import { UniqueConstraintError, ValidationError, AggregateError } from 'sequelize' 2 | import { logger } from '../support/logger.js' 3 | import { APIError } from '../utils/api-errors.js' 4 | 5 | /** 6 | * Error handling middleware for Express 7 | * 8 | * @param {Error} error - The error object 9 | * @param {Request} req - The Express request object 10 | * @param {Response} res - The Express response object 11 | * @param {Function} _next - The next middleware function 12 | */ 13 | const errorHandler = (error, req, res, _next) => { 14 | logger.error(error) 15 | 16 | // catch api error 17 | if (error instanceof APIError) { 18 | return res.status(error.status).json({ 19 | error: { 20 | code: error.status, 21 | message: error.message 22 | } 23 | }) 24 | } 25 | 26 | // catch db error 27 | if (error instanceof UniqueConstraintError) { 28 | return res.status(400).json({ 29 | error: { 30 | code: 400, 31 | message: `duplicate_${error.parent.constraint}` 32 | } 33 | }) 34 | } 35 | if (error instanceof ValidationError) { 36 | return res.status(400).json({ 37 | error: { 38 | code: 400, 39 | message: error.message 40 | } 41 | }) 42 | } 43 | if (error instanceof AggregateError) { 44 | const firstErrorMessage = error.errors[0]?.message || 'Unknown error' 45 | return res.status(400).json({ 46 | error: { 47 | code: 400, 48 | message: firstErrorMessage 49 | } 50 | }) 51 | } 52 | 53 | // connect all errors 54 | return res.status(500).json({ 55 | error: { 56 | code: 500, 57 | message: 'Something went wrong!' 58 | } 59 | }) 60 | } 61 | 62 | export default errorHandler 63 | -------------------------------------------------------------------------------- /src/middlewares/express-callback.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Middleware to handle Express callback for a given controller. 3 | * 4 | * @param {Function} controller - The controller function to handle the HTTP request. 5 | * @returns {Function} - An asynchronous function to handle the Express request and response. 6 | */ 7 | const expressCallback = (controller) => async (req, res) => { 8 | const httpRequest = { 9 | body: req.body, 10 | query: req.query, 11 | params: req.params, 12 | ip: req.ip, 13 | method: req.method, 14 | path: req.path, 15 | headers: { 16 | 'Content-Type': req.get('Content-Type'), 17 | Authorization: req.get('Authorization'), 18 | Referer: req.get('referer'), 19 | 'User-Agent': req.get('User-Agent') 20 | } 21 | } 22 | const httpResponse = await controller(httpRequest) 23 | if (httpResponse.headers) res.set(httpResponse.headers) 24 | return res.status(httpResponse.statusCode).json(httpResponse.data) 25 | } 26 | 27 | export default expressCallback 28 | -------------------------------------------------------------------------------- /src/middlewares/index.js: -------------------------------------------------------------------------------- 1 | import auth from './auth.js' 2 | import errorHandler from './error-handler.js' 3 | import authorize from './authorize.js' 4 | import badJsonHandler from './validate-json.js' 5 | import notFoundHandler from './not-found-error.js' 6 | import makeExpressCallback from './express-callback.js' 7 | import makeValidatorCallback from './validator-callback.js' 8 | 9 | export { 10 | auth, 11 | authorize, 12 | errorHandler, 13 | badJsonHandler, 14 | notFoundHandler, 15 | makeExpressCallback, 16 | makeValidatorCallback 17 | } 18 | -------------------------------------------------------------------------------- /src/middlewares/logRoutes.js: -------------------------------------------------------------------------------- 1 | const logRoutes = (app) => { 2 | if (!app._router) { 3 | console.error('No routes found on the app.') 4 | return 5 | } 6 | 7 | app._router.stack.forEach((middleware) => { 8 | if (middleware.route) { // Routes registered directly on the app 9 | const methods = Object.keys(middleware.route.methods).join(', ').toUpperCase() 10 | console.log(`${methods} ${middleware.route.path}`) 11 | } else if (middleware.name === 'router') { // Router middleware 12 | middleware.handle.stack.forEach((handler) => { 13 | const methods = Object.keys(handler.route.methods).join(', ').toUpperCase() 14 | console.log(`${methods} ${handler.route.path}`) 15 | }) 16 | } 17 | }) 18 | } 19 | 20 | export default logRoutes 21 | -------------------------------------------------------------------------------- /src/middlewares/not-found-error.js: -------------------------------------------------------------------------------- 1 | import { NotFoundError } from '../utils/api-errors.js' 2 | 3 | /** 4 | * Middleware to handle not found errors. 5 | * 6 | * @param {Object} req - The request object. 7 | * @param {Object} res - The response object. 8 | */ 9 | const notFoundErrorHandler = (req, _res) => { 10 | const errorMessage = `Not Found: ${req.method} on ${req.url}` 11 | throw new NotFoundError(errorMessage) 12 | } 13 | 14 | export default notFoundErrorHandler 15 | -------------------------------------------------------------------------------- /src/middlewares/validate-json.js: -------------------------------------------------------------------------------- 1 | import { BadRequestError } from '../utils/api-errors.js' 2 | 3 | /** 4 | * Middleware to validate JSON syntax in request body 5 | * 6 | * @param {Object} err - The error object 7 | * @param {Object} req - The request object 8 | * @param {Object} res - The response object 9 | * @param {Function} next - The next middleware function 10 | */ 11 | const validateJson = (err, req, res, next) => { 12 | if (err instanceof SyntaxError && err.status === 400 && 'body' in err) { 13 | throw new BadRequestError(err.message) 14 | } 15 | return next() 16 | } 17 | 18 | export default validateJson 19 | -------------------------------------------------------------------------------- /src/middlewares/validator-callback.js: -------------------------------------------------------------------------------- 1 | import { BadRequestError } from '../utils/api-errors.js' 2 | 3 | /** 4 | * Middleware to validate request using the provided validator function. 5 | * 6 | * @param {Function} validator - The validation function to validate the request. 7 | * @returns {Function} - Express middleware function. 8 | */ 9 | const validatorCallback = (validator) => (req, res, next) => { 10 | const httpRequest = { 11 | body: req.body, 12 | query: req.query, 13 | params: req.params 14 | } 15 | const { error, value } = validator(httpRequest) 16 | if (error) { 17 | throw new BadRequestError(error.message) 18 | } 19 | req.body = value 20 | return next() 21 | } 22 | 23 | export default validatorCallback 24 | -------------------------------------------------------------------------------- /src/modules/app-health/app-health.controller.js: -------------------------------------------------------------------------------- 1 | import AppHealthService from './app-health.service.js' 2 | import { generateResponse } from '../../utils/helper.js' 3 | 4 | /** 5 | * Controller for handling application health-related requests. 6 | */ 7 | const AppHealthController = { 8 | /** 9 | * Retrieves the health status of the application. 10 | * 11 | * @param {Object} httpRequest - The HTTP request object. 12 | * @returns {Promise} The response object containing application health data. 13 | */ 14 | getAppHealth: async (httpRequest) => { 15 | const appHealthData = await AppHealthService.doGetAppHealth(httpRequest) 16 | return generateResponse(appHealthData) 17 | } 18 | } 19 | 20 | export default AppHealthController 21 | -------------------------------------------------------------------------------- /src/modules/app-health/app-health.module.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { makeExpressCallback } from '../../middlewares/index.js' 3 | import AppHealthService from './app-health.service.js' 4 | import AppHealthController from './app-health.controller.js' 5 | import createRoutes from './app-health.routes.js' 6 | 7 | /** 8 | * Module for handling application health-related functionality. 9 | * @module AppHealthModule 10 | */ 11 | 12 | // Initialize the router 13 | const router = Router() 14 | 15 | // Initialize routes with dependencies 16 | const routes = createRoutes({ 17 | router, 18 | AppHealthController, 19 | makeExpressCallback 20 | }) 21 | 22 | export { 23 | AppHealthController, 24 | AppHealthService, 25 | routes as AppHealthRoutes 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/app-health/app-health.routes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sets up the app health routes. 3 | * 4 | * @param {Object} dependencies - The dependencies object. 5 | * @param {Object} dependencies.router - The Express router instance. 6 | * @param {Object} dependencies.AppHealthController - The app health controller. 7 | * @param {Function} dependencies.makeExpressCallback - The function to create an Express callback. 8 | * @returns {Object} The configured router. 9 | */ 10 | export default ({ router, AppHealthController, makeExpressCallback }) => { 11 | router.get('/', makeExpressCallback(AppHealthController.getAppHealth)) 12 | return router 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/app-health/app-health.service.js: -------------------------------------------------------------------------------- 1 | import { sequelize } from '../../db/models/index.js' 2 | 3 | /** 4 | * Service to check the health status of the application and its database. 5 | */ 6 | const AppHealthService = { 7 | /** 8 | * Checks the health status of the application and its database. 9 | * @returns {Promise} An object containing the health status of the app and database. 10 | */ 11 | doGetAppHealth: async () => { 12 | const appHealthStatus = { 13 | database: { status: 'down' }, 14 | app: { status: 'down' } 15 | } 16 | 17 | try { 18 | await sequelize.authenticate() 19 | appHealthStatus.database.status = 'up' 20 | } catch (error) { 21 | // Database connection error 22 | appHealthStatus.database.status = 'down' 23 | } 24 | 25 | appHealthStatus.app.status = 'up' 26 | 27 | return appHealthStatus 28 | } 29 | } 30 | 31 | export default AppHealthService 32 | -------------------------------------------------------------------------------- /src/modules/auth/auth.controller.js: -------------------------------------------------------------------------------- 1 | import AuthService from './auth.service.js' 2 | import { generateResponse } from '../../utils/helper.js' 3 | 4 | const AuthController = { 5 | /** 6 | * Handle logging in user. 7 | * @async 8 | * @function 9 | * @param {ExpressRequest} httpRequest incoming http request 10 | * @returns {Promise. } 11 | */ 12 | login: async (httpRequest) => { 13 | const loginData = await AuthService.doLogin(httpRequest.body) 14 | return generateResponse(loginData) 15 | } 16 | } 17 | 18 | export default AuthController 19 | -------------------------------------------------------------------------------- /src/modules/auth/auth.module.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import { makeExpressCallback, makeValidatorCallback } from '../../middlewares/index.js' 3 | import AuthValidator from './auth.validator.js' 4 | import AuthService from './auth.service.js' 5 | import AuthController from './auth.controller.js' 6 | import createRoutes from './auth.routes.js' 7 | 8 | /** 9 | * Initializes the router and sets up the routes for the authentication module. 10 | */ 11 | const router = Router() 12 | 13 | /** 14 | * Sets up the routes for the authentication module. 15 | * @param {Object} dependencies - The dependencies required for setting up the routes. 16 | * @param {Router} dependencies.router - The Express router. 17 | * @param {Object} dependencies.AuthController - The authentication controller. 18 | * @param {Object} dependencies.AuthValidator - The authentication validator. 19 | * @param {Function} dependencies.makeValidatorCallback - Middleware for validation. 20 | * @param {Function} dependencies.makeExpressCallback - Middleware for handling Express callbacks. 21 | * @returns {Router} - The configured router. 22 | */ 23 | const routes = createRoutes({ 24 | router, 25 | AuthController, 26 | AuthValidator, 27 | makeValidatorCallback, 28 | makeExpressCallback 29 | }) 30 | 31 | export { 32 | AuthController, 33 | AuthService, 34 | routes as AuthRoutes 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/auth/auth.routes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param {object} AuthRouter 4 | * @param {ExpressRouter} AuthRouter.router 5 | * @param {AuthController} AuthRouter.AuthController 6 | * @param {AuthValidator} AuthRouter.AuthValidator 7 | * @param {makeExpressCallback} AuthRouter.makeExpressCallback 8 | * @param {makeValidatorCallback} AuthRouter.makeValidatorCallback 9 | * @returns {ExpressRouter} 10 | */ 11 | export default ({ 12 | router, 13 | AuthController, 14 | AuthValidator, 15 | makeValidatorCallback, 16 | makeExpressCallback 17 | }) => { 18 | router.post( 19 | '/login', 20 | makeValidatorCallback(AuthValidator.validateLogin), 21 | makeExpressCallback(AuthController.login) 22 | ) 23 | return router 24 | } 25 | -------------------------------------------------------------------------------- /src/modules/auth/auth.service.js: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcryptjs' 2 | import User from '../../db/models/User.js' 3 | import { generateJWT } from './jwt.service.js' 4 | import { BadRequestError, NotFoundError } from '../../utils/api-errors.js' 5 | 6 | /** 7 | * AuthService module to handle authentication related operations. 8 | * @module AuthService 9 | */ 10 | const AuthService = { 11 | /** 12 | * Logs in a user and generates a token. 13 | * @async 14 | * @function 15 | * @param {Object} requestBody - Request Body 16 | * @param {string} requestBody.phone - User's phone number 17 | * @param {string} requestBody.password - User's password 18 | * @returns {Promise} Context object containing accessToken, userId, and role 19 | * @throws {NotFoundError} If the user is not found. 20 | * @throws {BadRequestError} If the password is invalid. 21 | */ 22 | doLogin: async (requestBody) => { 23 | const { phone, password } = requestBody 24 | const user = await User.findOne({ 25 | where: { 26 | phone 27 | } 28 | }) 29 | if (!user) { 30 | throw new NotFoundError('User not found') 31 | } 32 | const isValidPass = bcrypt.compareSync(password, user.password) 33 | if (!isValidPass) { 34 | throw new BadRequestError('Username or Password is invalid!') 35 | } 36 | 37 | const payload = { 38 | userId: user.id, 39 | role: user.role 40 | } 41 | 42 | const accessToken = await generateJWT({ 43 | payload 44 | }) 45 | return { 46 | accessToken, 47 | ...payload 48 | } 49 | } 50 | } 51 | 52 | export default AuthService 53 | -------------------------------------------------------------------------------- /src/modules/auth/auth.types.js: -------------------------------------------------------------------------------- 1 | /** 2 | * AuthController 3 | * @typedef {import('./auth.controller.js')} AuthController 4 | */ 5 | 6 | /** 7 | * AuthService 8 | * @typedef {import('./auth.service.js')} AuthService 9 | */ 10 | 11 | /** 12 | * AuthValidator 13 | * @typedef {import('./auth.validator.js')} AuthValidator 14 | */ 15 | -------------------------------------------------------------------------------- /src/modules/auth/auth.validator.js: -------------------------------------------------------------------------------- 1 | import Joi from 'joi' 2 | 3 | const options = { 4 | errors: { 5 | wrap: { 6 | label: '' 7 | } 8 | } 9 | } 10 | 11 | export default { 12 | /** 13 | * Validates a login request. 14 | * @param {object} httpRequest - The HTTP request object. 15 | * @param {object} httpRequest.body - The request body. 16 | * @param {string} httpRequest.body.phone - The phone number to validate. 17 | * @param {string} httpRequest.body.password - The password to validate. 18 | * @returns {object} - The validation result. 19 | */ 20 | validateLogin: (httpRequest) => { 21 | const schema = Joi.object({ 22 | phone: Joi.string() 23 | .pattern(/^[6-9]\d{9}$/) 24 | .required() 25 | .messages({ 26 | 'string.pattern.base': 'Provide valid phone number!' 27 | }), 28 | password: Joi.string().min(8).max(20).alphanum().required() 29 | }) 30 | return schema.validate(httpRequest.body, options) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/modules/auth/jwt.service.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | import config from 'config' 3 | import { BadRequestError } from '../../utils/api-errors.js' 4 | 5 | /** 6 | * Generates a JWT token. 7 | * @param {object} root0 - The input object. 8 | * @param {object} root0.payload - The payload to sign. 9 | * @param {string} [root0.secretKey=JWT_ACCESS_TOKEN_SECRET] - The secret key to use for signing. 10 | * @param {object} [root0.signOption=JWT_SIGN_OPTIONS] - The sign options to use. 11 | * @returns {Promise} - The generated JWT token. 12 | * @throws {BadRequestError} - If there is an error generating the token. 13 | */ 14 | export const generateJWT = async ({ 15 | payload, 16 | secretKey = config.JWT_ACCESS_TOKEN_SECRET, 17 | signOption = config.JWT_SIGN_OPTIONS 18 | }) => { 19 | try { 20 | const token = `Bearer ${jwt.sign(payload, secretKey, signOption)}` 21 | return token 22 | } catch (error) { 23 | throw new BadRequestError(error.message) 24 | } 25 | } 26 | 27 | /** 28 | * Verifies a JWT token. 29 | * @param {object} root0 - The input object. 30 | * @param {string} root0.token - The token to verify. 31 | * @param {string} [root0.secretKey=JWT_ACCESS_TOKEN_SECRET] - The secret key to use for verification. 32 | * @param {object} [root0.signOption=JWT_SIGN_OPTIONS] - The sign options to use. 33 | * @returns {Promise} - The decoded token data. 34 | * @throws {BadRequestError} - If there is an error verifying the token. 35 | */ 36 | export const verifyJWT = async ({ 37 | token, 38 | secretKey = config.JWT_ACCESS_TOKEN_SECRET, 39 | signOption = config.JWT_SIGN_OPTIONS 40 | }) => { 41 | try { 42 | const data = jwt.verify(token, secretKey, signOption) 43 | return data 44 | } catch (error) { 45 | throw new BadRequestError(error.message) 46 | } 47 | } 48 | 49 | export default { generateJWT, verifyJWT } 50 | -------------------------------------------------------------------------------- /src/modules/typedefs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a context object. 3 | * @typedef {object} Context 4 | * @property {string} userId - The user ID. 5 | * @property {string} role - The user role. 6 | * @property {string} accessToken - The access token. 7 | */ 8 | 9 | /** 10 | * Represents an Express request object. 11 | * @typedef {import('express').Request} ExpressRequest 12 | */ 13 | 14 | /** 15 | * Represents an Express response object. 16 | * @typedef {import('express').Response} ExpressResponse 17 | */ 18 | 19 | /** 20 | * Represents an Express next function. 21 | * @typedef {import('express').NextFunction} ExpressNextFunction 22 | */ 23 | 24 | /** 25 | * Represents a controller response object. 26 | * @typedef {object} ControllerResponse 27 | * @property {number} statusCode - The HTTP status code. 28 | * @property {{data: (object | Array)}} body - The response body. 29 | */ 30 | 31 | /** 32 | * A function that creates an Express validator callback. 33 | * @typedef {Function} makeValidatorCallback 34 | * @param {Function} validator - The validation function. 35 | * @returns {function(ExpressRequest, ExpressResponse, ExpressNextFunction): void} - The Express validator callback. 36 | */ 37 | 38 | /** 39 | * A function that creates an Express callback. 40 | * @typedef {Function} makeExpressCallback 41 | * @param {Function} validator - The validation function. 42 | */ 43 | 44 | /** 45 | * Represents a user data transfer object. 46 | * @typedef {object} UserDto 47 | * @property {string} id - The user ID. 48 | * @property {string} phone - The user phone number. 49 | * @property {string} password - The user password. 50 | * @property {Date} createdAt - The date the user was created. 51 | * @property {Date} updatedAt - The date the user was last updated. 52 | */ 53 | -------------------------------------------------------------------------------- /src/modules/user/user.module.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sujeet-agrahari/node-express-modular-architecture/90c50ac3b3cfcba5fb61c2ffcb539accab6ea1b0/src/modules/user/user.module.js -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | import http from 'http' 2 | import stoppable from 'stoppable' 3 | import app from './app.js' 4 | import normalizePort from './utils/normalize-port.js' 5 | import gracefulShutdown from './utils/graceful-shutdown.js' 6 | 7 | /* eslint-disable no-console */ 8 | 9 | /** 10 | * Get port from environment and store in Express. 11 | */ 12 | const port = normalizePort(process.env.PORT || '3000') 13 | app.set('port', port) 14 | 15 | /** 16 | * Create HTTP server. 17 | */ 18 | const server = http.createServer(app) 19 | 20 | /** 21 | * Listen on provided port, on all network interfaces. 22 | */ 23 | server.listen(port) 24 | 25 | /** 26 | * Handle server errors. 27 | * @param {Error} error - The error to handle. 28 | * @throws {Error} - If the error is not a listen error or is not a known error code. 29 | */ 30 | function onError (error) { 31 | if (error.syscall !== 'listen') { 32 | throw error 33 | } 34 | 35 | const bind = typeof port === 'string' ? `Pipe ${port}` : `Port ${port}` 36 | 37 | // handle specific listen errors with friendly messages 38 | switch (error.code) { 39 | case 'EACCES': 40 | console.error(`${bind} requires elevated privileges`) 41 | process.exit(1) 42 | break 43 | case 'EADDRINUSE': 44 | console.error(`${bind} is already in use`) 45 | process.exit(1) 46 | break 47 | default: 48 | throw error 49 | } 50 | } 51 | 52 | /** 53 | * Event listener for HTTP server "listening" event. 54 | */ 55 | function onListening () { 56 | const addr = server.address() 57 | const bind = typeof addr === 'string' ? `pipe ${addr}` : `port ${addr.port}` 58 | console.info(`Listening on ${bind} in ${process.env.NODE_ENV} environment`) 59 | } 60 | 61 | server.on('error', onError) 62 | server.on('listening', onListening) 63 | 64 | // quit on ctrl+c when running docker in terminal 65 | process.on('SIGINT', async () => { 66 | console.info( 67 | 'Got SIGINT (aka ctrl+c in docker). Graceful shutdown', 68 | new Date().toISOString() 69 | ) 70 | await gracefulShutdown(stoppable(server)) 71 | }) 72 | 73 | // quit properly on docker stop 74 | process.on('SIGTERM', async () => { 75 | console.log( 76 | 'Got SIGTERM (docker container stop). Graceful shutdown', 77 | new Date().toISOString() 78 | ) 79 | await gracefulShutdown(stoppable(server)) 80 | }) 81 | -------------------------------------------------------------------------------- /src/support/logger.js: -------------------------------------------------------------------------------- 1 | import winston from 'winston' 2 | import config from 'config' 3 | import expressWinston from 'express-winston' 4 | import { readJsonFileSync } from '../utils/helper.js' 5 | 6 | // Example usage of the helper function 7 | const packageName = readJsonFileSync('../package.json') 8 | 9 | // Log formatter function 10 | const logFormatter = winston.format.printf((info) => { 11 | const { timestamp, level, stack, message } = info 12 | const errorMessage = stack || message 13 | 14 | const symbols = Object.getOwnPropertySymbols(info) 15 | if (info[symbols[0]] !== 'error') { 16 | return `${timestamp} ${level}: ${message}` 17 | } 18 | 19 | return `${timestamp} ${level}: ${errorMessage}` 20 | }) 21 | 22 | // Create the base logger configuration 23 | const baseLoggerConfig = { 24 | maxsize: 5242880, // 5MB 25 | maxFiles: 5, 26 | level: 'debug', 27 | format: winston.format.combine( 28 | winston.format.timestamp({ 29 | format: 'YYYY-MM-DD HH:mm:ss' 30 | }), 31 | winston.format.errors({ stack: true }), 32 | winston.format.splat(), 33 | winston.format.json() 34 | ), 35 | defaultMeta: { service: `${packageName.name.toLocaleLowerCase()}-service` } 36 | } 37 | 38 | // Create the console transport configuration 39 | const consoleTransport = new winston.transports.Console({ 40 | format: winston.format.combine(winston.format.colorize(), logFormatter) 41 | }) 42 | 43 | // Create the logger with the console transport 44 | const logger = winston.createLogger({ 45 | ...baseLoggerConfig, 46 | transports: [consoleTransport] 47 | }) 48 | 49 | // Add file transports if in production environment 50 | if (config.NODE_ENV === 'production') { 51 | logger.add( 52 | new winston.transports.File({ filename: 'logs/error.log', level: 'error' }) 53 | ) 54 | logger.add( 55 | new winston.transports.File({ 56 | filename: 'logs/combined.log', 57 | level: 'debug' 58 | }) 59 | ) 60 | } 61 | 62 | /** 63 | * Request logger middleware for Express. 64 | * @type {import('express').RequestHandler} 65 | */ 66 | const requestLogger = expressWinston.logger({ 67 | transports: [new winston.transports.Console()], 68 | format: winston.format.combine( 69 | winston.format.json(), 70 | winston.format.prettyPrint() 71 | ), 72 | meta: true, 73 | msg: 'HTTP {{req.method}} {{req.url}}', 74 | expressFormat: true, 75 | colorize: false, 76 | /** 77 | * A function that always returns `false`. 78 | * @param {object} _req - The request object. 79 | * @param {object} _res - The response object. 80 | * @returns {boolean} - Always returns `false`. 81 | */ 82 | ignoreRoute (_req, _res) { 83 | return false 84 | } 85 | }) 86 | 87 | export { logger, requestLogger } 88 | -------------------------------------------------------------------------------- /src/utils/api-errors.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Base class for API errors. 3 | */ 4 | class APIError extends Error { 5 | /** 6 | * Create a new HTTP error. 7 | * @param {number} status - The HTTP status code of the error. 8 | * @param {string} message - The error message. 9 | */ 10 | constructor (status, message) { 11 | super() 12 | this.status = status 13 | this.message = message 14 | } 15 | } 16 | 17 | /** 18 | * Class representing a Bad Request error. 19 | */ 20 | class BadRequestError extends APIError { 21 | /** 22 | * Create a new `BadRequest` error. 23 | * @param {string} [message='Bad Request'] - The error message. 24 | */ 25 | constructor (message = 'Bad Request') { 26 | super(400, message) 27 | } 28 | } 29 | 30 | /** 31 | * Class representing an Access Denied error. 32 | */ 33 | class AccessDeniedError extends APIError { 34 | /** 35 | * Create a new `AccessDenied` error. 36 | * @param {string} [message='Access Denied'] - The error message. 37 | */ 38 | constructor (message = 'Access Denied') { 39 | super(401, message) 40 | } 41 | } 42 | 43 | /** 44 | * Class representing an Unauthorized error. 45 | */ 46 | class UnauthorizedError extends APIError { 47 | /** 48 | * Create a new `Unauthorized` error. 49 | * @param {string} [message='Unauthorized'] - The error message. 50 | */ 51 | constructor (message = 'Unauthorized') { 52 | super(403, message) 53 | } 54 | } 55 | 56 | /** 57 | * Class representing a Forbidden error. 58 | */ 59 | class ForbiddenError extends APIError { 60 | /** 61 | * Create a new `Forbidden` error. 62 | * @param {string} [message='Forbidden'] - The error message. 63 | */ 64 | constructor (message = 'Forbidden') { 65 | super(403, message) 66 | } 67 | } 68 | 69 | /** 70 | * Class representing a Not Found error. 71 | */ 72 | class NotFoundError extends APIError { 73 | /** 74 | * Create a new `NotFound` error. 75 | * @param {string} [message='Not Found'] - The error message. 76 | */ 77 | constructor (message = 'Not Found') { 78 | super(404, message) 79 | } 80 | } 81 | 82 | /** 83 | * Class representing a Method Not Allowed error. 84 | */ 85 | class MethodNotAllowedError extends APIError { 86 | /** 87 | * Create a new `MethodNotAllowed` error. 88 | * @param {string} [message='Method Not Allowed'] - The error message. 89 | */ 90 | constructor (message = 'Method Not Allowed') { 91 | super(405, message) 92 | } 93 | } 94 | 95 | /** 96 | * Class representing a Conflict error. 97 | */ 98 | class ConflictError extends APIError { 99 | /** 100 | * Create a new `Conflict` error. 101 | * @param {string} [message='Conflict'] - The error message. 102 | */ 103 | constructor (message = 'Conflict') { 104 | super(408, message) 105 | } 106 | } 107 | 108 | /** 109 | * Class representing an Unsupported Media Type error. 110 | */ 111 | class UnSupportedMediaTypeError extends APIError { 112 | /** 113 | * Create a new `UnsupportedMediaType` error. 114 | * @param {string} [message='Unsupported Media Type'] - The error message. 115 | */ 116 | constructor (message = 'Unsupported Media Type') { 117 | super(415, message) 118 | } 119 | } 120 | 121 | /** 122 | * Class representing an Unprocessable Entity error. 123 | */ 124 | class UnProcessableEntityError extends APIError { 125 | /** 126 | * Create a new `UnProcessableEntity` error. 127 | * @param {string} [message='Unprocessable Entity'] - The error message. 128 | */ 129 | constructor (message = 'Unprocessable Entity') { 130 | super(422, message) 131 | } 132 | } 133 | 134 | /** 135 | * Class representing an Internal Server error. 136 | */ 137 | class InternalServerError extends APIError { 138 | /** 139 | * Create a new `InternalServer` error. 140 | * @param {string} [message='Internal Server Error'] - The error message. 141 | */ 142 | constructor (message = 'Internal Server Error') { 143 | super(500, message) 144 | } 145 | } 146 | 147 | export { 148 | APIError, 149 | ConflictError, 150 | ForbiddenError, 151 | NotFoundError, 152 | BadRequestError, 153 | UnauthorizedError, 154 | AccessDeniedError, 155 | InternalServerError, 156 | MethodNotAllowedError, 157 | UnProcessableEntityError, 158 | UnSupportedMediaTypeError 159 | } 160 | -------------------------------------------------------------------------------- /src/utils/graceful-shutdown.js: -------------------------------------------------------------------------------- 1 | import { sequelize } from '../db/models/index.js' 2 | import { logger } from '../support/logger.js' 3 | 4 | /** 5 | * Close the server and database connections and exit the process. 6 | * @param {import('http').Server} server - The server object to close. 7 | * @returns {Promise} - A promise that resolves when the server and database connections are closed and the process is exited. 8 | */ 9 | const gracefulShutdown = async (server) => { 10 | try { 11 | await sequelize.close() 12 | logger.info('Closed database connection!') 13 | await server.close() 14 | process.exit() 15 | } catch (error) { 16 | logger.error(error.message) 17 | process.exit(1) 18 | } 19 | } 20 | 21 | export default gracefulShutdown 22 | -------------------------------------------------------------------------------- /src/utils/helper.js: -------------------------------------------------------------------------------- 1 | import httpStatus from './httpStatus.js' 2 | import fs from 'fs' 3 | import path from 'path' 4 | 5 | /** 6 | * Get the ID parameter from a request object. 7 | * @param {object} req - The request object. 8 | * @param {object} req.params - The parameters of the request. 9 | * @param {string} req.params.id - The ID parameter as a string. 10 | * @returns {number} - The ID parameter as a number. 11 | * @throws {TypeError} - If the ID parameter is not a valid number. 12 | */ 13 | export function getIdParam (req) { 14 | const { id } = req.params 15 | if (/^\d+$/.test(id)) { 16 | return Number.parseInt(id, 10) 17 | } 18 | throw new TypeError(`Invalid ':id' param: "${id}"`) 19 | } 20 | 21 | /** 22 | * Generates a response object with the provided data and status code. 23 | * 24 | * @param {*} data - The data to include in the response. 25 | * @param {number} [statusCode=httpStatus.OK] - The status code for the response (default: 200). 26 | * @returns {Object} The response object containing the data and status code. 27 | */ 28 | export function generateResponse (data, statusCode = httpStatus.OK) { 29 | return { 30 | statusCode, 31 | data 32 | } 33 | } 34 | 35 | /** 36 | * Reads a JSON file synchronously and parses its content. 37 | * 38 | * @param {string} filePath - The path to the JSON file. 39 | * @returns {Object} The parsed JSON content. 40 | * @throws {Error} If the file cannot be read or parsed. 41 | */ 42 | export const readJsonFileSync = (filePath) => { 43 | const absolutePath = path.resolve(filePath) 44 | const fileContent = fs.readFileSync(absolutePath, 'utf-8') 45 | return JSON.parse(fileContent) 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/httpStatus.js: -------------------------------------------------------------------------------- 1 | export default { 2 | CONTINUE: 100, 3 | SWITCHING_PROTOCOLS: 101, 4 | OK: 200, 5 | CREATED: 201, 6 | ACCEPTED: 202, 7 | NO_CONTENT: 204, 8 | MOVED_PERMANENTLY: 301, 9 | FOUND: 302, 10 | SEE_OTHER: 303, 11 | NOT_MODIFIED: 304, 12 | BAD_REQUEST: 400, 13 | UNAUTHORIZED: 401, 14 | FORBIDDEN: 403, 15 | NOT_FOUND: 404, 16 | METHOD_NOT_ALLOWED: 405, 17 | CONFLICT: 409, 18 | INTERNAL_SERVER_ERROR: 500, 19 | NOT_IMPLEMENTED: 501, 20 | BAD_GATEWAY: 502, 21 | SERVICE_UNAVAILABLE: 503 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/normalize-port.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Normalize a port into a number, string, or false. 3 | * @param {string} val - The port value to normalize. 4 | * @returns {(number|string|false)} - The normalized port value. 5 | */ 6 | export default (val) => { 7 | const port = parseInt(val, 10) 8 | if (Number.isNaN(port)) { 9 | // named pipe 10 | return val 11 | } 12 | if (port >= 0) { 13 | // port number 14 | return port 15 | } 16 | return false 17 | } 18 | -------------------------------------------------------------------------------- /tests/fixtures/user.fixture.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | id: '5a406953-755c-44c4-940e-0fec7348a8a3', 3 | phone: '9934897867', 4 | password: 'jklsf0928304', 5 | role: 'User' 6 | } 7 | -------------------------------------------------------------------------------- /tests/middlewares/auth.test.js: -------------------------------------------------------------------------------- 1 | import authMiddleware from '../../src/middlewares/auth.js' 2 | import JwtService from '../../src/modules/auth/jwt.service.js' 3 | import { UnauthorizedError } from '../../src/utils/api-errors.js' 4 | 5 | jest.mock('../../src/modules/auth/jwt.service.js') 6 | 7 | describe('auth middleware', () => { 8 | let req 9 | let res 10 | let next 11 | 12 | beforeEach(() => { 13 | req = { 14 | method: 'GET', 15 | path: '/api/v1/test', 16 | header: jest.fn() 17 | } 18 | res = {} 19 | next = jest.fn() 20 | }) 21 | 22 | afterEach(() => { 23 | jest.resetAllMocks() 24 | }) 25 | 26 | /** 27 | * Test case for OPTIONS method. 28 | * @returns {Promise} 29 | */ 30 | test('should call next if method is OPTIONS', async () => { 31 | expect.assertions(1) 32 | req.method = 'OPTIONS' 33 | 34 | await authMiddleware(req, res, next) 35 | 36 | expect(next).toHaveBeenCalled() 37 | }) 38 | 39 | /** 40 | * Test case for /api/v1/auth/login path. 41 | * @returns {Promise} 42 | */ 43 | test('should call next if path is /api/v1/auth/login', async () => { 44 | expect.assertions(1) 45 | 46 | req.path = '/api/v1/auth/login' 47 | 48 | await authMiddleware(req, res, next) 49 | 50 | expect(next).toHaveBeenCalled() 51 | }) 52 | 53 | /** 54 | * Test case for setting req.context if Authorization header is present. 55 | * @returns {Promise} 56 | */ 57 | test('should set req.context if Authorization header is present', async () => { 58 | expect.assertions(2) 59 | 60 | req.header.mockReturnValueOnce('Bearer token') 61 | JwtService.verifyJWT.mockResolvedValueOnce({ userId: 1 }) 62 | 63 | await authMiddleware(req, res, next) 64 | 65 | expect(req.context).toEqual({ userId: 1 }) 66 | expect(next).toHaveBeenCalled() 67 | }) 68 | 69 | /** 70 | * Test case for throwing UnauthorizedError if Authorization header is missing. 71 | * @returns {Promise} 72 | */ 73 | test('should throw UnauthorizedError if Authorization header is missing', async () => { 74 | expect.assertions(1) 75 | 76 | req.header.mockReturnValueOnce(undefined) 77 | 78 | await expect(authMiddleware(req, res, next)).rejects.toThrow( 79 | UnauthorizedError 80 | ) 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /tests/middlewares/authorize.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @fileOverview Tests for authorize middleware. 3 | */ 4 | 5 | import authorizeMiddleware from '../../src/middlewares/authorize' 6 | import { UnauthorizedError } from '../../src/utils/api-errors' 7 | 8 | describe('authorize middleware', () => { 9 | let req 10 | let res 11 | let next 12 | 13 | beforeEach(() => { 14 | req = { 15 | user: {} 16 | } 17 | res = {} 18 | next = jest.fn() 19 | }) 20 | 21 | afterEach(() => { 22 | jest.resetAllMocks() 23 | }) 24 | 25 | test('should call next if user role is included in allowed roles', async () => { 26 | expect.assertions(1) 27 | req.user.role = 'admin' 28 | 29 | await authorizeMiddleware(['admin'])(req, res, next) 30 | 31 | expect(next).toHaveBeenCalled() 32 | }) 33 | 34 | test('should throw UnauthorizedError if user role is not included in allowed roles', async () => { 35 | expect.assertions(2) 36 | 37 | const err = new UnauthorizedError() 38 | 39 | req.user.role = 'user' 40 | 41 | try { 42 | await authorizeMiddleware(['admin'])(req, res, next) 43 | } catch (error) { 44 | // eslint-disable-next-line jest/no-conditional-expect 45 | expect(error).toBeInstanceOf(UnauthorizedError) 46 | // eslint-disable-next-line jest/no-conditional-expect 47 | expect(error.message).toBe(err.message) 48 | } 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /tests/middlewares/error-handler.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | UniqueConstraintError, 3 | ValidationError, 4 | AggregateError 5 | } from 'sequelize' 6 | import { logger } from '../../src/support/logger' 7 | import { APIError } from '../../src/utils/api-errors' 8 | import errorHandlerMiddleware from '../../src/middlewares/error-handler' 9 | 10 | jest.mock('../../src/support/logger') 11 | 12 | /** 13 | * Tests for errorHandlerMiddleware 14 | */ 15 | describe('errorHandlerMiddleware', () => { 16 | test('should return APIError response if error is an instance of APIError', () => { 17 | expect.assertions(2) 18 | 19 | const error = new APIError('Test error', 400) 20 | const req = {} 21 | const res = { 22 | status: jest.fn().mockReturnThis(), 23 | json: jest.fn() 24 | } 25 | const next = jest.fn() 26 | 27 | errorHandlerMiddleware(error, req, res, next) 28 | 29 | expect(res.status).toHaveBeenCalledWith(error.status) 30 | expect(res.json).toHaveBeenCalledWith({ 31 | error: { 32 | code: error.status, 33 | message: error.message 34 | } 35 | }) 36 | }) 37 | 38 | test('should return UniqueConstraintError response if error is an instance of UniqueConstraintError', () => { 39 | expect.assertions(2) 40 | 41 | const error = new UniqueConstraintError({ 42 | message: 'Validation error', 43 | errors: [ 44 | { 45 | message: 'Duplicate key value violates unique constraint', 46 | path: 'email', 47 | type: 'unique violation', 48 | value: 'test@example.com', 49 | origin: 'DB', 50 | instance: null, 51 | validatorKey: 'not_unique' 52 | } 53 | ], 54 | fields: ['email'], 55 | parent: { 56 | constraint: 'users_email_key' 57 | } 58 | }) 59 | const req = {} 60 | const res = { 61 | status: jest.fn().mockReturnThis(), 62 | json: jest.fn() 63 | } 64 | const next = jest.fn() 65 | 66 | errorHandlerMiddleware(error, req, res, next) 67 | 68 | expect(res.status).toHaveBeenCalledWith(400) 69 | expect(res.json).toHaveBeenCalledWith({ 70 | error: { 71 | code: 400, 72 | message: `duplicate_${error.parent.constraint}` 73 | } 74 | }) 75 | }) 76 | 77 | test('should return ValidationError response if error is an instance of ValidationError', () => { 78 | expect.assertions(2) 79 | 80 | const error = new ValidationError('Validation error') 81 | const req = {} 82 | const res = { 83 | status: jest.fn().mockReturnThis(), 84 | json: jest.fn() 85 | } 86 | const next = jest.fn() 87 | 88 | errorHandlerMiddleware(error, req, res, next) 89 | 90 | expect(res.status).toHaveBeenCalledWith(400) 91 | expect(res.json).toHaveBeenCalledWith({ 92 | error: { 93 | code: 400, 94 | message: error.message 95 | } 96 | }) 97 | }) 98 | 99 | test('should json response with the first error message if error is instance of AggregationError', async () => { 100 | expect.assertions(2) 101 | 102 | const error = new AggregateError([ 103 | { 104 | message: 'Validation error-1' 105 | }, 106 | { 107 | message: 'Validation error-1' 108 | } 109 | ]) 110 | const req = {} 111 | const res = { 112 | status: jest.fn().mockReturnThis(), 113 | json: jest.fn() 114 | } 115 | const next = jest.fn() 116 | 117 | await errorHandlerMiddleware(error, req, res, next) 118 | 119 | expect(res.status).toHaveBeenCalledWith(400) 120 | expect(res.json).toHaveBeenCalledWith({ 121 | error: { 122 | code: 400, 123 | message: 'Validation error-1' 124 | } 125 | }) 126 | }) 127 | 128 | test('should json response with "Unknown error" no error message and error is instance of AggregationError', async () => { 129 | expect.assertions(2) 130 | 131 | const error = new AggregateError([]) 132 | const req = {} 133 | const res = { 134 | status: jest.fn().mockReturnThis(), 135 | json: jest.fn() 136 | } 137 | const next = jest.fn() 138 | 139 | await errorHandlerMiddleware(error, req, res, next) 140 | 141 | expect(res.status).toHaveBeenCalledWith(400) 142 | expect(res.json).toHaveBeenCalledWith({ 143 | error: { 144 | code: 400, 145 | message: 'Unknown error' 146 | } 147 | }) 148 | }) 149 | 150 | test('should return default error response if error is not an instance of any known error type', () => { 151 | expect.assertions(2) 152 | 153 | const error = new Error('Test error') 154 | const req = {} 155 | const res = { 156 | status: jest.fn().mockReturnThis(), 157 | json: jest.fn() 158 | } 159 | const next = jest.fn() 160 | 161 | errorHandlerMiddleware(error, req, res, next) 162 | 163 | expect(res.status).toHaveBeenCalledWith(500) 164 | expect(res.json).toHaveBeenCalledWith({ 165 | error: { 166 | code: 500, 167 | message: 'Something went wrong!' 168 | } 169 | }) 170 | }) 171 | 172 | test('should log error using logger', () => { 173 | expect.assertions(1) 174 | 175 | const error = new Error('Test error') 176 | const req = {} 177 | const res = { 178 | status: jest.fn().mockReturnThis(), 179 | json: jest.fn() 180 | } 181 | const next = jest.fn() 182 | 183 | errorHandlerMiddleware(error, req, res, next) 184 | 185 | expect(logger.error).toHaveBeenCalledWith(error) 186 | }) 187 | }) 188 | -------------------------------------------------------------------------------- /tests/middlewares/express-callback.test.js: -------------------------------------------------------------------------------- 1 | import expressCallbackMiddleware from '../../src/middlewares/express-callback' 2 | 3 | /** 4 | * Tests for express-callback middleware 5 | */ 6 | describe('express-callback middleware', () => { 7 | let req 8 | let res 9 | let controller 10 | 11 | beforeEach(() => { 12 | req = { 13 | body: {}, 14 | query: {}, 15 | params: {}, 16 | ip: '127.0.0.1', 17 | method: 'GET', 18 | path: '/api/v1/test', 19 | get: jest.fn() 20 | } 21 | res = { 22 | set: jest.fn(), 23 | status: jest.fn().mockReturnThis(), 24 | json: jest.fn() 25 | } 26 | controller = jest.fn().mockResolvedValue({ 27 | statusCode: 200, 28 | body: {}, 29 | headers: { 30 | 'Content-Type': 'application/json' 31 | } 32 | }) 33 | }) 34 | 35 | afterEach(() => { 36 | jest.resetAllMocks() 37 | }) 38 | 39 | /** 40 | * Test to ensure the controller is called with the correct httpRequest 41 | */ 42 | test('should call controller with correct httpRequest', async () => { 43 | expect.assertions(1) 44 | 45 | await expressCallbackMiddleware(controller)(req, res) 46 | 47 | expect(controller).toHaveBeenCalledWith({ 48 | body: req.body, 49 | query: req.query, 50 | params: req.params, 51 | ip: req.ip, 52 | method: req.method, 53 | path: req.path, 54 | headers: { 55 | 'Content-Type': req.get('Content-Type'), 56 | Authorization: req.get('Authorization'), 57 | Referer: req.get('referer'), 58 | 'User-Agent': req.get('User-Agent') 59 | } 60 | }) 61 | }) 62 | 63 | /** 64 | * Test to ensure headers are set and response is sent with correct status code and body 65 | */ 66 | test('should set headers and send response with correct status code and body', async () => { 67 | expect.assertions(3) 68 | 69 | const httpResponse = { 70 | statusCode: 200, 71 | data: {}, 72 | headers: { 73 | 'Content-Type': 'application/json' 74 | } 75 | } 76 | controller.mockResolvedValueOnce(httpResponse) 77 | 78 | await expressCallbackMiddleware(controller)(req, res) 79 | 80 | expect(res.set).toHaveBeenCalledWith(httpResponse.headers) 81 | expect(res.status).toHaveBeenCalledWith(httpResponse.statusCode) 82 | expect(res.json).toHaveBeenCalledWith(httpResponse.data) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /tests/middlewares/not-found.error.test.js: -------------------------------------------------------------------------------- 1 | import middleware from '../../src/middlewares/not-found-error' 2 | import { NotFoundError } from '../../src/utils/api-errors' 3 | 4 | /** 5 | * Tests for not-found-error middleware. 6 | */ 7 | describe('not-found-error middleware', () => { 8 | let req 9 | let res 10 | 11 | beforeEach(() => { 12 | req = { 13 | method: 'GET', 14 | url: '/api/v1/test' 15 | } 16 | res = {} 17 | }) 18 | 19 | test('should throw NotFoundError with correct error message', async () => { 20 | expect.assertions(2) 21 | const expectedErrorMessage = `Not Found: ${req.method} on ${req.url}` 22 | 23 | try { 24 | await middleware(req, res) 25 | } catch (error) { 26 | // eslint-disable-next-line jest/no-conditional-expect 27 | expect(error).toBeInstanceOf(NotFoundError) 28 | // eslint-disable-next-line jest/no-conditional-expect 29 | expect(error.message).toBe(expectedErrorMessage) 30 | } 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /tests/middlewares/validate-json.test.js: -------------------------------------------------------------------------------- 1 | import { BadRequestError } from '../../src/utils/api-errors' 2 | import validateJsonMiddleware from '../../src/middlewares/validate-json' 3 | 4 | /** 5 | * Tests for validate-json middleware 6 | */ 7 | describe('validate-json middleware', () => { 8 | /** 9 | * Test to ensure BadRequestError is thrown if err is a SyntaxError with status 400 and body property 10 | */ 11 | test('should throw BadRequestError if err is a SyntaxError with status 400 and body property', async () => { 12 | expect.assertions(2) 13 | const err = new SyntaxError('Invalid JSON') 14 | err.status = 400 15 | err.body = '{ "foo": "bar }' 16 | 17 | const req = {} 18 | const res = {} 19 | const next = jest.fn() 20 | 21 | try { 22 | await validateJsonMiddleware(err, req, res, next) 23 | } catch (error) { 24 | // eslint-disable-next-line jest/no-conditional-expect 25 | expect(error).toBeInstanceOf(BadRequestError) 26 | // eslint-disable-next-line jest/no-conditional-expect 27 | expect(error.message).toBe(err.message) 28 | } 29 | }) 30 | 31 | /** 32 | * Test to ensure next is called if err is not a SyntaxError with status 400 and body property 33 | */ 34 | test('should call next if err is not a SyntaxError with status 400 and body property', () => { 35 | expect.assertions(1) 36 | 37 | const err = new Error('Internal server error') 38 | err.status = 500 39 | err.body = '{ "foo": "bar" }' 40 | 41 | const req = {} 42 | const res = {} 43 | const next = jest.fn() 44 | 45 | validateJsonMiddleware(err, req, res, next) 46 | 47 | expect(next).toHaveBeenCalled() 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /tests/middlewares/validator-callback.test.js: -------------------------------------------------------------------------------- 1 | import validatorCallback from '../../src/middlewares/validator-callback' 2 | import { BadRequestError } from '../../src/utils/api-errors' 3 | 4 | /** 5 | * Tests for validator-callback middleware 6 | */ 7 | describe('validator-callback middleware', () => { 8 | let req 9 | let res 10 | let next 11 | let validator 12 | 13 | beforeEach(() => { 14 | req = { 15 | body: {}, 16 | query: {}, 17 | params: {} 18 | } 19 | res = {} 20 | next = jest.fn() 21 | validator = jest.fn().mockReturnValue({ 22 | error: null, 23 | value: {} 24 | }) 25 | }) 26 | 27 | afterEach(() => { 28 | jest.resetAllMocks() 29 | }) 30 | 31 | /** 32 | * Test to ensure the validator is called with the correct httpRequest 33 | */ 34 | test('should call validator with correct httpRequest', async () => { 35 | expect.assertions(1) 36 | 37 | await validatorCallback(validator)(req, res, next) 38 | 39 | expect(validator).toHaveBeenCalledWith({ 40 | body: req.body, 41 | query: req.query, 42 | params: req.params 43 | }) 44 | }) 45 | 46 | /** 47 | * Test to ensure req.body is set with validated value and next is called 48 | */ 49 | test('should set req.body with validated value and call next', async () => { 50 | expect.assertions(2) 51 | 52 | const validatedValue = { foo: 'bar' } 53 | validator.mockReturnValueOnce({ 54 | error: null, 55 | value: validatedValue 56 | }) 57 | 58 | await validatorCallback(validator)(req, res, next) 59 | 60 | expect(req.body).toEqual(validatedValue) 61 | expect(next).toHaveBeenCalled() 62 | }) 63 | 64 | /** 65 | * Test to ensure BadRequestError is thrown if validator returns an error 66 | */ 67 | test('should throw BadRequestError if validator returns an error', async () => { 68 | expect.assertions(2) 69 | 70 | const validationError = new Error('Validation error') 71 | validator.mockReturnValueOnce({ 72 | error: validationError, 73 | value: null 74 | }) 75 | 76 | try { 77 | await validatorCallback(validator)(req, res, next) 78 | } catch (error) { 79 | // eslint-disable-next-line jest/no-conditional-expect 80 | expect(error).toBeInstanceOf(BadRequestError) 81 | // eslint-disable-next-line jest/no-conditional-expect 82 | expect(error.message).toBe(validationError.message) 83 | } 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /tests/modules/auth/auth.controller.test.js: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker' 2 | import config from 'config' 3 | import AuthController from '../../../src/modules/auth/auth.controller' 4 | import AuthService from '../../../src/modules/auth/auth.service' 5 | import JwtService from '../../../src/modules/auth/jwt.service' 6 | 7 | /** 8 | * Tests for AuthController 9 | */ 10 | describe('AuthController', () => { 11 | const payload = { 12 | userId: faker.datatype.uuid(), 13 | role: 'User' 14 | } 15 | 16 | let jwtToken 17 | 18 | /** 19 | * Generate JWT token before all tests 20 | */ 21 | beforeAll(async () => { 22 | jwtToken = await JwtService.generateJWT({ 23 | payload, 24 | secretKey: config.JWT_ACCESS_TOKEN_SECRET 25 | }) 26 | }) 27 | 28 | /** 29 | * Restore mocks after each test 30 | */ 31 | afterEach(() => { 32 | jest.restoreAllMocks() 33 | }) 34 | 35 | /** 36 | * Tests for login method 37 | */ 38 | describe('login', () => { 39 | it('should login user and return token', async () => { 40 | expect.assertions(2) 41 | // Arrange 42 | const httpRequest = { 43 | body: { 44 | phone: faker.phone.phoneNumber('##########'), 45 | password: faker.internet.password(8) 46 | } 47 | } 48 | 49 | const loginData = { 50 | ...payload, 51 | accessToken: jwtToken 52 | } 53 | 54 | const expected = { 55 | statusCode: 200, 56 | data: loginData 57 | } 58 | 59 | const doLoginMock = jest.fn().mockResolvedValue(loginData) 60 | AuthService.doLogin = doLoginMock 61 | 62 | // Act 63 | const result = await AuthController.login(httpRequest) 64 | 65 | // Assert 66 | expect(result).toEqual(expected) 67 | expect(doLoginMock).toHaveBeenCalledWith(httpRequest.body) 68 | }) 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /tests/modules/auth/auth.service.test.js: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcryptjs' 2 | import { faker } from '@faker-js/faker' 3 | 4 | import AuthService from '../../../src/modules/auth/auth.service' 5 | import JwtService from '../../../src/modules/auth/jwt.service' 6 | 7 | jest.mock('../../../src/db/models/User', () => { 8 | const User = { 9 | findOne: jest.fn().mockResolvedValue({ id: 'fake-id', role: 'fake-role' }) 10 | } 11 | return User 12 | }) 13 | 14 | /** 15 | * Test suite for AuthService 16 | */ 17 | describe('AuthService', () => { 18 | /** 19 | * Test case for login method 20 | */ 21 | describe('login', () => { 22 | it('should login user and return token', async () => { 23 | // Arrange 24 | expect.assertions(1) 25 | const requestBody = { 26 | phone: faker.phone.phoneNumber('##########'), 27 | password: faker.internet.password(8) 28 | } 29 | const fakeUser = { 30 | userId: 'fake-id', 31 | role: 'fake-role' 32 | } 33 | const fakeAccessToken = 'fake-access-token' 34 | jest.spyOn(bcrypt, 'compareSync').mockImplementation(() => true) 35 | jest.spyOn(JwtService, 'generateJWT').mockResolvedValue(fakeAccessToken) 36 | jest.spyOn(AuthService, 'doLogin').mockResolvedValue({ 37 | userId: fakeUser.userId, 38 | role: fakeUser.role, 39 | accessToken: fakeAccessToken 40 | }) 41 | const expected = { 42 | ...fakeUser, 43 | accessToken: fakeAccessToken 44 | } 45 | 46 | // Act 47 | const result = await AuthService.doLogin(requestBody) 48 | 49 | // Assert 50 | expect(result).toEqual(expected) 51 | }) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /tests/modules/auth/jwt.service.test.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | import { JWT_ACCESS_TOKEN_SECRET, JWT_SIGN_OPTIONS } from 'config' 3 | import { BadRequestError } from '../../../src/utils/api-errors' 4 | import { generateJWT, verifyJWT } from '../../../src/modules/auth/jwt.service' 5 | 6 | jest.mock('jsonwebtoken') 7 | 8 | /** 9 | * Tests for generateJWT function 10 | */ 11 | describe('generateJWT', () => { 12 | it('should generate a JWT token with the provided payload', async () => { 13 | expect.assertions(2) 14 | 15 | const payload = { userId: '123', role: 'user' } 16 | const expectedToken = 'Bearer generated-token' 17 | 18 | jwt.sign.mockReturnValueOnce('generated-token') 19 | 20 | const token = await generateJWT({ payload }) 21 | 22 | expect(jwt.sign).toHaveBeenCalledWith( 23 | payload, 24 | JWT_ACCESS_TOKEN_SECRET, 25 | JWT_SIGN_OPTIONS 26 | ) 27 | expect(token).toBe(expectedToken) 28 | }) 29 | 30 | it('should throw a BadRequestError if there is an error generating the token', async () => { 31 | expect.assertions(2) 32 | 33 | const payload = { userId: '123', role: 'user' } 34 | const expectedError = new Error('Token generation error') 35 | 36 | jwt.sign.mockImplementationOnce(() => { 37 | throw expectedError 38 | }) 39 | 40 | await expect(generateJWT({ payload })).rejects.toThrow(BadRequestError) 41 | expect(jwt.sign).toHaveBeenCalledWith( 42 | payload, 43 | JWT_ACCESS_TOKEN_SECRET, 44 | JWT_SIGN_OPTIONS 45 | ) 46 | }) 47 | }) 48 | 49 | /** 50 | * Tests for verifyJWT function 51 | */ 52 | describe('verifyJWT', () => { 53 | it('should verify a JWT token and return the decoded data', async () => { 54 | expect.assertions(2) 55 | 56 | const token = 'Bearer valid-token' 57 | const expectedData = { userId: '123', role: 'user' } 58 | 59 | jwt.verify.mockReturnValueOnce(expectedData) 60 | 61 | const data = await verifyJWT({ token }) 62 | 63 | expect(jwt.verify).toHaveBeenCalledWith( 64 | token, 65 | JWT_ACCESS_TOKEN_SECRET, 66 | JWT_SIGN_OPTIONS 67 | ) 68 | expect(data).toBe(expectedData) 69 | }) 70 | 71 | it('should throw a BadRequestError if there is an error verifying the token', async () => { 72 | expect.assertions(2) 73 | 74 | const token = 'Bearer invalid-token' 75 | const expectedError = new Error('Token verification error') 76 | 77 | jwt.verify.mockImplementationOnce(() => { 78 | throw expectedError 79 | }) 80 | 81 | await expect(verifyJWT({ token })).rejects.toThrow(BadRequestError) 82 | expect(jwt.verify).toHaveBeenCalledWith( 83 | token, 84 | JWT_ACCESS_TOKEN_SECRET, 85 | JWT_SIGN_OPTIONS 86 | ) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /tests/support/logger.test.js: -------------------------------------------------------------------------------- 1 | import { logger, requestLogger } from '../../src/support/logger' 2 | import winston from 'winston' 3 | import config from 'config' 4 | 5 | describe('Logger', () => { 6 | it('should create a logger with the correct configuration', () => { 7 | expect(logger).toBeDefined() 8 | expect(logger.transports).toHaveLength(1) 9 | expect(logger.transports[0]).toBeInstanceOf(winston.transports.Console) 10 | }) 11 | 12 | it('should add file transports in production environment', () => { 13 | const originalEnv = config.NODE_ENV 14 | config.NODE_ENV = 'production' 15 | 16 | const prodLogger = winston.createLogger({ 17 | ...logger, 18 | transports: [ 19 | new winston.transports.Console(), 20 | new winston.transports.File({ filename: 'logs/error.log', level: 'error' }), 21 | new winston.transports.File({ filename: 'logs/combined.log', level: 'debug' }) 22 | ] 23 | }) 24 | 25 | expect(prodLogger.transports).toHaveLength(3) 26 | expect(prodLogger.transports[1]).toBeInstanceOf(winston.transports.File) 27 | expect(prodLogger.transports[2]).toBeInstanceOf(winston.transports.File) 28 | 29 | config.NODE_ENV = originalEnv 30 | }) 31 | }) 32 | 33 | describe('Request Logger Middleware', () => { 34 | it('should create a request logger middleware with the correct configuration', () => { 35 | expect(requestLogger).toBeInstanceOf(Function) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /tests/utils/api-error.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | 3 | APIError, 4 | BadRequestError, 5 | AccessDeniedError, 6 | UnauthorizedError, 7 | ForbiddenError, 8 | NotFoundError, 9 | MethodNotAllowedError, 10 | ConflictError, 11 | UnSupportedMediaTypeError, 12 | UnProcessableEntityError, 13 | InternalServerError 14 | } from '../../src/utils/api-errors' 15 | 16 | /** 17 | * Test suite for APIError class 18 | */ 19 | describe('APIError', () => { 20 | it('should create an instance of APIError', () => { 21 | expect.assertions(1) 22 | const error = new APIError(500, 'Internal Server Error') 23 | expect(error).toBeInstanceOf(APIError) 24 | }) 25 | 26 | it('should have the correct status and message properties', () => { 27 | expect.assertions(2) 28 | const error = new APIError(500, 'Internal Server Error') 29 | expect(error.status).toBe(500) 30 | expect(error.message).toBe('Internal Server Error') 31 | }) 32 | }) 33 | 34 | /** 35 | * Test suite for BadRequestError class 36 | */ 37 | describe('BadRequestError', () => { 38 | it('should create an instance of BadRequestError', () => { 39 | expect.assertions(1) 40 | const error = new BadRequestError('Bad Request') 41 | expect(error).toBeInstanceOf(BadRequestError) 42 | }) 43 | 44 | it('should have the correct status and message properties', () => { 45 | expect.assertions(2) 46 | const error = new BadRequestError('Bad Request') 47 | expect(error.status).toBe(400) 48 | expect(error.message).toBe('Bad Request') 49 | }) 50 | }) 51 | 52 | /** 53 | * Test suite for AccessDeniedError class 54 | */ 55 | describe('AccessDeniedError', () => { 56 | it('should create an instance of AccessDeniedError', () => { 57 | expect.assertions(1) 58 | const error = new AccessDeniedError('Access Denied') 59 | expect(error).toBeInstanceOf(AccessDeniedError) 60 | }) 61 | 62 | it('should have the correct status and message properties', () => { 63 | expect.assertions(2) 64 | const error = new AccessDeniedError('Access Denied') 65 | expect(error.status).toBe(401) 66 | expect(error.message).toBe('Access Denied') 67 | }) 68 | }) 69 | 70 | /** 71 | * Test suite for UnauthorizedError class 72 | */ 73 | describe('UnauthorizedError', () => { 74 | it('should create an instance of UnauthorizedError', () => { 75 | expect.assertions(1) 76 | const error = new UnauthorizedError('Unauthorized') 77 | expect(error).toBeInstanceOf(UnauthorizedError) 78 | }) 79 | 80 | it('should have the correct status and message properties', () => { 81 | expect.assertions(2) 82 | const error = new UnauthorizedError('Unauthorized') 83 | expect(error.status).toBe(403) 84 | expect(error.message).toBe('Unauthorized') 85 | }) 86 | }) 87 | 88 | /** 89 | * Test suite for ForbiddenError class 90 | */ 91 | describe('ForbiddenError', () => { 92 | it('should create an instance of ForbiddenError', () => { 93 | expect.assertions(1) 94 | const error = new ForbiddenError('Forbidden') 95 | expect(error).toBeInstanceOf(ForbiddenError) 96 | }) 97 | 98 | it('should have the correct status and message properties', () => { 99 | expect.assertions(2) 100 | const error = new ForbiddenError('Forbidden') 101 | expect(error.status).toBe(403) 102 | expect(error.message).toBe('Forbidden') 103 | }) 104 | }) 105 | 106 | /** 107 | * Test suite for NotFoundError class 108 | */ 109 | describe('NotFoundError', () => { 110 | it('should create an instance of NotFoundError', () => { 111 | expect.assertions(1) 112 | const error = new NotFoundError('Not Found') 113 | expect(error).toBeInstanceOf(NotFoundError) 114 | }) 115 | 116 | it('should have the correct status and message properties', () => { 117 | expect.assertions(2) 118 | const error = new NotFoundError('Not Found') 119 | expect(error.status).toBe(404) 120 | expect(error.message).toBe('Not Found') 121 | }) 122 | }) 123 | 124 | /** 125 | * Test suite for MethodNotAllowedError class 126 | */ 127 | describe('MethodNotAllowedError', () => { 128 | it('should create an instance of MethodNotAllowedError', () => { 129 | expect.assertions(1) 130 | const error = new MethodNotAllowedError('Method Not Allowed') 131 | expect(error).toBeInstanceOf(MethodNotAllowedError) 132 | }) 133 | 134 | it('should have the correct status and message properties', () => { 135 | expect.assertions(2) 136 | const error = new MethodNotAllowedError('Method Not Allowed') 137 | expect(error.status).toBe(405) 138 | expect(error.message).toBe('Method Not Allowed') 139 | }) 140 | }) 141 | 142 | /** 143 | * Test suite for ConflictError class 144 | */ 145 | describe('ConflictError', () => { 146 | it('should create an instance of ConflictError', () => { 147 | expect.assertions(1) 148 | const error = new ConflictError('Conflict') 149 | expect(error).toBeInstanceOf(ConflictError) 150 | }) 151 | 152 | it('should have the correct status and message properties', () => { 153 | expect.assertions(2) 154 | const error = new ConflictError('Conflict') 155 | expect(error.status).toBe(408) 156 | expect(error.message).toBe('Conflict') 157 | }) 158 | }) 159 | 160 | /** 161 | * Test suite for UnSupportedMediaTypeError class 162 | */ 163 | describe('UnSupportedMediaTypeError', () => { 164 | it('should create an instance of UnSupportedMediaTypeError', () => { 165 | expect.assertions(1) 166 | const error = new UnSupportedMediaTypeError('Unsupported Media Type') 167 | expect(error).toBeInstanceOf(UnSupportedMediaTypeError) 168 | }) 169 | 170 | it('should have the correct status and message properties', () => { 171 | expect.assertions(2) 172 | const error = new UnSupportedMediaTypeError('Unsupported Media Type') 173 | expect(error.status).toBe(415) 174 | expect(error.message).toBe('Unsupported Media Type') 175 | }) 176 | }) 177 | 178 | /** 179 | * Test suite for UnProcessableEntityError class 180 | */ 181 | describe('UnProcessableEntityError', () => { 182 | it('should create an instance of UnProcessableEntityError', () => { 183 | expect.assertions(1) 184 | const error = new UnProcessableEntityError('Unprocessable Entity') 185 | expect(error).toBeInstanceOf(UnProcessableEntityError) 186 | }) 187 | 188 | it('should have the correct status and message properties', () => { 189 | expect.assertions(2) 190 | const error = new UnProcessableEntityError('Unprocessable Entity') 191 | expect(error.status).toBe(422) 192 | expect(error.message).toBe('Unprocessable Entity') 193 | }) 194 | }) 195 | 196 | /** 197 | * Test suite for InternalServerError class 198 | */ 199 | describe('InternalServerError', () => { 200 | it('should create an instance of InternalServerError', () => { 201 | expect.assertions(1) 202 | const error = new InternalServerError('Internal Server Error') 203 | expect(error).toBeInstanceOf(InternalServerError) 204 | }) 205 | 206 | it('should have the correct status and message properties', () => { 207 | expect.assertions(2) 208 | const error = new InternalServerError('Internal Server Error') 209 | expect(error.status).toBe(500) 210 | expect(error.message).toBe('Internal Server Error') 211 | }) 212 | }) 213 | -------------------------------------------------------------------------------- /tests/utils/graceful-shutdown.test.js: -------------------------------------------------------------------------------- 1 | import { sequelize } from '../../src/db/models' 2 | import { logger } from '../../src/support/logger' 3 | import gracefulShutdown from '../../src/utils/graceful-shutdown' 4 | 5 | /** 6 | * Tests for gracefulShutdown utility function. 7 | */ 8 | describe('gracefulShutdown', () => { 9 | let server 10 | let exitSpy 11 | let sequelizeCloseSpy 12 | let loggerInfoSpy 13 | let loggerErrorSpy 14 | 15 | beforeEach(() => { 16 | server = { 17 | close: jest.fn() 18 | } 19 | exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {}) 20 | sequelizeCloseSpy = jest.spyOn(sequelize, 'close') 21 | loggerInfoSpy = jest.spyOn(logger, 'info').mockImplementation(() => {}) 22 | loggerErrorSpy = jest.spyOn(logger, 'error').mockImplementation(() => {}) 23 | }) 24 | 25 | afterEach(() => { 26 | exitSpy.mockRestore() 27 | sequelizeCloseSpy.mockRestore() 28 | loggerInfoSpy.mockRestore() 29 | loggerErrorSpy.mockRestore() 30 | }) 31 | 32 | afterAll(() => { 33 | jest.clearAllMocks() 34 | }) 35 | 36 | /** 37 | * Test to ensure gracefulShutdown closes the server and database connections and exits the process. 38 | */ 39 | it('should close the server and database connections and exit the process', async () => { 40 | expect.assertions(3) 41 | await gracefulShutdown(server) 42 | sequelizeCloseSpy.mockResolvedValueOnce() 43 | expect(sequelizeCloseSpy).toHaveBeenCalledTimes(1) 44 | expect(loggerInfoSpy).toHaveBeenCalledWith('Closed database connection!') 45 | expect(exitSpy).toHaveBeenCalledWith() 46 | }) 47 | 48 | /** 49 | * Test to ensure gracefulShutdown logs and exits with an error if an error occurs. 50 | */ 51 | it('should log and exit with an error if an error occurs', async () => { 52 | expect.assertions(2) 53 | const error = new Error('Test error') 54 | sequelizeCloseSpy.mockRejectedValueOnce(error) 55 | await gracefulShutdown(server) 56 | expect(loggerErrorSpy).toHaveBeenCalledWith('Test error') 57 | expect(exitSpy).toHaveBeenCalledWith(1) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /tests/utils/helper.test.js: -------------------------------------------------------------------------------- 1 | import { getIdParam } from '../../src/utils/helper' 2 | 3 | /** 4 | * Tests for the getIdParam function. 5 | */ 6 | describe('getIdParam', () => { 7 | /** 8 | * Test to check if getIdParam returns the ID parameter as a number. 9 | */ 10 | it('should return the ID parameter as a number', () => { 11 | expect.assertions(1) 12 | 13 | const req = { 14 | params: { 15 | id: '12345' 16 | } 17 | } 18 | const result = getIdParam(req) 19 | expect(result).toBe(12345) 20 | }) 21 | 22 | /** 23 | * Test to check if getIdParam throws a TypeError when the ID parameter is not a valid number. 24 | */ 25 | it('should throw a TypeError if the ID parameter is not a valid number', () => { 26 | expect.assertions(1) 27 | 28 | const req = { 29 | params: { 30 | id: 'abc' 31 | } 32 | } 33 | expect(() => getIdParam(req)).toThrow(TypeError) 34 | }) 35 | 36 | /** 37 | * Test to check if getIdParam throws a TypeError with the correct error message. 38 | */ 39 | it('should throw a TypeError with the correct error message', () => { 40 | expect.assertions(1) 41 | 42 | const req = { 43 | params: { 44 | id: 'abc' 45 | } 46 | } 47 | expect(() => getIdParam(req)).toThrow('Invalid \':id\' param: "abc"') 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /tests/utils/normalize-port.test.js: -------------------------------------------------------------------------------- 1 | import normalizePort from '../../src/utils/normalize-port' 2 | import { describe, it, expect } from '@jest/globals' 3 | 4 | /** 5 | * Test suite for the normalizePort utility function. 6 | */ 7 | describe('normalizePort', () => { 8 | /** 9 | * Test case for when the port value is a valid number. 10 | */ 11 | it('should return a number if the port value is a valid number', () => { 12 | expect.assertions(1) 13 | 14 | const result = normalizePort('3000') 15 | expect(result).toBe(3000) 16 | }) 17 | 18 | /** 19 | * Test case for when the port value is not a valid number. 20 | */ 21 | it('should return the port value as a string if it is not a valid number', () => { 22 | expect.assertions(1) 23 | 24 | const result = normalizePort('socket') 25 | expect(result).toBe('socket') 26 | }) 27 | 28 | /** 29 | * Test case for when the port value is a negative number. 30 | */ 31 | it('should return false if the port value is a negative number', () => { 32 | expect.assertions(1) 33 | 34 | const result = normalizePort('-5000') 35 | expect(result).toBe(false) 36 | }) 37 | }) 38 | --------------------------------------------------------------------------------