Repository: mkosir/typeorm-express-typescript Branch: main Commit: 117f6647c5aa Files: 76 Total size: 66.8 KB Directory structure: gitextract_whv901lw/ ├── .commitlintrc.ts ├── .czrc ├── .dockerignore ├── .eslintrc.js ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ └── config.yml │ └── workflows/ │ └── main.yml ├── .gitignore ├── .husky/ │ ├── commit-msg │ ├── pre-commit │ └── pre-push ├── .lintstagedrc ├── .prettierignore ├── .prettierrc ├── Dockerfile ├── LICENSE ├── README.md ├── config/ │ ├── prod.env │ └── stage.env ├── database/ │ └── .gitkeep ├── docker-compose.dev.yml ├── docker-compose.prod.yml ├── docker-compose.stage.yml ├── docker-compose.yml ├── log/ │ └── .gitkeep ├── package.json ├── postman/ │ ├── RESTful_API_Boilerplate.postman_collection.json │ └── RESTful_API_Boilerplate.postman_environment.json ├── renovate.json ├── scripts/ │ ├── be-node-dev.sh │ └── wait-for-it.sh ├── src/ │ ├── consts/ │ │ └── ConstsUser.ts │ ├── controllers/ │ │ ├── auth/ │ │ │ ├── changePassword.ts │ │ │ ├── index.ts │ │ │ ├── login.test.ts │ │ │ ├── login.ts │ │ │ ├── register.test.ts │ │ │ └── register.ts │ │ └── users/ │ │ ├── destroy.ts │ │ ├── edit.ts │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── list.ts │ │ └── show.ts │ ├── index.ts │ ├── middleware/ │ │ ├── checkJwt.ts │ │ ├── checkRole.ts │ │ ├── errorHandler.ts │ │ ├── getLanguage.ts │ │ └── validation/ │ │ ├── auth/ │ │ │ ├── index.ts │ │ │ ├── validatorChangePassword.ts │ │ │ ├── validatorLogin.ts │ │ │ └── validatorRegister.ts │ │ └── users/ │ │ ├── index.ts │ │ └── validatorEdit.ts │ ├── orm/ │ │ ├── config/ │ │ │ ├── ormconfig-seed.ts │ │ │ └── ormconfig.ts │ │ ├── dbCreateConnection.ts │ │ ├── entities/ │ │ │ └── users/ │ │ │ ├── User.ts │ │ │ └── types.ts │ │ ├── migrations/ │ │ │ └── 1590521920166-CreateUsers.ts │ │ └── seeds/ │ │ └── 1590519635401-SeedUsers.ts │ ├── routes/ │ │ ├── index.ts │ │ ├── pages/ │ │ │ ├── 404.ts │ │ │ └── root.ts │ │ └── v1/ │ │ ├── auth.ts │ │ ├── index.ts │ │ └── users.ts │ ├── types/ │ │ ├── JwtPayload.ts │ │ ├── ProcessEnv.d.ts │ │ └── express/ │ │ └── index.d.ts │ └── utils/ │ ├── createJwtToken.ts │ └── response/ │ ├── custom-error/ │ │ ├── CustomError.ts │ │ └── types.ts │ └── customSuccess.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .commitlintrc.ts ================================================ import type { UserConfig } from '@commitlint/types'; const commitlintConfig: UserConfig = { extends: ['@commitlint/config-conventional'], }; module.exports = commitlintConfig; ================================================ FILE: .czrc ================================================ { "path": "node_modules/cz-conventional-changelog" } ================================================ FILE: .dockerignore ================================================ config database dist node_modules scripts .git .env ================================================ FILE: .eslintrc.js ================================================ module.exports = { parser: '@typescript-eslint/parser', extends: [ 'plugin:@typescript-eslint/recommended', 'prettier', 'plugin:prettier/recommended', 'plugin:no-array-reduce/recommended', ], plugins: ['@typescript-eslint', 'import'], rules: { // General '@typescript-eslint/no-unused-vars': 0, '@typescript-eslint/explicit-module-boundary-types': 0, '@typescript-eslint/no-explicit-any': 0, '@typescript-eslint/no-non-null-assertion': 0, '@typescript-eslint/ban-ts-comment': 0, '@typescript-eslint/no-empty-interface': 0, // Import 'import/order': [ 'error', { groups: ['builtin', 'external', 'internal', 'parent', 'sibling'], 'newlines-between': 'always', alphabetize: { order: 'asc', caseInsensitive: true, }, }, ], }, parserOptions: { ecmaVersion: 2018, sourceType: 'module', }, settings: { 'import/resolver': { node: { extensions: ['.js', '.jsx', '.ts', '.tsx'], moduleDirectory: ['node_modules', 'src/'], }, }, }, }; ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: 'Bug Report' description: 'File a bug report' body: - type: 'textarea' id: 'description' attributes: label: 'Description' description: 'A clear and concise description of what the bug is.' placeholder: | Bug description validations: required: true - type: 'textarea' id: 'additional-information' attributes: label: 'Additional Information' description: | Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Ask a question url: https://github.com/mkosir/express-typescript-typeorm-boilerplate/discussions about: Ask questions and discuss topics with other community members ================================================ FILE: .github/workflows/main.yml ================================================ name: CI on: push jobs: validate: runs-on: ubuntu-latest steps: - name: Checkout 🛎️ uses: actions/checkout@v2 - uses: actions/setup-node@v2 with: node-version: '16.x' - name: Cache dependencies ⚡ id: cache uses: actions/cache@v2 with: path: node_modules key: node-modules-${{ hashFiles('package-lock.json') }} - name: Install dependencies 🔧 if: steps.cache.outputs.cache-hit != 'true' run: npm ci - name: Lint ✅ run: npm run lint - name: Build 🏗️ run: npm run build ================================================ FILE: .gitignore ================================================ .idea/ .vscode/ .DS_Store node_modules/ build/ dist/ tmp/ temp/ database/* !database/.gitkeep log/* !log/.gitkeep *.log # keep config folder with env files ONLY for demo purposes # config/ # .env ================================================ FILE: .husky/commit-msg ================================================ #!/bin/sh . "$(dirname "$0")/_/husky.sh" npx --no-install commitlint --edit "$1" ================================================ FILE: .husky/pre-commit ================================================ #!/bin/sh . "$(dirname "$0")/_/husky.sh" npm run lint-staged-husky ================================================ FILE: .husky/pre-push ================================================ #!/bin/sh . "$(dirname "$0")/_/husky.sh" npm run build ================================================ FILE: .lintstagedrc ================================================ { "*": [ "pretty-quick --staged" ], "*.{js,jsx,ts,tsx}": [ "eslint --max-warnings 0", "tsc-files --noEmit" ] } ================================================ FILE: .prettierignore ================================================ dist ================================================ FILE: .prettierrc ================================================ { "printWidth": 120, "semi": true, "singleQuote": true, "trailingComma": "all" } ================================================ FILE: Dockerfile ================================================ FROM node:16.14.0-alpine WORKDIR /app COPY ./package.json . COPY ./package-lock.json . RUN npm install && npm cache clean --force COPY . . RUN npm run build EXPOSE 4000 CMD [ "npm", "start" ] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 mkosir Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # TypeORM / Express / TypeScript RESTful API boilerplate [![CI][build-badge]][build-url] [![TypeScript][typescript-badge]][typescript-url] [![prettier][prettier-badge]][prettier-url] ![Heisenberg](misc/heisenberg.png) Boilerplate with focus on best practices and painless developer experience: - Minimal setup that can be extended 🔧 - Spin it up with single command 🌀 - TypeScript first - RESTful APIs - JWT authentication with role based authorization ## Requirements - [Node v16+](https://nodejs.org/) - [Docker](https://www.docker.com/) ## Running _Easily set up a local development environment with single command!_ - clone the repo - `npm run docker:dev` 🚀 Visit [localhost:4000](http://localhost:4000/) or if using Postman grab [config](/postman). ### _What happened_ 💥 Containers created: - Postgres database container seeded with 💊 Breaking Bad characters in `Users` table (default credentials `user=walter`, `password=white` in [.env file](./.env)) - Node (v16 Alpine) container with running boilerplate RESTful API service - and one Node container instance to run tests locally or in CI ## Features: - [Express](https://github.com/expressjs/express) framework - [TypeScript v4](https://github.com/microsoft/TypeScript) codebase - [TypeORM](https://typeorm.io/) using Data Mapper pattern - [Docker](https://www.docker.com/) environment: - Easily start local development using [Docker Compose](https://docs.docker.com/compose/) with single command `npm run docker:dev` - Connect to different staging or production environments `npm run docker:[stage|prod]` - Ready for **microservices** development and deployment. Once API changes are made, just build and push new docker image with your favourite CI/CD tool `docker build -t /api-boilerplate:latest .` `docker push /api-boilerplate:latest` - Run unit, integration (or setup with your frontend E2E) tests as `docker exec -ti be_boilerplate_test sh` and `npm run test` - Contract first REST API design: - never break API again with HTTP responses and requests payloads using [type definitions](./src/types/express/index.d.ts) - Consistent schema error [response](./src/utils/response/custom-error/types.ts). Your frontend will always know how to handle errors thrown in `try...catch` statements 💪 - JWT authentication and role based authorization using custom middleware - Set local, stage or production [environmental variables](./config) with [type definitions](./src/types/ProcessEnv.d.ts) - Logging with [morgan](https://github.com/expressjs/morgan) - Unit and integration tests with [Mocha](https://mochajs.org/) and [Chai](https://www.chaijs.com/) - Linting with [ESLint](https://eslint.org/) - [Prettier](https://prettier.io/) code formatter - Git hooks with [Husky](https://github.com/typicode/husky) and [lint-staged](https://github.com/okonet/lint-staged) - Automated npm & Docker dependency updates with [Renovate](https://github.com/renovatebot/renovate) (set to patch version only) - Commit messages must meet [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) format. After staging changes just run `npm run commit` and get instant feedback on your commit message formatting and be prompted for required fields by [Commitizen](https://github.com/commitizen/cz-cli) ## Other awesome boilerplates: Each boilerplate comes with it's own flavor of libraries and setup, check out others: - [Express and TypeORM with TypeScript](https://github.com/typeorm/typescript-express-example) - [Node.js, Express.js & TypeScript Boilerplate for Web Apps](https://github.com/jverhoelen/node-express-typescript-boilerplate) - [Express boilerplate for building RESTful APIs](https://github.com/danielfsousa/express-rest-es2017-boilerplate) - [A delightful way to building a RESTful API with NodeJs & TypeScript by @w3tecch](https://github.com/w3tecch/express-typescript-boilerplate) [build-badge]: https://github.com/mkosir/express-typescript-typeorm-boilerplate/actions/workflows/main.yml/badge.svg [build-url]: https://github.com/mkosir/express-typescript-typeorm-boilerplate/actions/workflows/main.yml [typescript-badge]: https://badges.frapsoft.com/typescript/code/typescript.svg?v=101 [typescript-url]: https://github.com/microsoft/TypeScript [prettier-badge]: https://img.shields.io/badge/code_style-prettier-ff69b4.svg [prettier-url]: https://github.com/prettier/prettier ## Contributing All contributions are welcome! ================================================ FILE: config/prod.env ================================================ NODE_ENV=production PORT=4000 ### Database - Postgres PG_HOST=host.production.com PG_PORT=5432 POSTGRES_USER=walter POSTGRES_PASSWORD=white POSTGRES_DB=production_boilerplate_db ### JWT JWT_SECRET=4353dhf8gset8h523sfreh6qedn37dfposefsawd56n381jsd JWT_EXPIRATION=15m ================================================ FILE: config/stage.env ================================================ NODE_ENV=stage PORT=4000 ### Database - Postgres PG_HOST=host.stage.com PG_PORT=5432 POSTGRES_USER=walter POSTGRES_PASSWORD=white POSTGRES_DB=stage_boilerplate_db ### JWT JWT_SECRET=ert423dhf8gh523reh6qedn37dfposdgawdn381js25w JWT_EXPIRATION=15m ================================================ FILE: database/.gitkeep ================================================ ================================================ FILE: docker-compose.dev.yml ================================================ version: '3' services: db_postgres: container_name: 'db_boilerplate' image: 'postgres:14.2-alpine' restart: always env_file: - .env ports: - '5432:5432' volumes: - ./database/boilerplate:/var/lib/postgresql/data/ be_boilerplate: entrypoint: /bin/sh './scripts/be-node-dev.sh' env_file: - .env ports: - '4000:4000' depends_on: - db_postgres links: - db_postgres be_boilerplate_test: container_name: 'be_boilerplate_test' command: sh -c "echo 'Test container ready' && tail -f /dev/null" build: . stdin_open: true tty: true depends_on: - db_postgres links: - db_postgres env_file: - .env volumes: - .:/app/ - /app/node_modules ================================================ FILE: docker-compose.prod.yml ================================================ version: '3' services: be_boilerplate: deploy: resources: limits: cpus: '0.90' # memory: 400M command: /bin/sh -c "echo 'Running API on production!' && npm run build && npm start" ports: - '4000:4000' env_file: - ./config/prod.env ================================================ FILE: docker-compose.stage.yml ================================================ version: '3' services: be_boilerplate: command: /bin/sh -c "echo 'Running API on production!' && npm run build && npm start" ports: - '4000:4000' env_file: - ./config/stage.env ================================================ FILE: docker-compose.yml ================================================ version: '3' services: be_boilerplate: container_name: 'be_boilerplate' build: . restart: always volumes: - .:/app/ - /app/node_modules ================================================ FILE: log/.gitkeep ================================================ ================================================ FILE: package.json ================================================ { "name": "express-typescript-typeorm-boilerplate", "version": "1.0.0", "description": "RESTful API boilerplate with Typescript, TypeORM, ExpressJs & Mocha.", "scripts": { "dev": "debug=* NODE_PATH=./src ts-node-dev --respawn ./src/index.ts", "build": "rimraf dist && tsc", "start": "NODE_PATH=./dist node ./dist/index.js", "prepare": "husky install", "lint": "eslint --max-warnings 0 . && npm run tsc", "lint-fix": "eslint --fix .", "lint-staged-husky": "lint-staged", "tsc": "tsc --noEmit", "format-lint": "prettier --config .prettierrc -l --ignore-unknown .", "format-fix": "prettier --config .prettierrc --write --ignore-unknown .", "test": "NODE_PATH=./src mocha --require ts-node/register 'src/**/*.test.ts' --timeout 20000 --exit", "commit": "git-cz", "docker:dev": "docker-compose --file docker-compose.yml --file docker-compose.dev.yml --compatibility up --build", "docker:stage": "docker-compose --file docker-compose.yml --file docker-compose.stage.yml --compatibility up --build", "docker:prod": "docker-compose --file docker-compose.yml --file docker-compose.prod.yml --compatibility up --build", "migration:run": "NODE_PATH=./src ts-node ./node_modules/typeorm/cli.js migration:run --config ./src/orm/config/ormconfig.ts", "migration:run:dev": "PG_HOST=localhost NODE_PATH=./src ts-node ./node_modules/typeorm/cli.js migration:run --config ./src/orm/config/ormconfig.ts", "migration:revert": "PG_HOST=localhost NODE_PATH=./src ts-node ./node_modules/typeorm/cli.js migration:revert --config ./src/orm/config/ormconfig.ts", "migration:generate": "PG_HOST=localhost NODE_PATH=./src ts-node ./node_modules/.bin/typeorm migration:generate --pretty --config ./src/orm/config/ormconfig.ts -n ", "migration:create": "PG_HOST=localhost NODE_PATH=./src ts-node ./node_modules/.bin/typeorm migration:create --config ./src/orm/config/ormconfig.ts -n ", "seed:run": "NODE_PATH=./src ts-node ./node_modules/.bin/typeorm migration:run --config ./src/orm/config/ormconfig-seed.ts", "seed:run:dev": "PG_HOST=localhost NODE_PATH=./src ts-node ./node_modules/.bin/typeorm migration:run --config ./src/orm/config/ormconfig-seed.ts", "seed:create": "PG_HOST=localhost NODE_PATH=./src ts-node ./node_modules/.bin/typeorm migration:create --config ./src/orm/config/ormconfig-seed.ts -n " }, "dependencies": { "bcryptjs": "^2.4.3", "cors": "^2.8.5", "express": "^4.17.3", "helmet": "^5.0.2", "jsonwebtoken": "^8.5.1", "morgan": "^1.10.0", "pg": "^8.7.3", "reflect-metadata": "^0.1.13", "typeorm": "^0.2.45", "typeorm-naming-strategies": "^4.0.0", "validator": "^13.7.0" }, "devDependencies": { "@commitlint/cli": "^16.2.4", "@commitlint/config-conventional": "^16.2.4", "@types/bcryptjs": "^2.4.2", "@types/body-parser": "^1.19.2", "@types/chai": "^4.3.1", "@types/cors": "^2.8.12", "@types/express": "^4.17.13", "@types/jsonwebtoken": "^8.5.8", "@types/mocha": "^9.1.1", "@types/morgan": "^1.9.3", "@types/node": "^17.0.33", "@types/supertest": "^2.0.12", "@types/validator": "^13.7.2", "@typescript-eslint/eslint-plugin": "^5.23.0", "@typescript-eslint/parser": "^5.23.0", "chai": "^4.3.6", "commitizen": "^4.2.4", "dotenv": "^16.0.1", "eslint": "^8.15.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "^2.26.0", "eslint-plugin-no-array-reduce": "^1.0.58", "eslint-plugin-prettier": "^4.0.0", "husky": "^8.0.1", "lint-staged": "^12.4.1", "mocha": "^9.2.2", "nyc": "^15.1.0", "prettier": "^2.6.2", "pretty-quick": "^3.1.3", "supertest": "^6.2.2", "ts-node": "10.7.0", "ts-node-dev": "^1.1.8", "tsc-files": "^1.1.3", "typescript": "4.6.4" } } ================================================ FILE: postman/RESTful_API_Boilerplate.postman_collection.json ================================================ { "info": { "_postman_id": "da0fe49f-e424-4b96-8f55-49f7db0583dc", "name": "RESTful API Boilerplate", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ { "name": "/auth", "item": [ { "name": "/login", "event": [ { "listen": "test", "script": { "id": "81ebd7f4-cc11-473a-a6b3-85b003f7222b", "exec": [ "var jsonData = JSON.parse(responseBody);", "", "postman.setEnvironmentVariable(\"token\", jsonData.data);" ], "type": "text/javascript" } } ], "request": { "method": "POST", "header": [ { "key": "Content-Type", "name": "Content-Type", "type": "text", "value": "application/x-www-form-urlencoded" }, { "key": "Accept-Language", "value": "{{language}}", "type": "text" } ], "body": { "mode": "urlencoded", "urlencoded": [ { "key": "email", "value": "admin@admin.com", "type": "text" }, { "key": "password", "value": "pass1", "type": "text" } ] }, "url": { "raw": "{{baseUrl}}/login", "host": ["{{baseUrl}}"], "path": ["login"] } }, "response": [] }, { "name": "/register", "request": { "method": "POST", "header": [ { "key": "Content-Type", "name": "Content-Type", "value": "application/x-www-form-urlencoded", "type": "text" }, { "key": "Accept-Language", "value": "{{language}}", "type": "text" } ], "body": { "mode": "urlencoded", "urlencoded": [ { "key": "email", "value": "admin1@admin.com", "type": "text" }, { "key": "password", "value": "admin", "type": "text" }, { "key": "passwordConfirm", "value": "admin", "type": "text" } ] }, "url": { "raw": "{{baseUrl}}/register", "host": ["{{baseUrl}}"], "path": ["register"] } }, "response": [] }, { "name": "/change-password", "request": { "method": "POST", "header": [ { "key": "Content-Type", "name": "Content-Type", "type": "text", "value": "application/x-www-form-urlencoded" }, { "key": "Authorization", "value": "{{token}}", "type": "text" }, { "key": "Accept-Language", "value": "{{language}}", "type": "text" } ], "body": { "mode": "urlencoded", "urlencoded": [ { "key": "password", "value": "admin", "type": "text" }, { "key": "passwordNew", "value": "admin", "type": "text" }, { "key": "passwordConfirm", "value": "admin", "type": "text" } ] }, "url": { "raw": "{{baseUrl}}/change-password", "host": ["{{baseUrl}}"], "path": ["change-password"] } }, "response": [] } ], "protocolProfileBehavior": {} }, { "name": "/misc", "item": [ { "name": "/change-language", "request": { "method": "POST", "header": [ { "key": "Content-Type", "name": "Content-Type", "type": "text", "value": "application/x-www-form-urlencoded" }, { "key": "Accept-Language", "type": "text", "value": "sl-SI" } ], "body": { "mode": "urlencoded", "urlencoded": [ { "key": "language", "value": "sl-SI", "type": "text" } ] }, "url": { "raw": "{{baseUrl}}/misc/change-language", "host": ["{{baseUrl}}"], "path": ["misc", "change-language"] } }, "response": [] } ], "protocolProfileBehavior": {} }, { "name": "/users", "item": [ { "name": "/", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Content-Type", "name": "Content-Type", "type": "text", "value": "application/x-www-form-urlencoded" }, { "key": "Accept-Language", "type": "text", "value": "sl-SI" }, { "key": "Authorization", "value": "{{token}}", "type": "text" } ], "body": { "mode": "urlencoded", "urlencoded": [] }, "url": { "raw": "{{baseUrl}}/users", "host": ["{{baseUrl}}"], "path": ["users"] } }, "response": [] }, { "name": "/:id", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Content-Type", "name": "Content-Type", "type": "text", "value": "application/x-www-form-urlencoded" }, { "key": "Accept-Language", "type": "text", "value": "sl-SI" }, { "key": "Authorization", "type": "text", "value": "{{token}}" } ], "body": { "mode": "urlencoded", "urlencoded": [] }, "url": { "raw": "{{baseUrl}}/users/3", "host": ["{{baseUrl}}"], "path": ["users", "3"] } }, "response": [] }, { "name": "/:id", "request": { "method": "DELETE", "header": [ { "key": "Content-Type", "name": "Content-Type", "type": "text", "value": "application/x-www-form-urlencoded" }, { "key": "Accept-Language", "type": "text", "value": "sl-SI" }, { "key": "Authorization", "type": "text", "value": "{{token}}" } ], "body": { "mode": "urlencoded", "urlencoded": [] }, "url": { "raw": "{{baseUrl}}/users/9", "host": ["{{baseUrl}}"], "path": ["users", "9"] } }, "response": [] }, { "name": "/:id", "request": { "method": "PATCH", "header": [ { "key": "Content-Type", "name": "Content-Type", "type": "text", "value": "application/x-www-form-urlencoded" }, { "key": "Accept-Language", "type": "text", "value": "sl-SI" }, { "key": "Authorization", "type": "text", "value": "{{token}}" } ], "body": { "mode": "urlencoded", "urlencoded": [ { "key": "username", "value": "Tyrion1", "type": "text" }, { "key": "name", "value": "test name", "type": "text" } ] }, "url": { "raw": "{{baseUrl}}/users/5", "host": ["{{baseUrl}}"], "path": ["users", "5"] } }, "response": [] } ], "protocolProfileBehavior": {} } ], "protocolProfileBehavior": {} } ================================================ FILE: postman/RESTful_API_Boilerplate.postman_environment.json ================================================ { "id": "a8df1c58-9c26-4452-a61e-0f0d2e865ee1", "name": "RESTful API Boilerplate", "values": [ { "key": "host", "value": "http://localhost:4000", "enabled": true }, { "key": "baseUrl", "value": "{{host}}", "enabled": true }, { "key": "language", "value": "en", "enabled": true }, { "key": "token", "value": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwibmFtZSI6IldhbHRlciBXaGl0ZSIsImVtYWlsIjoiYWRtaW5AYWRtaW4uY29tIiwicm9sZSI6IkFETUlOSVNUUkFUT1IiLCJjcmVhdGVkX2F0IjoiMjAyMC0wNi0xM1QwODoyNjoxNS41MjZaIiwiaWF0IjoxNTkyMTYxMjcwLCJleHAiOjE1OTIxNjIxNzB9.Fi-jszKGqt2plCqJj9jCM5x5l0KSQVkuQu9-qqElMug", "enabled": true } ], "_postman_variable_scope": "environment", "_postman_exported_at": "2020-06-14T19:04:35.409Z", "_postman_exported_using": "Postman/7.26.0" } ================================================ FILE: renovate.json ================================================ { "extends": ["config:base", ":disableDependencyDashboard"], "separateMinorPatch": true, "assignees": ["mkosir"], "assignAutomerge": true, "requiredStatusChecks": null, "rangeStrategy": "bump", "enabled": false, "packageRules": [ { "matchUpdateTypes": ["minor", "major"], "enabled": false }, { "automerge": true, "labels": ["automerge", "dependencies", "patch"], "groupName": "group:dependencies", "matchDepTypes": ["dependencies"] }, { "automerge": true, "labels": ["automerge", "devDependencies", "patch"], "groupName": "group:devDependencies", "matchDepTypes": ["devDependencies"] } ] } ================================================ FILE: scripts/be-node-dev.sh ================================================ #!/bin/sh echo "Install bash and execute 'wait-for-it.sh' script" apk add --update bash ./scripts/wait-for-it.sh $PG_HOST:5432 --timeout=30 --strict -- echo "postgres up and running" npm run migration:run npm run seed:run npm run dev ================================================ FILE: scripts/wait-for-it.sh ================================================ #!/usr/bin/env bash WAITFORIT_cmdname=${0##*/} echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } usage() { cat << USAGE >&2 Usage: $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] -h HOST | --host=HOST Host or IP under test -p PORT | --port=PORT TCP port under test Alternatively, you specify the host and port as host:port -s | --strict Only execute subcommand if the test succeeds -q | --quiet Don't output any status messages -t TIMEOUT | --timeout=TIMEOUT Timeout in seconds, zero for no timeout -- COMMAND ARGS Execute command with args after the test finishes USAGE exit 1 } wait_for() { if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" else echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" fi WAITFORIT_start_ts=$(date +%s) while : do if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then nc -z $WAITFORIT_HOST $WAITFORIT_PORT WAITFORIT_result=$? else (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 WAITFORIT_result=$? fi if [[ $WAITFORIT_result -eq 0 ]]; then WAITFORIT_end_ts=$(date +%s) echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" break fi sleep 1 done return $WAITFORIT_result } wait_for_wrapper() { if [[ $WAITFORIT_QUIET -eq 1 ]]; then timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & else timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & fi WAITFORIT_PID=$! trap "kill -INT -$WAITFORIT_PID" INT wait $WAITFORIT_PID WAITFORIT_RESULT=$? if [[ $WAITFORIT_RESULT -ne 0 ]]; then echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" fi return $WAITFORIT_RESULT } # process arguments while [[ $# -gt 0 ]] do case "$1" in *:* ) WAITFORIT_hostport=(${1//:/ }) WAITFORIT_HOST=${WAITFORIT_hostport[0]} WAITFORIT_PORT=${WAITFORIT_hostport[1]} shift 1 ;; --child) WAITFORIT_CHILD=1 shift 1 ;; -q | --quiet) WAITFORIT_QUIET=1 shift 1 ;; -s | --strict) WAITFORIT_STRICT=1 shift 1 ;; -h) WAITFORIT_HOST="$2" if [[ $WAITFORIT_HOST == "" ]]; then break; fi shift 2 ;; --host=*) WAITFORIT_HOST="${1#*=}" shift 1 ;; -p) WAITFORIT_PORT="$2" if [[ $WAITFORIT_PORT == "" ]]; then break; fi shift 2 ;; --port=*) WAITFORIT_PORT="${1#*=}" shift 1 ;; -t) WAITFORIT_TIMEOUT="$2" if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi shift 2 ;; --timeout=*) WAITFORIT_TIMEOUT="${1#*=}" shift 1 ;; --) shift WAITFORIT_CLI=("$@") break ;; --help) usage ;; *) echoerr "Unknown argument: $1" usage ;; esac done if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then echoerr "Error: you need to provide a host and port to test." usage fi WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} # Check to see if timeout is from busybox? WAITFORIT_TIMEOUT_PATH=$(type -p timeout) WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) WAITFORIT_BUSYTIMEFLAG="" if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then WAITFORIT_ISBUSY=1 # Check if busybox timeout uses -t flag # (recent Alpine versions don't support -t anymore) if timeout &>/dev/stdout | grep -q -e '-t '; then WAITFORIT_BUSYTIMEFLAG="-t" fi else WAITFORIT_ISBUSY=0 fi if [[ $WAITFORIT_CHILD -gt 0 ]]; then wait_for WAITFORIT_RESULT=$? exit $WAITFORIT_RESULT else if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then wait_for_wrapper WAITFORIT_RESULT=$? else wait_for WAITFORIT_RESULT=$? fi fi if [[ $WAITFORIT_CLI != "" ]]; then if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" exit $WAITFORIT_RESULT fi exec "${WAITFORIT_CLI[@]}" else exit $WAITFORIT_RESULT fi ================================================ FILE: src/consts/ConstsUser.ts ================================================ export enum ConstsUser { PASSWORD_MIN_CHAR = 4, } ================================================ FILE: src/controllers/auth/changePassword.ts ================================================ import { Request, Response, NextFunction } from 'express'; import { getRepository } from 'typeorm'; import { User } from 'orm/entities/users/User'; import { CustomError } from 'utils/response/custom-error/CustomError'; export const changePassword = async (req: Request, res: Response, next: NextFunction) => { const { password, passwordNew } = req.body; const { id, name } = req.jwtPayload; const userRepository = getRepository(User); try { const user = await userRepository.findOne({ where: { id } }); if (!user) { const customError = new CustomError(404, 'General', 'Not Found', [`User ${name} not found.`]); return next(customError); } if (!user.checkIfPasswordMatch(password)) { const customError = new CustomError(400, 'General', 'Not Found', ['Incorrect password']); return next(customError); } user.password = passwordNew; user.hashPassword(); userRepository.save(user); res.customSuccess(200, 'Password successfully changed.'); } catch (err) { const customError = new CustomError(400, 'Raw', 'Error', null, err); return next(customError); } }; ================================================ FILE: src/controllers/auth/index.ts ================================================ export * from './changePassword'; export * from './login'; export * from './register'; ================================================ FILE: src/controllers/auth/login.test.ts ================================================ import 'mocha'; import { expect } from 'chai'; import { agent as request } from 'supertest'; import { getRepository, Connection, Repository } from 'typeorm'; import { dbCreateConnection } from 'orm/dbCreateConnection'; import { Role } from 'orm/entities/users/types'; import { User } from 'orm/entities/users/User'; import { app } from '../../'; describe('Login', () => { let dbConnection: Connection; let userRepository: Repository; const userPassword = 'pass1'; const user = new User(); user.username = 'Badger'; user.name = 'Brandon Mayhew'; user.email = 'brandon.mayhew@test.com'; user.password = userPassword; user.hashPassword(); user.role = 'ADMINISTRATOR' as Role; before(async () => { dbConnection = await dbCreateConnection(); userRepository = getRepository(User); }); beforeEach(async () => { await userRepository.save(user); }); afterEach(async () => { await userRepository.delete(user.id); }); it('should return a JWT token', async () => { const res = await request(app).post('/v1/auth/login').send({ email: user.email, password: userPassword }); expect(res.status).to.equal(200); expect(res.body.message).to.equal('Token successfully created.'); expect(res.body.data).not.to.be.empty; expect(res.body.data).to.be.an('string'); }); it("should report error when email and password don't match", async () => { const res = await request(app).post('/v1/auth/login').send({ email: user.email, password: 'wrong_password' }); expect(res.status).to.equal(404); expect(res.body.errorType).to.equal('General'); expect(res.body.errors).to.eql(['Incorrect email or password']); expect(res.body.errorRaw).to.an('null'); expect(res.body.errorsValidation).to.an('null'); }); it('should report error when the email provided is not valid', async () => { const res = await request(app).post('/v1/auth/login').send({ email: 'not_valid_email', password: userPassword }); expect(res.status).to.equal(400); expect(res.body.errorType).to.equal('Validation'); expect(res.body.errorMessage).to.equal('Login validation error'); expect(res.body.errors).to.an('null'); expect(res.body.errorRaw).to.an('null'); expect(res.body.errorsValidation).to.eql([ { email: 'Email is invalid', }, ]); }); }); ================================================ FILE: src/controllers/auth/login.ts ================================================ import { Request, Response, NextFunction } from 'express'; import { getRepository } from 'typeorm'; import { Role } from 'orm/entities/users/types'; import { User } from 'orm/entities/users/User'; import { JwtPayload } from 'types/JwtPayload'; import { createJwtToken } from 'utils/createJwtToken'; import { CustomError } from 'utils/response/custom-error/CustomError'; export const login = async (req: Request, res: Response, next: NextFunction) => { const { email, password } = req.body; const userRepository = getRepository(User); try { const user = await userRepository.findOne({ where: { email } }); if (!user) { const customError = new CustomError(404, 'General', 'Not Found', ['Incorrect email or password']); return next(customError); } if (!user.checkIfPasswordMatch(password)) { const customError = new CustomError(404, 'General', 'Not Found', ['Incorrect email or password']); return next(customError); } const jwtPayload: JwtPayload = { id: user.id, name: user.name, email: user.email, role: user.role as Role, created_at: user.created_at, }; try { const token = createJwtToken(jwtPayload); res.customSuccess(200, 'Token successfully created.', `Bearer ${token}`); } catch (err) { const customError = new CustomError(400, 'Raw', "Token can't be created", null, err); return next(customError); } } catch (err) { const customError = new CustomError(400, 'Raw', 'Error', null, err); return next(customError); } }; ================================================ FILE: src/controllers/auth/register.test.ts ================================================ import 'mocha'; import { expect } from 'chai'; import { agent as request } from 'supertest'; import { getRepository, Connection, Repository } from 'typeorm'; import { dbCreateConnection } from 'orm/dbCreateConnection'; import { User } from 'orm/entities/users/User'; import { app } from '../../'; describe('Register', () => { let dbConnection: Connection; let userRepository: Repository; const userPassword = 'pass1'; const user = new User(); user.email = 'brandon.mayhew@test.com'; user.password = userPassword; user.hashPassword(); before(async () => { dbConnection = await dbCreateConnection(); userRepository = getRepository(User); }); it('should register a new user', async () => { const res = await request(app) .post('/v1/auth/register') .send({ email: user.email, password: userPassword, passwordConfirm: userPassword }); expect(res.status).to.equal(200); expect(res.body.message).to.equal('User successfully created.'); expect(res.body.data).to.be.an('null'); await userRepository.delete({ email: user.email }); }); it('should report error when email already exists', async () => { let res = await request(app) .post('/v1/auth/register') .send({ email: user.email, password: userPassword, passwordConfirm: userPassword }); res = await request(app) .post('/v1/auth/register') .send({ email: user.email, password: userPassword, passwordConfirm: userPassword }); expect(res.status).to.equal(400); expect(res.body.errorType).to.equal('General'); expect(res.body.errorMessage).to.equal('User already exists'); expect(res.body.errors).to.eql([`Email '${user.email}' already exists`]); expect(res.body.errorRaw).to.an('null'); expect(res.body.errorsValidation).to.an('null'); await userRepository.delete({ email: user.email }); }); }); ================================================ FILE: src/controllers/auth/register.ts ================================================ import { Request, Response, NextFunction } from 'express'; import { getRepository } from 'typeorm'; import { User } from 'orm/entities/users/User'; import { CustomError } from 'utils/response/custom-error/CustomError'; export const register = async (req: Request, res: Response, next: NextFunction) => { const { email, password } = req.body; const userRepository = getRepository(User); try { const user = await userRepository.findOne({ where: { email } }); if (user) { const customError = new CustomError(400, 'General', 'User already exists', [ `Email '${user.email}' already exists`, ]); return next(customError); } try { const newUser = new User(); newUser.email = email; newUser.password = password; newUser.hashPassword(); await userRepository.save(newUser); res.customSuccess(200, 'User successfully created.'); } catch (err) { const customError = new CustomError(400, 'Raw', `User '${email}' can't be created`, null, err); return next(customError); } } catch (err) { const customError = new CustomError(400, 'Raw', 'Error', null, err); return next(customError); } }; ================================================ FILE: src/controllers/users/destroy.ts ================================================ import { Request, Response, NextFunction } from 'express'; import { getRepository } from 'typeorm'; import { User } from 'orm/entities/users/User'; import { CustomError } from 'utils/response/custom-error/CustomError'; export const destroy = async (req: Request, res: Response, next: NextFunction) => { const id = req.params.id; const userRepository = getRepository(User); try { const user = await userRepository.findOne({ where: { id } }); if (!user) { const customError = new CustomError(404, 'General', 'Not Found', [`User with id:${id} doesn't exists.`]); return next(customError); } userRepository.delete(id); res.customSuccess(200, 'User successfully deleted.', { id: user.id, name: user.name, email: user.email }); } catch (err) { const customError = new CustomError(400, 'Raw', 'Error', null, err); return next(customError); } }; ================================================ FILE: src/controllers/users/edit.ts ================================================ import { Request, Response, NextFunction } from 'express'; import { getRepository } from 'typeorm'; import { User } from 'orm/entities/users/User'; import { CustomError } from 'utils/response/custom-error/CustomError'; export const edit = async (req: Request, res: Response, next: NextFunction) => { const id = req.params.id; const { username, name } = req.body; const userRepository = getRepository(User); try { const user = await userRepository.findOne({ where: { id } }); if (!user) { const customError = new CustomError(404, 'General', `User with id:${id} not found.`, ['User not found.']); return next(customError); } user.username = username; user.name = name; try { await userRepository.save(user); res.customSuccess(200, 'User successfully saved.'); } catch (err) { const customError = new CustomError(409, 'Raw', `User '${user.email}' can't be saved.`, null, err); return next(customError); } } catch (err) { const customError = new CustomError(400, 'Raw', 'Error', null, err); return next(customError); } }; ================================================ FILE: src/controllers/users/index.test.ts ================================================ import 'mocha'; import { expect } from 'chai'; import { agent as request } from 'supertest'; import { getRepository, Connection, Repository } from 'typeorm'; import { dbCreateConnection } from 'orm/dbCreateConnection'; import { Role } from 'orm/entities/users/types'; import { User } from 'orm/entities/users/User'; import { app } from '../../'; describe('Users', () => { let dbConnection: Connection; let userRepository: Repository; const userPassword = 'pass1'; let adminUserToken = null; const adminUser = new User(); adminUser.username = 'Badger'; adminUser.name = 'Brandon Mayhew'; adminUser.email = 'brandon.mayhew@test.com'; adminUser.password = userPassword; adminUser.hashPassword(); adminUser.role = 'ADMINISTRATOR' as Role; let standardUserToken = null; const standardUser = new User(); standardUser.username = 'Toddy'; standardUser.name = 'Todd Alquist'; standardUser.email = 'todd.alquist@test.com'; standardUser.password = userPassword; standardUser.hashPassword(); standardUser.role = 'STANDARD' as Role; before(async () => { dbConnection = await dbCreateConnection(); userRepository = getRepository(User); }); beforeEach(async () => { await userRepository.save([adminUser, standardUser]); let res = await request(app).post('/v1/auth/login').send({ email: adminUser.email, password: userPassword }); adminUserToken = res.body.data; res = await request(app).post('/v1/auth/login').send({ email: standardUser.email, password: userPassword }); standardUserToken = res.body.data; }); afterEach(async () => { await userRepository.delete([adminUser.id, standardUser.id]); }); describe('GET /v1/auth/users', () => { it('should get all users', async () => { const res = await request(app).get('/v1/users').set('Authorization', adminUserToken); expect(res.status).to.equal(200); expect(res.body.message).to.equal('List of users.'); expect(res.body.data[3].email).to.eql('hank.schrader@test.com'); }); it('should report error of unauthorized user', async () => { const res = await request(app).get('/v1/users').set('Authorization', standardUserToken); expect(res.status).to.equal(401); expect(res.body.errorType).to.equal('Unauthorized'); expect(res.body.errorMessage).to.equal('Unauthorized - Insufficient user rights'); expect(res.body.errors).to.eql([ 'Unauthorized - Insufficient user rights', 'Current role: STANDARD. Required role: ADMINISTRATOR', ]); expect(res.body.errorRaw).to.an('null'); expect(res.body.errorsValidation).to.an('null'); }); }); describe('GET /v1/auth/users//:id([0-9]+)', () => { it('should get user', async () => { const user = await userRepository.findOne({ email: adminUser.email }); const res = await request(app).get(`/v1/users/${user.id}`).set('Authorization', adminUserToken); expect(res.status).to.equal(200); expect(res.body.message).to.equal('User found'); expect(res.body.data.email).to.eql(adminUser.email); }); }); }); ================================================ FILE: src/controllers/users/index.ts ================================================ export * from './destroy'; export * from './edit'; export * from './list'; export * from './show'; ================================================ FILE: src/controllers/users/list.ts ================================================ import { Request, Response, NextFunction } from 'express'; import { getRepository } from 'typeorm'; import { User } from 'orm/entities/users/User'; import { CustomError } from 'utils/response/custom-error/CustomError'; export const list = async (req: Request, res: Response, next: NextFunction) => { const userRepository = getRepository(User); try { const users = await userRepository.find({ select: ['id', 'username', 'name', 'email', 'role', 'language', 'created_at', 'updated_at'], }); res.customSuccess(200, 'List of users.', users); } catch (err) { const customError = new CustomError(400, 'Raw', `Can't retrieve list of users.`, null, err); return next(customError); } }; ================================================ FILE: src/controllers/users/show.ts ================================================ import { Request, Response, NextFunction } from 'express'; import { getRepository } from 'typeorm'; import { User } from 'orm/entities/users/User'; import { CustomError } from 'utils/response/custom-error/CustomError'; export const show = async (req: Request, res: Response, next: NextFunction) => { const id = req.params.id; const userRepository = getRepository(User); try { const user = await userRepository.findOne(id, { select: ['id', 'username', 'name', 'email', 'role', 'language', 'created_at', 'updated_at'], }); if (!user) { const customError = new CustomError(404, 'General', `User with id:${id} not found.`, ['User not found.']); return next(customError); } res.customSuccess(200, 'User found', user); } catch (err) { const customError = new CustomError(400, 'Raw', 'Error', null, err); return next(customError); } }; ================================================ FILE: src/index.ts ================================================ import 'dotenv/config'; import 'reflect-metadata'; import fs from 'fs'; import path from 'path'; import bodyParser from 'body-parser'; import cors from 'cors'; import express from 'express'; import helmet from 'helmet'; import morgan from 'morgan'; import './utils/response/customSuccess'; import { errorHandler } from './middleware/errorHandler'; import { getLanguage } from './middleware/getLanguage'; import { dbCreateConnection } from './orm/dbCreateConnection'; import routes from './routes'; export const app = express(); app.use(cors()); app.use(helmet()); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: false })); app.use(getLanguage); try { const accessLogStream = fs.createWriteStream(path.join(__dirname, '../log/access.log'), { flags: 'a', }); app.use(morgan('combined', { stream: accessLogStream })); } catch (err) { console.log(err); } app.use(morgan('combined')); app.use('/', routes); app.use(errorHandler); const port = process.env.PORT || 4000; app.listen(port, () => { console.log(`Server running on port ${port}`); }); (async () => { await dbCreateConnection(); })(); ================================================ FILE: src/middleware/checkJwt.ts ================================================ import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; import { JwtPayload } from '../types/JwtPayload'; import { createJwtToken } from '../utils/createJwtToken'; import { CustomError } from '../utils/response/custom-error/CustomError'; export const checkJwt = (req: Request, res: Response, next: NextFunction) => { const authHeader = req.get('Authorization'); if (!authHeader) { const customError = new CustomError(400, 'General', 'Authorization header not provided'); return next(customError); } const token = authHeader.split(' ')[1]; let jwtPayload: { [key: string]: any }; try { jwtPayload = jwt.verify(token, process.env.JWT_SECRET as string) as { [key: string]: any }; ['iat', 'exp'].forEach((keyToRemove) => delete jwtPayload[keyToRemove]); req.jwtPayload = jwtPayload as JwtPayload; } catch (err) { const customError = new CustomError(401, 'Raw', 'JWT error', null, err); return next(customError); } try { // Refresh and send a new token on every request const newToken = createJwtToken(jwtPayload as JwtPayload); res.setHeader('token', `Bearer ${newToken}`); return next(); } catch (err) { const customError = new CustomError(400, 'Raw', "Token can't be created", null, err); return next(customError); } }; ================================================ FILE: src/middleware/checkRole.ts ================================================ import { Request, Response, NextFunction } from 'express'; import { Role } from '../orm/entities/users/types'; import { CustomError } from '../utils/response/custom-error/CustomError'; export const checkRole = (roles: Role[], isSelfAllowed = false) => { return async (req: Request, res: Response, next: NextFunction) => { const { id, role } = req.jwtPayload; const { id: requestId } = req.params; let errorSelfAllowed: string | null = null; if (isSelfAllowed) { if (id === parseInt(requestId)) { return next(); } errorSelfAllowed = 'Self allowed action.'; } if (roles.indexOf(role) === -1) { const errors = [ 'Unauthorized - Insufficient user rights', `Current role: ${role}. Required role: ${roles.toString()}`, ]; if (errorSelfAllowed) { errors.push(errorSelfAllowed); } const customError = new CustomError(401, 'Unauthorized', 'Unauthorized - Insufficient user rights', errors); return next(customError); } return next(); }; }; ================================================ FILE: src/middleware/errorHandler.ts ================================================ import { Request, Response, NextFunction } from 'express'; import { CustomError } from '../utils/response/custom-error/CustomError'; export const errorHandler = (err: CustomError, req: Request, res: Response, next: NextFunction) => { return res.status(err.HttpStatusCode).json(err.JSON); }; ================================================ FILE: src/middleware/getLanguage.ts ================================================ import { Request, Response, NextFunction } from 'express'; import { Language } from '../orm/entities/users/types'; export const getLanguage = (req: Request, res: Response, next: NextFunction) => { const acceptLanguageHeader = req.get('Accept-Language') as Language | null; if (!acceptLanguageHeader) { req.language = 'en-US'; return next(); } req.language = acceptLanguageHeader; return next(); }; ================================================ FILE: src/middleware/validation/auth/index.ts ================================================ export * from './validatorChangePassword'; export * from './validatorLogin'; export * from './validatorRegister'; ================================================ FILE: src/middleware/validation/auth/validatorChangePassword.ts ================================================ import { Request, Response, NextFunction } from 'express'; import validator from 'validator'; import { ConstsUser } from 'consts/ConstsUser'; import { CustomError } from 'utils/response/custom-error/CustomError'; import { ErrorValidation } from 'utils/response/custom-error/types'; export const validatorChangePassword = (req: Request, res: Response, next: NextFunction) => { let { password, passwordNew, passwordConfirm } = req.body; const errorsValidation: ErrorValidation[] = []; password = !password ? '' : password; passwordNew = !passwordNew ? '' : passwordNew; passwordConfirm = !passwordConfirm ? '' : passwordConfirm; if (validator.isEmpty(password)) { errorsValidation.push({ password: 'Password is required' }); } if (validator.isEmpty(passwordNew)) { errorsValidation.push({ passwordNew: 'New password is required' }); } if (validator.isEmpty(passwordConfirm)) { errorsValidation.push({ passwordConfirm: 'Password confirm is required' }); } if (!validator.isLength(passwordNew, { min: ConstsUser.PASSWORD_MIN_CHAR })) { errorsValidation.push({ passwordNew: `Password must be at least ${ConstsUser.PASSWORD_MIN_CHAR} characters`, }); } if (!validator.equals(passwordNew, passwordConfirm)) { errorsValidation.push({ passwordConfirm: 'Passwords must match' }); } if (errorsValidation.length !== 0) { const customError = new CustomError( 400, 'Validation', 'Change password validation error', null, null, errorsValidation, ); return next(customError); } return next(); }; ================================================ FILE: src/middleware/validation/auth/validatorLogin.ts ================================================ import { Request, Response, NextFunction } from 'express'; import validator from 'validator'; import { CustomError } from 'utils/response/custom-error/CustomError'; import { ErrorValidation } from 'utils/response/custom-error/types'; export const validatorLogin = (req: Request, res: Response, next: NextFunction) => { let { email, password } = req.body; const errorsValidation: ErrorValidation[] = []; email = !email ? '' : email; password = !password ? '' : password; if (!validator.isEmail(email)) { errorsValidation.push({ email: 'Email is invalid' }); } if (validator.isEmpty(email)) { errorsValidation.push({ email: 'Email field is required' }); } if (validator.isEmpty(password)) { errorsValidation.push({ password: 'Password field is required' }); } if (errorsValidation.length !== 0) { const customError = new CustomError(400, 'Validation', 'Login validation error', null, null, errorsValidation); return next(customError); } return next(); }; ================================================ FILE: src/middleware/validation/auth/validatorRegister.ts ================================================ import { Request, Response, NextFunction } from 'express'; import validator from 'validator'; import { ConstsUser } from 'consts/ConstsUser'; import { CustomError } from 'utils/response/custom-error/CustomError'; import { ErrorValidation } from 'utils/response/custom-error/types'; export const validatorRegister = (req: Request, res: Response, next: NextFunction) => { let { email, password, passwordConfirm } = req.body; const errorsValidation: ErrorValidation[] = []; email = !email ? '' : email; password = !password ? '' : password; passwordConfirm = !passwordConfirm ? '' : passwordConfirm; if (!validator.isEmail(email)) { errorsValidation.push({ email: 'Email is invalid' }); } if (validator.isEmpty(email)) { errorsValidation.push({ email: 'Email is required' }); } if (validator.isEmpty(password)) { errorsValidation.push({ password: 'Password is required' }); } if (!validator.isLength(password, { min: ConstsUser.PASSWORD_MIN_CHAR })) { errorsValidation.push({ password: `Password must be at least ${ConstsUser.PASSWORD_MIN_CHAR} characters`, }); } if (validator.isEmpty(passwordConfirm)) { errorsValidation.push({ passwordConfirm: 'Confirm password is required' }); } if (!validator.equals(password, passwordConfirm)) { errorsValidation.push({ passwordConfirm: 'Passwords must match' }); } if (errorsValidation.length !== 0) { const customError = new CustomError(400, 'Validation', 'Register validation error', null, null, errorsValidation); return next(customError); } return next(); }; ================================================ FILE: src/middleware/validation/users/index.ts ================================================ export * from './validatorEdit'; ================================================ FILE: src/middleware/validation/users/validatorEdit.ts ================================================ import { Request, Response, NextFunction } from 'express'; import { getRepository } from 'typeorm'; import { User } from 'orm/entities/users/User'; import { CustomError } from 'utils/response/custom-error/CustomError'; import { ErrorValidation } from 'utils/response/custom-error/types'; export const validatorEdit = async (req: Request, res: Response, next: NextFunction) => { let { username, name } = req.body; const errorsValidation: ErrorValidation[] = []; const userRepository = getRepository(User); username = !username ? '' : username; name = !name ? '' : name; const user = await userRepository.findOne({ username }); if (user) { errorsValidation.push({ username: `Username '${username}' already exists` }); } if (errorsValidation.length !== 0) { const customError = new CustomError(400, 'Validation', 'Edit user validation error', null, null, errorsValidation); return next(customError); } return next(); }; ================================================ FILE: src/orm/config/ormconfig-seed.ts ================================================ import { ConnectionOptions } from 'typeorm'; import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; const configSeed: ConnectionOptions = { type: 'postgres', host: process.env.PG_HOST, port: Number(process.env.PG_PORT), username: process.env.POSTGRES_USER, password: process.env.POSTGRES_PASSWORD, database: process.env.POSTGRES_DB, synchronize: false, logging: false, entities: ['src/orm/entities/**/*.ts'], migrations: ['src/orm/seeds/**/*.ts'], cli: { migrationsDir: 'src/orm/seeds', }, namingStrategy: new SnakeNamingStrategy(), }; export = configSeed; ================================================ FILE: src/orm/config/ormconfig.ts ================================================ import { ConnectionOptions } from 'typeorm'; import { SnakeNamingStrategy } from 'typeorm-naming-strategies'; const config: ConnectionOptions = { type: 'postgres', name: 'default', host: process.env.PG_HOST, port: Number(process.env.PG_PORT), username: process.env.POSTGRES_USER, password: process.env.POSTGRES_PASSWORD, database: process.env.POSTGRES_DB, synchronize: false, logging: false, entities: ['src/orm/entities/**/*.ts'], migrations: ['src/orm/migrations/**/*.ts'], subscribers: ['src/orm/subscriber/**/*.ts'], cli: { entitiesDir: 'src/orm/entities', migrationsDir: 'src/orm/migrations', subscribersDir: 'src/orm/subscriber', }, namingStrategy: new SnakeNamingStrategy(), }; export = config; ================================================ FILE: src/orm/dbCreateConnection.ts ================================================ import { Connection, createConnection, getConnectionManager } from 'typeorm'; import config from './config/ormconfig'; export const dbCreateConnection = async (): Promise => { try { const conn = await createConnection(config); console.log(`Database connection success. Connection name: '${conn.name}' Database: '${conn.options.database}'`); } catch (err) { if (err.name === 'AlreadyHasActiveConnectionError') { const activeConnection = getConnectionManager().get(config.name); return activeConnection; } console.log(err); } return null; }; ================================================ FILE: src/orm/entities/users/User.ts ================================================ import bcrypt from 'bcryptjs'; import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; import { Role, Language } from './types'; @Entity('users') export class User { @PrimaryGeneratedColumn() id: number; @Column({ unique: true, }) email: string; @Column() password: string; @Column({ nullable: true, unique: true, }) username: string; @Column({ nullable: true, }) name: string; @Column({ default: 'STANDARD' as Role, length: 30, }) role: string; @Column({ default: 'en-US' as Language, length: 15, }) language: string; @Column() @CreateDateColumn() created_at: Date; @Column() @UpdateDateColumn() updated_at: Date; setLanguage(language: Language) { this.language = language; } hashPassword() { this.password = bcrypt.hashSync(this.password, 8); } checkIfPasswordMatch(unencryptedPassword: string) { return bcrypt.compareSync(unencryptedPassword, this.password); } } ================================================ FILE: src/orm/entities/users/types.ts ================================================ export type Role = 'ADMINISTRATOR' | 'STANDARD'; export type Language = 'en-US' | 'sl-SI'; ================================================ FILE: src/orm/migrations/1590521920166-CreateUsers.ts ================================================ import { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateUsers1590521920166 implements MigrationInterface { name = 'CreateUsers1590521920166'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "users" ("id" SERIAL NOT NULL, "username" character varying(40), "name" character varying(40), "email" character varying(100) NOT NULL, "password" character varying NOT NULL, "role" character varying(30) NOT NULL DEFAULT 'STANDARD', "language" character varying(15) NOT NULL DEFAULT 'en-US', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_fe0bb3f6520ee0469504521e710" UNIQUE ("username"), CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email"), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`, undefined, ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP TABLE "users"`, undefined); } } ================================================ FILE: src/orm/seeds/1590519635401-SeedUsers.ts ================================================ import { MigrationInterface, QueryRunner, getRepository } from 'typeorm'; import { Role } from '../entities/users/types'; import { User } from '../entities/users/User'; export class SeedUsers1590519635401 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { let user = new User(); const userRepository = getRepository(User); user.username = 'Heisenberg'; user.name = 'Walter White'; user.email = 'admin@admin.com'; user.password = 'pass1'; user.hashPassword(); user.role = 'ADMINISTRATOR' as Role; await userRepository.save(user); user = new User(); user.username = 'Jesse'; user.name = 'Jesse Pinkman'; user.email = 'standard@standard.com'; user.password = 'pass1'; user.hashPassword(); user.role = 'STANDARD' as Role; await userRepository.save(user); user = new User(); user.username = 'Sky'; user.name = 'Skyler White'; user.email = 'skyler.white@test.com'; user.password = 'pass1'; user.hashPassword(); await userRepository.save(user); user = new User(); user.username = 'Hank'; user.name = 'Hank Schrader'; user.email = 'hank.schrader@test.com'; user.password = 'pass1'; user.hashPassword(); await userRepository.save(user); user = new User(); user.username = 'Marie'; user.name = 'Marie Schrader'; user.email = 'marie.schrader@test.com'; user.password = 'pass1'; user.hashPassword(); await userRepository.save(user); user = new User(); user.username = 'The Lawyer'; user.name = 'Saul Goodman'; user.email = 'saul.goodman@test.com'; user.password = 'pass1'; user.hashPassword(); await userRepository.save(user); user = new User(); user.username = 'Gus'; user.name = 'Gustavo Fring'; user.email = 'gustavo.fring@test.com'; user.password = 'pass1'; user.hashPassword(); await userRepository.save(user); user = new User(); user.username = 'Mike'; user.name = 'Michael Ehrmantraut'; user.email = 'michael.ehrmantraut@test.com'; user.password = 'pass1'; user.hashPassword(); await userRepository.save(user); user = new User(); user.username = 'Tio'; user.name = 'Hector Salamanca'; user.email = 'hector.salamanca@test.com'; user.password = 'pass1'; user.hashPassword(); await userRepository.save(user); user = new User(); user.username = 'Tuco'; user.name = 'Alberto Salamanca'; user.email = 'alberto.salamanca@test.com'; user.password = 'pass1'; user.hashPassword(); await userRepository.save(user); } public async down(queryRunner: QueryRunner): Promise { console.log('Not implemented'); } } ================================================ FILE: src/routes/index.ts ================================================ import { Router } from 'express'; import page404 from './pages/404'; import pageRoot from './pages/root'; import v1 from './v1/'; const router = Router(); router.use(`/v1`, v1); router.use(pageRoot); router.use(page404); export default router; ================================================ FILE: src/routes/pages/404.ts ================================================ import { Router } from 'express'; const router = Router(); router.get('*', (req, res, next) => { return res.status(404).json('404 Not Found'); }); export default router; ================================================ FILE: src/routes/pages/root.ts ================================================ import { Router } from 'express'; const router = Router(); router.get('/', (req, res, next) => { res.status(200).header('Content-Type', 'text/html').send(`

💊 RESTful API boilerplate

`); }); export default router; ================================================ FILE: src/routes/v1/auth.ts ================================================ import { Router } from 'express'; import { login, register, changePassword } from 'controllers/auth'; import { checkJwt } from 'middleware/checkJwt'; import { validatorLogin, validatorRegister, validatorChangePassword } from 'middleware/validation/auth'; const router = Router(); router.post('/login', [validatorLogin], login); router.post('/register', [validatorRegister], register); router.post('/change-password', [checkJwt, validatorChangePassword], changePassword); export default router; ================================================ FILE: src/routes/v1/index.ts ================================================ import { Router } from 'express'; import auth from './auth'; import users from './users'; const router = Router(); router.use('/auth', auth); router.use('/users', users); export default router; ================================================ FILE: src/routes/v1/users.ts ================================================ import { Router } from 'express'; import { list, show, edit, destroy } from 'controllers/users'; import { checkJwt } from 'middleware/checkJwt'; import { checkRole } from 'middleware/checkRole'; import { validatorEdit } from 'middleware/validation/users'; const router = Router(); router.get('/', [checkJwt, checkRole(['ADMINISTRATOR'])], list); router.get('/:id([0-9]+)', [checkJwt, checkRole(['ADMINISTRATOR'], true)], show); router.patch('/:id([0-9]+)', [checkJwt, checkRole(['ADMINISTRATOR'], true), validatorEdit], edit); router.delete('/:id([0-9]+)', [checkJwt, checkRole(['ADMINISTRATOR'], true)], destroy); export default router; ================================================ FILE: src/types/JwtPayload.ts ================================================ import { Role } from '../orm/entities/users/types'; export type JwtPayload = { id: number; name: string; email: string; role: Role; created_at: Date; }; ================================================ FILE: src/types/ProcessEnv.d.ts ================================================ declare namespace NodeJS { export interface ProcessEnv { PORT: string; NODE_ENV: string; PG_HOST: string; PG_PORT: string; POSTGRES_USER: string; POSTGRES_PASSWORD: string; POSTGRES_DB: string; JWT_SECRET: string; JWT_EXPIRATION: string; } } ================================================ FILE: src/types/express/index.d.ts ================================================ import { Language } from 'orm/entities/users/types'; import { JwtPayload } from '../JwtPayload'; declare global { namespace Express { export interface Request { jwtPayload: JwtPayload; language: Language; } export interface Response { customSuccess(httpStatusCode: number, message: string, data?: any): Response; } } } ================================================ FILE: src/utils/createJwtToken.ts ================================================ import jwt from 'jsonwebtoken'; import { JwtPayload } from '../types/JwtPayload'; export const createJwtToken = (payload: JwtPayload): string => { return jwt.sign(payload, process.env.JWT_SECRET!, { expiresIn: process.env.JWT_EXPIRATION, }); }; ================================================ FILE: src/utils/response/custom-error/CustomError.ts ================================================ import { ErrorType, ErrorValidation, ErrorResponse } from './types'; export class CustomError extends Error { private httpStatusCode: number; private errorType: ErrorType; private errors: string[] | null; private errorRaw: any; private errorsValidation: ErrorValidation[] | null; constructor( httpStatusCode: number, errorType: ErrorType, message: string, errors: string[] | null = null, errorRaw: any = null, errorsValidation: ErrorValidation[] | null = null, ) { super(message); this.name = this.constructor.name; this.httpStatusCode = httpStatusCode; this.errorType = errorType; this.errors = errors; this.errorRaw = errorRaw; this.errorsValidation = errorsValidation; } get HttpStatusCode() { return this.httpStatusCode; } get JSON(): ErrorResponse { return { errorType: this.errorType, errorMessage: this.message, errors: this.errors, errorRaw: this.errorRaw, errorsValidation: this.errorsValidation, stack: this.stack, }; } } ================================================ FILE: src/utils/response/custom-error/types.ts ================================================ export type ErrorResponse = { errorType: ErrorType; errorMessage: string; errors: string[] | null; errorRaw: any; errorsValidation: ErrorValidation[] | null; stack?: string; }; export type ErrorType = 'General' | 'Raw' | 'Validation' | 'Unauthorized'; export type ErrorValidation = { [key: string]: string }; ================================================ FILE: src/utils/response/customSuccess.ts ================================================ import { response, Response } from 'express'; response.customSuccess = function (httpStatusCode: number, message: string, data: any = null): Response { return this.status(httpStatusCode).json({ message, data }); }; ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "es6", "module": "commonjs", "lib": ["dom", "es6", "es2017", "esnext.asynciterable"], "sourceMap": true, "outDir": "./dist", "moduleResolution": "node", "removeComments": true, "allowSyntheticDefaultImports": true, "esModuleInterop": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "resolveJsonModule": true, "typeRoots": ["./src/types", "./node_modules/@types"], "baseUrl": "src/", "noImplicitReturns": true, "noFallthroughCasesInSwitch": true }, "include": ["./src/**/*.tsx", "./src/**/*.ts"], "exclude": ["node_modules", "test/**/*.ts"] }