├── .dockerignore
├── .env.development.local
├── .env.production.local
├── .env.test.local
├── .eslintignore
├── .eslintrc
├── .github
└── workflows
│ └── tests.yml
├── .gitignore
├── .prettierrc
├── Dockerfile
├── README.md
├── docker-compose-db.yml
├── docker-compose.yml
├── ecosystem.config.js
├── jest.config.js
├── package.json
├── src
├── __tests__
│ ├── api
│ │ └── v1
│ │ │ └── auth
│ │ │ └── register.integeration.test.ts
│ └── jest
│ │ ├── config.ts
│ │ ├── db.ts
│ │ ├── factories
│ │ ├── faker.ts
│ │ ├── index.ts
│ │ └── user.ts
│ │ ├── globalSetup.ts
│ │ ├── globalTeardown.ts
│ │ └── setupFile.ts
├── api
│ └── v1
│ │ ├── auth
│ │ ├── auth.controller.ts
│ │ └── dtos
│ │ │ ├── forgotPassword.dto.ts
│ │ │ ├── login.dto.ts
│ │ │ ├── logout.dto.ts
│ │ │ ├── refreshToken.dto.ts
│ │ │ ├── register.dto.ts
│ │ │ └── resetPassword.dto.ts
│ │ ├── index.ts
│ │ └── user
│ │ └── user.controller.ts
├── app.ts
├── common
│ ├── constants
│ │ └── index.ts
│ └── interfaces
│ │ ├── crud.interface.ts
│ │ └── timestamp.interface.ts
├── config
│ ├── index.ts
│ └── passport.ts
├── exceptions
│ └── HttpException.ts
├── index.ts
├── middlewares
│ ├── auth.middleware.ts
│ ├── handlingErrors.middleware.ts
│ └── validation.middleware.ts
├── models
│ ├── tokens.model.ts
│ └── users.model.ts
├── services
│ └── v1
│ │ ├── auth.service.ts
│ │ ├── index.ts
│ │ ├── token.service.ts
│ │ └── user.service.ts
└── utils
│ └── toJSON.plugin.ts
├── tsconfig.json
└── yarn.lock
/.dockerignore:
--------------------------------------------------------------------------------
1 | # compiled output
2 | .vscode
3 | /node_modules
4 |
5 | # code formatter
6 | .eslintrc
7 | .eslintignore
8 | .editorconfig
9 |
10 | .prettierrc
11 |
12 | # test
13 | jest.config.js
14 |
15 | # docker
16 | Dockerfile
17 | docker-compose.yml
18 |
--------------------------------------------------------------------------------
/.env.development.local:
--------------------------------------------------------------------------------
1 | PORT=3000
2 | MONGO_URI=mongodb://127.0.0.1:27017
3 | DATABASE=express_typescript_boilerplate
4 | CORS_ORIGINS=["http://localhost:3001"]
5 | CREDENTIALS =true
6 |
7 | SENTRY_DSN=SENTRY_DSN
8 |
9 | #JWT
10 | JWT_SECRET=JWT_SECRET
11 | JWT_ACCESS_EXPIRE_IN=60
12 | JWT_ACCESS_EXPIRE_FORMAT=minutes
13 | JWT_REFRESH_EXPIRE_IN=30
14 | JWT_REFRESH_EXPIRE_FORMAT=days
15 | JWT_RESET_PASSWORD_EXPIRE_IN=30
16 | JWT_RESET_PASSWORD_EXPIRE_FORMAT=minutes
--------------------------------------------------------------------------------
/.env.production.local:
--------------------------------------------------------------------------------
1 | PORT=3000
2 | MONGO_URI=mongodb://127.0.0.1:27017
3 | DATABASE=express_typescript_boilerplate
4 |
5 | CORS_ORIGINS=["http://localhost:3001"]
6 | CREDENTIALS =true
7 |
8 | SENTRY_DSN=SENTRY_DSN
9 |
10 |
11 | #JWT
12 | JWT_SECRET=JWT_SECRET
13 | JWT_ACCESS_EXPIRE_IN=60
14 | JWT_ACCESS_EXPIRE_FORMAT=minutes
15 | JWT_REFRESH_EXPIRE_IN=30
16 | JWT_REFRESH_EXPIRE_FORMAT=days
17 | JWT_RESET_PASSWORD_EXPIRE_IN=30
18 | JWT_RESET_PASSWORD_EXPIRE_FORMAT=minutes
19 |
--------------------------------------------------------------------------------
/.env.test.local:
--------------------------------------------------------------------------------
1 | PORT=8080
2 | MONGO_URI=mongodb://127.0.0.1:27017
3 | DATABASE=express_typescript_boilerplate_test
4 |
5 | CORS_ORIGINS=["http://localhost:3001"]
6 | CREDENTIALS =true
7 |
8 |
9 | SENTRY_DSN=SENTRY_DSN
10 |
11 | #JWT
12 | JWT_SECRET=JWT_SECRET
13 | JWT_ACCESS_EXPIRE_IN=60
14 | JWT_ACCESS_EXPIRE_FORMAT=minutes
15 | JWT_REFRESH_EXPIRE_IN=30
16 | JWT_REFRESH_EXPIRE_FORMAT=days
17 | JWT_RESET_PASSWORD_EXPIRE_IN=30
18 | JWT_RESET_PASSWORD_EXPIRE_FORMAT=minutes
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | /dist
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "extends": ["prettier", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"],
4 | "parserOptions": {
5 | "ecmaVersion": 2018,
6 | "sourceType": "module"
7 | },
8 | "plugins": ["simple-import-sort"],
9 | "rules": {
10 | "@typescript-eslint/explicit-member-accessibility": 0,
11 | "@typescript-eslint/explicit-function-return-type": 0,
12 | "@typescript-eslint/no-parameter-properties": 0,
13 | "@typescript-eslint/interface-name-prefix": 0,
14 | "@typescript-eslint/explicit-module-boundary-types": 0,
15 | "@typescript-eslint/no-explicit-any": "off",
16 | "@typescript-eslint/ban-types": "off",
17 | "@typescript-eslint/no-var-requires": "off",
18 | "simple-import-sort/exports": "warn",
19 | "simple-import-sort/imports": [
20 | "warn",
21 | {
22 | "groups": [
23 | ["^@?\\w"],
24 | ["^(@common|@models|@v1|@config|@app|@middlewares|@exceptions|@utils|@services|@__tests__)(/.*|$)"],
25 | // Parent imports. Put `..` last.
26 | ["^\\.\\.(?!/?$)", "^\\.\\./?$"],
27 | // Other relative imports. Put same-folder imports and `.` last.
28 | ["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"],
29 |
30 | ]
31 | }
32 | ]
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: Tests CI
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | test:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v2
10 | - name: Test using Node.js
11 | uses: actions/setup-node@v1
12 | with:
13 | node-version: '16'
14 | - run: yarn install
15 | - run: yarn test:ci
16 |
17 | - name: Tests ✅
18 | if: ${{ success() }}
19 | run: |
20 | curl --request POST \
21 | --url https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }} \
22 | --header 'authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' \
23 | --header 'content-type: application/json' \
24 | --data '{
25 | "context": "tests",
26 | "state": "success",
27 | "description": "Tests passed",
28 | "target_url": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
29 | }'
30 |
31 | - name: Tests 🚨
32 | if: ${{ failure() }}
33 | run: |
34 | curl --request POST \
35 | --url https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }} \
36 | --header 'authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' \
37 | --header 'content-type: application/json' \
38 | --data '{
39 | "context": "tests",
40 | "state": "failure",
41 | "description": "Tests failed",
42 | "target_url": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
43 | }'
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 |
3 | # Logs
4 | logs
5 | *.log
6 | npm-debug.log*
7 | yarn-debug.log*
8 | yarn-error.log*
9 |
10 | coverage/
11 |
12 | .env
13 | dist
14 | # Output of 'npm pack'
15 | *.tgz
16 |
17 | # Yarn Integrity file
18 | .yarn-integrity
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 150,
3 | "tabWidth": 2,
4 | "singleQuote": true,
5 | "trailingComma": "all",
6 | "semi": true,
7 | "arrowParens": "avoid"
8 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # build stage
2 | FROM node:16.14.0-alpine as build-stage
3 |
4 | COPY . ./app
5 |
6 | WORKDIR /app
7 |
8 | RUN npm install
9 |
10 | RUN npm install -g yarn
11 |
12 |
13 | EXPOSE 3001
14 |
15 | # dev
16 | FROM build-stage as dev-build-stage
17 |
18 | ENV NODE_ENV development
19 |
20 | CMD ["yarn", "dev"]
21 |
22 | # Production
23 | FROM build-stage as prod-build-stage
24 |
25 | ENV NODE_ENV production
26 |
27 | CMD ["run", "start"]
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 🎫 A Class Base Routing Boilerplate for Node.js, Express.js, MongoDB with Typescript.
2 |
3 | ### What is it?
4 | Try to implement A Class Base Routing with clean structure and scalable boilerplate in Node.js,Express.js and Typescript.
5 |
6 | ### Features
7 |
8 | - **Sentry** catch errors.
9 | - **API Documentation** using **Swagger**.
10 | - **Basic Security Features** using [Helmet](https://github.com/helmetjs/helmet), [hpp](https://github.com/analog-nico/hpp) and [xss clean](https://github.com/jsonmaur/xss-clean).
11 | - **Validation** using [class-validator](https://github.com/typestack/class-validator)
12 | - **class base routing** using [routing-controllers](https://github.com/typestack/routing-controllers)
13 | - **Authentication** - using [Passport.js](https://github.com/jaredhanson/passport) [passport-jwt](https://github.com/mikenicholson/passport-jwt) which is compatible with Express.js and is a authentication middleware for Node.js.
14 | - **Database** using [mongoose](https://mongoosejs.com/) odm for interacting with mongoDB.
15 | - **run testing** using [Jest](https://jestjs.io/)
16 | - **linting** using [ESLint](https://eslint.org/)
17 | - **prettier** using [Prettier](https://prettier.io/)
18 |
19 |
20 |
21 |
22 | ## Getting Started
23 |
24 | install dependencies
25 |
26 | ```bash
27 | yarn
28 | ```
29 |
30 |
31 | ### Without Docker
32 | Note: It is assumed here that you have MongoDB running in the background.
33 |
34 | set `.env.development.local` file with your credentials.(like DB URL)
35 |
36 | Run the app
37 | ```bash
38 | yarn dev
39 | ```
40 |
41 |
42 | ### With Docker
43 | Note: It is assumed here that you have installed Docker and running in the background.
44 | ```bash
45 | yarn docker:db
46 | ```
47 | set `.env.development.local` file with your credentials.(like DB URL)
48 |
49 | Run the app
50 | ```bash
51 | yarn dev
52 | ```
53 |
54 |
55 |
56 |
57 |
58 |
59 | ### Route Documents
60 |
61 | you can access swagger documentation at `http://localhost:3000/api-docs`
62 |
63 |
64 |
65 |
66 |
67 | ### What is the Structure of template?
68 | ```
69 | express-typescript-boilerplate
70 | ├─ .github
71 | │ └─ workflows
72 | │ └─ tests.yml
73 | ├─ README.md
74 | ├─ ecosystem.config.js
75 | ├─ jest.config.js
76 | ├─ package.json
77 | ├─ src
78 | │ ├─ __tests__
79 | │ │ ├─ api
80 | │ │ │ └─ v1
81 | │ │ │ └─ auth
82 | │ │ │ └─ users
83 | │ ├─ api
84 | │ │ └─ v1
85 | │ │ ├─ auth
86 | │ │ │ ├─ auth.controller.ts
87 | │ │ │ └─ dtos
88 | │ │ ├─ index.ts
89 | │ │ └─ user
90 | │ │ └─ user.controller.ts
91 | │ ├─ app.ts
92 | │ ├─ common
93 | │ │ ├─ constants
94 | │ │ │ └─ index.ts
95 | │ │ ├─ interfaces
96 | │ │ │ ├─ crud.interface.ts
97 | │ │ │ └─ timestamp.interface.ts
98 | │ │ └─ types
99 | │ ├─ config
100 | │ │ ├─ index.ts
101 | │ │ └─ passport.ts
102 | │ ├─ exceptions
103 | │ │ └─ HttpException.ts
104 | │ ├─ index.ts
105 | │ ├─ middlewares
106 | │ │ ├─ auth.middleware.ts
107 | │ │ ├─ handlingErrors.middleware.ts
108 | │ │ └─ validation.middleware.ts
109 | │ ├─ models
110 | │ │ ├─ tokens.model.ts
111 | │ │ └─ users.model.ts
112 | │ ├─ services
113 | │ │ └─ v1
114 | │ │ ├─ auth.service.ts
115 | │ │ ├─ index.ts
116 | │ │ ├─ token.service.ts
117 | │ │ └─ user.service.ts
118 | │ └─ utils
119 | │ └─ toJSON.plugin.ts
120 | ├─ tsconfig.json
121 |
122 | ```
--------------------------------------------------------------------------------
/docker-compose-db.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 | services:
3 | mongodb:
4 | container_name: database_mongo
5 | image: mongo:5.0
6 | ports:
7 | - 27017:27017
8 | volumes:
9 | - ~/apps/mongo:/data/db
10 | networks:
11 | - backend
12 |
13 | networks:
14 | backend:
15 | driver: bridge
16 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 | services:
3 | server:
4 | build:
5 | context: ./
6 | target: dev-build-stage
7 | dockerfile: Dockerfile
8 | container_name: server
9 | ports:
10 | - '3001:3001'
11 | volumes:
12 | - ./:/app
13 | - /app/node_modules
14 | restart: 'unless-stopped'
15 | networks:
16 | - backend
17 | depends_on:
18 | - mongodb
19 |
20 | mongodb:
21 | container_name: database
22 | image: mongo:5.0
23 | ports:
24 | - 27017:27017
25 | volumes:
26 | - ~/apps/mongo:/data/db
27 | networks:
28 | - backend
29 |
30 | networks:
31 | backend:
32 | driver: bridge
33 |
--------------------------------------------------------------------------------
/ecosystem.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | apps: [
3 | {
4 | script: 'dist/index.js',
5 | name: 'prod_api',
6 | autorestart: true,
7 | watch: false,
8 | max_memory_restart: '1G',
9 | merge_logs: true,
10 | env: {
11 | PORT: 3001,
12 | NODE_ENV: 'production',
13 | },
14 | },
15 | ],
16 |
17 | deploy: {
18 | production: {
19 | user: 'SSH_USERNAME',
20 | host: '0.0.0.0',
21 | ref: 'origin/master',
22 | repo: 'GIT_REPOSITORY',
23 | path: 'DESTINATION_PATH',
24 | 'pre-deploy-local': '',
25 | 'post-deploy': 'yarn install && yarn run build && pm2 reload ecosystem.config.js --only prod_api',
26 | 'pre-setup': '',
27 | },
28 | },
29 | };
30 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | const { pathsToModuleNameMapper } = require("ts-jest");
2 | const { compilerOptions } = require("./tsconfig");
3 |
4 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
5 | module.exports = {
6 | preset: "ts-jest",
7 | testEnvironment: "node",
8 | roots: ["/src"],
9 | transform: {
10 | "^.+\\.tsx?$": "ts-jest",
11 | },
12 | globalSetup: "/src/__tests__/jest/globalSetup.ts",
13 | globalTeardown: "/src/__tests__/jest/globalTeardown.ts",
14 | setupFilesAfterEnv: ["/src/__tests__/jest/setupFile.ts"],
15 | coveragePathIgnorePatterns: ["/node_modules/", "/__tests__/jest/"],
16 | testPathIgnorePatterns: ["/src/__tests__/jest"],
17 | clearMocks: true,
18 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, {
19 | prefix: "/src",
20 | }),
21 | };
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "api",
3 | "version": "1.0.0",
4 | "main": "dist/index.js",
5 | "private": false,
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/miladr0/express-typescript-mongodb.git"
9 | },
10 | "bug": "https://github.com/miladr0/express-typescript-mongodb/issues",
11 | "homepage": "https://github.com/miladr0/express-typescript-mongodb",
12 | "author": {
13 | "name": "Milad",
14 | "email": "miladr0r@gmail.com",
15 | "url": "https://miladr0.com"
16 | },
17 | "license": "MIT",
18 | "scripts": {
19 | "start": "yarn build && cross-env NODE_ENV=development node dist/index.js",
20 | "dev": "cross-env NODE_ENV=development ts-node-dev -r tsconfig-paths/register src/index.ts",
21 | "prod": "yarn run build && pm2 start ecosystem.config.js --only prod_api",
22 | "seed": "cross-env NODE_ENV=development ts-node -r tsconfig-paths/register src/scripts/seed.ts",
23 | "build": "tsc -b && tsc-alias",
24 | "lint": "eslint --ignore-path .gitignore --ext .ts src/",
25 | "lint:fix": "yarn lint -- --fix",
26 | "test:watch": "cross-env NODE_ENV=test jest --runInBand --verbose --watchAll",
27 | "test": "cross-env NODE_ENV=test node --expose-gc node_modules/jest/bin/jest --runInBand --verbose --coverage",
28 | "test:ci": "yarn test -- --ci --logHeapUsage",
29 | "docker:db": "docker-compose -f docker-compose-db.yml up"
30 | },
31 | "dependencies": {
32 | "@sentry/node": "^6.19.7",
33 | "bcrypt": "^5.0.1",
34 | "body-parser": "^1.19.0",
35 | "class-transformer": "^0.5.1",
36 | "class-validator": "^0.13.2",
37 | "class-validator-jsonschema": "^3.1.0",
38 | "cookie-parser": "^1.4.6",
39 | "cors": "^2.8.5",
40 | "cross-env": "^7.0.3",
41 | "dotenv": "^16.0.1",
42 | "express": "^4.17.1",
43 | "helmet": "^5.1.0",
44 | "hpp": "^0.2.3",
45 | "http-status-codes": "^2.2.0",
46 | "jsonwebtoken": "^8.5.1",
47 | "lodash": "^4.17.21",
48 | "moment": "^2.29.3",
49 | "mongoose": "^6.3.4",
50 | "passport": "^0.6.0",
51 | "passport-jwt": "^4.0.0",
52 | "pm2": "^5.2.0",
53 | "reflect-metadata": "^0.1.13",
54 | "routing-controllers": "^0.9.0",
55 | "routing-controllers-openapi": "^3.1.0",
56 | "swagger-ui-express": "^4.4.0",
57 | "xss-clean": "^0.1.1"
58 | },
59 | "devDependencies": {
60 | "@types/body-parser": "^1.19.2",
61 | "@types/cookie-parser": "^1.4.3",
62 | "@types/cors": "^2.8.12",
63 | "@types/express": "^4.17.13",
64 | "@types/express-handlebars": "^6.0.0",
65 | "@types/hpp": "^0.2.2",
66 | "@types/jest": "^27.5.1",
67 | "@types/jsonwebtoken": "^8.5.8",
68 | "@types/lodash": "^4.14.182",
69 | "@types/swagger-ui-express": "^4.1.3",
70 | "@typescript-eslint/eslint-plugin": "^5.26.0",
71 | "@typescript-eslint/parser": "^5.28.0",
72 | "eslint": "^8.16.0",
73 | "eslint-config-prettier": "^8.5.0",
74 | "eslint-plugin-prettier": "^4.0.0",
75 | "eslint-plugin-simple-import-sort": "^7.0.0",
76 | "jest": "^28.1.0",
77 | "mongodb-memory-server": "8.4.0",
78 | "prettier": "^2.6.2",
79 | "supertest": "^6.2.3",
80 | "ts-jest": "^28.0.3",
81 | "ts-node": "^10.8.0",
82 | "ts-node-dev": "^2.0.0",
83 | "tsc-alias": "^1.6.7",
84 | "tsconfig-paths": "^4.0.0",
85 | "typescript": "^4.7.2"
86 | },
87 | "engines": {
88 | "node": ">=16.14.0"
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/__tests__/api/v1/auth/register.integeration.test.ts:
--------------------------------------------------------------------------------
1 | import supertest, { SuperTest, Test } from 'supertest';
2 |
3 | import { clearDB } from '@__tests__/jest/db';
4 | import { fakerData } from '@__tests__/jest/factories';
5 | import { userFactory } from '@__tests__/jest/factories';
6 | import App from '@app';
7 | import { AuthControllerV1 } from '@v1/index';
8 |
9 | let server: SuperTest;
10 | const baseUrl = '/api/v1/auth';
11 |
12 | describe('register test suit', () => {
13 | beforeEach(async () => {
14 | await clearDB();
15 | const app = new App([AuthControllerV1]);
16 | await App.initDB();
17 | server = supertest(app.getServer());
18 | });
19 |
20 | test('email is not valid', async () => {
21 | const newUser = {
22 | email: 'notemail',
23 | username: 'abcabc',
24 | password: '123123',
25 | };
26 | const { body } = await server.post(`${baseUrl}/register`).send(newUser).expect(400);
27 | expect(body.message).toBe('email must be an email');
28 | });
29 |
30 | test('username should at least 4 character', async () => {
31 | const newUser = {
32 | email: fakerData.internet.email(),
33 | username: 'abc',
34 | password: '123123',
35 | };
36 | const { body } = await server.post(`${baseUrl}/register`).send(newUser).expect(400);
37 | expect(body.message).toBe('username must be longer than or equal to 4 characters');
38 | });
39 |
40 | test('password should at least 6 character', async () => {
41 | const newUser = {
42 | email: fakerData.internet.email(),
43 | username: 'abcd',
44 | password: '1231',
45 | };
46 | const { body } = await server.post(`${baseUrl}/register`).send(newUser).expect(400);
47 | expect(body.message).toBe('password must be longer than or equal to 6 characters');
48 | });
49 |
50 | test('email should be unique', async () => {
51 | const email = fakerData.internet.email();
52 | await userFactory({ email });
53 | const newUser2 = {
54 | email: email,
55 | username: 'abcd',
56 | password: '123123',
57 | };
58 |
59 | const { body } = await server.post(`${baseUrl}/register`).send(newUser2).expect(500);
60 | expect(body.message).toBe('Email already Taken');
61 | });
62 |
63 | test('email should be unique', async () => {
64 | const newUser = {
65 | email: fakerData.internet.email(),
66 | username: fakerData.internet.userName(),
67 | password: fakerData.internet.password(),
68 | };
69 |
70 | const { body } = await server.post(`${baseUrl}/register`).send(newUser).expect(201);
71 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
72 | const { password, ...userResult } = newUser;
73 |
74 | expect(body.user).toMatchObject(userResult);
75 | expect(body.tokens).toBeDefined();
76 | expect(body.tokens.access.token).toBeDefined();
77 | expect(body.tokens.refresh.token).toBeDefined();
78 | });
79 | });
80 |
--------------------------------------------------------------------------------
/src/__tests__/jest/config.ts:
--------------------------------------------------------------------------------
1 | const config = {
2 | Memory: true,
3 | MongoURI: 'mongodb://localhost:27017',
4 | Database: 'express_typescript_boilerplate_test',
5 | };
6 |
7 | export default config;
8 |
--------------------------------------------------------------------------------
/src/__tests__/jest/db.ts:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose';
2 |
3 | import config from './config';
4 |
5 | export const connect = async () => {
6 | await mongoose.connect(`${process.env.MONGO_URI}/${config.Database}`);
7 | };
8 |
9 | export const disconnect = async () => {
10 | await mongoose.connection.dropDatabase();
11 | await mongoose.connection.close();
12 | await mongoose.disconnect();
13 | };
14 |
15 | export const clearDB = async () => {
16 | const collections = mongoose.connection.collections;
17 |
18 | for (const key in collections) {
19 | const collection = collections[key];
20 | await collection.deleteMany({});
21 | }
22 | };
23 |
--------------------------------------------------------------------------------
/src/__tests__/jest/factories/faker.ts:
--------------------------------------------------------------------------------
1 | function getRandomInt(min = 1, max = 1000) {
2 | return Math.floor(Math.random() * (max - min + 1)) + min;
3 | }
4 |
5 | class faker {
6 | get internet() {
7 | return {
8 | email: () => `test${getRandomInt()}@gmail.com`,
9 | userName: () => `test${getRandomInt()}`,
10 | password: () => `12345${getRandomInt()}`,
11 | };
12 | }
13 | }
14 | export const fakerData = new faker();
15 |
--------------------------------------------------------------------------------
/src/__tests__/jest/factories/index.ts:
--------------------------------------------------------------------------------
1 | export * from './faker';
2 | export * from './user';
3 |
--------------------------------------------------------------------------------
/src/__tests__/jest/factories/user.ts:
--------------------------------------------------------------------------------
1 | import User, { IUser } from '@models/users.model';
2 |
3 | import { fakerData } from './faker';
4 |
5 | export async function userFactory(user: Partial = {}) {
6 | return User.create({
7 | email: fakerData.internet.email(),
8 | username: fakerData.internet.userName(),
9 | password: fakerData.internet.password(),
10 | ...user,
11 | });
12 | }
13 |
--------------------------------------------------------------------------------
/src/__tests__/jest/globalSetup.ts:
--------------------------------------------------------------------------------
1 | import { MongoMemoryServer } from 'mongodb-memory-server';
2 | import mongoose from 'mongoose';
3 |
4 | import config from './config';
5 |
6 | export default async function globalSetup() {
7 | if (config.Memory) {
8 | // Config to decided if an mongodb-memory-server instance should be used
9 | // it's needed in global space, because we don't want to create a new instance every test-suite
10 | const instance = await MongoMemoryServer.create();
11 | const uri = instance.getUri();
12 | (global as any).__MONGOINSTANCE = instance;
13 | process.env.MONGO_URI = uri.slice(0, uri.lastIndexOf('/'));
14 | } else {
15 | process.env.MONGO_URI = config.MongoURI;
16 | }
17 |
18 | // The following is to make sure the database is clean before an test starts
19 | await mongoose.connect(`${process.env.MONGO_URI}/${config.Database}`);
20 | await mongoose.connection.db.dropDatabase();
21 | await mongoose.disconnect();
22 | }
23 |
--------------------------------------------------------------------------------
/src/__tests__/jest/globalTeardown.ts:
--------------------------------------------------------------------------------
1 | import { MongoMemoryServer } from 'mongodb-memory-server';
2 |
3 | import config from './config';
4 |
5 | export default async function globalTeardown() {
6 | if (config.Memory) {
7 | // Config to decided if an mongodb-memory-server instance should be used
8 | const instance: MongoMemoryServer = (global as any).__MONGOINSTANCE;
9 | await instance.stop();
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/__tests__/jest/setupFile.ts:
--------------------------------------------------------------------------------
1 | import { connect, disconnect } from './db';
2 |
3 | beforeAll(async () => {
4 | await connect();
5 | });
6 |
7 | afterAll(async () => {
8 | await disconnect();
9 | });
10 |
--------------------------------------------------------------------------------
/src/api/v1/auth/auth.controller.ts:
--------------------------------------------------------------------------------
1 | import { Body, HttpCode, JsonController, Post, UseBefore } from 'routing-controllers';
2 | import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
3 |
4 | import { validationMiddleware } from '@middlewares/validation.middleware';
5 | import { IUser } from '@models/users.model';
6 | import { AuthService, TokenService, UserService } from '@services/v1';
7 |
8 | import ForgotPasswordDto from './dtos/forgotPassword.dto';
9 | import LoginDto, { LoginResponseSchema } from './dtos/login.dto';
10 | import LogoutDto from './dtos/logout.dto';
11 | import RefreshTokenDto from './dtos/refreshToken.dto';
12 | import RegisterDto from './dtos/register.dto';
13 | import ResetPasswordDto from './dtos/resetPassword.dto';
14 |
15 | @JsonController('/v1/auth', { transformResponse: false })
16 | export class AuthController {
17 | private readonly tokenService = new TokenService();
18 | private readonly userService = new UserService();
19 | private readonly authService = new AuthService();
20 |
21 | @Post('/register')
22 | @HttpCode(201)
23 | @OpenAPI({ summary: 'register new user' })
24 | @ResponseSchema(IUser)
25 | @UseBefore(validationMiddleware(RegisterDto, 'body'))
26 | async register(@Body() userData: RegisterDto) {
27 | const user = await this.userService.createUser(userData);
28 | const tokens = await this.tokenService.generateAuthTokens(user);
29 |
30 | return { user, tokens };
31 | }
32 |
33 | @Post('/login')
34 | @OpenAPI({
35 | description: 'user data and tokens',
36 | responses: LoginResponseSchema,
37 | })
38 | @UseBefore(validationMiddleware(LoginDto, 'body'))
39 | async login(@Body() userData: LoginDto) {
40 | const user = await this.authService.loginUserWithEmailAndPassword(userData.email, userData.password);
41 | const tokens = await this.tokenService.generateAuthTokens(user);
42 |
43 | return { user, tokens };
44 | }
45 |
46 | @Post('/logout')
47 | @OpenAPI({ summary: 'logout the user' })
48 | @UseBefore(validationMiddleware(LogoutDto, 'body'))
49 | async logout(@Body() userData: LogoutDto) {
50 | await this.authService.logout(userData.refreshToken);
51 |
52 | return { message: 'logout success' };
53 | }
54 |
55 | @Post('/refresh-tokens')
56 | @OpenAPI({ description: 'renew user token and refresh token', responses: LoginResponseSchema })
57 | @UseBefore(validationMiddleware(RefreshTokenDto, 'body'))
58 | async refreshToken(@Body() userData: RefreshTokenDto) {
59 | const result = await this.authService.refreshAuth(userData.refreshToken);
60 |
61 | return { ...result };
62 | }
63 |
64 | @Post('/forgot-password')
65 | @OpenAPI({ summary: 'send reset token to reset the password' })
66 | @UseBefore(validationMiddleware(ForgotPasswordDto, 'body'))
67 | async forgotPassword(@Body() userData: ForgotPasswordDto) {
68 | const token = await this.tokenService.generateResetPasswordToken(userData.email);
69 |
70 | // should use email service to send the token to email owner, not return it!
71 | return { token };
72 | }
73 |
74 | @Post('/reset-password')
75 | @OpenAPI({ summary: 'reset user password' })
76 | @UseBefore(validationMiddleware(ResetPasswordDto, 'body'))
77 | async resetPassword(@Body() userData: ResetPasswordDto) {
78 | await this.authService.resetPassword(userData.token, userData.password);
79 |
80 | return { message: 'password successfully updated' };
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/src/api/v1/auth/dtos/forgotPassword.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsEmail, IsNotEmpty } from 'class-validator';
2 |
3 | export default class ForgotPasswordDto {
4 | @IsNotEmpty()
5 | @IsEmail()
6 | email: string;
7 | }
8 |
--------------------------------------------------------------------------------
/src/api/v1/auth/dtos/login.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsEmail, IsNotEmpty, MinLength } from 'class-validator';
2 |
3 | export default class LoginDto {
4 | @IsNotEmpty()
5 | @IsEmail()
6 | email: string;
7 |
8 | @MinLength(6)
9 | password: string;
10 | }
11 |
12 | export const LoginResponseSchema = {
13 | '200': {
14 | content: {
15 | 'application/json': {
16 | schema: {
17 | type: 'object',
18 | properties: {
19 | user: {
20 | type: 'object',
21 | properties: {
22 | username: {
23 | type: 'string',
24 | },
25 | },
26 | },
27 | tokens: {
28 | type: 'object',
29 | properties: {
30 | accessToken: {
31 | type: 'object',
32 | properties: {
33 | token: {
34 | type: 'string',
35 | },
36 | expires: {
37 | type: 'integer',
38 | },
39 | },
40 | },
41 | refreshToken: {
42 | type: 'object',
43 | properties: {
44 | token: {
45 | type: 'string',
46 | },
47 | expires: {
48 | type: 'integer',
49 | },
50 | },
51 | },
52 | },
53 | },
54 | },
55 | required: ['user', 'tokens'],
56 | },
57 | },
58 | },
59 | description: 'Successful response',
60 | },
61 | };
62 |
--------------------------------------------------------------------------------
/src/api/v1/auth/dtos/logout.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsNotEmpty, IsString } from 'class-validator';
2 |
3 | export default class LogoutDto {
4 | @IsNotEmpty()
5 | @IsString()
6 | refreshToken: string;
7 | }
8 |
--------------------------------------------------------------------------------
/src/api/v1/auth/dtos/refreshToken.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsNotEmpty, IsString } from 'class-validator';
2 |
3 | export default class RefreshTokenDto {
4 | @IsNotEmpty()
5 | @IsString()
6 | refreshToken: string;
7 | }
8 |
--------------------------------------------------------------------------------
/src/api/v1/auth/dtos/register.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsEmail, IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
2 |
3 | export default class RegisterDto {
4 | @IsNotEmpty()
5 | @IsEmail()
6 | email: string;
7 |
8 | @IsNotEmpty()
9 | @IsString()
10 | @MinLength(4)
11 | @MaxLength(15)
12 | username: string;
13 |
14 | @MinLength(6)
15 | password: string;
16 | }
17 |
--------------------------------------------------------------------------------
/src/api/v1/auth/dtos/resetPassword.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsNotEmpty, IsString, MinLength } from 'class-validator';
2 |
3 | export default class ResetPasswordDto {
4 | @IsNotEmpty()
5 | @IsString()
6 | token: string;
7 |
8 | @MinLength(6)
9 | password: string;
10 | }
11 |
--------------------------------------------------------------------------------
/src/api/v1/index.ts:
--------------------------------------------------------------------------------
1 | export { AuthController as AuthControllerV1 } from '@v1/auth/auth.controller';
2 | export { UserController as UserControllerV1 } from '@v1/user/user.controller';
3 |
--------------------------------------------------------------------------------
/src/api/v1/user/user.controller.ts:
--------------------------------------------------------------------------------
1 | import { Get, JsonController, UseBefore } from 'routing-controllers';
2 | import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
3 |
4 | import auth from '@middlewares/auth.middleware';
5 | import { IUser } from '@models/users.model';
6 | import { UserService } from '@services/v1';
7 |
8 | @JsonController('/v1/users', { transformResponse: false })
9 | export class UserController {
10 | private readonly userService = new UserService();
11 |
12 | @Get('/')
13 | @OpenAPI({ summary: 'get users' })
14 | @ResponseSchema(IUser, { isArray: true })
15 | @UseBefore(auth())
16 | async register() {
17 | const users = await this.userService.findAll();
18 |
19 | return { users };
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/app.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line simple-import-sort/imports
2 | import 'reflect-metadata';
3 | import { CORS_ORIGINS, CREDENTIALS, MONGO_URI, DATABASE, isProduction, PORT, SENTRY_DSN, jwtStrategy } from './config';
4 |
5 | import * as Sentry from '@sentry/node';
6 | import bodyParser from 'body-parser';
7 |
8 | import cookieParser from 'cookie-parser';
9 | import cors from 'cors';
10 | import express, { Application, ErrorRequestHandler, RequestHandler } from 'express';
11 | import helmet from 'helmet';
12 | import hpp from 'hpp';
13 | import http from 'http';
14 | import mongoose from 'mongoose';
15 | import passport from 'passport';
16 | import { useExpressServer } from 'routing-controllers';
17 | import xss from 'xss-clean';
18 |
19 | import handlingErrorsMiddleware from './middlewares/handlingErrors.middleware';
20 |
21 | let serverConnection: http.Server;
22 |
23 | export default class App {
24 | private app: Application;
25 | private port: string | number;
26 | private controllers: Function[] = [];
27 |
28 | constructor(controllers: Function[]) {
29 | this.app = express();
30 | this.port = PORT || 8080;
31 | this.controllers = controllers;
32 |
33 | this.initSentry();
34 | this.initMiddlewares();
35 | this.initRoutes(controllers);
36 |
37 | this.initHandlingErrors();
38 | }
39 |
40 | private initSentry() {
41 | if (isProduction) {
42 | Sentry.init({ dsn: SENTRY_DSN });
43 | // The request handler must be the first middleware on the app
44 | this.app.use(Sentry.Handlers.requestHandler() as RequestHandler);
45 | }
46 | }
47 | private initMiddlewares() {
48 | this.app.use(helmet());
49 | this.app.use(cors({ origin: CORS_ORIGINS }));
50 |
51 | this.app.use(bodyParser.json());
52 | this.app.use(bodyParser.urlencoded({ extended: true }));
53 | // sanitize user data
54 | this.app.use(hpp());
55 | this.app.use(xss());
56 | this.app.use(cookieParser());
57 |
58 | // jwt authentication
59 | this.app.use(passport.initialize());
60 | passport.use('jwt', jwtStrategy);
61 | }
62 |
63 | private initRoutes(controllers: Function[]) {
64 | useExpressServer(this.app, {
65 | cors: {
66 | origin: CORS_ORIGINS,
67 | credentials: CREDENTIALS,
68 | },
69 | routePrefix: '/api',
70 | controllers: controllers,
71 | defaultErrorHandler: false,
72 | });
73 | }
74 |
75 | private initHandlingErrors() {
76 | if (isProduction) {
77 | // The error handler must be before any other error middleware and after all controllers
78 | this.app.use(Sentry.Handlers.errorHandler() as ErrorRequestHandler);
79 | }
80 | this.app.use(handlingErrorsMiddleware);
81 | }
82 |
83 | static async initDB() {
84 | await mongoose.connect(`${MONGO_URI}/${DATABASE}`);
85 | }
86 |
87 | static async closeDB() {
88 | await mongoose.disconnect();
89 | }
90 |
91 | public initWebServer = async () => {
92 | return new Promise(resolve => {
93 | serverConnection = this.app.listen(this.port, () => {
94 | console.log(`✅ Ready on port http://localhost:${this.port}`);
95 |
96 | resolve(serverConnection.address());
97 | });
98 | });
99 | };
100 |
101 | public initServerWithDB = async () => {
102 | await Promise.all([App.initDB(), this.initWebServer()]);
103 | };
104 |
105 | public stopWebServer = async () => {
106 | return new Promise(resolve => {
107 | serverConnection.close(() => {
108 | resolve(void 0);
109 | });
110 | });
111 | };
112 |
113 | public getServer = () => {
114 | return this.app;
115 | };
116 |
117 | public get getControllers() {
118 | return this.controllers;
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/src/common/constants/index.ts:
--------------------------------------------------------------------------------
1 | export enum MODELS {
2 | USERS = 'USERS',
3 | TOKENS = 'TOKENS',
4 | }
5 |
6 | export enum TokenTypes {
7 | ACCESS = 'access',
8 | REFRESH = 'refresh',
9 | RESET_PASSWORD = 'resetPassword',
10 | }
11 |
--------------------------------------------------------------------------------
/src/common/interfaces/crud.interface.ts:
--------------------------------------------------------------------------------
1 | import { LeanDocument, ObjectId } from 'mongoose';
2 |
3 | export default interface CRUD {
4 | findAll: (
5 | limit: number,
6 | page: number,
7 | ) => Promise<{
8 | docs: Array | Array>;
9 | meta: {
10 | totalDocs: number;
11 | totalPages: number;
12 | page: number;
13 | };
14 | }>;
15 | getById: (id: ObjectId) => Promise;
16 | }
17 |
--------------------------------------------------------------------------------
/src/common/interfaces/timestamp.interface.ts:
--------------------------------------------------------------------------------
1 | import { IsDate } from 'class-validator';
2 |
3 | export default class ITimesStamp {
4 | @IsDate()
5 | createdAt: Date;
6 | @IsDate()
7 | updatedAt: Date;
8 | }
9 |
--------------------------------------------------------------------------------
/src/config/index.ts:
--------------------------------------------------------------------------------
1 | import dotenv from 'dotenv';
2 | dotenv.config({ path: `.env.${process.env.NODE_ENV || 'development'}.local` });
3 |
4 | const checkEnv = (envVar: string, defaultValue?: string) => {
5 | if (!process.env[envVar]) {
6 | if (defaultValue) {
7 | return defaultValue;
8 | }
9 | throw new Error(`Please define the Enviroment variable"${envVar}"`);
10 | } else {
11 | return process.env[envVar] as string;
12 | }
13 | };
14 |
15 | export const PORT: number = parseInt(checkEnv('PORT'), 10);
16 | export const MONGO_URI: string = checkEnv('MONGO_URI');
17 | export const DATABASE: string = checkEnv('DATABASE');
18 | export const CORS_ORIGINS = JSON.parse(checkEnv('CORS_ORIGINS'));
19 | export const CREDENTIALS = checkEnv('CREDENTIALS') === 'true';
20 |
21 | export const isProduction = checkEnv('NODE_ENV') === 'production';
22 | export const isTest = checkEnv('NODE_ENV') === 'test';
23 |
24 | export const SENTRY_DSN = checkEnv('SENTRY_DSN');
25 |
26 | export const jwt = {
27 | secret: checkEnv('JWT_SECRET'),
28 | accessExpireIn: checkEnv('JWT_ACCESS_EXPIRE_IN'),
29 | accessExpireFormat: checkEnv('JWT_ACCESS_EXPIRE_FORMAT'),
30 | refreshExpireIn: checkEnv('JWT_REFRESH_EXPIRE_IN'),
31 | refreshExpireFormat: checkEnv('JWT_REFRESH_EXPIRE_FORMAT'),
32 | resetPasswordExpireIn: checkEnv('JWT_RESET_PASSWORD_EXPIRE_IN'),
33 | resetPasswordExpireFormat: checkEnv('JWT_RESET_PASSWORD_EXPIRE_FORMAT'),
34 | };
35 |
36 | export * from './passport';
37 |
--------------------------------------------------------------------------------
/src/config/passport.ts:
--------------------------------------------------------------------------------
1 | import { ExtractJwt, Strategy as JwtStrategy } from 'passport-jwt';
2 |
3 | import { TokenTypes } from '@common/constants';
4 | import Users from '@models/users.model';
5 |
6 | import { jwt } from './index';
7 |
8 | const jwtOptions = {
9 | secretOrKey: jwt.secret,
10 |
11 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
12 | };
13 |
14 | const jwtVerify = async (payload, done) => {
15 | try {
16 | if (payload.type !== TokenTypes.ACCESS) {
17 | throw new Error('Invalid token type');
18 | }
19 | const user = await Users.findById(payload.sub);
20 | if (!user) {
21 | return done(null, false);
22 | }
23 | done(null, user);
24 | } catch (error) {
25 | done(error, false);
26 | }
27 | };
28 |
29 | export const jwtStrategy = new JwtStrategy(jwtOptions, jwtVerify);
30 |
--------------------------------------------------------------------------------
/src/exceptions/HttpException.ts:
--------------------------------------------------------------------------------
1 | import { HttpError } from 'routing-controllers';
2 |
3 | export default class HttpException extends HttpError {
4 | public status: number;
5 | public message: string;
6 |
7 | constructor(status: number, message: string) {
8 | super(status, message);
9 | this.status = status;
10 | this.message = message;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { defaultMetadataStorage as classTransformerDefaultMetadataStorage } from 'class-transformer/cjs/storage';
2 | import { validationMetadatasToSchemas } from 'class-validator-jsonschema';
3 | import { getMetadataArgsStorage } from 'routing-controllers';
4 | import { routingControllersToSpec } from 'routing-controllers-openapi';
5 | import swaggerUi from 'swagger-ui-express';
6 |
7 | import { AuthControllerV1, UserControllerV1 } from '@v1/index';
8 |
9 | import App from './app';
10 |
11 | function initSwagger(server: App) {
12 | const schemas = validationMetadatasToSchemas({
13 | classTransformerMetadataStorage: classTransformerDefaultMetadataStorage,
14 | refPointerPrefix: '#/components/schemas/',
15 | });
16 | const routingControllersOptions = {
17 | controllers: server.getControllers,
18 | };
19 | const storage = getMetadataArgsStorage();
20 | const spec = routingControllersToSpec(storage, routingControllersOptions, {
21 | components: {
22 | schemas,
23 | securitySchemes: {
24 | basicAuth: {
25 | scheme: 'basic',
26 | type: 'http',
27 | },
28 | },
29 | },
30 | info: {
31 | description: 'API Generated with `routing-controllers-openapi` package',
32 | title: 'API',
33 | version: '1.0.0',
34 | },
35 | });
36 | server.getServer().use('/api-docs', swaggerUi.serve, swaggerUi.setup(spec));
37 | }
38 |
39 | const server = new App([AuthControllerV1, UserControllerV1]);
40 | initSwagger(server);
41 |
42 | (async () => {
43 | await server.initServerWithDB();
44 | })();
45 |
46 | const gracefulShutdown = async () => {
47 | try {
48 | await server.stopWebServer();
49 | await App.closeDB();
50 |
51 | console.log(`Process ${process.pid} received a graceful shutdown signal`);
52 | process.exit(0);
53 | } catch (error) {
54 | console.log(`graceful shutdown Process ${process.pid} got failed!`);
55 | process.exit(1);
56 | }
57 | };
58 |
59 | process.on('SIGTERM', gracefulShutdown).on('SIGINT', gracefulShutdown);
60 |
--------------------------------------------------------------------------------
/src/middlewares/auth.middleware.ts:
--------------------------------------------------------------------------------
1 | import passport from 'passport';
2 | import { UnauthorizedError } from 'routing-controllers';
3 |
4 | const verifyCallback = (req, resolve, reject) => async (err, user, info) => {
5 | if (err || info || !user) {
6 | return reject(new UnauthorizedError('Please authenticate'));
7 | }
8 | req.user = user;
9 |
10 | resolve();
11 | };
12 |
13 | const auth = () => async (req, res, next) => {
14 | return new Promise((resolve, reject) => {
15 | passport.authenticate('jwt', { session: false }, verifyCallback(req, resolve, reject))(req, res, next);
16 | })
17 | .then(() => next())
18 | .catch(err => next(err));
19 | };
20 |
21 | export default auth;
22 |
--------------------------------------------------------------------------------
/src/middlewares/handlingErrors.middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction, Request, Response } from 'express';
2 | import { getReasonPhrase, StatusCodes } from 'http-status-codes';
3 | const handlingErrors = (error: any, req: Request, res: Response, next: NextFunction) => {
4 | try {
5 | const statusCode: number = error.status || StatusCodes.INTERNAL_SERVER_ERROR;
6 | const message: string = error.message || getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR);
7 |
8 | res.status(statusCode).json({ message });
9 | } catch (err) {
10 | next(err);
11 | }
12 | };
13 |
14 | export default handlingErrors;
15 |
--------------------------------------------------------------------------------
/src/middlewares/validation.middleware.ts:
--------------------------------------------------------------------------------
1 | import { plainToInstance } from 'class-transformer';
2 | import { validate, ValidationError } from 'class-validator';
3 | import { RequestHandler } from 'express';
4 |
5 | import HttpException from '@exceptions/HttpException';
6 |
7 | const getAllNestedErrors = (error: ValidationError) => {
8 | if (error.constraints) {
9 | return Object.values(error.constraints);
10 | }
11 | return error.children.map(getAllNestedErrors).join(',');
12 | };
13 |
14 | export const validationMiddleware = (
15 | type: any,
16 | value: string | 'body' | 'query' | 'params' = 'body',
17 | skipMissingProperties = false,
18 | whitelist = true,
19 | forbidNonWhitelisted = true,
20 | ): RequestHandler => {
21 | return (req, res, next) => {
22 | const obj = plainToInstance(type, req[value]);
23 | validate(obj, {
24 | skipMissingProperties,
25 | whitelist,
26 | forbidNonWhitelisted,
27 | }).then((errors: ValidationError[]) => {
28 | if (errors.length > 0) {
29 | const message = errors.map(getAllNestedErrors).join(', ');
30 | next(new HttpException(400, message));
31 | } else {
32 | next();
33 | }
34 | });
35 | };
36 | };
37 |
--------------------------------------------------------------------------------
/src/models/tokens.model.ts:
--------------------------------------------------------------------------------
1 | import { IsBoolean, IsDate, IsString } from 'class-validator';
2 | import mongoose, { Document, ObjectId, Schema } from 'mongoose';
3 |
4 | import { MODELS, TokenTypes } from '@common/constants';
5 | import ITimesStamp from '@common/interfaces/timestamp.interface';
6 | import toJSON from '@utils/toJSON.plugin';
7 |
8 | export class IToken extends ITimesStamp {
9 | @IsString()
10 | token: string;
11 |
12 | @IsString()
13 | userId: ObjectId;
14 |
15 | @IsString()
16 | type: string;
17 |
18 | @IsDate()
19 | expires: Date;
20 |
21 | @IsBoolean()
22 | blacklisted: boolean;
23 | }
24 |
25 | export interface ITokenSchema extends Document, IToken {}
26 |
27 | const tokenSchema: Schema = new Schema(
28 | {
29 | token: {
30 | type: String,
31 | required: true,
32 | index: true,
33 | },
34 | userId: {
35 | type: Schema.Types.ObjectId,
36 | ref: MODELS.USERS,
37 | required: true,
38 | },
39 | type: {
40 | type: String,
41 | enum: [TokenTypes.ACCESS, TokenTypes.REFRESH, TokenTypes.RESET_PASSWORD],
42 | required: true,
43 | },
44 | expires: {
45 | type: Date,
46 | required: true,
47 | },
48 | blacklisted: {
49 | type: Boolean,
50 | default: false,
51 | },
52 | },
53 | {
54 | timestamps: true,
55 | },
56 | );
57 |
58 | tokenSchema.plugin(toJSON);
59 |
60 | export default mongoose.model(MODELS.TOKENS, tokenSchema);
61 |
--------------------------------------------------------------------------------
/src/models/users.model.ts:
--------------------------------------------------------------------------------
1 | import bcrypt from 'bcrypt';
2 | import { IsBoolean, IsEmail, IsString } from 'class-validator';
3 | import mongoose, { Document, Schema } from 'mongoose';
4 |
5 | import { MODELS } from '@common/constants';
6 | import ITimesStamp from '@common/interfaces/timestamp.interface';
7 | import toJSON from '@utils/toJSON.plugin';
8 |
9 | export class IUser extends ITimesStamp {
10 | @IsString()
11 | username: string;
12 |
13 | @IsEmail()
14 | email: string;
15 |
16 | @IsString()
17 | password: string;
18 |
19 | @IsBoolean()
20 | isEmailVerified: boolean;
21 | }
22 |
23 | export interface IUserSchema extends Document, IUser {}
24 |
25 | const userSchema: Schema = new Schema(
26 | {
27 | username: {
28 | type: String,
29 | required: true,
30 | maxlength: 20,
31 | trim: true,
32 | },
33 | email: {
34 | type: String,
35 | required: true,
36 | unique: true,
37 | trim: true,
38 | lowercase: true,
39 | },
40 | password: {
41 | type: String,
42 | required: true,
43 | trim: true,
44 | minlength: 6,
45 | private: true,
46 | },
47 | isEmailVerified: {
48 | type: Boolean,
49 | default: false,
50 | },
51 | },
52 | {
53 | timestamps: true,
54 | },
55 | );
56 |
57 | userSchema.pre('save', async function (next) {
58 | // eslint-disable-next-line @typescript-eslint/no-this-alias
59 | const user = this;
60 | if (user.isModified('password')) {
61 | user.password = await bcrypt.hash(user.password, 8);
62 | }
63 | next();
64 | });
65 |
66 | userSchema.plugin(toJSON);
67 |
68 | export default mongoose.model(MODELS.USERS, userSchema);
69 |
--------------------------------------------------------------------------------
/src/services/v1/auth.service.ts:
--------------------------------------------------------------------------------
1 | import { NotFoundError, UnauthorizedError } from 'routing-controllers';
2 |
3 | import { TokenTypes } from '@common/constants';
4 | import Tokens from '@models/tokens.model';
5 | import { TokenService, UserService } from '@services/v1';
6 |
7 | export class AuthService {
8 | private readonly tokenModel = Tokens;
9 | private readonly userService = new UserService();
10 | private readonly tokenService = new TokenService();
11 |
12 | async loginUserWithEmailAndPassword(email: string, password: string) {
13 | const user = await this.userService.getUserByEmail(email);
14 |
15 | if (!user || !(await this.userService.comparePassword(password, user.password))) {
16 | throw new UnauthorizedError('Invalid credentials');
17 | }
18 |
19 | return user;
20 | }
21 |
22 | async logout(refreshToken: string) {
23 | const token = await this.tokenModel.findOne({ token: refreshToken, type: TokenTypes.REFRESH, blacklisted: false });
24 |
25 | if (!token) {
26 | throw new NotFoundError('Not Found');
27 | }
28 |
29 | await token.remove();
30 | }
31 |
32 | async refreshAuth(refreshToken: string) {
33 | try {
34 | const refreshTokenDoc = await this.tokenService.verifyToken(refreshToken, TokenTypes.REFRESH);
35 | const user = await this.userService.getById(refreshTokenDoc.userId);
36 | if (!user) {
37 | throw new Error();
38 | }
39 |
40 | await refreshTokenDoc.remove();
41 | const tokens = await this.tokenService.generateAuthTokens(user);
42 | return { user, tokens };
43 | } catch (error) {
44 | if (error.message === 'Token not found' || error.message === 'jwt expired') {
45 | throw new UnauthorizedError('Token not found');
46 | }
47 | throw new UnauthorizedError('Please authenticate');
48 | }
49 | }
50 |
51 | async resetPassword(token: string, password: string) {
52 | try {
53 | const tokenDoc = await this.tokenService.verifyToken(token, TokenTypes.RESET_PASSWORD);
54 | const user = await this.userService.getById(tokenDoc.userId);
55 | if (!user) {
56 | throw new NotFoundError('User not found');
57 | }
58 |
59 | await this.userService.updateById(user.id, { password });
60 | await this.tokenModel.deleteMany({ userId: user.id });
61 | } catch (error) {
62 | if (error.message === 'Token not found' || error.message === 'jwt expired') {
63 | throw new UnauthorizedError('Token not found');
64 | }
65 | throw error;
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/services/v1/index.ts:
--------------------------------------------------------------------------------
1 | export * from './auth.service';
2 | export * from './token.service';
3 | export * from './user.service';
4 |
--------------------------------------------------------------------------------
/src/services/v1/token.service.ts:
--------------------------------------------------------------------------------
1 | import jsonwebtoken from 'jsonwebtoken';
2 | import moment from 'moment';
3 | import { ObjectId } from 'mongoose';
4 | import { NotFoundError } from 'routing-controllers';
5 |
6 | import { TokenTypes } from '@common/constants';
7 | import { jwt } from '@config';
8 | import Tokens from '@models/tokens.model';
9 | import { IUserSchema } from '@models/users.model';
10 |
11 | import { UserService } from './user.service';
12 |
13 | export class TokenService {
14 | private readonly userService = new UserService();
15 |
16 | async generateAuthTokens(user: IUserSchema) {
17 | const accessTokenExpire = moment().add(jwt.accessExpireIn as moment.unitOfTime.DurationConstructor, jwt.accessExpireFormat);
18 | const accessToken = this.generateToken(user.id, accessTokenExpire.unix(), TokenTypes.ACCESS);
19 |
20 | const refreshTokenExpire = moment().add(jwt.refreshExpireIn as moment.unitOfTime.DurationConstructor, jwt.refreshExpireFormat);
21 | const refreshToken = this.generateToken(user.id, refreshTokenExpire.unix(), TokenTypes.REFRESH);
22 |
23 | await this.saveToken(refreshToken, user.id, refreshTokenExpire.toDate(), TokenTypes.REFRESH);
24 |
25 | return {
26 | access: {
27 | token: accessToken,
28 | expires: accessTokenExpire.unix(),
29 | },
30 | refresh: {
31 | token: refreshToken,
32 | expire: refreshTokenExpire.unix(),
33 | },
34 | };
35 | }
36 |
37 | generateToken(userId: ObjectId, expire: number, type: string) {
38 | const payload = {
39 | sub: userId,
40 | iat: moment().unix(),
41 | exp: expire,
42 | type,
43 | };
44 |
45 | return jsonwebtoken.sign(payload, jwt.secret);
46 | }
47 |
48 | async saveToken(token: string, userId: ObjectId, expires: Date, type: TokenTypes, blacklisted = false) {
49 | return await Tokens.create({
50 | token,
51 | userId,
52 | expires,
53 | type,
54 | blacklisted,
55 | });
56 | }
57 |
58 | async verifyToken(token: string, type: string) {
59 | const payload = jsonwebtoken.verify(token, jwt.secret);
60 | const tokenDoc = await Tokens.findOne({ token, type, userId: payload.sub, blacklisted: false });
61 | if (!tokenDoc) {
62 | throw new Error('Token not found');
63 | }
64 | return tokenDoc;
65 | }
66 |
67 | async generateResetPasswordToken(email: string) {
68 | const user = await this.userService.getUserByEmail(email);
69 | if (!user) {
70 | throw new NotFoundError('User not exists with this email');
71 | }
72 |
73 | const expireIn = moment().add(jwt.resetPasswordExpireIn as moment.unitOfTime.DurationConstructor, jwt.resetPasswordExpireFormat);
74 | const resetPasswordToken = this.generateToken(user.id, expireIn.unix(), TokenTypes.RESET_PASSWORD);
75 | await this.saveToken(resetPasswordToken, user.id, expireIn.toDate(), TokenTypes.RESET_PASSWORD);
76 |
77 | return resetPasswordToken;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/services/v1/user.service.ts:
--------------------------------------------------------------------------------
1 | import bcrypt from 'bcrypt';
2 | import { ObjectId } from 'mongoose';
3 | import { BadRequestError } from 'routing-controllers';
4 |
5 | import CRUD from '@common/interfaces/crud.interface';
6 | import Users, { IUser, IUserSchema } from '@models/users.model';
7 | import RegisterDto from '@v1/auth/dtos/register.dto';
8 |
9 | export class UserService implements CRUD {
10 | private readonly userModel = Users;
11 |
12 | async isEmailTaken(email: string): Promise {
13 | const user = await this.userModel.findOne({ email });
14 |
15 | return !!user;
16 | }
17 |
18 | async createUser(userData: RegisterDto) {
19 | const { email } = userData;
20 | if (await this.isEmailTaken(email)) {
21 | throw new BadRequestError('Email already Taken');
22 | }
23 |
24 | const user = await this.userModel.create({ ...userData });
25 | return user;
26 | }
27 |
28 | async getUserByEmail(email: string) {
29 | return await this.userModel.findOne({ email });
30 | }
31 |
32 | async comparePassword(inputPass: string, userPass: string) {
33 | return await bcrypt.compare(inputPass, userPass);
34 | }
35 |
36 | async getById(id: ObjectId): Promise {
37 | return await this.userModel.findById(id);
38 | }
39 |
40 | async updateById(id: ObjectId, updateBody: Partial): Promise {
41 | // prevent user change his email
42 | if (updateBody.email) {
43 | delete updateBody.email;
44 | }
45 |
46 | const user = await this.getById(id);
47 | if (!user) {
48 | throw new BadRequestError('User not found');
49 | }
50 |
51 | Object.assign(user, updateBody);
52 | await user.save();
53 | return user;
54 | }
55 |
56 | async findAll(limit = 10, page = 0) {
57 | const query = {};
58 | const totalDocs = await this.userModel.countDocuments(query);
59 | const docs = await this.userModel
60 | .find(query)
61 | .limit(limit)
62 | .skip(limit * page)
63 | .sort({ createdAt: -1 })
64 | .lean();
65 |
66 | return {
67 | docs: JSON.parse(JSON.stringify(docs)),
68 | meta: {
69 | totalDocs,
70 | totalPages: Math.ceil(totalDocs / limit) || 0,
71 | page,
72 | },
73 | };
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/utils/toJSON.plugin.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-param-reassign */
2 | // eslint-disable-next-line
3 | // @ts-nocheck
4 |
5 | /**
6 | * A mongoose schema plugin which applies the following in the toJSON transform call:
7 | * - removes __v and any path that has private: true
8 | * - replaces _id with id
9 | */
10 |
11 | const deleteAtPath = (obj: Document, path: string, index: number) => {
12 | if (index === path.length - 1) {
13 | delete obj[path[index]];
14 | return;
15 | }
16 | deleteAtPath(obj[path[index]], path, index + 1);
17 | };
18 |
19 | const toJSON = schema => {
20 | let transform;
21 | if (schema.options.toJSON && schema.options.toJSON.transform) {
22 | transform = schema.options.toJSON.transform;
23 | }
24 |
25 | schema.options.toJSON = Object.assign(schema.options.toJSON || {}, {
26 | transform(doc, ret, options) {
27 | Object.keys(schema.paths).forEach(path => {
28 | if (schema.paths[path].options && schema.paths[path].options.private) {
29 | deleteAtPath(ret, path.split('.'), 0);
30 | }
31 | });
32 |
33 | ret.id = ret._id.toString();
34 | delete ret._id;
35 | delete ret.__v;
36 | if (transform) {
37 | return transform(doc, ret, options);
38 | }
39 | },
40 | });
41 | };
42 |
43 | export default toJSON;
44 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "incremental": true,
4 | "target": "ES2017",
5 | "module": "commonjs",
6 | "sourceMap": true,
7 | "outDir": "dist",
8 | "esModuleInterop": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "moduleResolution": "node",
11 | "emitDecoratorMetadata": true,
12 | "experimentalDecorators": true,
13 | "baseUrl": "src",
14 | "paths": {
15 | "@app": ["app"],
16 | "@config": ["config"],
17 | "@middlewares/*": ["middlewares/*"],
18 | "@models/*": ["models/*"],
19 | "@common/*": ["common/*"],
20 | "@exceptions/*": ["exceptions/*"],
21 | "@v1/*": ["api/v1/*"],
22 | "@utils/*": ["utils/*"],
23 | "@services/*": ["services/*"],
24 | "@__tests__/*": ["__tests__/*"]
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------