├── .dockerignore ├── .editorconfig ├── .env.docker ├── .env.example ├── .eslintignore ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md ├── pull_request_template.md └── workflows │ ├── ci.yml │ └── codeql.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc ├── LICENSE ├── Makefile ├── README.md ├── api.rest ├── docker-compose.yml ├── jest.config.js ├── knexfile.ts ├── logs └── .gitignore ├── nodemon.json ├── package.json ├── scripts ├── fake-loader.ts └── mail-test.ts ├── src ├── app.ts ├── config │ ├── config.ts │ ├── db.ts │ └── mail.ts ├── controllers │ ├── auth.ts │ ├── home.ts │ └── user.ts ├── database │ ├── factories │ │ ├── index.ts │ │ └── userFactory.ts │ ├── migrations │ │ ├── 20170517164638_create_user_roles_table.ts │ │ ├── 20180130005620_create_users_table.ts │ │ └── 20180517164647_create_user_sessions_table.ts │ └── seeds │ │ └── user_table_seeder.ts ├── domain │ ├── entities │ │ ├── UserDetail.ts │ │ └── UserSessionDetail.ts │ ├── misc │ │ ├── JWTPayload.ts │ │ ├── LoggedInUser.ts │ │ ├── MailOptions.ts │ │ └── ResponseData.ts │ ├── requests │ │ ├── LoginPayload.ts │ │ ├── UserPayload.ts │ │ └── UserSessionPayload.ts │ └── responses │ │ ├── APIResponse.ts │ │ └── TokenResponse.ts ├── exceptions │ ├── BadRequestError.ts │ ├── Error.ts │ ├── ForbiddenError.ts │ └── UnauthorizedError.ts ├── index.ts ├── middlewares │ ├── authenticate.ts │ ├── genericErrorHandler.ts │ ├── nodeErrorHandler.ts │ ├── notFoundHandler.ts │ ├── rateLimitHandler.ts │ ├── transactionHandler.ts │ ├── validate.ts │ └── validateRefreshToken.ts ├── models │ ├── User.ts │ ├── UserRole.ts │ └── UserSession.ts ├── resources │ ├── constants │ │ ├── endpoints.ts │ │ └── maps.ts │ ├── enums │ │ ├── ErrorType.ts │ │ ├── Role.ts │ │ └── Table.ts │ ├── lang │ │ ├── errors.json │ │ └── messages.json │ ├── stubs │ │ ├── migration.stub │ │ └── seed.stub │ └── validators │ │ ├── loginRequest.ts │ │ └── userRequest.ts ├── routes.ts ├── services │ ├── authService.ts │ ├── sessionService.ts │ └── userService.ts ├── types │ └── nodemailer-markdown.d.ts └── utils │ ├── array.ts │ ├── bcrypt.ts │ ├── context.ts │ ├── fake.ts │ ├── jwt.ts │ ├── logger.ts │ ├── mail.ts │ ├── string.ts │ └── transform.ts ├── test ├── api │ ├── auth.spec.ts │ ├── home.spec.ts │ └── user.spec.ts ├── helper.ts └── unit │ ├── array.spec.ts │ ├── bcrypt.spec.ts │ └── string.spec.ts ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | charset = utf-8 9 | end_of_line = lf 10 | indent_size = 2 11 | indent_style = space 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | 15 | [Makefile] 16 | indent_size = 4 17 | indent_style = tab 18 | -------------------------------------------------------------------------------- /.env.docker: -------------------------------------------------------------------------------- 1 | APP_PORT=8000 2 | 3 | CODE_SOURCE=/source 4 | 5 | # Database Configurations 6 | DB_CLIENT=pg 7 | DB_HOST=postgres 8 | DB_PORT=5432 9 | DB_NAME=starter 10 | DB_USER=starter 11 | DB_PASSWORD=secret 12 | 13 | # Log 14 | LOGGING_LEVEL=debug 15 | LOG_FILE_GENERATION_SUPPORT=false 16 | 17 | # Authentication 18 | ACCESS_TOKEN_DURATION=10m 19 | REFRESH_TOKEN_DURATION=24h 20 | ACCESS_TOKEN_SECRET_KEY= 21 | REFRESH_TOKEN_SECRET_KEY=REFRESH_TOKEN_SECRET_KEY> 22 | 23 | # Mail 24 | MAIL_PORT=2525 25 | MAIL_HOST=smtp.mailtrap.io 26 | MAIL_SMTP_USERNAME= 27 | MAIL_SMTP_PASSWORD= 28 | 29 | # Tests 30 | TEST_APP_PORT=8888 31 | TEST_DB_NAME=starter_test 32 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_PORT=8000 2 | 3 | # Database Configurations 4 | DB_CLIENT=pg 5 | DB_HOST=127.0.0.1 6 | DB_PORT=5432 7 | DB_NAME= 8 | DB_USER= 9 | DB_PASSWORD= 10 | 11 | # Log 12 | LOGGING_DIR= 13 | LOGGING_LEVEL=debug 14 | 15 | # Authentication 16 | ACCESS_TOKEN_DURATION=10m 17 | REFRESH_TOKEN_DURATION=24h 18 | ACCESS_TOKEN_SECRET_KEY= 19 | REFRESH_TOKEN_SECRET_KEY=REFRESH_TOKEN_SECRET_KEY> 20 | 21 | # Mail 22 | MAIL_PORT=2525 23 | MAIL_HOST=smtp.mailtrap.io 24 | MAIL_SMTP_USERNAME= 25 | MAIL_SMTP_PASSWORD= 26 | 27 | # Tests 28 | TEST_APP_PORT=8080 29 | TEST_DB_NAME=starter_test 30 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | volumes 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["sonarjs", "simple-import-sort", "jsdoc"], 5 | "parserOptions": { 6 | "ecmaVersion": "latest", 7 | "sourceType": "module" 8 | }, 9 | "ignorePatterns": ["./node_modules/*"], 10 | "extends": [ 11 | "eslint:recommended", 12 | "plugin:@typescript-eslint/recommended", 13 | "plugin:sonarjs/recommended", 14 | "plugin:jsdoc/recommended-typescript" 15 | ], 16 | "rules": { 17 | "no-error-on-unmatched-pattern": "off", 18 | "@typescript-eslint/no-explicit-any": "warn", 19 | "sonarjs/cognitive-complexity": "off", 20 | "@typescript-eslint/no-shadow": ["error"], 21 | "@typescript-eslint/ban-ts-comment": "off", 22 | "simple-import-sort/imports": "warn", 23 | "jsdoc/no-types": "off", 24 | "jsdoc/tag-lines": "off", 25 | "jsdoc/require-returns-description": "off" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | # Expected Behavior 10 | 11 | Please describe the behavior you are expecting 12 | 13 | # Current Behavior 14 | 15 | What is the current behavior? 16 | 17 | # Failure Information (for bugs) 18 | 19 | Please help provide information about the failure if this is a bug. If it is not a bug, please remove the rest of this template. 20 | 21 | ## Steps to Reproduce 22 | 23 | Please provide detailed steps for reproducing the issue. 24 | 25 | 1. step 1 26 | 2. step 2 27 | 3. you get it... 28 | 29 | ## Context 30 | 31 | Please provide any relevant information about your setup. This is important in case the issue is not reproducible except for under certain conditions. 32 | 33 | - Firmware Version: 34 | - Operating System: 35 | - SDK version: 36 | - Toolchain version: 37 | 38 | ## Failure Logs or Screenshots 39 | 40 | Please include any relevant log snippets or files here. 41 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | 9 | Note: **_Please delete options that are not relevant._** 10 | 11 | - [ ] Bug fix (non-breaking change which fixes an issue) 12 | - [ ] New feature (non-breaking change which adds functionality) 13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 14 | - [ ] Documentation update 15 | 16 | ## How Has This Been Tested? 17 | 18 | Please describe the tests that you ran to verify your changes. Please also note any relevant details for your test configuration. 19 | 20 | - [ ] Test A 21 | - [ ] Test B 22 | 23 | ## Checklist: 24 | 25 | - [ ] My code follows the style guidelines of this project. 26 | - [ ] Any dependent changes have been merged. 27 | - [ ] I have performed a self-review of my own code. 28 | - [ ] Existing unit tests pass locally with my changes. 29 | - [ ] I have added tests that prove my fix is effective or that my feature works 30 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Starter CI 2 | 3 | on: 4 | push: 5 | branches: [dev, main] 6 | pull_request: 7 | branches: [dev, main] 8 | 9 | jobs: 10 | build: 11 | strategy: 12 | matrix: 13 | node-version: [20.x] 14 | platform: [ubuntu-latest] 15 | 16 | runs-on: ${{ matrix.platform }} 17 | 18 | services: 19 | postgres: 20 | image: postgres 21 | env: 22 | POSTGRES_DB: starter_test 23 | POSTGRES_USER: postgres 24 | POSTGRES_PASSWORD: secret 25 | ports: 26 | - 5432:5432 27 | # Set health checks to wait until postgres has started 28 | options: >- 29 | --health-cmd pg_isready 30 | --health-interval 10s 31 | --health-timeout 5s 32 | --health-retries 5 33 | 34 | steps: 35 | - name: Check out repository code 36 | uses: actions/checkout@v2 37 | 38 | - name: Use Node.js ${{ matrix.node-version }} 39 | uses: actions/setup-node@v1 40 | with: 41 | node-version: ${{ matrix.node-version }} 42 | 43 | - name: Install dependencies 44 | run: yarn install 45 | 46 | - name: Building source code 47 | run: yarn build 48 | 49 | - name: Running tests 50 | run: yarn test 51 | env: 52 | NODE_ENV: test 53 | DB_CLIENT: pg 54 | DB_PORT: 5432 55 | DB_HOST: 127.0.0.1 56 | DB_USER: postgres 57 | DB_PASSWORD: secret 58 | TEST_DB_NAME: starter_test 59 | TEST_APP_PORT: 8888 60 | ACCESS_TOKEN_SECRET_KEY: 'somerandoma' 61 | REFRESH_TOKEN_SECRET_KEY: 'somerandomr' 62 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "dev", "main" ] 17 | pull_request: 18 | branches: [ "dev", "main" ] 19 | schedule: 20 | - cron: '35 1 * * 4' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze 25 | # Runner size impacts CodeQL analysis time. To learn more, please see: 26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 27 | # - https://gh.io/supported-runners-and-hardware-resources 28 | # - https://gh.io/using-larger-runners 29 | # Consider using larger runners for possible analysis time improvements. 30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 31 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 32 | permissions: 33 | # required for all workflows 34 | security-events: write 35 | 36 | # only required for workflows in private repositories 37 | actions: read 38 | contents: read 39 | 40 | strategy: 41 | fail-fast: false 42 | matrix: 43 | language: [ 'javascript-typescript' ] 44 | # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] 45 | # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both 46 | # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 47 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 48 | 49 | steps: 50 | - name: Checkout repository 51 | uses: actions/checkout@v4 52 | 53 | # Initializes the CodeQL tools for scanning. 54 | - name: Initialize CodeQL 55 | uses: github/codeql-action/init@v3 56 | with: 57 | languages: ${{ matrix.language }} 58 | # If you wish to specify custom queries, you can do so here or in a config file. 59 | # By default, queries listed here will override any specified in a config file. 60 | # Prefix the list here with "+" to use these queries and those in the config file. 61 | 62 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 63 | # queries: security-extended,security-and-quality 64 | 65 | 66 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). 67 | # If this step fails, then you should remove it and run the build manually (see below) 68 | - name: Autobuild 69 | uses: github/codeql-action/autobuild@v3 70 | 71 | # ℹ️ Command-line programs to run using the OS shell. 72 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 73 | 74 | # If the Autobuild fails above, remove it and uncomment the following three lines. 75 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 76 | 77 | # - run: | 78 | # echo "Run, Build Application using script" 79 | # ./location_of_script_within_repo/buildscript.sh 80 | 81 | - name: Perform CodeQL Analysis 82 | uses: github/codeql-action/analyze@v3 83 | with: 84 | category: "/language:${{matrix.language}}" 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | *.log 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Runtime data 8 | pids 9 | *.pid 10 | *.seed 11 | *.pid.lock 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # nyc test coverage 20 | .nyc_output 21 | 22 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 23 | .grunt 24 | 25 | # Bower dependency directory (https://bower.io/) 26 | bower_components 27 | 28 | # node-waf configuration 29 | .lock-wscript 30 | 31 | # Compiled binary addons (http://nodejs.org/api/addons.html) 32 | build/Release 33 | 34 | # Dependency directories 35 | node_modules/ 36 | jspm_packages/ 37 | 38 | # Typescript v1 declaration files 39 | typings/ 40 | 41 | # Optional npm cache directory 42 | .npm 43 | 44 | # Optional eslint cache 45 | .eslintcache 46 | 47 | # Optional REPL history 48 | .node_repl_history 49 | 50 | # Output of 'npm pack' 51 | *.tgz 52 | 53 | # Yarn Integrity file 54 | .yarn-integrity 55 | 56 | # dotenv environment variables file 57 | .env 58 | 59 | # IntelliJ IDE 60 | .idea 61 | 62 | # Visual Studio Code 63 | .vscode 64 | 65 | # Build directory 66 | dist 67 | build 68 | volumes 69 | 70 | # Yarn 2 71 | .yarn/* 72 | !.yarn/patches 73 | !.yarn/releases 74 | !.yarn/plugins 75 | !.yarn/sdks 76 | !.yarn/versions 77 | .pnp.* 78 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | 6 | # check if the .env file exist 7 | if [ ! -f .env ]; then 8 | exit 0 9 | fi 10 | 11 | npx sync-dotenv 12 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.json 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "printWidth": 120, 6 | "parser": "typescript", 7 | "tabWidth": 2 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Sagar Chamling 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help clean 2 | .DEFAULT_GOAL := help 3 | 4 | define BROWSER_PYSCRIPT 5 | import webbrowser, sys 6 | 7 | webbrowser.open(sys.argv[1]) 8 | endef 9 | export BROWSER_PYSCRIPT 10 | 11 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 12 | 13 | define PRINT_HELP_PYSCRIPT 14 | import re, sys 15 | 16 | for line in sys.stdin: 17 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 18 | if match: 19 | target, help = match.groups() 20 | print("%-20s %s" % (target, help)) 21 | endef 22 | export PRINT_HELP_PYSCRIPT 23 | 24 | clean: ## Remove log file. 25 | @rm -rf logs/*.{log,json} 26 | @rm -rf build 27 | @echo "Clean Successful." 28 | 29 | help: ## Help 30 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Typescript API Starter

2 |

3 | 4 | Build Status 5 | 6 |

7 | 8 | This is a API starter template for building a Node.js and Express.js using Typescript and PostgreSQL as database. It includes popular tools such as jsonwebtoken, joi, Knex, Objection.js, and more. 9 | 10 | ## Requirements 11 | 12 | - [Node.js](https://yarnpkg.com/en/docs/install) 13 | - [Yarn](https://yarnpkg.com/en/docs/install) 14 | - [NPM](https://docs.npmjs.com/getting-started/installing-node) 15 | - [Docker](https://docs.docker.com/install/) 16 | 17 | ## Getting Started 18 | 19 | ```bash 20 | # Clone repository 21 | $ git clone git@github.com:cham11ng/typescript-api-starter.git 22 | 23 | $ cd 24 | 25 | # Update database credentials 26 | $ cp .env.example .env 27 | 28 | # Install dependencies 29 | $ yarn install 30 | 31 | $ yarn migrate 32 | ``` 33 | 34 | ```bash 35 | # Load fake data in database. 36 | $ yarn load:fake 37 | ``` 38 | 39 |

40 | 41 |

42 | 43 | Start the application. 44 | 45 | ```bash 46 | # For production 47 | $ yarn build 48 | 49 | # For development 50 | $ yarn dev 51 | ``` 52 | 53 |

54 | 55 |

56 | 57 | ### Using Docker 58 | 59 | ```bash 60 | $ docker compose up -d api 61 | 62 | # Make sure server is started checking logs before running this command 63 | $ docker compose exec api sh yarn migrate 64 | ``` 65 | 66 | ```bash 67 | # View logs of the container. 68 | $ docker compose logs -f api 69 | 70 | # To stop the services. 71 | $ docker compose stop api postgres 72 | ``` 73 | 74 | ## Generating Migrations and Seeds 75 | 76 | ```bash 77 | # To create migration use `make:migration` 78 | $ yarn make:migration create_{table_name}_table 79 | 80 | # To create seed use `make:seeder` 81 | $ yarn make:seeder {table_name}_table_seeder 82 | ``` 83 | 84 | ```bash 85 | # Example 86 | $ yarn make:migration create_posts_table 87 | $ yarn make:seeder post_table_seeder 88 | ``` 89 | 90 | Modify migration and seeder file as per the requirement. Then finally: 91 | 92 | ```bash 93 | # to migrate 94 | $ yarn migrate 95 | 96 | # to seed 97 | $ yarn seed 98 | ``` 99 | 100 | ## Setting up REST Client 101 | 102 | Create a file or add following lines in `.vscode` > `settings.json` and switch an environment `Cmd/Ctrl + Shift + P` > `REST Client: Switch Environment`. Then, you can request APIs from `api.rest` file. 103 | 104 | ```json 105 | { 106 | "rest-client.environmentVariables": { 107 | "$shared": { 108 | "refreshToken": "foo", 109 | "accessToken": "bar", 110 | "email": "sgr.raee@gmail.com", 111 | "password": "secret" 112 | }, 113 | "local": { 114 | "host": "localhost", 115 | "refreshToken": "{{$shared refreshToken}}", 116 | "accessToken": "{{$shared accessToken}}", 117 | "email": "{{$shared email}}", 118 | "password": "{{$shared password}}" 119 | } 120 | } 121 | } 122 | ``` 123 | 124 | ## Contributing 125 | 126 | Feel free to send pull requests. 127 | 128 | ## License 129 | 130 | typescript-api-starter is under [MIT License](LICENSE). 131 | -------------------------------------------------------------------------------- /api.rest: -------------------------------------------------------------------------------- 1 | GET {{host}} HTTP/1.1 2 | 3 | ### 4 | 5 | GET {{host}}/users HTTP/1.1 6 | Authorization: {{accessToken}} 7 | 8 | ### 9 | 10 | POST {{host}}/login HTTP/1.1 11 | Content-Type: application/json 12 | 13 | { 14 | "email": "{{email}}", 15 | "password": "{{password}}" 16 | } 17 | 18 | ### 19 | 20 | POST {{host}}/refresh HTTP/1.1 21 | Authorization: {{refreshToken}} 22 | 23 | ### 24 | 25 | POST {{host}}/logout HTTP/1.1 26 | Authorization: {{refreshToken}} 27 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | postgres: 5 | image: "postgres:latest" 6 | container_name: "starter-postgres" 7 | volumes: 8 | - './volumes/postgres:/var/lib/postgresql/data' 9 | ports: 10 | - "5432:5432" 11 | environment: 12 | POSTGRES_DB: ${DB_NAME:-starter} 13 | POSTGRES_USER: ${DB_USERNAME:-starter} 14 | POSTGRES_PASSWORD: ${DB_PASSWORD:-secret} 15 | 16 | api: 17 | image: "node:latest" 18 | env_file: '.env.docker' 19 | container_name: "starter-api" 20 | volumes: 21 | - "./:/source" 22 | - "/source/node_modules" 23 | working_dir: /source 24 | depends_on: 25 | - postgres 26 | ports: 27 | - "8000:8000" 28 | command: bash -c "yarn && yarn dev" 29 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: true, 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | coverageDirectory: 'coverage', 6 | testMatch: ['**/*/*.spec.ts'], 7 | moduleFileExtensions: ['js', 'ts'], 8 | moduleDirectories: ['node_modules'] 9 | }; 10 | -------------------------------------------------------------------------------- /knexfile.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | 3 | dotenv.config(); 4 | 5 | module.exports = { 6 | development: { 7 | client: process.env.DB_CLIENT, 8 | connection: { 9 | charset: 'utf8', 10 | timezone: 'UTC', 11 | host: process.env.DB_HOST, 12 | port: process.env.DB_PORT, 13 | database: process.env.DB_NAME, 14 | user: process.env.DB_USER, 15 | password: process.env.DB_PASSWORD 16 | }, 17 | pool: { 18 | min: 2, 19 | max: 10 20 | }, 21 | migrations: { 22 | directory: 'src/database/migrations', 23 | tableName: 'migrations', 24 | stub: 'src/resources/stubs/migration.stub' 25 | }, 26 | seeds: { 27 | directory: 'src/database/seeds', 28 | stub: 'src/resources/stubs/seed.stub' 29 | } 30 | }, 31 | test: { 32 | client: process.env.DB_CLIENT, 33 | connection: { 34 | charset: 'utf8', 35 | timezone: 'UTC', 36 | host: process.env.DB_HOST, 37 | user: process.env.DB_USER, 38 | port: process.env.DB_PORT, 39 | password: process.env.DB_PASSWORD, 40 | database: process.env.TEST_DB_NAME 41 | }, 42 | migrations: { 43 | directory: 'src/database/migrations', 44 | tableName: 'migrations' 45 | }, 46 | seeds: { 47 | directory: 'src/database/seeds' 48 | } 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src/**/*", 4 | ".env" 5 | ], 6 | "ext": "ts,.env", 7 | "verbose": true 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-api-starter", 3 | "version": "0.3.0", 4 | "description": "Starter for Node.js express API with Typescript", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git@github.com:cham11ng/typescript-api-starter.git" 9 | }, 10 | "author": { 11 | "name": "Sagar Chamling", 12 | "email": "sgr.raee@gmail.com" 13 | }, 14 | "engines": { 15 | "node": ">= 20.9.0" 16 | }, 17 | "scripts": { 18 | "dev": "concurrently 'npx tsc --watch' 'nodemon -q build/index.js'", 19 | "transpile": "tsc", 20 | "clean": "rimraf build", 21 | "sync-env": "sync-dotenv", 22 | "build": "concurrently 'yarn clean' 'yarn lint:fix' 'yarn transpile'", 23 | "send:mail": "ts-node scripts/mail-test", 24 | "load:fake": "ts-node scripts/fake-loader", 25 | "test": "NODE_ENV=test yarn migrate && NODE_ENV=test jest --forceExit --detectOpenHandles --maxWorkers=1 --verbose", 26 | "seed": "knex seed:run --knexfile=knexfile.ts --verbose", 27 | "migrate": "knex migrate:latest --knexfile=knexfile.ts --verbose", 28 | "rollback": "knex migrate:rollback --knexfile=knexfile.ts --verbose", 29 | "make:seeder": "knex seed:make --knexfile=knexfile.ts -x ts", 30 | "make:migration": "knex migrate:make --knexfile=knexfile.ts -x ts", 31 | "prepare": "husky install", 32 | "lint": "eslint . --ext .ts,.json", 33 | "lint:fix": "eslint --fix . --ext .ts,.json", 34 | "prettify": "prettier 'src/**/*.ts' --write", 35 | "prettier:check": "prettier --list-different 'src/**/*.ts'", 36 | "format:code": "concurrently 'yarn lint:fix' 'yarn prettify' 'sync-dotenv'" 37 | }, 38 | "lint-staged": { 39 | "*.{ts,json}": [ 40 | "eslint --fix", 41 | "prettier --write" 42 | ] 43 | }, 44 | "private": true, 45 | "license": "MIT", 46 | "keywords": [ 47 | "api", 48 | "es6", 49 | "node", 50 | "express", 51 | "javascript", 52 | "typescript" 53 | ], 54 | "dependencies": { 55 | "bcrypt": "^5.1.1", 56 | "cors": "^2.8.5", 57 | "dotenv": "^16.4.5", 58 | "express": "4.20.0", 59 | "express-rate-limit": "^7.2.0", 60 | "helmet": "^7.1.0", 61 | "http-status-codes": "^2.3.0", 62 | "joi": "^17.12.2", 63 | "jsonwebtoken": "^9.0.2", 64 | "knex": "^3.1.0", 65 | "nodemailer": "^6.9.13", 66 | "nodemailer-markdown": "^1.0.3", 67 | "objection": "^3.1.4", 68 | "pg": "^8.11.3", 69 | "winston": "^3.13.0", 70 | "winston-daily-rotate-file": "^5.0.0" 71 | }, 72 | "devDependencies": { 73 | "@faker-js/faker": "^8.4.1", 74 | "@types/bcrypt": "^5.0.2", 75 | "@types/cors": "^2.8.17", 76 | "@types/express": "^4.17.21", 77 | "@types/jest": "^29.5.12", 78 | "@types/jsonwebtoken": "^9.0.6", 79 | "@types/node": "^20.11.30", 80 | "@types/nodemailer": "^6.4.14", 81 | "@types/supertest": "^6.0.2", 82 | "@typescript-eslint/eslint-plugin": "^7.4.0", 83 | "@typescript-eslint/parser": "^7.4.0", 84 | "concurrently": "^8.2.2", 85 | "eslint": "^8.57.0", 86 | "eslint-plugin-jsdoc": "^48.2.2", 87 | "eslint-plugin-simple-import-sort": "^12.0.0", 88 | "eslint-plugin-sonarjs": "^0.25.0", 89 | "husky": "^9.0.11", 90 | "jest": "^29.7.0", 91 | "lint-staged": "^15.2.2", 92 | "nodemon": "^3.1.0", 93 | "prettier": "^3.2.5", 94 | "rimraf": "^5.0.5", 95 | "supertest": "^6.3.4", 96 | "sync-dotenv": "^2.7.0", 97 | "ts-jest": "^29.1.2", 98 | "ts-node": "^10.9.2", 99 | "typescript": "^5.4.3" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /scripts/fake-loader.ts: -------------------------------------------------------------------------------- 1 | import { bindModel } from '../src/config/db'; 2 | import factories, { FactoryType } from '../src/database/factories'; 3 | import * as fake from '../src/utils/fake'; 4 | 5 | const { info } = console; 6 | 7 | /** 8 | * Print the data in JSON format. 9 | * 10 | * @param data - Data to print. 11 | * @returns {void} 12 | */ 13 | function print(data: T): void { 14 | const jsonData = JSON.stringify(data, null, ' '); 15 | 16 | info(jsonData); 17 | } 18 | 19 | (async (): Promise => { 20 | try { 21 | const table = process.argv[2]; 22 | const total = +process.argv[3] || 1; 23 | 24 | const factoryCallback = factories[table as FactoryType].run; 25 | 26 | bindModel(); 27 | 28 | print(await fake.generate(factoryCallback, total)); 29 | 30 | process.exit(0); 31 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 32 | } catch (err: any) { 33 | info(err.message); 34 | 35 | process.exit(1); 36 | } 37 | })(); 38 | -------------------------------------------------------------------------------- /scripts/mail-test.ts: -------------------------------------------------------------------------------- 1 | import * as mail from '../src/utils/mail'; 2 | 3 | (async (): Promise => { 4 | try { 5 | await mail.send({ 6 | to: 'sgr.raee@gmail.com', 7 | subject: 'This is awesome', 8 | markdown: '# Hello world!\n\nThis is a **markdown** message' 9 | }); 10 | 11 | process.exit(0); 12 | } catch (err) { 13 | process.exit(1); 14 | } 15 | })(); 16 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import cors from 'cors'; 2 | import express from 'express'; 3 | import helmet from 'helmet'; 4 | 5 | import { bindModel } from './config/db'; 6 | import genericErrorHandler from './middlewares/genericErrorHandler'; 7 | import notFoundHandler from './middlewares/notFoundHandler'; 8 | import rateLimitMiddleware from './middlewares/rateLimitHandler'; 9 | import transactionHandler from './middlewares/transactionHandler'; 10 | import routes from './routes'; 11 | 12 | const app: express.Application = express(); 13 | 14 | bindModel(); 15 | 16 | app.use(cors()); 17 | app.use(helmet()); 18 | app.use(transactionHandler); 19 | app.use(rateLimitMiddleware); 20 | app.use(express.json({ limit: '300kb' })); 21 | app.use(express.urlencoded({ extended: true })); 22 | 23 | app.use('/', routes); 24 | 25 | app.use(genericErrorHandler); 26 | app.use(notFoundHandler); 27 | 28 | export default app; 29 | -------------------------------------------------------------------------------- /src/config/config.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | 3 | import errors from '../resources/lang/errors.json'; 4 | import messages from '../resources/lang/messages.json'; 5 | 6 | dotenv.config(); 7 | 8 | const isTestEnvironment = process.env.NODE_ENV === 'test'; 9 | 10 | export default { 11 | errors, 12 | messages, 13 | name: process.env.npm_package_name, 14 | version: process.env.npm_package_version, 15 | appUrl: process.env.APP_URL, 16 | environment: process.env.NODE_ENV || 'development', 17 | port: isTestEnvironment ? process.env.TEST_APP_PORT : process.env.APP_PORT, 18 | pagination: { 19 | page: 1, 20 | maxRows: 20 21 | }, 22 | auth: { 23 | saltRounds: process.env.SALT_ROUNDS || 11, 24 | accessTokenDuration: process.env.ACCESS_TOKEN_DURATION || '10m', 25 | refreshTokenDuration: process.env.REFRESH_TOKEN_DURATION || '24h', 26 | emailVerificationDuration: process.env.EMAIL_VERIFICATION_DURATION || 24, 27 | accessTokenSecretKey: process.env.ACCESS_TOKEN_SECRET_KEY || '', 28 | refreshTokenSecretKey: process.env.REFRESH_TOKEN_SECRET_KEY || '' 29 | }, 30 | logging: { 31 | level: process.env.LOGGING_LEVEL || 'info', 32 | maxSize: process.env.LOGGING_MAX_SIZE || '20m', 33 | maxFiles: process.env.LOGGING_MAX_FILES || '7d', 34 | datePattern: process.env.LOGGING_DATE_PATTERN || 'YYYY-MM-DD', 35 | logFileGenarationSupport: process.env.LOG_FILE_GENERATION_SUPPORT || 'true' 36 | }, 37 | db: { 38 | client: process.env.DB_CLIENT, 39 | connection: { 40 | charset: 'utf8', 41 | timezone: 'UTC', 42 | host: process.env.DB_HOST, 43 | port: +(process.env.DB_PORT || 5432), 44 | database: 45 | process.env.NODE_ENV === 'test' 46 | ? process.env.TEST_DB_NAME 47 | : process.env.DB_NAME, 48 | user: process.env.DB_USER, 49 | password: process.env.DB_PASSWORD 50 | } 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /src/config/db.ts: -------------------------------------------------------------------------------- 1 | import Knex from 'knex'; 2 | import { knexSnakeCaseMappers, Model } from 'objection'; 3 | 4 | import config from './config'; 5 | 6 | const dbConfig = config.db; 7 | 8 | const knex = Knex({ ...dbConfig, ...knexSnakeCaseMappers() }); 9 | 10 | /** 11 | * Bind model with Knex. 12 | */ 13 | export function bindModel() { 14 | Model.knex(knex); 15 | } 16 | 17 | export default knex; 18 | -------------------------------------------------------------------------------- /src/config/mail.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | 3 | dotenv.config(); 4 | 5 | export default { 6 | smtp: { 7 | port: process.env.MAIL_PORT || 2525, 8 | host: process.env.MAIL_HOST || 'smtp.mailtrap.io', 9 | auth: { 10 | user: process.env.MAIL_SMTP_USERNAME || 'MAILTRAP_SMTP_USERNAME', 11 | pass: process.env.MAIL_SMTP_PASSWORD || 'MAILTRAP_SMTP_PASSWORD' 12 | } 13 | }, 14 | from: { 15 | address: 'test@starter.com', 16 | name: 'Typescript API Starter' 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/controllers/auth.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import { StatusCodes } from 'http-status-codes'; 3 | 4 | import config from '../config/config'; 5 | import JWTPayload from '../domain/misc/JWTPayload'; 6 | import * as authService from '../services/authService'; 7 | 8 | const { messages } = config; 9 | 10 | export const login = async (req: Request, res: Response, next: NextFunction): Promise => { 11 | try { 12 | const data = await authService.login(req.body); 13 | 14 | res.status(StatusCodes.OK).json({ 15 | data, 16 | code: StatusCodes.OK, 17 | message: messages.auth.loginSuccess 18 | }); 19 | } catch (error) { 20 | next(error); 21 | } 22 | }; 23 | 24 | export const refresh = async (_: Request, res: Response, next: NextFunction): Promise => { 25 | try { 26 | const token = String(res.locals.refreshToken); 27 | const jwtPayload = res.locals.jwtPayload as JWTPayload; 28 | const data = await authService.refresh(token, jwtPayload); 29 | 30 | res.status(StatusCodes.OK).json({ 31 | data, 32 | code: StatusCodes.OK, 33 | message: messages.auth.accessTokenRefreshed 34 | }); 35 | } catch (error) { 36 | next(error); 37 | } 38 | }; 39 | 40 | export const logout = async (_: Request, res: Response, next: NextFunction): Promise => { 41 | try { 42 | const token = String(res.locals.refreshToken); 43 | await authService.logout(token); 44 | 45 | res.status(StatusCodes.OK).json({ 46 | code: StatusCodes.OK, 47 | message: messages.auth.logoutSuccess 48 | }); 49 | } catch (error) { 50 | next(error); 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /src/controllers/home.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { StatusCodes } from 'http-status-codes'; 3 | 4 | import config from '../config/config'; 5 | 6 | const { name, version } = config; 7 | 8 | export const index = (_: Request, res: Response): void => { 9 | res.status(StatusCodes.OK).json({ 10 | name, 11 | version 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /src/controllers/user.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import { StatusCodes } from 'http-status-codes'; 3 | 4 | import config from '../config/config'; 5 | import UserPayload from '../domain/requests/UserPayload'; 6 | import * as userService from '../services/userService'; 7 | 8 | const { messages } = config; 9 | 10 | export const index = async (_: Request, res: Response, next: NextFunction): Promise => { 11 | try { 12 | const response = await userService.fetchAll(); 13 | 14 | res.status(StatusCodes.OK).json({ 15 | code: StatusCodes.OK, 16 | data: response, 17 | message: messages.users.fetchAll 18 | }); 19 | } catch (err) { 20 | next(err); 21 | } 22 | }; 23 | 24 | export const store = async (req: Request, res: Response, next: NextFunction): Promise => { 25 | try { 26 | const userPayload = req.body as UserPayload; 27 | 28 | const response = await userService.insert(userPayload); 29 | 30 | res.status(StatusCodes.OK).json({ 31 | code: StatusCodes.OK, 32 | data: response, 33 | message: messages.users.insert 34 | }); 35 | } catch (err) { 36 | next(err); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /src/database/factories/index.ts: -------------------------------------------------------------------------------- 1 | import UserDetail from '../../domain/entities/UserDetail'; 2 | import * as userFactory from './userFactory'; 3 | 4 | interface Callback { 5 | run: () => Promise; 6 | } 7 | 8 | export enum FactoryType { 9 | USER = 'User' 10 | } 11 | 12 | export interface Factories { 13 | [FactoryType.USER]: Callback; 14 | } 15 | 16 | const factories: Factories = { [FactoryType.USER]: userFactory }; 17 | 18 | export default factories; 19 | -------------------------------------------------------------------------------- /src/database/factories/userFactory.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | 3 | import UserDetail from '../../domain/entities/UserDetail'; 4 | import * as userService from '../../services/userService'; 5 | 6 | /** 7 | * Returns user fake data. 8 | * 9 | * @returns {Promise} 10 | */ 11 | export function run(): Promise { 12 | return userService.insert({ 13 | password: 'secret', 14 | name: faker.person.fullName(), 15 | email: faker.internet.email() 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /src/database/migrations/20170517164638_create_user_roles_table.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex'; 2 | 3 | import Table from '../../resources/enums/Table'; 4 | 5 | /** 6 | * Add user_roles table. 7 | * 8 | * @param {Knex} knex - knex instance. 9 | * @returns {Promise} 10 | */ 11 | export function up(knex: Knex): Promise { 12 | return knex.schema 13 | .createTable(Table.USER_ROLES, (table) => { 14 | table.increments('id').primary(); 15 | 16 | table.string('name', 50).unique().notNullable(); 17 | table.string('description', 100).nullable(); 18 | 19 | table.timestamps(true, true); 20 | }) 21 | .then(async () => { 22 | await knex(Table.USER_ROLES) 23 | .truncate() 24 | .insert([ 25 | { 26 | id: 1, 27 | name: 'Admin', 28 | description: 'This is super admin.' 29 | }, 30 | { 31 | id: 2, 32 | name: 'Normal User', 33 | description: 'This is normal user.' 34 | } 35 | ]); 36 | }); 37 | } 38 | 39 | /** 40 | * Drop user_roles table. 41 | * 42 | * @param {Knex} knex - knex instance. 43 | * @returns {Knex.SchemaBuilder} 44 | */ 45 | export function down(knex: Knex): Knex.SchemaBuilder { 46 | return knex.schema.dropTable(Table.USER_ROLES); 47 | } 48 | -------------------------------------------------------------------------------- /src/database/migrations/20180130005620_create_users_table.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex'; 2 | 3 | import Table from '../../resources/enums/Table'; 4 | 5 | /** 6 | * Add users table. 7 | * 8 | * @param {Knex} knex - knex instance. 9 | * @returns {Knex.SchemaBuilder} 10 | */ 11 | export function up(knex: Knex): Knex.SchemaBuilder { 12 | return knex.schema.createTable(Table.USERS, (table) => { 13 | table.increments('id').primary(); 14 | table.string('name').notNullable(); 15 | table.string('email').notNullable().unique(); 16 | table.string('password').notNullable(); 17 | table.integer('role_id').unsigned().notNullable().references('id').inTable(Table.USER_ROLES); 18 | 19 | table.timestamps(true, true); 20 | }); 21 | } 22 | 23 | /** 24 | * Drop users table. 25 | * 26 | * @param {Knex} knex - knex instance. 27 | * @returns {Knex.SchemaBuilder} 28 | */ 29 | export function down(knex: Knex): Knex.SchemaBuilder { 30 | return knex.schema.dropTable(Table.USERS); 31 | } 32 | -------------------------------------------------------------------------------- /src/database/migrations/20180517164647_create_user_sessions_table.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex'; 2 | 3 | import Table from '../../resources/enums/Table'; 4 | 5 | /** 6 | * Add user_sessions table. 7 | * 8 | * @param knex - knex instance. 9 | * @returns {Knex.SchemaBuilder} 10 | */ 11 | export function up(knex: Knex): Knex.SchemaBuilder { 12 | return knex.schema.createTable(Table.USER_SESSIONS, (table) => { 13 | table.increments('id').primary(); 14 | 15 | table.text('token').notNullable(); 16 | table.integer('user_id').unsigned().notNullable().references('id').inTable(Table.USERS); 17 | table.boolean('is_active').notNullable().defaultTo(true); 18 | 19 | table.timestamps(true, true); 20 | }); 21 | } 22 | 23 | /** 24 | * Drop user_sessions table. 25 | * 26 | * @param knex - knex instance. 27 | * @returns {Knex.SchemaBuilder} 28 | */ 29 | export function down(knex: Knex): Knex.SchemaBuilder { 30 | return knex.schema.dropTable(Table.USER_SESSIONS); 31 | } 32 | -------------------------------------------------------------------------------- /src/database/seeds/user_table_seeder.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex'; 2 | 3 | import Role from '../../resources/enums/Role'; 4 | import Table from '../../resources/enums/Table'; 5 | import * as bcrypt from '../../utils/bcrypt'; 6 | 7 | /** 8 | * Seed users table. 9 | * 10 | * @param {Knex} knex - knex instance. 11 | * @returns {Promise} 12 | */ 13 | export function seed(knex: Knex): Promise { 14 | return knex(Table.USERS).then(async () => { 15 | return Promise.all([ 16 | knex(Table.USERS).insert([ 17 | { 18 | role_id: Role.ADMIN, 19 | name: 'Sagar Chamling', 20 | email: 'sgr.raee@gmail.com', 21 | password: await bcrypt.hash('secret') 22 | } 23 | ]) 24 | ]); 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /src/domain/entities/UserDetail.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * UserDetail Interface. 3 | */ 4 | interface UserDetail { 5 | id?: number; 6 | name: string; 7 | email: string; 8 | roleId: number; 9 | createdAt: string; 10 | updatedAt: string; 11 | } 12 | 13 | export default UserDetail; 14 | -------------------------------------------------------------------------------- /src/domain/entities/UserSessionDetail.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * UserSessionDetail interface. 3 | */ 4 | interface UserSessionDetail { 5 | id: number; 6 | token: string; 7 | userId: number; 8 | isActive: boolean; 9 | updatedBy?: string; 10 | createdBy?: string; 11 | } 12 | 13 | export default UserSessionDetail; 14 | -------------------------------------------------------------------------------- /src/domain/misc/JWTPayload.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * JWTPayload Interface. 3 | */ 4 | interface JWTPayload { 5 | name: string; 6 | email: string; 7 | userId: number; 8 | roleId: number; 9 | } 10 | 11 | export default JWTPayload; 12 | -------------------------------------------------------------------------------- /src/domain/misc/LoggedInUser.ts: -------------------------------------------------------------------------------- 1 | import JWTPayload from './JWTPayload'; 2 | 3 | /** 4 | * LoggedInUser Interface. 5 | */ 6 | interface LoggedInUser extends JWTPayload { 7 | sessionId: number; 8 | } 9 | 10 | export default LoggedInUser; 11 | -------------------------------------------------------------------------------- /src/domain/misc/MailOptions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * MailOptions Interface. 3 | */ 4 | interface MailOptions { 5 | from?: { 6 | name: string; 7 | address: string; 8 | }; 9 | to: string; 10 | html?: string; 11 | text?: string; 12 | subject: string; 13 | markdown?: string; 14 | } 15 | 16 | export default MailOptions; 17 | -------------------------------------------------------------------------------- /src/domain/misc/ResponseData.ts: -------------------------------------------------------------------------------- 1 | import JWTPayload from './JWTPayload'; 2 | 3 | type ResponseData = Request & { data: JWTPayload }; 4 | 5 | export default ResponseData; 6 | -------------------------------------------------------------------------------- /src/domain/requests/LoginPayload.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * LoginPayload Interface. 3 | */ 4 | interface LoginPayload { 5 | email: string; 6 | password: string; 7 | } 8 | 9 | export default LoginPayload; 10 | -------------------------------------------------------------------------------- /src/domain/requests/UserPayload.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * UserPayload Interface. 3 | */ 4 | interface UserPayload { 5 | name: string; 6 | email: string; 7 | password: string; 8 | } 9 | 10 | export default UserPayload; 11 | -------------------------------------------------------------------------------- /src/domain/requests/UserSessionPayload.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * UserSessionPayload Interface. 3 | */ 4 | interface UserSessionPayload { 5 | token: string; 6 | userId: number; 7 | } 8 | 9 | export default UserSessionPayload; 10 | -------------------------------------------------------------------------------- /src/domain/responses/APIResponse.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * API Response Interface. 3 | */ 4 | interface APIResponse { 5 | code: number; 6 | message: string; 7 | data?: Data; 8 | } 9 | 10 | export default APIResponse; 11 | -------------------------------------------------------------------------------- /src/domain/responses/TokenResponse.ts: -------------------------------------------------------------------------------- 1 | interface TokenResponse { 2 | accessToken: string; 3 | refreshToken?: string; 4 | } 5 | 6 | export default TokenResponse; 7 | -------------------------------------------------------------------------------- /src/exceptions/BadRequestError.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | 3 | import Error from './Error'; 4 | 5 | /** 6 | */ 7 | class BadRequestError extends Error { 8 | /** 9 | * Error message to be thrown. 10 | */ 11 | message: string; 12 | 13 | /** 14 | * Creates an instance of BadRequestError. 15 | * 16 | * @param {string} message - Error message. 17 | */ 18 | constructor(message: string) { 19 | super(message, StatusCodes.BAD_REQUEST); 20 | 21 | this.message = message; 22 | } 23 | } 24 | 25 | export default BadRequestError; 26 | -------------------------------------------------------------------------------- /src/exceptions/Error.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Override the default Error interface to throw custom error messages. 3 | * 4 | */ 5 | class Error { 6 | /** 7 | * Error message to be thrown. 8 | */ 9 | message: string; 10 | 11 | /** 12 | * The type of error message. Similar to isBoom, isJoi etc. 13 | */ 14 | isCustom: boolean; 15 | 16 | /** 17 | * HTTP Status code to be sent as response status. 18 | */ 19 | statusCode: number; 20 | 21 | /** 22 | * Creates an instance of Error. 23 | * 24 | * @param {string} message - Error message. 25 | * @param {number} statusCode - HTTP Status code. 26 | */ 27 | constructor(message: string, statusCode: number) { 28 | this.isCustom = true; 29 | this.message = message; 30 | this.statusCode = statusCode; 31 | } 32 | } 33 | 34 | export default Error; 35 | -------------------------------------------------------------------------------- /src/exceptions/ForbiddenError.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | 3 | import Error from './Error'; 4 | 5 | /** 6 | */ 7 | class ForbiddenError extends Error { 8 | /** 9 | * Error message to be thrown. 10 | * 11 | */ 12 | message: string; 13 | 14 | /** 15 | * Creates an instance of ForbiddenError. 16 | * 17 | * @param {string} message - Error message. 18 | */ 19 | constructor(message: string) { 20 | super(message, StatusCodes.FORBIDDEN); 21 | 22 | this.message = message; 23 | } 24 | } 25 | 26 | export default ForbiddenError; 27 | -------------------------------------------------------------------------------- /src/exceptions/UnauthorizedError.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | 3 | import Error from './Error'; 4 | 5 | /** 6 | */ 7 | class UnauthorizedError extends Error { 8 | /** 9 | * Error message to be thrown. 10 | * 11 | */ 12 | message: string; 13 | 14 | /** 15 | * Creates an instance of UnauthorizedError. 16 | * 17 | * @param {string} message - Error message. 18 | */ 19 | constructor(message: string) { 20 | super(message, StatusCodes.UNAUTHORIZED); 21 | 22 | this.message = message; 23 | } 24 | } 25 | 26 | export default UnauthorizedError; 27 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import app from './app'; 2 | import config from './config/config'; 3 | import nodeErrorHandler from './middlewares/nodeErrorHandler'; 4 | import logger from './utils/logger'; 5 | 6 | const { port } = config; 7 | 8 | if (!port) { 9 | throw new Error('App Port not assigned.'); 10 | } 11 | 12 | app 13 | .listen(+port, () => { 14 | logger.log('info', `Server started at http://localhost:${port}`); 15 | }) 16 | .on('error', nodeErrorHandler); 17 | -------------------------------------------------------------------------------- /src/middlewares/authenticate.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import { JsonWebTokenError } from 'jsonwebtoken'; 3 | 4 | import config from '../config/config'; 5 | import ResponseData from '../domain/misc/ResponseData'; 6 | import BadRequestError from '../exceptions/BadRequestError'; 7 | import UnauthorizedError from '../exceptions/UnauthorizedError'; 8 | import { tokenErrorMessageMap } from '../resources/constants/maps'; 9 | import * as jwt from '../utils/jwt'; 10 | import logger from '../utils/logger'; 11 | import { JWTErrorType } from './../resources/enums/ErrorType'; 12 | 13 | const { errors } = config; 14 | 15 | const authenticate = async (req: Request, res: Response, next: NextFunction): Promise => { 16 | try { 17 | res.locals.accessToken = String(req.headers.authorization).replace('Bearer ', ''); 18 | 19 | if (!req.headers.authorization || !res.locals.accessToken) { 20 | throw new BadRequestError(errors.noToken); 21 | } 22 | 23 | logger.log('info', 'JWT: Verifying token - %s', res.locals.accessToken); 24 | const response = jwt.verifyAccessToken(res.locals.accessToken) as ResponseData; 25 | 26 | res.locals.loggedInPayload = response.data; 27 | 28 | logger.log('debug', 'JWT: Authentication verified -', res.locals.loggedInPayload); 29 | 30 | next(); 31 | } catch (err) { 32 | if (err instanceof JsonWebTokenError) { 33 | const tokenErrorMessage = tokenErrorMessageMap[err.name as JWTErrorType]; 34 | logger.log('error', 'JWT: Authentication failed - %s', err.message); 35 | 36 | if (tokenErrorMessage) { 37 | logger.log('error', 'JWT: Token error - %s', tokenErrorMessage); 38 | 39 | next(new UnauthorizedError(tokenErrorMessage)); 40 | 41 | return; 42 | } 43 | } 44 | 45 | next(err); 46 | } 47 | }; 48 | 49 | export default authenticate; 50 | -------------------------------------------------------------------------------- /src/middlewares/genericErrorHandler.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import { getReasonPhrase, StatusCodes } from 'http-status-codes'; 3 | 4 | import APIResponseInterface from '../domain/responses/APIResponse'; 5 | import logger from '../utils/logger'; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | export const buildError = (err: any): APIResponseInterface<{ code: number; message: string; data?: any }> => { 9 | if (err.isJoi) { 10 | return { 11 | code: StatusCodes.BAD_REQUEST, 12 | message: getReasonPhrase(StatusCodes.BAD_REQUEST), 13 | data: 14 | err.details && 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | err.details.map((error: any) => ({ 17 | param: error.path.join('.'), 18 | message: error.message 19 | })) 20 | }; 21 | } 22 | 23 | if (err.isBoom) { 24 | return { 25 | code: err.output.statusCode, 26 | message: err.output.payload.message || err.output.payload.error 27 | }; 28 | } 29 | 30 | if (err.isCustom) { 31 | return { 32 | code: err.statusCode, 33 | message: err.message 34 | }; 35 | } 36 | 37 | return { 38 | code: StatusCodes.INTERNAL_SERVER_ERROR, 39 | message: getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR) 40 | }; 41 | }; 42 | 43 | const genericErrorHandler = ( 44 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 45 | err: any, 46 | _: Request, 47 | res: Response, 48 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 49 | __: NextFunction 50 | ): void => { 51 | const error = buildError(err); 52 | 53 | logger.log('error', 'Error: %s', err.stack || err.message); 54 | 55 | res.status(error.code).json(error); 56 | }; 57 | 58 | export default genericErrorHandler; 59 | -------------------------------------------------------------------------------- /src/middlewares/nodeErrorHandler.ts: -------------------------------------------------------------------------------- 1 | import config from '../config/config'; 2 | import logger from '../utils/logger'; 3 | 4 | const { errors } = config; 5 | 6 | const nodeErrorHandler = (err: NodeJS.ErrnoException): void => { 7 | switch (err.code) { 8 | case 'EACCES': 9 | logger.log('error', errors.portRequirePrivilege); 10 | break; 11 | 12 | case 'EADDRINUSE': 13 | logger.log('error', errors.portInUse); 14 | break; 15 | 16 | default: 17 | throw err; 18 | } 19 | 20 | process.exit(1); 21 | }; 22 | 23 | export default nodeErrorHandler; 24 | -------------------------------------------------------------------------------- /src/middlewares/notFoundHandler.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import { getReasonPhrase, StatusCodes } from 'http-status-codes'; 3 | 4 | const notFoundError = ( 5 | _: Request, 6 | res: Response, 7 | // TODO: Remove this 8 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 9 | __: NextFunction 10 | ): void => { 11 | res.status(StatusCodes.NOT_FOUND).json({ 12 | error: { 13 | code: StatusCodes.NOT_FOUND, 14 | message: getReasonPhrase(StatusCodes.NOT_FOUND) 15 | } 16 | }); 17 | }; 18 | 19 | export default notFoundError; 20 | -------------------------------------------------------------------------------- /src/middlewares/rateLimitHandler.ts: -------------------------------------------------------------------------------- 1 | import { rateLimit } from 'express-rate-limit'; 2 | 3 | const rateLimitMiddleware = rateLimit({ 4 | windowMs: 15 * 60 * 1000, // 15 minutes 5 | max: 100, // limit each IP to 100 requests per windowMs 6 | message: 'Too many requests from this IP, please try again after 15 minutes', 7 | headers: true, 8 | legacyHeaders: false, 9 | standardHeaders: true 10 | }); 11 | 12 | export default rateLimitMiddleware; 13 | -------------------------------------------------------------------------------- /src/middlewares/transactionHandler.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'crypto'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | 4 | import context from '../utils/context'; 5 | 6 | const transactionHandler = (req: Request, _: Response, next: NextFunction): void => { 7 | // The first asyncLocalStorage.run argument is the initialization of the store state, the second argument is the function that has access to that store 8 | context.run(new Map(), () => { 9 | // Try to extract the TransactionId from the request header, or generate a new one if it doesn't exist 10 | const transactionId = req.headers['transactionId'] || randomUUID(); 11 | 12 | // Set the TransactionId inside the store 13 | context.getStore()?.set('transactionId', transactionId); 14 | 15 | next(); 16 | }); 17 | }; 18 | 19 | export default transactionHandler; 20 | -------------------------------------------------------------------------------- /src/middlewares/validate.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | import Joi from 'joi'; 3 | 4 | import logger from '../utils/logger'; 5 | 6 | /** 7 | * A middleware to validate schema. 8 | * 9 | * @param {Joi.Schema} schema - Joi schema. 10 | * @returns {Function} 11 | */ 12 | export default function validate(schema: Joi.Schema) { 13 | return async (req: Request, _: Response, next: NextFunction): Promise => { 14 | try { 15 | logger.log('info', 'Validating schema'); 16 | 17 | logger.log('debug', 'Validation Payload', req.body); 18 | const value = await schema.validateAsync(req.body); 19 | logger.log('debug', 'Validation Response:', value); 20 | 21 | next(); 22 | } catch (err) { 23 | next(err); 24 | } 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/middlewares/validateRefreshToken.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | 3 | import config from '../config/config'; 4 | import ResponseData from '../domain/misc/ResponseData'; 5 | import BadRequestError from '../exceptions/BadRequestError'; 6 | import UnauthorizedError from '../exceptions/UnauthorizedError'; 7 | import { tokenErrorMessageMap } from '../resources/constants/maps'; 8 | import { JWTErrorType } from '../resources/enums/ErrorType'; 9 | import * as jwt from '../utils/jwt'; 10 | import logger from '../utils/logger'; 11 | 12 | const { errors } = config; 13 | 14 | const validateRefreshToken = async (req: Request, res: Response, next: NextFunction): Promise => { 15 | try { 16 | res.locals.refreshToken = String(req.headers.authorization).replace('Bearer ', ''); 17 | 18 | if (!req.headers.authorization || !res.locals.refreshToken) { 19 | throw new BadRequestError(errors.noToken); 20 | } 21 | 22 | logger.log('info', 'JWT: Verifying token - %s', res.locals.refreshToken); 23 | const response = jwt.verifyRefreshToken(res.locals.refreshToken) as ResponseData; 24 | 25 | res.locals.jwtPayload = response.data; 26 | 27 | logger.log('debug', 'JWT: Authentication verified -', res.locals.jwtPayload); 28 | 29 | next(); 30 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 31 | } catch (err: any) { 32 | const tokenErrorMessage = tokenErrorMessageMap[err.name as JWTErrorType]; 33 | logger.log('error', 'JWT: Authentication failed - %s', err.message); 34 | 35 | if (tokenErrorMessage) { 36 | logger.log('error', 'JWT: Token error - %s', tokenErrorMessage); 37 | 38 | next(new UnauthorizedError(tokenErrorMessage)); 39 | } else { 40 | next(err); 41 | } 42 | } 43 | }; 44 | 45 | export default validateRefreshToken; 46 | -------------------------------------------------------------------------------- /src/models/User.ts: -------------------------------------------------------------------------------- 1 | import { Model } from 'objection'; 2 | 3 | import Table from '../resources/enums/Table'; 4 | 5 | class User extends Model { 6 | id!: number; 7 | name!: string; 8 | email!: string; 9 | password!: string; 10 | roleId!: number; 11 | createdAt!: string; 12 | updatedAt!: string; 13 | 14 | static get tableName(): string { 15 | return Table.USERS; 16 | } 17 | 18 | $beforeInsert() { 19 | this.createdAt = new Date().toISOString(); 20 | } 21 | 22 | $beforeUpdate() { 23 | this.updatedAt = new Date().toISOString(); 24 | } 25 | } 26 | 27 | export default User; 28 | -------------------------------------------------------------------------------- /src/models/UserRole.ts: -------------------------------------------------------------------------------- 1 | import { Model } from 'objection'; 2 | 3 | import Table from '../resources/enums/Table'; 4 | 5 | class UserRole extends Model { 6 | id!: number; 7 | name!: number; 8 | description!: number; 9 | createdAt!: string; 10 | updatedAt!: string; 11 | 12 | static get tableName(): string { 13 | return Table.USER_ROLES; 14 | } 15 | 16 | $beforeInsert() { 17 | this.createdAt = new Date().toISOString(); 18 | } 19 | 20 | $beforeUpdate() { 21 | this.updatedAt = new Date().toISOString(); 22 | } 23 | } 24 | 25 | export default UserRole; 26 | -------------------------------------------------------------------------------- /src/models/UserSession.ts: -------------------------------------------------------------------------------- 1 | import { Model } from 'objection'; 2 | 3 | import Table from '../resources/enums/Table'; 4 | 5 | class UserSession extends Model { 6 | id!: number; 7 | token!: string; 8 | userId!: number; 9 | isActive!: boolean; 10 | updatedBy!: string; 11 | createdBy!: string; 12 | 13 | static get tableName(): string { 14 | return Table.USER_SESSIONS; 15 | } 16 | } 17 | 18 | export default UserSession; 19 | -------------------------------------------------------------------------------- /src/resources/constants/endpoints.ts: -------------------------------------------------------------------------------- 1 | export const home = '/'; 2 | export const users = '/users'; 3 | -------------------------------------------------------------------------------- /src/resources/constants/maps.ts: -------------------------------------------------------------------------------- 1 | import config from '../../config/config'; 2 | import { JWTErrorType } from '../enums/ErrorType'; 3 | 4 | const { errors } = config; 5 | 6 | export const tokenErrorMessageMap = { 7 | [JWTErrorType.INVALID]: errors.invalidToken, 8 | [JWTErrorType.EXPIRED]: errors.refreshTokenExpired 9 | }; 10 | -------------------------------------------------------------------------------- /src/resources/enums/ErrorType.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * List of JWTErrorType. 3 | */ 4 | export enum JWTErrorType { 5 | INVALID = 'JsonWebTokenError', 6 | EXPIRED = 'TokenExpiredError' 7 | } 8 | -------------------------------------------------------------------------------- /src/resources/enums/Role.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * List of roles. 3 | */ 4 | enum Role { 5 | ADMIN = 1, 6 | NORMAL_USER = 2 7 | } 8 | 9 | export default Role; 10 | -------------------------------------------------------------------------------- /src/resources/enums/Table.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * List of database tables. 3 | */ 4 | enum Table { 5 | USERS = 'users', 6 | USER_ROLES = 'user_roles', 7 | USER_SESSIONS = 'user_sessions' 8 | } 9 | 10 | export default Table; 11 | -------------------------------------------------------------------------------- /src/resources/lang/errors.json: -------------------------------------------------------------------------------- 1 | { 2 | "portInUse": "Port is already in use.", 3 | "invalidToken": "Your token is invalid.", 4 | "invalidCredentials": "Invalid Credentials", 5 | "accessTokenExpired": "Access token expired.", 6 | "noToken": "No token in authorization header.", 7 | "sessionNotMaintained": "Session not maintained.", 8 | "refreshTokenExpired": "Session has been expired.", 9 | "portRequirePrivilege": "Port requires elevated privileges.", 10 | "unAuthorized": "You are not authorized to perform this action." 11 | } 12 | -------------------------------------------------------------------------------- /src/resources/lang/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "auth": { 3 | "loginSuccess": "Login successful.", 4 | "logoutSuccess": "Logout successful.", 5 | "invalidCredentials": "Invalid Credentials", 6 | "accessTokenRefreshed": "Access token refreshed successfully." 7 | }, 8 | "users": { 9 | "insert": "User inserted successfully.", 10 | "fetch": "User detail fetched successfully.", 11 | "delete": "User removed successfully.", 12 | "fetchAll": "List of users." 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/resources/stubs/migration.stub: -------------------------------------------------------------------------------- 1 | import * as Knex from 'knex'; 2 | 3 | /** 4 | * Add :table table. 5 | * 6 | * @param {Knex} knex - knex instance. 7 | * @returns {Promise} 8 | */ 9 | export function up(knex: Knex): Knex.SchemaBuilder { 10 | return knex.schema.createTable('table_name', (table) => { 11 | table.increments('id').primary(); 12 | 13 | table.timestamps(true, true); 14 | }); 15 | } 16 | 17 | /** 18 | * Drop :table table. 19 | * 20 | * @param {Knex} knex - knex instance. 21 | * @returns {Knex.SchemaBuilder} 22 | */ 23 | export function down(knex: Knex): Knex.SchemaBuilder { 24 | return knex.schema.dropTable('table_name'); 25 | } 26 | -------------------------------------------------------------------------------- /src/resources/stubs/seed.stub: -------------------------------------------------------------------------------- 1 | import * as Knex from 'knex'; 2 | 3 | /** 4 | * Seed :table table 5 | 6 | * @param {Knex} knex - knex instance. 7 | * @returns {Promise} 8 | */ 9 | export function seed(knex: Knex): Promise { 10 | return knex('table_name') 11 | .del() 12 | .then(() => { 13 | return Promise.all([ 14 | knex('table_name').insert([ 15 | { 16 | colName: 'rowValue', 17 | colName2: 'rowValue' 18 | } 19 | ]) 20 | ]); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/resources/validators/loginRequest.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | export const loginSchema = Joi.object() 4 | .options({ abortEarly: false }) 5 | .keys({ 6 | email: Joi.string().max(100).label('Email').required(), 7 | password: Joi.string().min(6).max(100).label('Password').required() 8 | }); 9 | -------------------------------------------------------------------------------- /src/resources/validators/userRequest.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi'; 2 | 3 | export const userPOSTSchema = Joi.object() 4 | .options({ abortEarly: false }) 5 | .keys({ 6 | name: Joi.string().min(4).max(100).label('Name').required(), 7 | email: Joi.string().min(10).max(100).label('Email').required(), 8 | password: Joi.string().min(6).max(100).label('Password').required() 9 | }); 10 | -------------------------------------------------------------------------------- /src/routes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | import * as authController from './controllers/auth'; 4 | import * as homeController from './controllers/home'; 5 | import * as userController from './controllers/user'; 6 | import authenticate from './middlewares/authenticate'; 7 | import validate from './middlewares/validate'; 8 | import validateRefreshToken from './middlewares/validateRefreshToken'; 9 | import { loginSchema } from './resources/validators/loginRequest'; 10 | import { userPOSTSchema } from './resources/validators/userRequest'; 11 | 12 | const router: Router = Router(); 13 | 14 | router.get('/', homeController.index); 15 | 16 | router.post('/login', validate(loginSchema), authController.login); 17 | router.post('/refresh', validateRefreshToken, authController.refresh); 18 | router.post('/logout', validateRefreshToken, authController.logout); 19 | 20 | router.get('/users', authenticate, userController.index); 21 | router.post('/users', authenticate, validate(userPOSTSchema), userController.store); 22 | 23 | export default router; 24 | -------------------------------------------------------------------------------- /src/services/authService.ts: -------------------------------------------------------------------------------- 1 | import config from '../config/config'; 2 | import JWTPayload from '../domain/misc/JWTPayload'; 3 | import LoginPayload from '../domain/requests/LoginPayload'; 4 | import TokenResponse from '../domain/responses/TokenResponse'; 5 | import ForbiddenError from '../exceptions/ForbiddenError'; 6 | import UnauthorizedError from '../exceptions/UnauthorizedError'; 7 | import User from '../models/User'; 8 | import UserSession from '../models/UserSession'; 9 | import * as sessionService from '../services/sessionService'; 10 | import * as bcrypt from '../utils/bcrypt'; 11 | import * as jwt from '../utils/jwt'; 12 | import logger from '../utils/logger'; 13 | 14 | const { errors } = config; 15 | 16 | /** 17 | * Create user session for valid user login. 18 | * 19 | * @param {LoginPayload} loginPayload - Login payload. 20 | * @returns {Promise} 21 | */ 22 | export async function login(loginPayload: LoginPayload): Promise { 23 | const { email, password } = loginPayload; 24 | 25 | logger.log('info', 'Checking email: %s', email); 26 | const user = await User.query().findOne({ email }); 27 | 28 | if (user) { 29 | logger.log('debug', 'Login: Fetched user by email -', user); 30 | logger.log('debug', 'Login: Comparing password'); 31 | 32 | const isSame = await bcrypt.compare(password, user.password); 33 | 34 | logger.log('debug', 'Login: Password match status - %s', isSame); 35 | 36 | if (isSame) { 37 | const { name, roleId, id: userId } = user; 38 | const loggedInUser = { name, email, userId, roleId }; 39 | const refreshToken = jwt.generateRefreshToken(loggedInUser); 40 | const userSessionPayload = { userId, token: refreshToken }; 41 | const session = await sessionService.create(userSessionPayload); 42 | const accessToken = jwt.generateAccessToken({ 43 | ...loggedInUser, 44 | sessionId: session.id 45 | }); 46 | 47 | return { refreshToken, accessToken }; 48 | } 49 | } 50 | 51 | throw new UnauthorizedError(errors.invalidCredentials); 52 | } 53 | 54 | /** 55 | * Refresh new access token. 56 | * 57 | * @param {string} token - Refresh token. 58 | * @param {jwtPayload} jwtPayload - JWT payload. 59 | * @returns {Promise} 60 | */ 61 | export async function refresh(token: string, jwtPayload: JWTPayload): Promise { 62 | logger.log('info', 'User Session: Fetching session of token - %s', token); 63 | 64 | const session = await UserSession.query().findOne({ 65 | token, 66 | isActive: true 67 | }); 68 | if (!session) { 69 | throw new ForbiddenError(errors.sessionNotMaintained); 70 | } 71 | 72 | logger.log('debug', 'User Session: Fetched session -', session); 73 | logger.log('info', 'JWT: Generating new access token'); 74 | 75 | const accessToken = jwt.generateAccessToken({ 76 | ...jwtPayload, 77 | sessionId: session.id 78 | }); 79 | 80 | return { 81 | accessToken 82 | }; 83 | } 84 | 85 | /** 86 | * Remove user session. 87 | * 88 | * @param {string} token - Session token. 89 | * @returns {Promise} 90 | */ 91 | export async function logout(token: string): Promise { 92 | logger.log('info', 'Logout: Logging out user session - %s', token); 93 | 94 | const session = await sessionService.remove(token); 95 | 96 | if (!session) { 97 | throw new ForbiddenError(errors.sessionNotMaintained); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/services/sessionService.ts: -------------------------------------------------------------------------------- 1 | import config from '../config/config'; 2 | import UserSessionDetail from '../domain/entities/UserSessionDetail'; 3 | import UserSessionPayload from '../domain/requests/UserSessionPayload'; 4 | import ForbiddenError from '../exceptions/ForbiddenError'; 5 | import UserSession from '../models/UserSession'; 6 | import logger from '../utils/logger'; 7 | 8 | const { errors } = config; 9 | 10 | /** 11 | * Insert user from given user payload. 12 | * 13 | * @param {UserSessionPayload} params - User payload. 14 | * @returns {Promise} 15 | */ 16 | export async function create(params: UserSessionPayload): Promise { 17 | logger.log('info', 'User Session: Creating session -', params); 18 | 19 | const session = await UserSession.query().insert(params).returning('*'); 20 | 21 | logger.log('debug', 'User Session: Session created successfully -', session); 22 | 23 | return session; 24 | } 25 | 26 | /** 27 | * Deactivate user session. 28 | * 29 | * @param {string} token - Session token. 30 | * @returns {Promise} 31 | */ 32 | export async function remove(token: string): Promise { 33 | logger.log('info', 'User Session: Deactivating token - %s', token); 34 | 35 | const session = await UserSession.query().findOne({ 36 | token, 37 | isActive: true 38 | }); 39 | 40 | if (!session) { 41 | throw new ForbiddenError(errors.sessionNotMaintained); 42 | } 43 | 44 | const updatedSession = await session.$query().updateAndFetch({ isActive: false }); 45 | logger.log('debug', 'User Session: Deactivated session -', updatedSession); 46 | 47 | return updatedSession; 48 | } 49 | -------------------------------------------------------------------------------- /src/services/userService.ts: -------------------------------------------------------------------------------- 1 | import UserDetail from '../domain/entities/UserDetail'; 2 | import UserPayload from '../domain/requests/UserPayload'; 3 | import User from '../models/User'; 4 | import Role from '../resources/enums/Role'; 5 | import * as bcrypt from '../utils/bcrypt'; 6 | import logger from '../utils/logger'; 7 | import transform from '../utils/transform'; 8 | 9 | /** 10 | * Fetch all users from users table. 11 | * 12 | * @returns {Promise} 13 | */ 14 | export async function fetchAll(): Promise { 15 | logger.log('info', 'Fetching users from database'); 16 | 17 | const users = await await User.query(); 18 | const res = transform(users, (user: UserDetail) => ({ 19 | name: user.name, 20 | email: user.email, 21 | roleId: user.roleId, 22 | updatedAt: new Date(user.updatedAt).toLocaleString(), 23 | createdAt: new Date(user.updatedAt).toLocaleString() 24 | })); 25 | 26 | logger.log('debug', 'Fetched all users successfully:', res); 27 | 28 | return res; 29 | } 30 | 31 | /** 32 | * Insert user from given user payload 33 | * 34 | * @param {UserPayload} params - User payload 35 | * @returns {Promise} 36 | */ 37 | export async function insert(params: UserPayload): Promise { 38 | logger.log('info', 'Inserting user into database:', params); 39 | 40 | const password = await bcrypt.hash(params.password); 41 | const user = await User.query() 42 | .insert({ ...params, password, roleId: Role.NORMAL_USER }) 43 | .returning('*'); 44 | 45 | logger.log('debug', 'Inserted user successfully:', user); 46 | 47 | return user; 48 | } 49 | -------------------------------------------------------------------------------- /src/types/nodemailer-markdown.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'nodemailer-markdown'; 2 | -------------------------------------------------------------------------------- /src/utils/array.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check the given parameter is array or not. 3 | * 4 | * @param {any} arr - Parameter to check. 5 | * @returns {boolean} 6 | */ 7 | export function isArray(arr: unknown): boolean { 8 | return arr !== undefined && arr !== null && Array.isArray(arr); 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/bcrypt.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt'; 2 | 3 | import config from '../config/config'; 4 | 5 | /** 6 | * Create a hash for a string. 7 | * 8 | * @param {string} value - Value to hash. 9 | * @returns {Promise} 10 | */ 11 | export function hash(value: string): Promise { 12 | const saltRounds = config.auth.saltRounds; 13 | 14 | return bcrypt.hash(value, saltRounds); 15 | } 16 | 17 | /** 18 | * Compare a string with the hash. 19 | * 20 | * @param {string} value - Value to compare. 21 | * @param {string} hashedValue - Hashed value to compare. 22 | * @returns {Promise} 23 | */ 24 | export function compare(value: string, hashedValue: string): Promise { 25 | return bcrypt.compare(value, hashedValue); 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/context.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from 'async_hooks'; 2 | 3 | interface ContextAttributes { 4 | get: (key: string) => string; 5 | set: (key: string, value: string | string[]) => void; 6 | } 7 | 8 | const context = new AsyncLocalStorage(); 9 | 10 | export default context; 11 | -------------------------------------------------------------------------------- /src/utils/fake.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generate fake data from given factory callback. 3 | * 4 | * @param {(()=>Promise)} factoryCallback - Factory callback to generate data. 5 | * @param {number} total - Total data to generate. 6 | * @returns {Promise} 7 | */ 8 | export async function generate(factoryCallback: () => Promise, total = 1): Promise { 9 | const data: T[] = []; 10 | for (let i = 0; i < total; i++) { 11 | const res = await factoryCallback(); 12 | 13 | data.push(res); 14 | } 15 | 16 | return data; 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/jwt.ts: -------------------------------------------------------------------------------- 1 | import jwbt from 'jsonwebtoken'; 2 | 3 | import config from '../config/config'; 4 | import JWTPayload from '../domain/misc/JWTPayload'; 5 | import LoggedInUser from '../domain/misc/LoggedInUser'; 6 | import logger from './logger'; 7 | 8 | const { accessTokenDuration, accessTokenSecretKey, refreshTokenDuration, refreshTokenSecretKey } = config.auth; 9 | 10 | if (!refreshTokenSecretKey || !accessTokenSecretKey) { 11 | throw new Error('Auth refresh and access token secrets cannot be empty.'); 12 | } 13 | 14 | /** 15 | * Generate access token from given data 16 | * 17 | * @param {LoggedInUser} data - User data to generate token. 18 | * @returns {string} 19 | */ 20 | export function generateAccessToken(data: LoggedInUser): string { 21 | logger.log('info', 'JWT: Generating access token -', { 22 | data, 23 | expiresIn: accessTokenDuration 24 | }); 25 | 26 | return jwbt.sign({ data }, accessTokenSecretKey, { 27 | expiresIn: accessTokenDuration 28 | }); 29 | } 30 | 31 | /** 32 | * Generate refresh token from given data 33 | * 34 | * @param {JWTPayload} data - Data to generate token. 35 | * @returns {string} 36 | */ 37 | export function generateRefreshToken(data: JWTPayload): string { 38 | logger.log('info', 'JWT: Generating refresh token -', { 39 | data, 40 | expiresIn: refreshTokenDuration 41 | }); 42 | 43 | return jwbt.sign({ data }, refreshTokenSecretKey, { 44 | expiresIn: refreshTokenDuration 45 | }); 46 | } 47 | 48 | /** 49 | * Verify access token. 50 | * 51 | * @param {string} token - Token to verify. 52 | * @returns {object | string} 53 | */ 54 | export function verifyAccessToken(token: string): jwbt.JwtPayload | string { 55 | return jwbt.verify(token, accessTokenSecretKey); 56 | } 57 | 58 | /** 59 | * Verify refresh token. 60 | * 61 | * @param {string} token - Token to verify. 62 | * @returns {object | string} 63 | */ 64 | export function verifyRefreshToken(token: string): jwbt.JwtPayload | string { 65 | return jwbt.verify(token, refreshTokenSecretKey); 66 | } 67 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { TransformableInfo } from 'logform'; 3 | import { createLogger, format, Logger, transports } from 'winston'; 4 | import DailyRotateFile from 'winston-daily-rotate-file'; 5 | 6 | import app from '../config/config'; 7 | import context from './context'; 8 | 9 | const { environment, logging } = app; 10 | const { combine, colorize, splat, printf, timestamp } = format; 11 | 12 | const keysToFilter = ['password', 'token']; 13 | 14 | const formatter = printf((info: TransformableInfo) => { 15 | const { level, message, timestamp: ts, ...restMeta } = info; 16 | const transactionId = context?.getStore()?.get('transactionId') || '-'; 17 | 18 | const meta = 19 | restMeta && Object.keys(restMeta).length 20 | ? JSON.stringify(restMeta, (key: string, value) => (keysToFilter.includes(key) ? '******' : value), 2) 21 | : restMeta instanceof Object 22 | ? '' 23 | : restMeta; 24 | 25 | return `[ ${ts} ] [ ${transactionId} ] - [ ${level} ] ${message} ${meta}`; 26 | }); 27 | 28 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 29 | let trans: any = []; 30 | let logger: Logger; 31 | 32 | /* 33 | Send logs to console if log file generation is not supported. 34 | Heroku is an example cloud service provider that uses plug-ins to monitor logs 35 | and logs are sent to console. Accessing the log files generated by the app poses a difficult task. 36 | */ 37 | if (logging.logFileGenarationSupport === 'false') { 38 | logger = createLogger({ 39 | level: logging.level, 40 | format: combine(splat(), colorize(), timestamp(), formatter), 41 | transports: [new transports.Console()] 42 | }); 43 | } else { 44 | if (!fs.existsSync('logs')) { 45 | fs.mkdirSync('logs'); 46 | } 47 | 48 | if (environment === 'development') { 49 | trans = [new transports.Console()]; 50 | } 51 | 52 | logger = createLogger({ 53 | level: logging.level, 54 | format: combine(splat(), colorize(), timestamp(), formatter), 55 | transports: [ 56 | ...trans, 57 | new DailyRotateFile({ 58 | maxSize: logging.maxSize, 59 | maxFiles: logging.maxFiles, 60 | datePattern: logging.datePattern, 61 | zippedArchive: true, 62 | filename: `logs/${logging.level}-%DATE%.log` 63 | }) 64 | ] 65 | }); 66 | } 67 | 68 | export default logger; 69 | -------------------------------------------------------------------------------- /src/utils/mail.ts: -------------------------------------------------------------------------------- 1 | import nodemailer, { Transporter, TransportOptions } from 'nodemailer'; 2 | import { markdown } from 'nodemailer-markdown'; 3 | 4 | import mail from '../config/mail'; 5 | import MailOptions from '../domain/misc/MailOptions'; 6 | import logger from './logger'; 7 | 8 | const { smtp, from } = mail; 9 | 10 | const transporter: Transporter = nodemailer.createTransport(smtp as TransportOptions); 11 | transporter.use('compile', markdown()); 12 | 13 | /** 14 | * Send email using nodemailer transporter. 15 | * 16 | * @param {MailOptions} mailOptions - Email options. 17 | * @returns {Promise} 18 | */ 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | export async function send(mailOptions: MailOptions): Promise { 21 | try { 22 | if (!mailOptions.from) { 23 | mailOptions = { ...mailOptions, from }; 24 | } 25 | 26 | logger.log('debug', 'Mail: Sending email with options -', mailOptions); 27 | 28 | return await transporter.sendMail(mailOptions); 29 | } catch (err) { 30 | logger.log('error', 'Mail: Failed to send email - %s', err instanceof Error ? err.message : err); 31 | } 32 | } 33 | 34 | export default transporter; 35 | -------------------------------------------------------------------------------- /src/utils/string.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Capitalize the first letter of given word. 3 | * 4 | * @param {string} word - Word to capitalize. 5 | * @returns {string} 6 | */ 7 | export function capitalize(word: string): string { 8 | return `${word.slice(0, 1).toUpperCase()}${word.slice(1).toLowerCase()}`; 9 | } 10 | 11 | /** 12 | * Camel case given word or sentence, separator replaces to capital letters. 13 | * E.g. example_text => exampleText. 14 | * 15 | * @param {string} text - Text to camel case. 16 | * @param {string} [separator] - Separator to replace with capital letters. 17 | * @returns {string} 18 | */ 19 | export function camelcase(text: string, separator = '_'): string { 20 | if (!(typeof text === 'string')) { 21 | return text; 22 | } 23 | 24 | const words = text.split(separator); 25 | 26 | return [words[0], ...words.slice(1).map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`)].join(''); 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/transform.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Transform all the given detail with given transformation callback. 3 | * 4 | * @param {T[]} data - Data to transform. 5 | * @param {(info:T)=>T} transformCallback - Transformation callback. 6 | * @returns {T} 7 | */ 8 | export default function transform(data: T[], transformCallback: (info: T) => T): T[] { 9 | return data.map(transformCallback); 10 | } 11 | -------------------------------------------------------------------------------- /test/api/auth.spec.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { StatusCodes } from 'http-status-codes'; 3 | import request from 'supertest'; 4 | 5 | import app from '../../src/app'; 6 | import { getRandomElement, init, TEST_EMAIL, TEST_PASSWORD } from '../helper'; 7 | 8 | describe('Auth Workflow', () => { 9 | const email = TEST_EMAIL; 10 | const password = TEST_PASSWORD; 11 | 12 | let accessToken: string; 13 | let authorization: string; 14 | 15 | beforeAll(async () => { 16 | await init(); 17 | 18 | const response = await request(app).post('/login').send({ email, password: TEST_PASSWORD }); 19 | 20 | authorization = `Bearer ${response.body.data.refreshToken}`; 21 | }); 22 | 23 | describe('Login API test', () => { 24 | test('should login successfully with valid credentials.', () => { 25 | const expectedResponse = { 26 | code: StatusCodes.OK, 27 | message: expect.any(String), 28 | data: { 29 | accessToken: expect.any(String), 30 | refreshToken: expect.any(String) 31 | } 32 | }; 33 | 34 | return request(app) 35 | .post('/login') 36 | .send({ email, password }) 37 | .then((res) => { 38 | expect(res.status).toBe(StatusCodes.OK); 39 | expect(res.body).toEqual(expectedResponse); 40 | }); 41 | }); 42 | 43 | test('should fail login with invalid credentials.', () => { 44 | const expectedResponse = { 45 | code: StatusCodes.UNAUTHORIZED, 46 | message: expect.any(String) 47 | }; 48 | 49 | return request(app) 50 | .post('/login') 51 | .send({ email, password: `${password}${password}` }) 52 | .then((res) => { 53 | expect(res.status).toBe(StatusCodes.UNAUTHORIZED); 54 | expect(res.body).toEqual(expectedResponse); 55 | }); 56 | }); 57 | 58 | test('should fail login without login payload.', () => { 59 | const expectedResponse = { 60 | code: StatusCodes.BAD_REQUEST, 61 | message: expect.any(String), 62 | data: expect.any(Array) 63 | }; 64 | 65 | const badRequestResponse = { 66 | param: expect.any(String), 67 | message: expect.any(String) 68 | }; 69 | 70 | return request(app) 71 | .post('/login') 72 | .then((res) => { 73 | const errorResponse = getRandomElement(res.body.data); 74 | 75 | expect(res.status).toBe(StatusCodes.BAD_REQUEST); 76 | expect(res.body).toEqual(expectedResponse); 77 | expect(errorResponse).toEqual(badRequestResponse); 78 | }); 79 | }); 80 | }); 81 | 82 | describe('Refresh token API test', () => { 83 | test('should refresh access token successfully with valid authorization token.', () => { 84 | const expectedResponse = { 85 | code: StatusCodes.OK, 86 | message: expect.any(String), 87 | data: { 88 | accessToken: expect.any(String) 89 | } 90 | }; 91 | 92 | return request(app) 93 | .post('/refresh') 94 | .set({ authorization }) 95 | .then((res) => { 96 | accessToken = res.body.data.accessToken; 97 | 98 | expect(res.status).toBe(StatusCodes.OK); 99 | expect(res.body).toEqual(expectedResponse); 100 | }); 101 | }); 102 | 103 | test('should successfully access API with new access token.', () => { 104 | const expectedResponse = { 105 | code: StatusCodes.OK, 106 | message: expect.any(String), 107 | data: expect.any(Array) 108 | }; 109 | const userResponse = { 110 | name: expect.any(String), 111 | email: expect.any(String), 112 | roleId: expect.any(Number), 113 | updatedAt: expect.any(String), 114 | createdAt: expect.any(String) 115 | }; 116 | 117 | return request(app) 118 | .get('/users') 119 | .set({ authorization: `Bearer ${accessToken}` }) 120 | .then((res) => { 121 | const userInfo = getRandomElement(res.body.data); 122 | 123 | expect(res.status).toBe(StatusCodes.OK); 124 | expect(res.body).toEqual(expectedResponse); 125 | expect(userInfo).toEqual(userResponse); 126 | }); 127 | }); 128 | 129 | test('should fail refresh with invalid authorization token.', () => { 130 | const expectedResponse = { 131 | code: StatusCodes.UNAUTHORIZED, 132 | message: expect.any(String) 133 | }; 134 | 135 | return request(app) 136 | .post('/refresh') 137 | .set({ authorization: faker.string.alphanumeric() }) 138 | .then((res) => { 139 | expect(res.status).toBe(StatusCodes.UNAUTHORIZED); 140 | expect(res.body).toEqual(expectedResponse); 141 | }); 142 | }); 143 | }); 144 | 145 | describe('Logout API test', () => { 146 | test('should logout successfully with valid authorization token.', () => { 147 | const expectedResponse = { 148 | code: StatusCodes.OK, 149 | message: expect.any(String) 150 | }; 151 | 152 | return request(app) 153 | .post('/logout') 154 | .set({ authorization }) 155 | .then((res) => { 156 | expect(res.status).toBe(StatusCodes.OK); 157 | expect(res.body).toEqual(expectedResponse); 158 | }); 159 | }); 160 | 161 | test('should fail logout with invalid authorization token.', () => { 162 | const expectedResponse = { 163 | code: StatusCodes.UNAUTHORIZED, 164 | message: expect.any(String) 165 | }; 166 | 167 | return request(app) 168 | .post('/logout') 169 | .set({ authorization: faker.string.alphanumeric() }) 170 | .then((res) => { 171 | expect(res.status).toBe(StatusCodes.UNAUTHORIZED); 172 | expect(res.body).toEqual(expectedResponse); 173 | }); 174 | }); 175 | }); 176 | }); 177 | -------------------------------------------------------------------------------- /test/api/home.spec.ts: -------------------------------------------------------------------------------- 1 | import { StatusCodes } from 'http-status-codes'; 2 | import request from 'supertest'; 3 | 4 | import app from '../../src/app'; 5 | import config from '../../src/config/config'; 6 | 7 | describe('API Information', () => { 8 | const expectedResponse = { 9 | name: config.name, 10 | version: config.version 11 | }; 12 | 13 | test('should return application information', () => { 14 | return request(app) 15 | .get('/') 16 | .then((res) => { 17 | expect(res.status).toBe(StatusCodes.OK); 18 | expect(res.body).toEqual(expectedResponse); 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/api/user.spec.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { StatusCodes } from 'http-status-codes'; 3 | import request from 'supertest'; 4 | 5 | import app from '../../src/app'; 6 | import { getRandomElement, init, TEST_EMAIL, TEST_PASSWORD } from '../helper'; 7 | 8 | describe('GET /users API test', () => { 9 | const email = TEST_EMAIL; 10 | const password = TEST_PASSWORD; 11 | 12 | let authorization: string; 13 | 14 | beforeAll(async () => { 15 | await init(); 16 | 17 | const response = await request(app).post('/login').send({ email, password }); 18 | 19 | authorization = `Bearer ${response.body.data.accessToken}`; 20 | }); 21 | 22 | test('should return users list.', () => { 23 | const expectedResponse = { 24 | code: StatusCodes.OK, 25 | message: expect.any(String), 26 | data: expect.any(Array) 27 | }; 28 | const userResponse = { 29 | name: expect.any(String), 30 | email: expect.any(String), 31 | roleId: expect.any(Number), 32 | updatedAt: expect.any(String), 33 | createdAt: expect.any(String) 34 | }; 35 | 36 | return request(app) 37 | .get('/users') 38 | .set({ authorization }) 39 | .then((res) => { 40 | const userInfo = getRandomElement(res.body.data); 41 | 42 | expect(res.status).toBe(StatusCodes.OK); 43 | expect(res.body).toEqual(expectedResponse); 44 | expect(userInfo).toEqual(userResponse); 45 | }); 46 | }); 47 | }); 48 | 49 | describe('POST /users API test', () => { 50 | let authorization: string; 51 | 52 | const email = TEST_EMAIL; 53 | const password = TEST_PASSWORD; 54 | 55 | beforeAll(async () => { 56 | await init(); 57 | 58 | const response = await request(app).post('/login').send({ email, password }); 59 | 60 | authorization = `Bearer ${response.body.data.accessToken}`; 61 | }); 62 | 63 | test('should successfully insert user detail into database and return inserted user detail.', () => { 64 | const userBody = { 65 | name: faker.person.fullName(), 66 | email: 'dummy-user@starter.com', 67 | password: faker.internet.password() 68 | }; 69 | const expectedResponse = { 70 | code: StatusCodes.OK, 71 | message: expect.any(String), 72 | data: { 73 | ...userBody, 74 | id: expect.any(Number), 75 | roleId: expect.any(Number), 76 | password: expect.any(String), 77 | createdAt: expect.any(String), 78 | updatedAt: expect.any(String) 79 | } 80 | }; 81 | 82 | return request(app) 83 | .post('/users') 84 | .set({ authorization }) 85 | .send(userBody) 86 | .then((res) => { 87 | expect(res.status).toBe(StatusCodes.OK); 88 | expect(res.body).toEqual(expectedResponse); 89 | }); 90 | }); 91 | 92 | test('should fail request when payload is incorrect.', () => { 93 | const expectedResponse = { 94 | code: StatusCodes.BAD_REQUEST, 95 | message: expect.any(String), 96 | data: expect.any(Array) 97 | }; 98 | const badRequestResponse = { 99 | param: expect.any(String), 100 | message: expect.any(String) 101 | }; 102 | 103 | return request(app) 104 | .post('/users') 105 | .set({ authorization }) 106 | .then((res) => { 107 | const errorResponse = getRandomElement(res.body.data); 108 | 109 | expect(res.status).toBe(StatusCodes.BAD_REQUEST); 110 | expect(res.body).toEqual(expectedResponse); 111 | expect(errorResponse).toEqual(badRequestResponse); 112 | }); 113 | }); 114 | 115 | test('should fail request without authorization token.', () => { 116 | return request(app) 117 | .post('/users') 118 | .then((res) => { 119 | expect(res.status).toBe(StatusCodes.BAD_REQUEST); 120 | }); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /test/helper.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | 3 | import knex from '../src/config/db'; 4 | import UserDetail from '../src/domain/entities/UserDetail'; 5 | import Table from '../src/resources/enums/Table'; 6 | import * as userService from '../src/services/userService'; 7 | 8 | const tables = [Table.USER_SESSIONS, Table.USERS]; 9 | 10 | export const TEST_EMAIL = faker.internet.email(); 11 | export const TEST_PASSWORD = faker.internet.password(); 12 | 13 | let userData: UserDetail; 14 | 15 | /** 16 | * Create user. 17 | * 18 | * @returns Promise 19 | */ 20 | async function createUser(): Promise { 21 | return await userService.insert({ 22 | email: TEST_EMAIL, 23 | password: TEST_PASSWORD, 24 | name: faker.person.fullName() 25 | }); 26 | } 27 | 28 | /** 29 | * Delete all table's data. 30 | * 31 | * @returns {Promise} 32 | */ 33 | export async function init(): Promise { 34 | if (userData) { 35 | return userData; 36 | } 37 | 38 | for (const table of tables) { 39 | await knex(table).del(); 40 | } 41 | 42 | userData = await createUser(); 43 | 44 | return userData; 45 | } 46 | 47 | /** 48 | * Get a random element from given array. 49 | * 50 | * @param {unknown[]} list - List of elements. 51 | * @returns {unknown} 52 | */ 53 | export function getRandomElement(list: unknown[]): unknown { 54 | return faker.helpers.arrayElement(list); 55 | } 56 | -------------------------------------------------------------------------------- /test/unit/array.spec.ts: -------------------------------------------------------------------------------- 1 | import { isArray } from '../../src/utils/array'; 2 | 3 | describe('Utils: isArray()', () => { 4 | const arr = [12, 14, 16, 19, 10]; 5 | 6 | test('should return true if parameter is an array', () => { 7 | const result = isArray(arr); 8 | 9 | expect(result).toEqual(true); 10 | }); 11 | 12 | test('should return false if parameter is an object', () => { 13 | const result = isArray({}); 14 | 15 | expect(result).toEqual(false); 16 | }); 17 | 18 | test('should return false if parameter is an null', () => { 19 | const result = isArray(null); 20 | 21 | expect(result).toEqual(false); 22 | }); 23 | 24 | test('should return false if parameter is an undefined', () => { 25 | const result = isArray(undefined); 26 | 27 | expect(result).toEqual(false); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/unit/bcrypt.spec.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | 3 | import { compare, hash } from '../../src/utils/bcrypt'; 4 | 5 | describe('Utils: compare()', () => { 6 | let hashedText: string; 7 | const plainText = faker.internet.password(); 8 | 9 | beforeAll(async () => { 10 | hashedText = await hash(plainText); 11 | }); 12 | 13 | test('should return true if text matches', async () => { 14 | const isSame = await compare(plainText, hashedText); 15 | 16 | expect(isSame).toEqual(true); 17 | }); 18 | 19 | test('should return false if text does not match', async () => { 20 | const isSame = await compare(plainText + 'hello', hashedText); 21 | 22 | expect(isSame).toEqual(false); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/unit/string.spec.ts: -------------------------------------------------------------------------------- 1 | import { camelcase, capitalize } from '../../src/utils/string'; 2 | 3 | describe('Utils: capitalize()', () => { 4 | const word = 'hello'; 5 | const capitalizeWord = 'Hello'; 6 | 7 | test('should capitalize the given word', () => { 8 | expect(capitalize(word)).toEqual(capitalizeWord); 9 | }); 10 | 11 | test('should capitalize single letter correctly', () => { 12 | expect(capitalize('a')).toEqual('A'); 13 | }); 14 | 15 | it('should handle empty string correctly', () => { 16 | expect(capitalize('')).toEqual(''); 17 | }); 18 | }); 19 | 20 | describe('Utils: camelcase()', () => { 21 | const camelcaseText = 'helloStarterAPI'; 22 | 23 | test('should camelcase the given text', () => { 24 | const text = 'hello_starter_API'; 25 | expect(camelcase(text)).toEqual(camelcaseText); 26 | }); 27 | 28 | test('should camelcase the given text with given separator', () => { 29 | const text = 'hello starter API'; 30 | expect(camelcase(text, ' ')).toEqual(camelcaseText); 31 | }); 32 | 33 | test('should handle single letter correctly', () => { 34 | expect(camelcase('a')).toEqual('a'); 35 | }); 36 | 37 | it('should handle empty string correctly', () => { 38 | expect(camelcase('')).toEqual(''); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "CommonJS", 5 | "lib": ["ESNext"], 6 | "outDir": "./build", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "resolveJsonModule": true, 11 | "allowSyntheticDefaultImports": true, 12 | "declaration": true, 13 | "sourceMap": true, 14 | "noImplicitAny": true, 15 | "noImplicitThis": true, 16 | "noUnusedLocals": true, 17 | "strictNullChecks": true, 18 | "noImplicitReturns": true, 19 | "noUnusedParameters": true, 20 | "strictFunctionTypes": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "baseUrl": "./", 23 | "paths": { 24 | "*": ["src/types/*"] 25 | }, 26 | "emitDecoratorMetadata": true, 27 | "experimentalDecorators": true 28 | }, 29 | "include": ["src/**/*"], 30 | "exclude": ["node_modules", "**/*.spec.ts"] 31 | } 32 | --------------------------------------------------------------------------------