├── .circleci └── config.yml ├── .editorconfig ├── .gitignore ├── LICENSE ├── README.md ├── apiary.apib ├── jest.config.js ├── package.json ├── public └── .gitkeep ├── src ├── app.ts ├── constants │ └── constants.ts ├── controllers │ └── api │ │ └── v1 │ │ ├── admin │ │ └── admin.controller.ts │ │ ├── authentication │ │ └── authentication.controller.ts │ │ ├── home │ │ └── home.controller.ts │ │ ├── index.ts │ │ └── user │ │ └── user.controller.ts ├── middleware │ ├── admin.middleware.ts │ ├── authorization.middleware.ts │ └── exception.middleware.ts ├── migration │ └── .gitkeep ├── models │ ├── index.ts │ └── user.ts ├── server.ts ├── subscriber │ └── .gitkeep └── utils │ └── ModelValidation.ts ├── tests ├── helpers │ └── index.ts ├── integration │ ├── admin.test.ts │ ├── authentication.test.ts │ ├── home.test.ts │ └── user.test.ts └── unit │ └── models │ └── user.test.ts ├── tsconfig.json ├── tslint.json ├── views └── index.pug ├── webpack.config.js └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:10.3 6 | 7 | steps: 8 | - checkout 9 | - run: echo 'export PATH=${HOME}/${CIRCLE_PROJECT_REPONAME}/node_modules/.bin:$PATH' >> $BASH_ENV 10 | - run: 11 | name: Install dependencies 12 | command: yarn 13 | - run: 14 | name: Run tests 15 | command: yarn test /tests/unit 16 | - run: 17 | name: Run linter 18 | command: yarn lint 19 | 20 | - save_cache: 21 | key: dependency-cache 22 | paths: 23 | - ~/.cache/yarn 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # node-waf configuration 17 | .lock-wscript 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | node_modules 24 | 25 | #dist folder 26 | dist 27 | 28 | #Webstorm metadata 29 | .idea 30 | 31 | #VSCode metadata 32 | .vscode 33 | 34 | # Mac files 35 | .DS_Store 36 | 37 | # Environment variables 38 | *.env 39 | .env 40 | .env.dev 41 | .env.staging 42 | .env.prod 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Kevin Quincke 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node API Base 2 | 3 | 4 | [![CircleCI](https://circleci.com/gh/kevquincke/node-api-base.svg?style=svg)](https://circleci.com/gh/kevquincke/node-api-base) 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/626847c10fc40fbb6c04/maintainability)](https://codeclimate.com/github/kevquincke/node-api-base/maintainability) 6 | 7 | Node Api Base is a boilerplate project for JSON RESTful APIs. 8 | It's based on Node v10.3.0 and Typescript v3.0.3. 9 | 10 | ## Features 11 | 12 | This template comes with: 13 | 14 | - Schema 15 | - Users table with roles (Admin and Regular by default) 16 | - Endpoints 17 | - Sign up regular user 18 | - Sign up admin user with authentication and authorization 19 | - Authentication for both kinds of users 20 | - Middleware 21 | - Authentication 22 | - Authorization (based on roles) 23 | - Exception handling 24 | - Tests 25 | - Unit tests for user 26 | - Integration tests for API 27 | - Code quality tools 28 | - API documentation following https://apiblueprint.org/ 29 | 30 | ## How to use 31 | 32 | 1. Clone this repo 33 | 2. Rename the folder and change `name` in `package.json` to the project name 34 | 3. Create an `.env` file 35 | 4. In the `.env` file set the following values: 36 | ``` 37 | JWT_KEY = secret -> a secret value for json web token hashing 38 | DATABASE_URL = database url with format postgres://user:secret@host:port/database_name 39 | ``` 40 | If you want to use another database instead of using postgres you'll need to change the `app.ts` database connection 41 | method and also set the database url to an accordingly format 42 | 5. Run `yarn` 43 | 6. Run `yarn run dev` to run on development 44 | 7. You can now try your REST services! 45 | 46 | **Note:** when creating an entity it must be exported in `models/index.ts` in order to be used. 47 | **Tests**: To run the tests after step 5, run `yarn run test` (make sure to point to a test database on `DATABASE_URL`) 48 | 49 | ## Deploying to Heroku 50 | 51 | 1. Run heroku create appName on the repo 52 | 2. Add Heroku Postgres add-on (or whatever database you're using) 53 | 3. Set environment values in heroku settings, as shown in the previous section (`DATABASE_URL` is probably already set) 54 | 4. Run `git push heroku branch` (whatever branch you want to push) 55 | 56 | ## NPM Packages 57 | 58 | 1. [body-parser](https://www.npmjs.com/package/body-parser) Node.js body parsing middleware 59 | 2. [class-validator](https://www.npmjs.com/package/class-validator) Validate incoming data 60 | 3. [dotenv](https://www.npmjs.com/package/dotenv) Loads environment variables from a `.env` file to `process.env` 61 | 4. [express](https://www.npmjs.com/package/express) Fast, unopinionated, minimalist web framework for NodeJS 62 | 5. [lodash](https://www.npmjs.com/package/lodash) The Lodash library exported as Node.js modules 63 | 6. [pg](https://www.npmjs.com/package/pg) Non-blocking PostgreSQL client for NodeJS 64 | 7. [pug](https://www.npmjs.com/package/pug) High performance template engine 65 | 8. [reflect-metadata](https://www.npmjs.com/package/reflect-metadata) Runtime reflection on types 66 | 9. [typeorm](https://www.npmjs.com/package/typeorm) ORM that can run in NodeJS 67 | 11. [ts-node](https://www.npmjs.com/package/ts-node) TypeScript execution and REPL for NodeJS 68 | 12. [tslint](https://www.npmjs.com/package/tslint) Extensible static analysis tool for TypeScript 69 | 13. [typescript](https://www.npmjs.com/package/typescript) Language for application-scale JavaScript 70 | 14. [bcrypt](https://www.npmjs.com/package/bcrypt) Lib to help to hash passwords 71 | 15. [express-async-errors](https://www.npmjs.com/package/express-async-errors) Simple ES6 async/await support hack for ExpressJS 72 | 16. [winston](https://www.npmjs.com/package/winston) A logger for just about everything 73 | 17. [jsonwebtoken](https://www.npmjs.com/package/jsonwebtoken) An implementation of JSON Web Tokens 74 | 18. [jest](https://www.npmjs.com/package/jest) Delightful JavaScript Testing 75 | 19. [ts-jest](https://www.npmjs.com/package/ts-jest) TypeScript preprocessor with source map support for Jest 76 | 20. [supertest](https://www.npmjs.com/package/supertest) HTTP assertions made easy via superagent 77 | 21. [tslint-eslint-rules](https://www.npmjs.com/package/tslint-eslint-rules) TypeScript rules available in ESLint 78 | 79 | ## Api Docs 80 | 81 | https://nodeapibase.docs.apiary.io 82 | 83 | ## Current version 84 | **v1.0.0** 85 | 86 | 87 | -------------------------------------------------------------------------------- /apiary.apib: -------------------------------------------------------------------------------- 1 | FORMAT: 1A 2 | HOST: http://localhost:3000 3 | 4 | # Node API Base 5 | 6 | API boilerplate project for NodeJS with Typescript 7 | 8 | ## Server [/status] 9 | 10 | ### Server status [GET] 11 | 12 | Check if the server is online 13 | 14 | + Response 200 (application/json) 15 | 16 | + Body 17 | 18 | { 19 | "online": true 20 | } 21 | 22 | ## User Collection [/user] 23 | 24 | ### Create User [POST] 25 | 26 | Create a new user in the app to authenticate afterwards 27 | 28 | + Request (application/json) 29 | 30 | { 31 | "email": "mail@example.com", 32 | "password": "shhhh" 33 | } 34 | 35 | + Response 200 (application/json) 36 | 37 | + Headers 38 | 39 | token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjNmMGU2N2NlLWFlOWItNDIwOC1hOGI0LWY1NGIyNmE0OWQxZiIsInJvbGUiOiJSZWd1bGFyIiwiaWF0IjoxNTM3MzEyMDExfQ.fMrmKpkUpc3mf-Ol7j-kwL99mX74L27oP7FZApTGxSU 40 | 41 | + Body 42 | 43 | { 44 | "id": "3f0e67ce-ae9b-4208-a8b4-f54b26a49d1f", 45 | "email": "mail@example.com" 46 | } 47 | 48 | ## Admin Collection [/admin] 49 | 50 | ### Create Admin [POST] 51 | 52 | Create a new admin in the app to manage the app. An admin 53 | can only be created by other admin (authentication and authorization 54 | required for this operation) 55 | 56 | + Request (application/json) 57 | 58 | { 59 | "email": "mail@example.com", 60 | "password": "shhhh" 61 | } 62 | 63 | + Response 200 (application/json) 64 | 65 | + Headers 66 | 67 | token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjNmMGU2N2NlLWFlOWItNDIwOC1hOGI0LWY1NGIyNmE0OWQxZiIsInJvbGUiOiJSZWd1bGFyIiwiaWF0IjoxNTM3MzEyMDExfQ.fMrmKpkUpc3mf-Ol7j-kwL99mX74L27oP7FZApTGxSU 68 | 69 | + Body 70 | 71 | { 72 | "id": "3f0e67ce-ae9b-4208-a8b4-f54b26a49d1f", 73 | "email": "mail@example.com" 74 | } 75 | 76 | 77 | ## Authorization [/auth] 78 | 79 | ### Login [POST] 80 | 81 | Authenticate in the app 82 | 83 | + Request (application/json) 84 | 85 | { 86 | "email": "mail@example.com", 87 | "password": "shhhh" 88 | } 89 | 90 | + Response 204 (application/json) 91 | 92 | + Headers 93 | 94 | token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjNmMGU2N2NlLWFlOWItNDIwOC1hOGI0LWY1NGIyNmE0OWQxZiIsInJvbGUiOiJSZWd1bGFyIiwiaWF0IjoxNTM3MzEyMDExfQ.fMrmKpkUpc3mf-Ol7j-kwL99mX74L27oP7FZApTGxSU -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "roots": [ 3 | "" 4 | ], 5 | "transform": { 6 | "^.+\\.tsx?$": "ts-jest" 7 | }, 8 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 9 | "moduleFileExtensions": [ 10 | "ts", 11 | "js", 12 | "json", 13 | "node" 14 | ], 15 | "moduleNameMapper": { 16 | "^constants/(.*)$": "/src/constants/$1", 17 | "^controllers/(.*)$": "/src/controllers/$1", 18 | "^middleware/(.*)$": "/src/middleware/$1", 19 | "^migration/(.*)$": "/src/migration/$1", 20 | "^models/(.*)$": "/src/models/$1", 21 | "^subscriber/(.*)$": "/src/subscriber/$1", 22 | "^utils/(.*)$": "/src/utils/$1", 23 | "^src/(.*)$": "/src/$1" 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-api-base", 3 | "version": "1.1.0", 4 | "description": "API boilerplate project for NodeJS with Typescript", 5 | "main": "dist/src/server.js", 6 | "engines": { 7 | "node": "10.3.0" 8 | }, 9 | "scripts": { 10 | "build": "rm -rf dist/ && webpack", 11 | "dev": "npm-run-all --parallel watch:server watch:build", 12 | "watch:server": "nodemon \"./dist/bundle.js\" --watch \"./build\" ", 13 | "watch:build": "webpack --watch", 14 | "start": "node dist/bundle.js", 15 | "postinstall": "npm run build", 16 | "test": "jest --verbose --coverage", 17 | "lint": "tslint -c tslint.json 'src/**/*.ts'" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://kevquincke@github.com/kevquincke/node-api-base.git" 22 | }, 23 | "keywords": [ 24 | "nodejs", 25 | "typescript", 26 | "boilerplate", 27 | "api", 28 | "base", 29 | "starter", 30 | "kit" 31 | ], 32 | "author": "Kevin Quincke", 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/kevquincke/node-api-base/issues" 36 | }, 37 | "homepage": "https://github.com/kevquincke/node-api-base#readme", 38 | "devDependencies": { 39 | "@types/bcrypt": "^2.0.0", 40 | "@types/body-parser": "^1.17.0", 41 | "@types/compression": "^0.0.36", 42 | "@types/dotenv": "^4.0.3", 43 | "@types/express": "^4.16.0", 44 | "@types/helmet": "^0.0.42", 45 | "@types/jest": "^23.3.2", 46 | "@types/jsonwebtoken": "^7.2.8", 47 | "@types/lodash": "^4.14.116", 48 | "@types/node": "^10.9.4", 49 | "@types/supertest": "^2.0.6", 50 | "@types/winston": "^2.4.4", 51 | "jest": "^23.5.0", 52 | "npm-run-all": "^4.1.3", 53 | "supertest": "^3.3.0", 54 | "ts-jest": "^23.1.4", 55 | "ts-loader": "^5.2.1", 56 | "ts-node": "^7.0.1", 57 | "tsconfig-paths": "^3.6.0", 58 | "tslint": "^5.11.0", 59 | "tslint-eslint-rules": "^5.4.0", 60 | "typescript": "^3.0.3", 61 | "webpack": "^4.20.2", 62 | "webpack-cli": "^3.1.1", 63 | "webpack-node-externals": "^1.7.2" 64 | }, 65 | "dependencies": { 66 | "bcrypt": "^3.0.0", 67 | "body-parser": "^1.18.3", 68 | "class-validator": "^0.9.1", 69 | "compression": "^1.7.3", 70 | "dotenv": "^6.0.0", 71 | "express": "^4.16.3", 72 | "express-async-errors": "^3.0.0", 73 | "helmet": "^3.13.0", 74 | "jsonwebtoken": "^8.3.0", 75 | "lodash": "^4.17.10", 76 | "pg": "^7.4.3", 77 | "pug": "^2.0.3", 78 | "reflect-metadata": "^0.1.12", 79 | "typeorm": "^0.2.7", 80 | "winston": "^3.1.0" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevquincke/node-api-base/e10b3d2d34e0bfe23aacb3fd74e01d1f5646c0c7/public/.gitkeep -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import dotenv from 'dotenv'; 3 | import bodyParser from 'body-parser'; 4 | import winston from 'winston'; 5 | import helmet from 'helmet'; 6 | import compression from 'compression'; 7 | import { createConnection } from 'typeorm'; 8 | import 'reflect-metadata'; 9 | import 'express-async-errors'; 10 | 11 | import { v1 } from 'controllers/api/v1'; 12 | import { exceptionMiddleware } from 'middleware/exception.middleware'; 13 | import entities from 'models/index'; 14 | 15 | class App { 16 | public server: express.Application; 17 | 18 | constructor() { 19 | this.server = express(); 20 | 21 | this.configureEnvironment(); 22 | this.configureLogging(); 23 | this.configureMiddleware(); 24 | this.configureRoutes(); 25 | this.configureViewEngine(); 26 | } 27 | 28 | public async connectToDatabase() { 29 | await createConnection({ 30 | type: 'postgres', 31 | url: process.env.DATABASE_URL, 32 | synchronize: true, 33 | logging: false, 34 | entities, 35 | }); 36 | } 37 | 38 | private configureViewEngine() { 39 | this.server.set('view engine', 'pug'); 40 | } 41 | 42 | private configureLogging() { 43 | winston.add(new winston.transports.File({ filename: 'logfile.log' })); 44 | winston.exceptions.handle( 45 | new winston.transports.File({ filename: 'uncaughtExceptions.log' }) 46 | ); 47 | 48 | process.on('unhandledRejection', (ex) => { 49 | winston.error(ex.message, ex); 50 | throw ex; 51 | }); 52 | } 53 | 54 | private configureMiddleware() { 55 | this.server.use(bodyParser.json()); 56 | this.server.use(bodyParser.urlencoded({ extended: false })); 57 | this.server.use(express.static('public')); 58 | this.server.use(helmet()); 59 | this.server.use(compression()); 60 | } 61 | 62 | private configureRoutes() { 63 | this.server.use('/api/v1', v1); 64 | this.server.use(exceptionMiddleware); 65 | } 66 | 67 | private configureEnvironment() { 68 | dotenv.config(); 69 | 70 | if (!process.env.JWT_KEY) { 71 | throw new Error('FATAL ERROR: JWT_KEY is not defined!'); 72 | } 73 | } 74 | } 75 | 76 | export default App; 77 | -------------------------------------------------------------------------------- /src/constants/constants.ts: -------------------------------------------------------------------------------- 1 | export const AUTH_HEADER: string = 'token'; 2 | -------------------------------------------------------------------------------- /src/controllers/api/v1/admin/admin.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Router } from 'express'; 2 | import { validate } from 'class-validator'; 3 | import * as _ from 'lodash'; 4 | 5 | import { authMiddleware } from 'middleware/authorization.middleware'; 6 | import { adminMiddleware } from 'middleware/admin.middleware'; 7 | import { User, UserRole } from 'models/user'; 8 | import { getValidationErrors } from 'utils/ModelValidation'; 9 | import { AUTH_HEADER } from 'constants/constants'; 10 | 11 | const router: Router = Router(); 12 | 13 | router.post('/', [authMiddleware, adminMiddleware], async (req: Request, res: Response) => { 14 | let user: User = await User.findOne({ email: req.body.email }); 15 | 16 | if (user) { 17 | return res.status(400).send('Email already in use'); 18 | } 19 | 20 | user = new User(req.body); 21 | user.role = UserRole.Admin; 22 | 23 | const errors = await validate(user); 24 | 25 | if (!_.isEmpty(errors)) { 26 | return res.status(400).send(getValidationErrors(errors)); 27 | } 28 | 29 | user = await user.save(); 30 | 31 | res.header(AUTH_HEADER, user.token).send(_.pick(user, ['id', 'email'])); 32 | }); 33 | 34 | export const AdminController: Router = router; 35 | -------------------------------------------------------------------------------- /src/controllers/api/v1/authentication/authentication.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Router } from 'express'; 2 | import { validate } from 'class-validator'; 3 | import * as _ from 'lodash'; 4 | import bcrypt from 'bcrypt'; 5 | 6 | import { User } from 'models/user'; 7 | import { AUTH_HEADER } from 'constants/constants'; 8 | 9 | const router: Router = Router(); 10 | 11 | router.post('/', async (req: Request, res: Response) => { 12 | const { email, password } = req.body; 13 | 14 | let user = new User(req.body); 15 | const errors = await validate(user); 16 | 17 | if (!_.isEmpty(errors)) { 18 | return res.status(400).send('Email or password incorrect'); 19 | } 20 | 21 | user = await User.findOne({ email }); 22 | 23 | if (!user) { 24 | return res.status(400).send('Email or password incorrect'); 25 | } 26 | 27 | const validPassword = await bcrypt.compare(password, user.password); 28 | 29 | if (!validPassword) { 30 | return res.status(400).send('Email or password incorrect'); 31 | } 32 | 33 | res.header(AUTH_HEADER, user.token).status(204).send(); 34 | }); 35 | 36 | export const AuthenticationController: Router = router; 37 | -------------------------------------------------------------------------------- /src/controllers/api/v1/home/home.controller.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from 'express'; 2 | import pkg from '../../../../../package.json'; 3 | 4 | const router: Router = Router(); 5 | 6 | router.get('', (req: Request, res: Response) => { 7 | res.render('index', { 8 | title: pkg.name, 9 | description: `Description: ${pkg.description}`, 10 | version: `Version: ${pkg.version}` 11 | }); 12 | }); 13 | 14 | router.get('/status', (req: Request, res: Response) => { 15 | res.send({ online: true }); 16 | }); 17 | 18 | export const HomeController: Router = router; 19 | -------------------------------------------------------------------------------- /src/controllers/api/v1/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { HomeController } from './home/home.controller'; 3 | import { UserController } from './user/user.controller'; 4 | import { AdminController } from './admin/admin.controller'; 5 | import { AuthenticationController } from './authentication/authentication.controller'; 6 | 7 | const router: Router = Router(); 8 | 9 | router.use('/', HomeController); 10 | router.use('/user', UserController); 11 | router.use('/admin', AdminController); 12 | router.use('/auth', AuthenticationController); 13 | 14 | export const v1: Router = router; 15 | -------------------------------------------------------------------------------- /src/controllers/api/v1/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from 'express'; 2 | import { validate } from 'class-validator'; 3 | import * as _ from 'lodash'; 4 | 5 | import { User } from 'models/user'; 6 | import { getValidationErrors } from 'utils/ModelValidation'; 7 | 8 | const router: Router = Router(); 9 | 10 | router.post('/', async (req: Request, res: Response) => { 11 | let user: User = await User.findOne({ email: req.body.email }); 12 | 13 | if (user) { 14 | return res.status(400).send('Email already in use'); 15 | } 16 | 17 | user = new User(req.body); 18 | 19 | const errors = await validate(user); 20 | 21 | if (!_.isEmpty(errors)) { 22 | return res.status(400).send(getValidationErrors(errors)); 23 | } 24 | 25 | user = await user.save(); 26 | 27 | res.header('x-auth-token', user.token).send(_.pick(user, ['id', 'email'])); 28 | }); 29 | 30 | export const UserController: Router = router; 31 | -------------------------------------------------------------------------------- /src/middleware/admin.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | 3 | import { User, UserRole } from 'models/user'; 4 | 5 | export const adminMiddleware = (req: Request, res: Response, next: NextFunction): Response => { 6 | const user: User = (req as any).user; 7 | 8 | if (user.role !== UserRole.Admin) { 9 | return res.status(403).send('Access denied'); 10 | } 11 | 12 | next(); 13 | }; 14 | -------------------------------------------------------------------------------- /src/middleware/authorization.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import jwt from 'jsonwebtoken'; 3 | 4 | import { AUTH_HEADER } from 'constants/constants'; 5 | 6 | export const authMiddleware = (req: Request, res: Response, next: NextFunction): void => { 7 | const token: string = req.header(AUTH_HEADER); 8 | 9 | if (!token) { 10 | res.status(401).send('Access denied. No token provided.'); 11 | } 12 | 13 | try { 14 | (req as any).user = jwt.verify(token, process.env.JWT_KEY); 15 | next(); 16 | } catch (ex) { 17 | res.status(400).send('Invalid token.'); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/middleware/exception.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express'; 2 | import winston from 'winston'; 3 | 4 | export const exceptionMiddleware = (err: any, req: Request, res: Response, next: NextFunction): void => { 5 | winston.error(err.message, err); 6 | 7 | res.status(500).send(err.message); 8 | }; 9 | -------------------------------------------------------------------------------- /src/migration/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevquincke/node-api-base/e10b3d2d34e0bfe23aacb3fd74e01d1f5646c0c7/src/migration/.gitkeep -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | import { User } from './user'; 2 | 3 | export default [ 4 | User 5 | ]; 6 | -------------------------------------------------------------------------------- /src/models/user.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt'; 2 | import { IsNotEmpty, IsString, IsEmail } from 'class-validator'; 3 | import { 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | Column, 7 | BaseEntity, 8 | BeforeInsert 9 | } from 'typeorm'; 10 | import jwt from 'jsonwebtoken'; 11 | 12 | export enum UserRole { 13 | Admin = 'Admin', 14 | Regular = 'Regular' 15 | } 16 | 17 | @Entity() 18 | export class User extends BaseEntity { 19 | @PrimaryGeneratedColumn('uuid') 20 | public id: string; 21 | 22 | @IsEmail() 23 | @IsNotEmpty() 24 | @Column({ unique: true }) 25 | public email: string; 26 | 27 | @IsString() 28 | @IsNotEmpty() 29 | @Column() 30 | public password: string; 31 | 32 | @Column({ 33 | type: 'enum', 34 | enum: UserRole, 35 | default: UserRole.Regular 36 | }) 37 | public role: string; 38 | 39 | public constructor(user?: User) { 40 | super(); 41 | 42 | if (user) { 43 | this.email = user.email; 44 | this.password = user.password; 45 | } 46 | } 47 | 48 | @BeforeInsert() 49 | public async hashPassword() { 50 | const salt = await bcrypt.genSalt(10); 51 | this.password = await bcrypt.hash(this.password, salt); 52 | } 53 | 54 | public get token() { 55 | return jwt.sign({ 56 | id: this.id, 57 | role: this.role 58 | }, process.env.JWT_KEY); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | 3 | import App from './app'; 4 | 5 | const port = parseInt(process.env.PORT) || 3000; 6 | const app = new App(); 7 | 8 | app.server.listen(port, () => winston.info(`Server running on port ${port} ...`)); 9 | app.connectToDatabase().then(() => winston.info('Connected to database ...')); 10 | -------------------------------------------------------------------------------- /src/subscriber/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevquincke/node-api-base/e10b3d2d34e0bfe23aacb3fd74e01d1f5646c0c7/src/subscriber/.gitkeep -------------------------------------------------------------------------------- /src/utils/ModelValidation.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import { ValidationError } from 'class-validator'; 3 | 4 | export interface IModelValidation { 5 | [key: string]: object[]; 6 | } 7 | 8 | export const getValidationErrors = (validationErrors: ValidationError[]): string => { 9 | const errors: IModelValidation = {}; 10 | 11 | _.forEach(validationErrors, (error: ValidationError) => { 12 | const allErrors: string[] = []; 13 | 14 | _.forEach(error.constraints, (constraint: string) => { 15 | allErrors.push(constraint); 16 | }); 17 | 18 | (errors as any)[error.property] = allErrors; 19 | }); 20 | 21 | return JSON.stringify(errors); 22 | }; 23 | -------------------------------------------------------------------------------- /tests/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, SuperTest } from 'supertest'; 2 | 3 | import { AUTH_HEADER } from 'constants/constants'; 4 | 5 | export const createUser = async ( 6 | request: SuperTest, 7 | user: any 8 | ): Promise => ( 9 | await request 10 | .post('/api/v1/user') 11 | .set('Content-Type', 'application/json') 12 | .send(user) 13 | ); 14 | 15 | export const createAdmin = async ( 16 | request: SuperTest, 17 | admin: any, 18 | token: string = '' 19 | ): Promise => ( 20 | await request 21 | .post('/api/v1/admin') 22 | .set('Content-Type', 'application/json') 23 | .set(AUTH_HEADER, token) 24 | .send(admin) 25 | ); 26 | 27 | export const authenticate = async ( 28 | request: SuperTest, 29 | user: any 30 | ): Promise => ( 31 | await request 32 | .post('/api/v1/auth') 33 | .set('Content-Type', 'application/json') 34 | .send(user) 35 | ); 36 | -------------------------------------------------------------------------------- /tests/integration/admin.test.ts: -------------------------------------------------------------------------------- 1 | import supertest, { SuperTest, Request } from 'supertest'; 2 | 3 | import App from 'src/App'; 4 | import { User, UserRole } from 'models/user'; 5 | import { AUTH_HEADER } from 'constants/constants'; 6 | import { authenticate, createAdmin } from '../helpers'; 7 | 8 | const superAdmin = { 9 | email: 'admin@example.com', 10 | password: 'password' 11 | }; 12 | 13 | const newAdmin = { 14 | email: 'admin2@example.com', 15 | password: 'password' 16 | }; 17 | 18 | describe('/api/v1/admin', () => { 19 | let request: SuperTest; 20 | 21 | beforeEach(async () => { 22 | await User.delete({ email: newAdmin.email }); 23 | }); 24 | 25 | beforeAll(async () => { 26 | const app = new App(); 27 | await app.connectToDatabase(); 28 | request = supertest(app.server); 29 | 30 | const { email, password } = superAdmin; 31 | await User.delete({ email }); 32 | 33 | const admin = new User(); 34 | admin.email = email; 35 | admin.password = password; 36 | admin.role = UserRole.Admin; 37 | await admin.save(); 38 | }); 39 | 40 | describe('POST /', () => { 41 | it('should return 401 if not logged in', async () => { 42 | const response = await createAdmin(request, newAdmin); 43 | 44 | expect(response.status).toBe(401); 45 | }); 46 | 47 | it('should return 200 if logged in and created by another admin', async () => { 48 | const auth = await authenticate(request, superAdmin); 49 | 50 | const token = auth.get(AUTH_HEADER); 51 | const response = await createAdmin(request, newAdmin, token); 52 | 53 | expect(response.status).toBe(200); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /tests/integration/authentication.test.ts: -------------------------------------------------------------------------------- 1 | import supertest, { SuperTest, Request } from 'supertest'; 2 | 3 | import { User } from 'models/user'; 4 | import App from 'src/app'; 5 | import { authenticate, createUser } from '../helpers'; 6 | import { AUTH_HEADER } from 'constants/constants'; 7 | 8 | const user = { 9 | email: 'test@example.com', 10 | password: 'password' 11 | }; 12 | 13 | describe('/api/v1/auth', () => { 14 | let request: SuperTest; 15 | 16 | beforeAll(async () => { 17 | const app = new App(); 18 | await app.connectToDatabase(); 19 | request = supertest(app.server); 20 | 21 | await User.delete({ email: user.email }); 22 | 23 | await createUser(request, user); 24 | }); 25 | 26 | describe('POST /', async () => { 27 | it('should return 200 if authentication is successful', async () => { 28 | const auth = await authenticate(request, user); 29 | 30 | expect(auth.status).toBe(204); 31 | }); 32 | 33 | it('should return a token if authentication is successful', async () => { 34 | const auth = await authenticate(request, user); 35 | 36 | expect(auth.get(AUTH_HEADER)).not.toBeNull(); 37 | }); 38 | 39 | it('should return 400 if email or password is wrong', async () => { 40 | const auth = await authenticate(request, { email: 'notok@example.com', password: 'password' }); 41 | const auth2 = await authenticate(request, { email: user.email, password: 'password2' }); 42 | 43 | expect(auth.status).toBe(400); 44 | expect(auth2.status).toBe(400); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /tests/integration/home.test.ts: -------------------------------------------------------------------------------- 1 | import supertest, { SuperTest, Request } from 'supertest'; 2 | 3 | import App from 'src/app'; 4 | 5 | describe('/api/v1/home', () => { 6 | let request: SuperTest; 7 | 8 | beforeAll(async () => { 9 | const app = new App(); 10 | await app.connectToDatabase(); 11 | request = supertest(app.server); 12 | }); 13 | 14 | describe('GET /', () => { 15 | it('should return 200', async () => { 16 | const response = await request.get('/api/v1/'); 17 | expect(response.status).toBe(200); 18 | }); 19 | }); 20 | 21 | describe('GET /status', () => { 22 | it('should return that server is online', async () => { 23 | const response = await request.get('/api/v1/status'); 24 | expect(response.body).toMatchObject({ online: true }); 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /tests/integration/user.test.ts: -------------------------------------------------------------------------------- 1 | import supertest, { SuperTest, Request } from 'supertest'; 2 | 3 | import { User } from 'models/user'; 4 | import App from 'src/app'; 5 | import { createUser } from '../helpers'; 6 | 7 | const user = { 8 | email: 'test@example.com', 9 | password: 'password' 10 | }; 11 | 12 | describe('/api/v1/user', () => { 13 | let request: SuperTest; 14 | 15 | beforeEach(async () => { 16 | await User.delete({ email: user.email }); 17 | }); 18 | 19 | beforeAll(async () => { 20 | const app = new App(); 21 | await app.connectToDatabase(); 22 | request = supertest(app.server); 23 | }); 24 | 25 | describe('POST /', async () => { 26 | it('should return 200 if mail is not in use', async () => { 27 | const response = await createUser(request, user); 28 | 29 | expect(response.status).toBe(200); 30 | }); 31 | 32 | it('should return a user object if everything is ok', async () => { 33 | const response = await createUser(request, user); 34 | 35 | expect(response.body).toMatchObject({ email: user.email }); 36 | }); 37 | 38 | it('should return 400 if using an existing mail', async () => { 39 | await createUser(request, user); 40 | const response = await createUser(request, user); 41 | 42 | expect(response.status).toBe(400); 43 | }); 44 | 45 | it('should return 400 if data provided to endpoint is wrong', async () => { 46 | const response = await createUser(request, { email: user.email }); 47 | 48 | expect(response.status).toBe(400); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /tests/unit/models/user.test.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | 3 | import { User, UserRole } from 'models/user'; 4 | 5 | describe('User model', () => { 6 | beforeAll(() => { 7 | process.env.JWT_KEY = 'shhhh'; 8 | }); 9 | 10 | it('should return a valid JWT', () => { 11 | const user = new User(); 12 | user.id = '46ce402a-4658-4884-b80d-82a7fee87691'; 13 | user.role = UserRole.Regular; 14 | 15 | const token = user.token; 16 | const decoded = jwt.verify(token, process.env.JWT_KEY); 17 | 18 | expect(decoded).toMatchObject({ 19 | id: '46ce402a-4658-4884-b80d-82a7fee87691', 20 | role: UserRole.Regular 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "src/server.ts" 4 | ], 5 | "include": [ 6 | "src/**/*.ts" 7 | ], 8 | "compilerOptions": { 9 | "module": "commonjs", 10 | "noImplicitAny": true, 11 | "target": "es6", 12 | "outDir": "dist", 13 | "rootDir": "", 14 | "emitDecoratorMetadata": true, 15 | "experimentalDecorators": true, 16 | "esModuleInterop": true, 17 | "resolveJsonModule": true, 18 | "sourceMap": true, 19 | "baseUrl": "./src", 20 | "moduleResolution": "node", 21 | "removeComments": true, 22 | "typeRoots": ["node_modules/@types"], 23 | "paths": { 24 | "constants/*": ["constants/*"], 25 | "controllers/*": ["controllers/*"], 26 | "middleware/*": ["middleware/*"], 27 | "migration/*": ["migration/*"], 28 | "models/*": ["models/*"], 29 | "subscriber/*": ["subscriber/*"], 30 | "utils/*": ["utils/*"], 31 | "src/*": ["*"], 32 | "*": [ 33 | "../node_modules/@types/*", 34 | "types/*" 35 | ] 36 | } 37 | }, 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended", 5 | "tslint-eslint-rules" 6 | ], 7 | "jsRules": {}, 8 | "rules": { 9 | "quotemark": [true, "single"], 10 | "object-literal-sort-keys": false, 11 | "trailing-comma": false, 12 | "no-trailing-whitespace": false, 13 | "no-var-requires": false, 14 | "ordered-imports": false, 15 | "radix": false, 16 | "no-debugger": true, 17 | "object-curly-spacing": true, 18 | "ter-indent": [true, 2], 19 | "no-unused-expressions": [1, { "allowShortCircuit": true, "allowTernary": true }], 20 | "comma-spacing": [1, { "before": false, "after": true }], 21 | "semi": [1, "always"], 22 | "no-multi-spaces": 1, 23 | "block-spacing": 1, 24 | "brace-style": [2, "1tbs", { "allowSingleLine": true }], 25 | "no-multiple-empty-lines": [1, { "max": 1 }], 26 | "space-before-blocks" : [1, "always"] 27 | }, 28 | "rulesDirectory": [] 29 | } 30 | -------------------------------------------------------------------------------- /views/index.pug: -------------------------------------------------------------------------------- 1 | html 2 | head 3 | title= title 4 | body 5 | h1= title 6 | h3= description 7 | h4= version -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const nodeExternals = require('webpack-node-externals'); 3 | 4 | const srcPath = subDir => path.join(__dirname, "src", subDir); 5 | 6 | module.exports = { 7 | entry: './src/server.ts', 8 | 9 | devtool: 'inline-source-map', 10 | 11 | output: { 12 | path: path.resolve(__dirname, 'dist'), 13 | filename: 'bundle.js' 14 | }, 15 | 16 | resolve: { 17 | extensions: ['.ts', '.js', '.json'], 18 | alias: { 19 | constants: srcPath('constants'), 20 | controllers: srcPath('controllers'), 21 | middleware: srcPath('middleware'), 22 | migration: srcPath('migration'), 23 | models: srcPath('models'), 24 | subscriber: srcPath('subscriber'), 25 | utils: srcPath('utils'), 26 | src: srcPath('') 27 | }, 28 | modules: ['node_modules'] 29 | }, 30 | 31 | module: { 32 | rules: [ 33 | { 34 | use: 'ts-loader', 35 | test: /\.ts?$/, 36 | exclude: /node_modules/ 37 | } 38 | ] 39 | }, 40 | 41 | externals: [nodeExternals()], 42 | 43 | target: 'node', 44 | 45 | mode: 'development' 46 | }; 47 | --------------------------------------------------------------------------------