├── .env.example ├── .eslintrc.json ├── .github └── workflows │ └── test-integration.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .sequelizerc ├── .vscode └── launch.json ├── Dockerfile ├── LICENSE ├── README.md ├── app.js ├── config └── sequelize.js ├── db ├── migrations │ └── 20190110092752-create-users.js ├── models │ └── Users.js ├── mongo.js └── sequelize.js ├── docker-compose.yml ├── helpers └── security.js ├── microservice.png ├── package-lock.json ├── package.json ├── process.json ├── routes ├── controllers │ └── users.js ├── middlewares │ ├── authorization.js │ └── logger.js └── routes.js ├── services └── users.js └── test ├── app.test.js └── routes └── users.test.js /.env.example: -------------------------------------------------------------------------------- 1 | PORT=4000 2 | LOG_LEVEL="info" 3 | DEBUG=* 4 | MONGO_HOST="127.0.0.1" 5 | MONGO_PORT=27017 6 | MONGO_DB="test" 7 | MONGO_USER= 8 | MONGO_PASS= 9 | MONGO_URL= 10 | SQL_HOST="127.0.0.1" 11 | SQL_HOST_READ="127.0.0.1" 12 | SQL_HOST_WRITE="127.0.0.1" 13 | SQL_PORT=5432 14 | SQL_DB="test" 15 | SQL_USER="postgres" 16 | SQL_PASS= 17 | SQL_DIALECT="postgres" 18 | SQL_POOL_LIMIT=100 -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb-base", "prettier"], 3 | "plugins": ["prettier", "import"], 4 | "env": { 5 | "es6": true, 6 | "node": true, 7 | "jest": true 8 | }, 9 | "rules": { 10 | "prettier/prettier": ["error", { "singleQuote": true }], 11 | "no-unused-vars": ["error", { "args": "none" }], 12 | "no-underscore-dangle": ["error", { "allow": ["_id"] }] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/test-integration.yml: -------------------------------------------------------------------------------- 1 | name: test-integration 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | timeout-minutes: 5 7 | steps: 8 | - name: Checkout 9 | uses: actions/checkout@master 10 | - name: Use Node.js v10 11 | uses: actions/setup-node@v1 12 | with: 13 | node-version: 10.x 14 | - name: Install 15 | run: npm install 16 | - name: Generate JWT keys 17 | run: | 18 | ssh-keygen -t rsa -b 2048 -q -N '' -m PEM -f private.key 19 | rm private.key.pub 20 | openssl rsa -in private.key -pubout -outform PEM -out public.key 21 | chmod 644 public.key private.key 22 | - name: Test 23 | run: docker-compose up --exit-code-from web -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | private.key 64 | public.key -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | package.json 3 | yarn.lock -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "useTabs": true, 4 | "tabWidth": 2, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.sequelizerc: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | 'config': path.resolve('config', 'sequelize.js'), 5 | 'models-path': path.resolve('db', 'models'), 6 | 'seeders-path': path.resolve('db', 'seeds'), 7 | 'migrations-path': path.resolve('db', 'migrations') 8 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Start", 9 | "type": "node", 10 | "request": "launch", 11 | "runtimeExecutable": "npm", 12 | "runtimeArgs": ["run-script", "debug"], 13 | "port": 9229 14 | }, 15 | { 16 | "name": "Attach", 17 | "type": "node", 18 | "request": "attach", 19 | "restart": true 20 | }, 21 | { 22 | "name": "Jest All", 23 | "type": "node", 24 | "request": "launch", 25 | "program": "${workspaceFolder}/node_modules/jest/bin/jest.js", 26 | "args": ["--setupFiles", "dotenv/config", "--forceExit", "--runInBand"], 27 | "console": "integratedTerminal", 28 | "internalConsoleOptions": "neverOpen" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10-alpine 2 | 3 | # Set the work directory 4 | RUN mkdir -p /var/www/app/current 5 | WORKDIR /var/www/app/current 6 | 7 | # Add our package.json and install *before* adding our application files 8 | ADD package.json ./ 9 | RUN npm i --production 10 | 11 | # Install pm2 *globally* so we can run our application 12 | RUN npm i -g pm2 13 | 14 | # Add application files 15 | ADD . /var/www/app/current 16 | 17 | EXPOSE 4000 18 | 19 | CMD ["pm2", "start", "process.json", "--no-daemon"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Sergey Onufrienko 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 | ![](microservice.png) 2 | 3 | # Ready to use Node.js microservice 4 | 5 | ## Features 6 | - **Framework**: Express 7 | - **Authentication**: JWT with public/private key file 8 | - **Database**: MongoDB (Native), PostgreSQL (Sequelize) 9 | - **Code**: ESLint, Prettier, Husky 10 | - **Debuging**: Debug, VS Code configurations 11 | - **Logging**: Winston 12 | - **Testing**: Jest, SuperTest, AutoCannon 13 | - **Continuous Integration**: GitHub Actions + Docker Compose 14 | - **Other**: PM2, DotEnv 15 | - Well structured 16 | - API versioning 17 | - Request Validation 18 | 19 | ## Getting Started 20 | ```shell 21 | git clone https://github.com/sonufrienko/microservice 22 | cd microservice 23 | 24 | # Create environment variables from example 25 | mv .env.example .env 26 | 27 | # Generate JWT keys 28 | ssh-keygen -t rsa -b 2048 -q -N '' -m PEM -f private.key \ 29 | && rm private.key.pub \ 30 | && openssl rsa -in private.key -pubout -outform PEM -out public.key 31 | 32 | # Install all dependencies 33 | npm install 34 | 35 | # Run on port 4000 36 | npm start 37 | ``` 38 | 39 | 40 | ## Running SQL database migrations 41 | ```shell 42 | npx sequelize db:migrate 43 | ``` 44 | 45 | ## Start with PM2 46 | ```shell 47 | pm2 start process.json 48 | ``` 49 | 50 | ## Start with Docker 51 | ```shell 52 | # Generate JWT keys 53 | ssh-keygen -t rsa -b 2048 -q -N '' -m PEM -f private.key \ 54 | && rm private.key.pub \ 55 | && openssl rsa -in private.key -pubout -outform PEM -out public.key 56 | 57 | # Build image 58 | docker build -t app/microservice:v1 . 59 | 60 | # Run on port 4000 61 | docker run -p 4000:4000 -d --name microservice app/microservice:v1 62 | 63 | # Run on host network 64 | docker run -d --name microservice --network=host app/microservice:v1 65 | ``` 66 | 67 | 68 | ## Environment variables 69 | 70 | Name | Value 71 | ------------ | ------------- 72 | PORT|4000 73 | LOG_LEVEL|info 74 | DEBUG|* 75 | MONGO_HOST|127.0.0.1 76 | MONGO_PORT|27017 77 | MONGO_DB|test 78 | MONGO_USER| 79 | MONGO_PASS| 80 | MONGO_URL| 81 | SQL_HOST|127.0.0.1 82 | SQL_HOST_READ|127.0.0.1 83 | SQL_HOST_WRITE|127.0.0.1 84 | SQL_PORT|5432 85 | SQL_DB|test 86 | SQL_USER|postgres 87 | SQL_PASS| 88 | SQL_DIALECT|postgres 89 | SQL_POOL_LIMIT|100 90 | 91 | ## Structure 92 | 93 | ``` 94 | . 95 | ├── config # App configuration files 96 | │ ├── sequelize.js # sequelize config 97 | │ └── ... # Other configurations 98 | ├── db # Data access stuff 99 | │ ├── migrations # Migrations 100 | │ ├── models # Models 101 | │ ├── seeds # Seeds 102 | │ └── mongo.js # MongoDB instantiation 103 | │ └── sequelize.js # Sequelize (PostgresSQL/MySQL) instantiation 104 | ├── docs # Documentation 105 | ├── helpers # Helpers (formats, validation, etc) 106 | ├── routes 107 | │ ├── controllers # Request managers 108 | │ ├── middlewares # Request middlewares 109 | │ └── routes.js # Define routes and middlewares here 110 | ├── scripts # Standalone scripts for dev uses 111 | ├── services # External services implementation 112 | │ ├── serviceOne 113 | │ └── serviceTwo 114 | ├── tests # Testing 115 | ├── .env # Environment variables 116 | ├── .sequelizerc # Sequelize CLI config 117 | ├── app.js # App starting point 118 | ├── Dockerfile # Dockerfile 119 | ├── process.json # pm2 init 120 | ├── package.json 121 | ├── private.key # Sign tokens 122 | ├── public.key # Validate tokens 123 | └── README.md 124 | ``` -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const app = require('express')(); 3 | const compression = require('compression'); 4 | const bodyParser = require('body-parser'); 5 | const winston = require('winston'); 6 | const { errors } = require('celebrate'); 7 | 8 | const mongo = require('./db/mongo'); 9 | const authorization = require('./routes/middlewares/authorization'); 10 | const logger = require('./routes/middlewares/logger'); 11 | const routes = require('./routes/routes'); 12 | 13 | // Enable KeepAlive to speed up HTTP requests to another microservices 14 | http.globalAgent.keepAlive = true; 15 | 16 | const { PORT, NODE_ENV } = process.env; 17 | 18 | app.disable('x-powered-by'); 19 | app.use(compression()); 20 | app.use(bodyParser.urlencoded({ extended: true })); 21 | app.use(bodyParser.json()); 22 | app.get('/healthcheck', (req, res) => { 23 | try { 24 | res.send({ 25 | uptime: Math.round(process.uptime()), 26 | message: 'OK', 27 | timestamp: Date.now(), 28 | mongodb: mongo.isConnected() 29 | }); 30 | } catch (e) { 31 | res.status(503).end(); 32 | } 33 | }); 34 | app.use(authorization); 35 | app.use('/v1', routes); 36 | app.use(errors()); 37 | app.use(logger); 38 | 39 | if (NODE_ENV !== 'test') { 40 | (async () => { 41 | await mongo.connectWithRetry(); 42 | app.listen(PORT, () => { 43 | winston.info(`Server listening on http://localhost:${PORT}`); 44 | }); 45 | })(); 46 | } 47 | 48 | module.exports = app; 49 | -------------------------------------------------------------------------------- /config/sequelize.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | module.exports = { 4 | development: { 5 | username: process.env.SQL_USER, 6 | password: process.env.SQL_PASS, 7 | database: process.env.SQL_DB, 8 | host: process.env.SQL_HOST, 9 | port: process.env.SQL_PORT, 10 | dialect: process.env.SQL_DIALECT, 11 | logging: false, 12 | pool: { 13 | max: Number(process.env.SQL_POOL_LIMIT), 14 | min: 0, 15 | acquire: 30000, 16 | idle: 10000 17 | }, 18 | define: { 19 | freezeTableName: true, 20 | timestamps: false 21 | } 22 | }, 23 | production: { 24 | username: process.env.SQL_USER, 25 | password: process.env.SQL_PASS, 26 | database: process.env.SQL_DB, 27 | port: process.env.SQL_PORT, 28 | dialect: process.env.SQL_DIALECT, 29 | logging: false, 30 | replication: { 31 | read: [{ host: process.env.SQL_HOST_READ }], 32 | write: { host: process.env.SQL_HOST_WRITE } 33 | }, 34 | pool: { 35 | max: Number(process.env.SQL_POOL_LIMIT), 36 | min: 0, 37 | acquire: 30000, 38 | idle: 10000 39 | }, 40 | define: { 41 | freezeTableName: true, 42 | timestamps: false 43 | } 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /db/migrations/20190110092752-create-users.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: (queryInterface, Sequelize) => 3 | queryInterface.createTable('Users', { 4 | id: { 5 | allowNull: false, 6 | primaryKey: true, 7 | autoIncrement: true, 8 | type: Sequelize.INTEGER 9 | }, 10 | email: { 11 | allowNull: false, 12 | type: Sequelize.STRING 13 | }, 14 | createdAt: { 15 | allowNull: false, 16 | type: Sequelize.DATE 17 | }, 18 | updatedAt: { 19 | allowNull: false, 20 | type: Sequelize.DATE 21 | } 22 | }), 23 | 24 | down: (queryInterface, Sequelize) => queryInterface.dropTable('Users') 25 | }; 26 | -------------------------------------------------------------------------------- /db/models/Users.js: -------------------------------------------------------------------------------- 1 | module.exports = (sequelize, DataTypes) => { 2 | const Users = sequelize.define('Users', { 3 | id: { 4 | allowNull: false, 5 | primaryKey: true, 6 | autoIncrement: true, 7 | type: DataTypes.INTEGER 8 | }, 9 | email: { 10 | allowNull: false, 11 | type: DataTypes.STRING 12 | }, 13 | createdAt: { 14 | allowNull: false, 15 | type: DataTypes.DATE, 16 | defaultValue: () => new Date() 17 | }, 18 | updatedAt: { 19 | allowNull: false, 20 | type: DataTypes.DATE, 21 | defaultValue: () => new Date() 22 | } 23 | }); 24 | 25 | return Users; 26 | }; 27 | -------------------------------------------------------------------------------- /db/mongo.js: -------------------------------------------------------------------------------- 1 | const { MongoClient } = require('mongodb'); 2 | const winston = require('winston'); 3 | 4 | const RECONNECT_INTERVAL = 1000; 5 | const CONNECT_OPTIONS = { 6 | reconnectTries: 60 * 60 * 24, 7 | reconnectInterval: RECONNECT_INTERVAL, 8 | useNewUrlParser: true, 9 | useUnifiedTopology: true 10 | }; 11 | 12 | const mongo = {}; 13 | 14 | const composeConnectionUrl = env => { 15 | const { 16 | MONGO_USER = '', 17 | MONGO_PASS = '', 18 | MONGO_HOST = '', 19 | MONGO_PORT = 27017, 20 | MONGO_DB = '' 21 | } = env; 22 | 23 | const credentials = 24 | MONGO_USER.length || MONGO_PASS.length 25 | ? `${MONGO_USER}:${MONGO_PASS}@` 26 | : ''; 27 | 28 | return `mongodb://${credentials}${MONGO_HOST}:${MONGO_PORT}/${MONGO_DB}`; 29 | }; 30 | 31 | const connectionUrlIsCorrect = uri => uri && uri.includes('mongodb://'); 32 | 33 | const getConnectionUrl = env => { 34 | const { MONGO_URL } = env; 35 | const dbUrl = connectionUrlIsCorrect(MONGO_URL) 36 | ? MONGO_URL 37 | : composeConnectionUrl(env); 38 | return dbUrl; 39 | }; 40 | 41 | const getDBNameFromUri = uri => uri.slice(uri.lastIndexOf('/') + 1); 42 | 43 | const connectAsync = ({ uri, options }) => 44 | new Promise((resolve, reject) => { 45 | MongoClient.connect(uri, options, (err, client) => 46 | err ? reject(err) : resolve(client) 47 | ); 48 | }); 49 | 50 | const connectWithRetry = async () => { 51 | const uri = getConnectionUrl(process.env); 52 | const dbName = getDBNameFromUri(uri); 53 | try { 54 | const client = await connectAsync({ 55 | uri, 56 | options: CONNECT_OPTIONS 57 | }); 58 | mongo.db = client.db(dbName); 59 | mongo.db.on('close', () => { 60 | winston.warn('MongoDB connection was closed'); 61 | connectWithRetry(); 62 | }); 63 | mongo.db.on('reconnect', () => { 64 | winston.warn('MongoDB reconnected'); 65 | }); 66 | winston.info(`MongoDB connected successfully. Database: ${dbName}.`); 67 | } catch (error) { 68 | winston.error( 69 | `MongoDB connection was failed: ${error.message}`, 70 | error.message 71 | ); 72 | setTimeout(connectWithRetry, RECONNECT_INTERVAL); 73 | } 74 | }; 75 | 76 | const isConnected = () => 77 | (mongo && mongo.db && mongo.db.topology && mongo.db.topology.isConnected()) || 78 | false; 79 | 80 | mongo.connectWithRetry = connectWithRetry; 81 | mongo.isConnected = isConnected; 82 | 83 | module.exports = mongo; 84 | -------------------------------------------------------------------------------- /db/sequelize.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const Sequelize = require('sequelize'); 4 | const configFile = require('../config/sequelize'); 5 | 6 | const env = 7 | process.env.NODE_ENV === 'production' ? 'production' : 'development'; 8 | const config = configFile[env]; 9 | const db = {}; 10 | const sequelize = new Sequelize(config); 11 | const modelsPath = path.join(__dirname, './models/'); 12 | 13 | fs.readdirSync(modelsPath) 14 | .filter(file => file.indexOf('.') !== 0 && file.slice(-3) === '.js') 15 | .forEach(file => { 16 | const model = sequelize.import(path.join(modelsPath, file)); 17 | db[model.name] = model; 18 | }); 19 | 20 | Object.keys(db).forEach(modelName => { 21 | if (db[modelName].associate) { 22 | db[modelName].associate(db); 23 | } 24 | }); 25 | 26 | db.sequelize = sequelize; 27 | db.Sequelize = Sequelize; 28 | 29 | module.exports = db; 30 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | web: 4 | image: "node:lts" 5 | user: "node" 6 | working_dir: /home/node/app 7 | environment: 8 | - NODE_ENV=test 9 | - PORT=4000 10 | - LOG_LEVEL=info 11 | - DEBUG= 12 | - MONGO_HOST= 13 | - MONGO_PORT= 14 | - MONGO_DB= 15 | - MONGO_USER= 16 | - MONGO_PASS= 17 | - MONGO_URL=mongodb://mongo/test 18 | - SQL_HOST= 19 | - SQL_HOST_READ= 20 | - SQL_HOST_WRITE= 21 | - SQL_PORT=5432 22 | - SQL_DB=test 23 | - SQL_USER=postgres 24 | - SQL_PASS= 25 | - SQL_DIALECT=postgres 26 | - SQL_POOL_LIMIT=100 27 | volumes: 28 | - ./:/home/node/app 29 | ports: 30 | - "4000:4000" 31 | depends_on: 32 | - mongo 33 | links: 34 | - mongo 35 | command: "npm run test:integration" 36 | 37 | mongo: 38 | image: mongo:latest 39 | logging: 40 | driver: none 41 | ports: 42 | - "27017:27017" -------------------------------------------------------------------------------- /helpers/security.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const jwt = require('jsonwebtoken'); 3 | 4 | const JWT_PRIVATE_KEY = fs.readFileSync('private.key'); 5 | 6 | const getSignedToken = (payload, jwtOptions) => 7 | new Promise((resolve, reject) => { 8 | jwt.sign( 9 | payload, 10 | JWT_PRIVATE_KEY, 11 | { 12 | ...jwtOptions, 13 | algorithm: 'RS256' 14 | }, 15 | (err, token) => (err ? reject(err) : resolve(token)) 16 | ); 17 | }); 18 | 19 | module.exports = { 20 | getSignedToken 21 | }; 22 | -------------------------------------------------------------------------------- /microservice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonufrienko/microservice/f376cc935e84ad0fe1a86f26deb88b7f6076471a/microservice.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "microservice", 3 | "version": "0.0.1", 4 | "description": "Ready to use Node.js microservice", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "node -r dotenv/config app.js", 8 | "debug": "node --inspect -r dotenv/config app.js", 9 | "prettier": "prettier --write \"**/*.js\"", 10 | "lint": "eslint .", 11 | "lint:fix": "eslint . --fix", 12 | "test": "jest --setupFiles dotenv/config --forceExit", 13 | "test:watch": "jest --watch --setupFiles dotenv/config", 14 | "test:cover": "jest --coverage --setupFiles dotenv/config", 15 | "test:load": "npx autocannon -c 100 -d 5 -p 10 localhost:4000/v1/users", 16 | "test:integration": "npm test" 17 | }, 18 | "lint-staged": { 19 | "*.js": [ 20 | "prettier --write", 21 | "eslint", 22 | "git add" 23 | ] 24 | }, 25 | "author": { 26 | "name": "Sergey Onufrienko ", 27 | "url": "https://github.com/sonufrienko" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/sonufrienko/microservice" 32 | }, 33 | "license": "MIT", 34 | "dependencies": { 35 | "body-parser": "^1.19.0", 36 | "celebrate": "^10.0.1", 37 | "compression": "^1.7.4", 38 | "debug": "^4.1.1", 39 | "dotenv": "^8.1.0", 40 | "express": "^4.17.1", 41 | "express-jwt": "^5.3.1", 42 | "jsonwebtoken": "^8.5.1", 43 | "mongodb": "^3.3.2", 44 | "pg": "^7.12.1", 45 | "pg-hstore": "^2.3.3", 46 | "sequelize": "^5.19.0", 47 | "sequelize-cli": "^5.5.1", 48 | "winston": "^3.2.1" 49 | }, 50 | "devDependencies": { 51 | "autocannon": "^4.1.1", 52 | "eslint": "^6.4.0", 53 | "eslint-config-airbnb-base": "^14.0.0", 54 | "eslint-config-prettier": "^6.3.0", 55 | "eslint-plugin-import": "^2.18.2", 56 | "eslint-plugin-prettier": "^3.1.1", 57 | "husky": "^3.0.5", 58 | "jest": "^24.9.0", 59 | "lint-staged": "^9.3.0", 60 | "prettier": "^1.18.2", 61 | "supertest": "^4.0.2" 62 | }, 63 | "husky": { 64 | "hooks": { 65 | "pre-commit": "lint-staged" 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /process.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "microservice", 5 | "script": "./app.js", 6 | "exec_mode": "cluster", 7 | "instances": 0, 8 | "max_restarts": 20, 9 | "node_args": "-r dotenv/config" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /routes/controllers/users.js: -------------------------------------------------------------------------------- 1 | const { celebrate, Joi } = require('celebrate'); 2 | const usersService = require('../../services/users'); 3 | 4 | const getUsers = async ({ params }, res) => { 5 | const users = await usersService.getUsers(); 6 | res.status(200).send(users); 7 | }; 8 | 9 | const getSingleUser = async ({ body, params: { userId } }, res) => { 10 | const user = await usersService.getSingleUser(userId); 11 | res.status(200).send(user); 12 | }; 13 | 14 | const getSingleUserValidation = celebrate({ 15 | params: { 16 | userId: Joi.string() 17 | .regex(/^[0-9a-fA-F]{24}$/) 18 | .required() 19 | } 20 | }); 21 | 22 | const addUser = async ({ body }, res) => { 23 | const user = await usersService.addUser(body); 24 | res.status(200).send(user); 25 | }; 26 | 27 | const addUserValidation = celebrate({ 28 | body: { 29 | email: Joi.string() 30 | .email() 31 | .required() 32 | } 33 | }); 34 | 35 | const updateUser = async ({ body, params: { userId } }, res) => { 36 | await usersService.updateUser(userId, body); 37 | res.status(200).end(); 38 | }; 39 | 40 | const deleteUser = async ({ body, params: { userId } }, res) => { 41 | const success = await usersService.deleteUser(userId); 42 | res.status(success ? 200 : 400).end(); 43 | }; 44 | 45 | module.exports = { 46 | getUsers, 47 | getSingleUser, 48 | getSingleUserValidation, 49 | addUser, 50 | addUserValidation, 51 | updateUser, 52 | deleteUser 53 | }; 54 | -------------------------------------------------------------------------------- /routes/middlewares/authorization.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const expressJwt = require('express-jwt'); 3 | 4 | const JWT_PUBLIC_KEY = fs.readFileSync('public.key'); 5 | const SET_TOKEN_AS_REVOKEN_ON_EXCEPTION = true; 6 | const TOKENS_BLACK_LIST = []; 7 | 8 | const getTokensBlacklist = async () => TOKENS_BLACK_LIST; 9 | 10 | const checkTokenInBlacklistCallback = async (req, payload, done) => { 11 | try { 12 | // jti (JWT ID) need to be included in payload 13 | const { jti } = payload; 14 | const blacklist = await getTokensBlacklist(); 15 | const tokenIsRevoked = blacklist.includes(jti); 16 | return done(null, tokenIsRevoked); 17 | } catch (e) { 18 | return done(e, SET_TOKEN_AS_REVOKEN_ON_EXCEPTION); 19 | } 20 | }; 21 | 22 | const authorization = expressJwt({ 23 | secret: JWT_PUBLIC_KEY, 24 | algorithm: 'RS256', 25 | isRevoked: checkTokenInBlacklistCallback 26 | }); 27 | 28 | module.exports = authorization; 29 | -------------------------------------------------------------------------------- /routes/middlewares/logger.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | 3 | const { LOG_LEVEL, NODE_ENV } = process.env; 4 | 5 | winston.configure({ 6 | transports: [ 7 | new winston.transports.Console({ 8 | // Show only errors on test 9 | level: NODE_ENV === 'test' ? 'error' : LOG_LEVEL, 10 | handleExceptions: true, 11 | format: winston.format.combine( 12 | winston.format.colorize(), 13 | winston.format.simple() 14 | ) 15 | }) 16 | // new winston.transports.File({ 17 | // level: 'info', 18 | // handleExceptions: true, 19 | // format: winston.format.json(), 20 | // filename: 'logs/server.log' 21 | // }) 22 | ] 23 | }); 24 | 25 | const logger = (err, req, res, next) => { 26 | if (err && err.name === 'UnauthorizedError') { 27 | // log unauthorized requests 28 | res.status(401).end(); 29 | } else if (err) { 30 | winston.error(err.stack); 31 | res.status(500).send({ 32 | error: true, 33 | message: err.message 34 | }); 35 | } else { 36 | res.status(405).end(); 37 | } 38 | }; 39 | 40 | module.exports = logger; 41 | -------------------------------------------------------------------------------- /routes/routes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const users = require('./controllers/users'); 3 | 4 | const router = express.Router(); 5 | 6 | router.get('/users', users.getUsers); 7 | router.get( 8 | '/users/:userId', 9 | users.getSingleUserValidation, 10 | users.getSingleUser 11 | ); 12 | router.post('/users', users.addUserValidation, users.addUser); 13 | router.put('/users/:userId', users.updateUser); 14 | router.delete('/users/:userId', users.deleteUser); 15 | 16 | module.exports = router; 17 | -------------------------------------------------------------------------------- /services/users.js: -------------------------------------------------------------------------------- 1 | const Debug = require('debug'); 2 | const { ObjectID } = require('mongodb'); 3 | const mongo = require('../db/mongo'); 4 | 5 | // const sequelize = require('../db/sequelize'); 6 | // const users = await sequelize.Users.findAll(); 7 | // const user = await sequelize.Users.create(data); 8 | 9 | const renameFields = item => ({ 10 | ...item, 11 | id: item._id, 12 | _id: undefined 13 | }); 14 | 15 | const getUsers = async () => { 16 | const debug = Debug('app:api:users'); 17 | debug('getUsers:start'); 18 | 19 | const itemsRaw = await mongo.db 20 | .collection('users') 21 | .find({}) 22 | .sort() 23 | .toArray(); 24 | 25 | const items = itemsRaw.map(renameFields); 26 | debug('getUsers:end'); 27 | 28 | return items; 29 | }; 30 | 31 | const getSingleUser = async userId => { 32 | const debug = Debug('app:api:users'); 33 | debug('getSingleUser:start'); 34 | const user = await mongo.db 35 | .collection('users') 36 | .findOne({ _id: new ObjectID(userId) }); 37 | 38 | debug('getSingleUser:end'); 39 | return renameFields(user); 40 | }; 41 | 42 | const addUser = async data => { 43 | const debug = Debug('app:api:users'); 44 | debug('add:start'); 45 | 46 | const insertResult = await mongo.db.collection('users').insertOne(data); 47 | const { _id: id } = insertResult.ops[0]; 48 | 49 | debug('add:end'); 50 | return { id }; 51 | }; 52 | 53 | const updateUser = async (userId, data) => { 54 | const debug = Debug('app:api:users'); 55 | debug('update:start'); 56 | await mongo.db 57 | .collection('users') 58 | .updateOne({ _id: new ObjectID(userId) }, { $set: data }); 59 | debug('update:end'); 60 | }; 61 | 62 | const deleteUser = async userId => { 63 | try { 64 | const debug = Debug('app:api:users'); 65 | debug('delete:start'); 66 | await mongo.db.collection('users').deleteOne({ _id: new ObjectID(userId) }); 67 | debug('delete:end'); 68 | return true; 69 | } catch (e) { 70 | return false; 71 | } 72 | }; 73 | 74 | module.exports = { 75 | getUsers, 76 | getSingleUser, 77 | addUser, 78 | updateUser, 79 | deleteUser 80 | }; 81 | -------------------------------------------------------------------------------- /test/app.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const app = require('../app'); 3 | const security = require('../helpers/security'); 4 | const mongo = require('../db/mongo'); 5 | 6 | const tokenPayload = { 7 | jti: '123', 8 | email: 'test@example.com' 9 | }; 10 | 11 | const delay = ms => setTimeout(() => Promise.resolve(), ms); 12 | 13 | describe('Server', () => { 14 | beforeAll(async () => { 15 | await mongo.connectWithRetry(); 16 | }); 17 | 18 | test('Healthcheck', async () => { 19 | await request(app) 20 | .get('/healthcheck') 21 | .expect(200) 22 | .expect('Content-Type', /json/) 23 | .then(res => { 24 | expect(res.body.mongodb).toBeTruthy(); 25 | expect(res.body.uptime).toBeGreaterThan(0); 26 | }); 27 | }); 28 | 29 | test('Valid authorization', async () => { 30 | const token = await security.getSignedToken(tokenPayload); 31 | await request(app) 32 | .get('/v1/users') 33 | .set('Authorization', `Bearer ${token}`) 34 | .expect(200) 35 | .expect('Content-Type', /json/); 36 | }); 37 | 38 | test('Without authorization header', async () => { 39 | await request(app) 40 | .get('/v1/users') 41 | .expect(401); 42 | }); 43 | 44 | test('Expired token', async () => { 45 | const token = await security.getSignedToken(tokenPayload, { 46 | expiresIn: '100' 47 | }); 48 | await delay(200); 49 | await request(app) 50 | .get('/v1/users') 51 | .set('Authorization', `Bearer ${token}`) 52 | .expect(401); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /test/routes/users.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const app = require('../../app'); 3 | const security = require('../../helpers/security'); 4 | const mongo = require('../../db/mongo'); 5 | 6 | const route = '/v1/users'; 7 | 8 | const tokenPayload = { 9 | jti: '123', 10 | email: 'test@example.com' 11 | }; 12 | 13 | const validUserData = { 14 | email: 'test@example.com' 15 | }; 16 | 17 | const invalidUserData = { 18 | mail: 'test@example.com' 19 | }; 20 | 21 | describe(route, () => { 22 | let userID = null; 23 | let token = null; 24 | 25 | beforeAll(async () => { 26 | await mongo.connectWithRetry(); 27 | token = await security.getSignedToken(tokenPayload); 28 | userID = await request(app) 29 | .post(route) 30 | .set('Authorization', `Bearer ${token}`) 31 | .send(validUserData) 32 | .then(res => res.body.id); 33 | }); 34 | 35 | test(`POST ${route}`, async () => { 36 | await request(app) 37 | .post(route) 38 | .set('Authorization', `Bearer ${token}`) 39 | .send(validUserData) 40 | .expect(200) 41 | .expect('Content-Type', /json/) 42 | .then(res => { 43 | expect(res.body).toHaveProperty('id'); 44 | }); 45 | }); 46 | 47 | test(`POST ${route} - invalid data`, async () => { 48 | await request(app) 49 | .post(route) 50 | .set('Authorization', `Bearer ${token}`) 51 | .send(invalidUserData) 52 | .expect(400) 53 | .expect('Content-Type', /json/) 54 | .then(res => { 55 | expect(res.body).toHaveProperty('error'); 56 | }); 57 | }); 58 | 59 | test(`GET ${route}`, async () => { 60 | await request(app) 61 | .get(route) 62 | .set('Authorization', `Bearer ${token}`) 63 | .expect(200) 64 | .expect('Content-Type', /json/) 65 | .expect(res => { 66 | expect(Array.isArray(res.body)).toBeTruthy(); 67 | const [firstItem] = res.body; 68 | expect(firstItem).toHaveProperty('id'); 69 | expect(firstItem).toHaveProperty('email'); 70 | }); 71 | }); 72 | 73 | test(`GET ${route}/`, async () => { 74 | await request(app) 75 | .get(`${route}/${userID}`) 76 | .set('Authorization', `Bearer ${token}`) 77 | .expect(200) 78 | .expect(res => { 79 | expect(res.body).toHaveProperty('id'); 80 | expect(res.body).toHaveProperty('email'); 81 | }); 82 | }); 83 | 84 | test(`GET ${route}/ - invalid data`, async () => { 85 | await request(app) 86 | .get(`${route}/111`) 87 | .set('Authorization', `Bearer ${token}`) 88 | .expect(400) 89 | .expect('Content-Type', /json/) 90 | .expect(res => { 91 | expect(res.body).toHaveProperty('error'); 92 | }); 93 | }); 94 | }); 95 | --------------------------------------------------------------------------------