├── _config.yml ├── .eslintignore ├── .dockerignore ├── .env.example ├── src ├── middlewares │ ├── not-found-error.js │ ├── index.js │ ├── response-handler.js │ ├── validate-json.js │ └── error-handler.js ├── routes │ ├── v1 │ │ ├── index.js │ │ └── user.route.js │ └── health.route.js ├── config │ ├── config.js │ ├── sequelize.js │ ├── swagger-config.js │ └── logger.js ├── utils │ ├── graceful-shutdown.js │ ├── generate-api-specs.js │ └── api-error.js ├── controllers │ ├── health.controller.js │ └── user.controller.js ├── validations │ └── users-request.schema.js ├── models │ ├── user.model.js │ ├── index.js │ └── skill.model.js ├── services │ └── user.service.js ├── server.js ├── migrations │ └── 20220403185733-inital-setup.cjs └── app.js ├── .sequelizerc ├── .github ├── workflows │ ├── labeler.yml │ ├── auto-pr.yaml │ ├── gh-pages-deploy.yaml │ └── ci.yaml ├── dependabot.yml ├── labeler.yml ├── settings.yml └── actions │ └── node-cache-setup │ └── action.yaml ├── tests ├── fixtures │ └── input.json ├── integration │ ├── routes │ │ ├── health.route.test.js │ │ └── v1 │ │ │ └── user.route.test.js │ └── setup.test.js └── db.test.js ├── Dockerfile ├── .esdoc.json ├── .vscode └── launch.json ├── .eslintrc ├── .gitignore ├── package.json ├── docs └── api-specs.yaml └── README.md /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # ignore 2 | 3 | .vscode/**/* 4 | mochawesome-report/ 5 | 6 | doc/* 7 | docs/* -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .dockerignore 2 | node_modules 3 | npm-debug.log 4 | Dockerfile 5 | .git 6 | .gitignore -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DB_HOST=localhost 2 | B_PORT=5432 3 | DB_NAME=postgres 4 | DB_USER=postgres 5 | DB_PASS=postgres 6 | -------------------------------------------------------------------------------- /src/middlewares/not-found-error.js: -------------------------------------------------------------------------------- 1 | import * as errors from '../utils/api-error.js'; 2 | 3 | const { NotFoundError } = errors.default; 4 | 5 | export default async (req, res, next) => next(new NotFoundError()); 6 | -------------------------------------------------------------------------------- /src/routes/v1/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import usersRoute from './user.route.js'; 4 | 5 | const router = express.Router(); 6 | 7 | router.use('/users', usersRoute); 8 | 9 | export default router; 10 | -------------------------------------------------------------------------------- /.sequelizerc: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | config: path.resolve('./src/config', './config.js'), 5 | "models-path": path.resolve('./src/models'), 6 | "migrations-path": path.resolve('./src/migrations'), 7 | } 8 | -------------------------------------------------------------------------------- /src/middlewares/index.js: -------------------------------------------------------------------------------- 1 | import badJsonHandler from './validate-json.js'; 2 | import notFoundHandler from './not-found-error.js'; 3 | import errorHandler from './error-handler.js'; 4 | 5 | export { 6 | badJsonHandler, 7 | notFoundHandler, 8 | errorHandler, 9 | }; 10 | -------------------------------------------------------------------------------- /src/middlewares/response-handler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Generic success response handler 3 | * 4 | * @author Chetan Patil 5 | * 6 | * @param {*} body - response that needs to be returned as part of API result 7 | */ 8 | export default body => ({ 9 | success: true, 10 | body, 11 | }); 12 | -------------------------------------------------------------------------------- /src/config/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Config details 3 | * 4 | * @author Chetan Patil 5 | * 6 | */ 7 | import 'dotenv/config'; 8 | 9 | export default { 10 | username: process.env.DB_USER, 11 | password: process.env.DB_PASS, 12 | database: process.env.DB_NAME, 13 | host: process.env.DB_HOST, 14 | dialect: 'postgres', 15 | }; 16 | -------------------------------------------------------------------------------- /src/middlewares/validate-json.js: -------------------------------------------------------------------------------- 1 | import * as errors from '../utils/api-error.js'; 2 | 3 | const { BadRequestError } = errors.default; 4 | 5 | export default async (err, req, res, next) => { 6 | if (err instanceof SyntaxError && err.status === 400 && 'body' in err) { 7 | throw new BadRequestError(err.message); 8 | } 9 | return next(); 10 | }; 11 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: "Pull Request Labeler" 2 | on: 3 | - pull_request_target 4 | 5 | jobs: 6 | triage: 7 | permissions: 8 | contents: read 9 | pull-requests: write 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/labeler@v4 13 | with: 14 | repo-token: "${{ secrets.PERSONAL_GH_TOKEN }}" 15 | -------------------------------------------------------------------------------- /src/utils/graceful-shutdown.js: -------------------------------------------------------------------------------- 1 | const gracefulShutdown = async server => { 2 | try { 3 | // await sequelize.close(); 4 | console.info('Closed database connection!'); 5 | server.close(); 6 | process.exit(); 7 | } catch (error) { 8 | console.info(error.message); 9 | process.exit(1); 10 | } 11 | }; 12 | 13 | export { 14 | gracefulShutdown, 15 | }; 16 | -------------------------------------------------------------------------------- /src/utils/generate-api-specs.js: -------------------------------------------------------------------------------- 1 | import { promises as fsPromises } from 'fs'; 2 | import yaml from 'js-yaml'; 3 | 4 | import { swaggerSpec } from '../config/swagger-config.js'; 5 | 6 | export async function generateSpecs() { 7 | const swaggerYaml = yaml.dump(swaggerSpec); 8 | await fsPromises.writeFile('./docs//api-specs.yaml', swaggerYaml); // write file asynchronously 9 | } 10 | -------------------------------------------------------------------------------- /tests/fixtures/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "valid": { 3 | "firstName": "Chetan", 4 | "lastName": "Patil", 5 | "gender": "Male", 6 | "skills": [ 7 | { 8 | "name": "Node.js", 9 | "proficiency": "Advanced" 10 | } 11 | ] 12 | }, 13 | "schemaError": { 14 | "firstNames": "Chetan", 15 | "lastName": "Patil", 16 | "gender": "Male" 17 | } 18 | } -------------------------------------------------------------------------------- /tests/integration/routes/health.route.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | request, expect, httpStatus, server, 3 | } from '../setup.test.js'; 4 | 5 | describe('Health Endpoint', () => { 6 | it('should return 200', async () => { 7 | const res = await request(server) 8 | .get('/health') 9 | .expect(httpStatus.OK); 10 | 11 | expect(res.body.uptime).to.not.be.undefined; 12 | expect(res.body.message).to.equal('Ok'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/integration/setup.test.js: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import { expect } from 'chai'; 3 | import httpStatus from 'http-status'; 4 | 5 | import server from '../../src/server.js'; 6 | import sequelize from '../../src/config/sequelize.js'; 7 | 8 | await sequelize.sync({ force: true }); 9 | console.log('All models were synchronized successfully.'); 10 | 11 | export { 12 | request, 13 | expect, 14 | httpStatus, 15 | server, 16 | }; 17 | -------------------------------------------------------------------------------- /tests/db.test.js: -------------------------------------------------------------------------------- 1 | import db from '../src/models/index.js'; 2 | 3 | const { User, Skill } = db.db; 4 | 5 | const truncateTables = async () => { 6 | await Skill.destroy({ truncate: true, cascade: true }); 7 | await User.destroy({ truncate: true, cascade: true }); 8 | }; 9 | 10 | const insertData = async data => { 11 | await User.create(data, { 12 | include: [ 13 | { model: Skill }, 14 | ], 15 | }); 16 | }; 17 | 18 | export { 19 | truncateTables, 20 | insertData, 21 | }; 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16 2 | 3 | # Create app directory 4 | # Copy files as a non-root user. The `node` user is built in the Node image. 5 | WORKDIR /src 6 | RUN chown node:node ./ 7 | USER node 8 | 9 | ARG NODE_ENV=production 10 | ENV NODE_ENV $NODE_ENV 11 | 12 | # Install app dependencies 13 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 14 | # where available (npm@5+) 15 | COPY package*.json ./ 16 | RUN npm ci && npm cache clean --force 17 | 18 | # Bundle app source 19 | COPY ./src ./ 20 | 21 | EXPOSE 8080 22 | CMD [ "npm", "start" ] 23 | -------------------------------------------------------------------------------- /src/controllers/health.controller.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Health controller 3 | * 4 | * @author Chetan Patil 5 | */ 6 | import httpStatus from 'http-status'; 7 | 8 | /** 9 | * Health controller entry function 10 | * 11 | * @param {*} req - express HTTP request object 12 | * @param {*} res - express HTTP response object 13 | */ 14 | const getHealth = async (req, res) => { 15 | const data = { 16 | uptime: process.uptime(), 17 | message: 'Ok', 18 | date: new Date(), 19 | }; 20 | 21 | res.status(httpStatus.OK).send(data); 22 | }; 23 | 24 | export { 25 | getHealth, 26 | }; 27 | -------------------------------------------------------------------------------- /.esdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": "./src", 3 | "destination": "./doc", 4 | "plugins": [ 5 | { 6 | "name": "esdoc-standard-plugin", 7 | "option": { 8 | "lint": {"enable": true}, 9 | "coverage": {"enable": true}, 10 | "accessor": {"access": ["public", "protected", "private"], "autoPrivate": true}, 11 | "undocumentIdentifier": {"enable": true}, 12 | "unexportedIdentifier": {"enable": false}, 13 | "typeInference": {"enable": true}, 14 | "test": { 15 | "type": "mocha", 16 | "source": "./tests/", 17 | "interfaces": ["describe", "it", "context", "suite", "test"], 18 | "includes": ["(spec|Spec|test|Test)\\.js$"], 19 | "excludes": ["\\.config\\.js$"] 20 | } 21 | } 22 | } 23 | ] 24 | } -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------- # 2 | # ******************************* General ******************************* # 3 | # ----------------------------------------------------------------------- # 4 | ci: 5 | - .github/workflows/* 6 | 7 | maintenance: 8 | - ./* 9 | - .github/* 10 | 11 | documentation: 12 | - README.md 13 | - ./**/README* 14 | 15 | # ----------------------------------------------------------------------- # 16 | # ******************************* Project ******************************* # 17 | # ----------------------------------------------------------------------- # 18 | 19 | # Source Code 20 | source-code: 21 | - src/** 22 | 23 | # Tests 24 | tests: 25 | - tests/** 26 | 27 | # Dockerfile 28 | docker: 29 | - Dockerfile 30 | -------------------------------------------------------------------------------- /src/config/sequelize.js: -------------------------------------------------------------------------------- 1 | import { Sequelize, Transaction } from 'sequelize'; 2 | import { createNamespace } from 'cls-hooked'; 3 | 4 | const namespace = createNamespace('my-namespace'); 5 | Sequelize.useCLS(namespace); 6 | 7 | const sequelize = new Sequelize( 8 | process.env.DB_NAME, 9 | process.env.DB_USER, 10 | process.env.DB_PASS, 11 | { 12 | host: process.env.DB_HOST, 13 | port: process.env.DB_PORT, 14 | dialect: 'postgres', 15 | logging: false, 16 | isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED, 17 | }, 18 | ); 19 | 20 | try { 21 | await sequelize.authenticate(); 22 | console.log('Connection has been established successfully.'); 23 | } catch (error) { 24 | console.error('Unable to connect to the database:', error); 25 | } 26 | 27 | export default sequelize; 28 | -------------------------------------------------------------------------------- /src/validations/users-request.schema.js: -------------------------------------------------------------------------------- 1 | const addUserSchema = { 2 | type: 'object', 3 | properties: { 4 | firstName: { 5 | type: 'string', 6 | }, 7 | lastName: { 8 | type: 'string', 9 | }, 10 | gender: { 11 | type: 'string', 12 | enum: ['Male', 'Female', 'Other'], 13 | }, 14 | skills: { 15 | type: 'array', 16 | items: { 17 | type: 'object', 18 | properties: { 19 | name: { 20 | type: 'string', 21 | }, 22 | proficiency: { 23 | type: 'string', 24 | enum: ['Beginer', 'Intermediate', 'Advanced'], 25 | }, 26 | }, 27 | }, 28 | }, 29 | }, 30 | required: [ 31 | 'firstName', 32 | 'lastName', 33 | 'gender', 34 | ], 35 | additionalProperties: false, 36 | }; 37 | 38 | export { 39 | addUserSchema, 40 | }; 41 | -------------------------------------------------------------------------------- /src/middlewares/error-handler.js: -------------------------------------------------------------------------------- 1 | import httpStatus from 'http-status'; 2 | import { ValidationError } from 'express-json-validator-middleware'; 3 | 4 | import * as errors from '../utils/api-error.js'; 5 | 6 | const { APIError } = errors.default; 7 | 8 | export default async (err, req, res, next) => { 9 | let { status } = err; 10 | const { message } = err; 11 | 12 | let error = {}; 13 | let msg; 14 | 15 | // catch all api errors 16 | if (err instanceof APIError) { 17 | msg = message; 18 | } else if (err instanceof ValidationError) { 19 | status = httpStatus.BAD_REQUEST; 20 | msg = httpStatus[httpStatus.BAD_REQUEST]; 21 | error = err.validationErrors; 22 | } else { 23 | status = httpStatus.INTERNAL_SERVER_ERROR; 24 | msg = 'Something went wrong!'; 25 | error = err; 26 | } 27 | 28 | // connect all errors 29 | return res.status(status).send({ 30 | success: false, 31 | msg, 32 | error, 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /.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": "Attach by Process ID", 9 | "processId": "${command:PickProcess}", 10 | "request": "attach", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "type": "pwa-node" 15 | }, 16 | { 17 | "args": [ 18 | "--timeout", 19 | "999999", 20 | "--colors", 21 | "--exit", 22 | "${workspaceFolder}/tests/**/*" 23 | ], 24 | "internalConsoleOptions": "openOnSessionStart", 25 | "name": "Mocha Tests", 26 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 27 | "request": "launch", 28 | "skipFiles": [ 29 | "/**" 30 | ], 31 | "type": "pwa-node" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /src/models/user.model.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Model class for "skill" 3 | * 4 | * @author Chetan Patil 5 | * 6 | * @param {Sequelize} sequelize - sequelize object 7 | * @param {Sequelize.DataTypes} DataTypes - sequelize datatypes 8 | * 9 | * @returns User - sequelize model for user entity 10 | */ 11 | export default (sequelize, DataTypes) => { 12 | const User = sequelize.define('User', { 13 | id: { 14 | type: DataTypes.INTEGER, 15 | allowNull: false, 16 | primaryKey: true, 17 | autoIncrement: true, 18 | }, 19 | firstName: DataTypes.STRING, 20 | lastName: DataTypes.STRING, 21 | gender: { 22 | type: DataTypes.ENUM, 23 | values: ['Male', 'Female', 'Other'], 24 | }, 25 | createdAt: DataTypes.DATE, 26 | updatedAt: DataTypes.DATE, 27 | }, { 28 | tableName: 'user', 29 | underscored: true, 30 | }); 31 | 32 | User.associate = models => { 33 | models.User.hasMany(models.Skill, { foreignKey: 'userId', targetId: 'id' }); 34 | }; 35 | return User; 36 | }; 37 | -------------------------------------------------------------------------------- /src/models/index.js: -------------------------------------------------------------------------------- 1 | /* eslint no-underscore-dangle: 0 */ 2 | 3 | import fs from 'fs'; 4 | import { join, dirname, basename } from 'path'; 5 | import { fileURLToPath } from 'url'; 6 | import { Sequelize, DataTypes } from 'sequelize'; 7 | 8 | import sequelize from '../config/sequelize.js'; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = dirname(__filename); 12 | const base = basename(__filename); 13 | const db = {}; 14 | 15 | const files = fs 16 | .readdirSync(__dirname) 17 | .filter(file => (file.indexOf('.') !== 0) && (file !== base) && (file.slice(-3) === '.js')); 18 | 19 | await Promise.all(files.map(async file => { 20 | const model = (await import(join(__dirname, file))).default(sequelize, DataTypes); 21 | db[model.name] = model; 22 | })); 23 | 24 | Object.keys(db).forEach(modelName => { 25 | if (db[modelName].associate) { 26 | db[modelName].associate(db); 27 | } 28 | }); 29 | 30 | db.sequelize = sequelize; 31 | db.Sequelize = Sequelize; 32 | 33 | export default { 34 | db, 35 | }; 36 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | repository: 2 | # See https://developer.github.com/v3/repos/#edit for all available settings. 3 | name: node-pg-sequelize 4 | description: Boilerplate code for Node PostgreSQL Sequelize with unit/integration test setup 5 | topics: nodejs, boilerplate, postgresql, sequelize 6 | visibility: public 7 | has_issues: true 8 | has_projects: false 9 | has_wiki: true 10 | has_downloads: true 11 | default_branch: main 12 | delete_branch_on_merge: true 13 | allow_squash_merge: true 14 | allow_merge_commit: true 15 | allow_rebase_merge: true 16 | 17 | labels: 18 | - name: maintenance 19 | color: 6BA890 20 | description: General repo maintenance / configuration 21 | - name: ci 22 | color: E8A4BC 23 | description: Changes related to automation 24 | ## Service related 25 | - name: source-code 26 | color: aaa1c7 27 | description: Changes made to the Source Code 28 | - name: tests 29 | color: 695a99 30 | description: Changes made to Tests 31 | - name: docker 32 | color: 1a8f25 33 | description: Changes made to Dockerfile 34 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2021": true, 4 | "node": true, 5 | "mocha": true, 6 | "es6": true 7 | }, 8 | "extends": ["eslint:recommended", "google", "airbnb-base"], 9 | "parserOptions": { 10 | "ecmaVersion": "latest", 11 | "sourceType": "module" 12 | }, 13 | "plugins": ["import"], 14 | "rules": { 15 | "no-console": "off", 16 | "import/prefer-default-export": "off", 17 | "arrow-parens": ["error", "as-needed"], 18 | "max-len": [ 19 | "error", 20 | 100, 21 | 2, 22 | { 23 | "ignoreUrls": true, 24 | "ignoreComments": false, 25 | "ignoreRegExpLiterals": true, 26 | "ignoreStrings": false, 27 | "ignoreTemplateLiterals": true 28 | } 29 | ], 30 | "no-unused-vars": ["error", { "argsIgnorePattern": "next" }], 31 | "import/extensions": [ 32 | "error", 33 | { 34 | "js": "ignorePackages" 35 | } 36 | ] 37 | }, 38 | "overrides": [ 39 | { 40 | "files": ["*.test.js", "*.spec.js"], 41 | "rules": { 42 | "no-unused-expressions": "off" 43 | } 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /.github/actions/node-cache-setup/action.yaml: -------------------------------------------------------------------------------- 1 | name: "Setup Node and Caching" 2 | description: "Setup a Node environment and restore cache if any" 3 | 4 | outputs: 5 | cache-hit: 6 | description: Variable defining wheather cache found or not 7 | value: ${{ steps.cache-node-modules.outputs.cache-hit }} 8 | 9 | runs: 10 | using: composite 11 | steps: 12 | - name: Set up Node 16 13 | uses: actions/setup-node@v2 14 | with: 15 | node-version: 16 16 | 17 | - name: Cache node modules 18 | id: cache-nodemodules 19 | uses: actions/cache@v2 20 | env: 21 | cache-name: cache-node-modules 22 | with: 23 | # caching node_modules 24 | path: node_modules 25 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 26 | restore-keys: | 27 | ${{ runner.os }}-build-${{ env.cache-name }}- 28 | ${{ runner.os }}-build- 29 | ${{ runner.os }}- 30 | 31 | - name: Install Dependencies 32 | id: install-dep 33 | if: steps.cache-nodemodules.outputs.cache-hit != 'true' 34 | shell: sh 35 | run: npm ci 36 | -------------------------------------------------------------------------------- /src/routes/health.route.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import { getHealth } from '../controllers/health.controller.js'; 4 | 5 | const router = express.Router(); 6 | 7 | /** 8 | * @openapi 9 | * /health: 10 | * get: 11 | * tags: 12 | * - Health 13 | * description: Health Endpoint 14 | * responses: 15 | * 200: 16 | * description: Application helath details. 17 | * content: 18 | * application/json: 19 | * schema: 20 | * type: object 21 | * properties: 22 | * uptime: 23 | * type: number 24 | * format: float 25 | * description: Time (in seconds) specifying apllication running from how long 26 | * message: 27 | * type: string 28 | * description: Status message ok 29 | * date: 30 | * type: string 31 | * format: date-time 32 | * description: Current date in ISO format 33 | */ 34 | router 35 | .route('/') 36 | .get(getHealth); 37 | 38 | export default router; 39 | -------------------------------------------------------------------------------- /.github/workflows/auto-pr.yaml: -------------------------------------------------------------------------------- 1 | name: Automatic draft PR 2 | 3 | on: 4 | push: 5 | branches: 6 | - feature/** 7 | - fix/** 8 | - feat/** 9 | - chore/** 10 | 11 | jobs: 12 | auto-pull-request: 13 | name: Create or update draft pull request 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Create or update pull request 17 | id: create-pr 18 | uses: vsoch/pull-request-action@1.0.19 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.PERSONAL_GH_TOKEN }} 21 | PASS_ON_ERROR: true 22 | PULL_REQUEST_UPDATE: false 23 | PULL_REQUEST_DRAFT: true 24 | PULL_REQUEST_ASSIGNEES: ${{ github.actor }} 25 | PULL_REQUEST_BRANCH: main 26 | PULL_REQUEST_TITLE: '${{ github.ref_name }}: Merge into main' 27 | PULL_REQUEST_BODY: | 28 | > This PR is auto created, please update PR Title and body as per your need. 29 | 30 | ## Description 31 | 32 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 33 | -------------------------------------------------------------------------------- /src/services/user.service.js: -------------------------------------------------------------------------------- 1 | /** 2 | * User service which serves DB operation 3 | * required by user controller 4 | * 5 | * @author Chetan Patil 6 | */ 7 | import db from '../models/index.js'; 8 | 9 | /** 10 | * @constant {Sequelize.models} - User & Skill model extracted from db import 11 | */ 12 | const { User, Skill } = db.db; 13 | 14 | /** 15 | * findAll function to retrieve all available users in system 16 | * 17 | * @returns {Promise} User object array 18 | */ 19 | const findAll = async () => User.findAll({ 20 | include: [ 21 | { model: Skill }, 22 | ], 23 | }); 24 | 25 | /** 26 | * findById function to fetch data for provided userId 27 | * 28 | * @param {number} userId - user id for which data needs to be fetched 29 | * @returns {Promise} User object 30 | */ 31 | const findById = async userId => User.findOne({ 32 | where: { id: userId }, 33 | include: [ 34 | { model: Skill }, 35 | ], 36 | }); 37 | 38 | /** 39 | * create function to add new user 40 | * 41 | * @param {object} data - user object with information to be saved in system 42 | * @returns {Promise} Created user object 43 | */ 44 | const create = async data => User.create(data, { 45 | include: [ 46 | { model: Skill }, 47 | ], 48 | }); 49 | 50 | export { 51 | findAll, 52 | findById, 53 | create, 54 | }; 55 | -------------------------------------------------------------------------------- /src/models/skill.model.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Model class for "skill" 3 | * 4 | * @author Chetan Patil 5 | * 6 | * @param {Sequelize} sequelize - sequelize object 7 | * @param {Sequelize.DataTypes} DataTypes - sequelize datatypes 8 | * 9 | * @returns Skill - sequelize model 10 | */ 11 | export default (sequelize, DataTypes) => { 12 | const Skill = sequelize.define('Skill', { 13 | id: { 14 | type: DataTypes.INTEGER, 15 | allowNull: false, 16 | primaryKey: true, 17 | autoIncrement: true, 18 | }, 19 | userId: { 20 | type: DataTypes.INTEGER, 21 | allowNull: false, 22 | references: { 23 | model: 'user', 24 | key: 'id', 25 | }, 26 | }, 27 | name: { 28 | type: DataTypes.STRING, 29 | allowNull: false, 30 | }, 31 | proficiency: { 32 | type: DataTypes.ENUM, 33 | values: ['Beginer', 'Intermediate', 'Advanced'], 34 | }, 35 | createdAt: DataTypes.DATE, 36 | updatedAt: DataTypes.DATE, 37 | }, { 38 | tableName: 'skill', 39 | underscored: true, 40 | name: { 41 | singular: 'skill', 42 | plural: 'skills', 43 | }, 44 | }); 45 | 46 | Skill.associate = models => { 47 | models.Skill.belongsTo(models.User, { foreignKey: 'userId', targetId: 'id' }); 48 | }; 49 | return Skill; 50 | }; 51 | -------------------------------------------------------------------------------- /src/config/swagger-config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Swagger definition configuration file 3 | * 4 | * @author Chetan Patil 5 | * 6 | */ 7 | /* eslint max-len: ["error", { "ignoreComments": true, "ignoreStrings": true }] */ 8 | import swaggerJSDoc from 'swagger-jsdoc'; 9 | 10 | /** 11 | * @constant {object} swaggerDefinition - OpenAPI specification details 12 | */ 13 | const swaggerDefinition = { 14 | openapi: '3.0.3', 15 | info: { 16 | title: 'Express API for User Profile', 17 | version: '1.0.0', 18 | description: 'Simple boilerplate code base for creating APIs with `Node.js Express` framework using `Sequelize` with `PostgreSQL` database.', 19 | contact: { 20 | name: 'Chetan Patil', 21 | url: 'https://github.com/Chetan07j', 22 | }, 23 | }, 24 | servers: [ 25 | { 26 | url: 'http://localhost:3000', 27 | description: 'Development server', 28 | }, 29 | ], 30 | }; 31 | 32 | /** 33 | * @constant {object} options - object holding swaggerDefintion and apis paths required for JSDoc parsing 34 | */ 35 | const options = { 36 | definition: swaggerDefinition, 37 | // Paths to files containing OpenAPI definitions 38 | apis: ['src/routes/**/*.route.js'], 39 | }; 40 | 41 | /** 42 | * @constant {object} swaggerSpec - swaggerJSDoc parsed specifications 43 | */ 44 | const swaggerSpec = swaggerJSDoc(options); 45 | 46 | export { 47 | swaggerSpec, 48 | }; 49 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages-deploy.yaml: -------------------------------------------------------------------------------- 1 | # Workflow to deploy static esdoc documentation to GitHub Pages 2 | name: Deploy ESDoc Documentation to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: [main] 8 | 9 | # # Allows you to run this workflow manually from the Actions tab 10 | # workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | deploy: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 🛎️ 29 | uses: actions/checkout@v3 30 | 31 | - name: Install 🔧 32 | run: npm install 33 | 34 | - name: Generate ESDoc 📖 35 | run: npm run esdoc 36 | 37 | - name: Setup Pages ⚙️ 38 | uses: actions/configure-pages@v3 39 | 40 | - name: Upload artifact 📁 41 | uses: actions/upload-pages-artifact@v1 42 | with: 43 | # Upload doc directory 44 | path: './doc' 45 | 46 | - name: Deploy to GitHub Pages 🚀 47 | id: deployment 48 | uses: actions/deploy-pages@v2 49 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unresolved 2 | import chalk from 'chalk'; 3 | import stoppable from 'stoppable'; 4 | import { fileURLToPath } from 'url'; 5 | import 'dotenv/config'; 6 | 7 | import app from './app.js'; 8 | import { gracefulShutdown } from './utils/graceful-shutdown.js'; 9 | import { Logger } from './config/logger.js'; 10 | 11 | const logger = Logger(fileURLToPath(import.meta.url)); 12 | 13 | const port = process.env.APP_PORT || 3000; 14 | 15 | const server = app.listen(port, () => { 16 | logger.info(`App running on port ${chalk.greenBright(port)}...`); 17 | }); 18 | 19 | // In case of an error 20 | app.on('error', (appErr, appCtx) => { 21 | logger.error(`App Error: '${appErr.stack}' on url: '${appCtx.req.url}' with headers: '${appCtx.req.headers}'`); 22 | }); 23 | 24 | // Handle unhandled promise rejections 25 | process.on('unhandledRejection', async err => { 26 | logger.error(chalk.bgRed('UNHANDLED REJECTION! 💥 Shutting down...')); 27 | logger.error(err.name, err.message); 28 | 29 | await gracefulShutdown(stoppable(server)); 30 | }); 31 | 32 | // Handle uncaught exceptions 33 | process.on('uncaughtException', async uncaughtExc => { 34 | // Won't execute 35 | logger.error(chalk.bgRed('UNCAUGHT EXCEPTION! 💥 Shutting down...')); 36 | logger.error(`UncaughtException Error: ${uncaughtExc}`); 37 | logger.error(`UncaughtException Stack: ${JSON.stringify(uncaughtExc.stack)}`); 38 | 39 | await gracefulShutdown(stoppable(server)); 40 | }); 41 | 42 | // Graceful shutdown on SIGINT and SIGTERM signals 43 | ['SIGINT', 'SIGTERM'].forEach(signal => { 44 | process.on(signal, async () => { 45 | logger.warn(`Received ${signal} signal. Shutting down...`); 46 | await gracefulShutdown(server); 47 | }); 48 | }); 49 | 50 | export default server; 51 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | lint: 8 | runs-on: ubuntu-latest 9 | name: Lint 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: ./.github/actions/node-cache-setup 13 | 14 | - name: Lint Check 15 | run: npm run lint 16 | 17 | test: 18 | runs-on: ubuntu-latest 19 | name: Unit tests 20 | 21 | # Service containers to run with `container-job` 22 | services: 23 | # Label used to access the service container 24 | postgres: 25 | # Docker Hub image 26 | image: postgres 27 | # Provide the password for postgres 28 | env: 29 | POSTGRES_USER: postgres 30 | POSTGRES_PASSWORD: postgres 31 | POSTGRES_DB: postgres 32 | # Set health checks to wait until postgres has started 33 | options: >- 34 | --health-cmd pg_isready 35 | --health-interval 10s 36 | --health-timeout 5s 37 | --health-retries 5 38 | ports: 39 | # Maps tcp port 5432 on service container to the host 40 | - 5432:5432 41 | 42 | steps: 43 | - uses: actions/checkout@v2 44 | - uses: ./.github/actions/node-cache-setup 45 | 46 | - name: Run tests with coverage 47 | run: npm run coverage 48 | env: 49 | DB_HOST: localhost 50 | DB_PORT: 5432 51 | DB_NAME: postgres 52 | DB_USER: postgres 53 | DB_PASS: postgres 54 | 55 | sql-check: 56 | name: SQL Check 57 | runs-on: ubuntu-latest 58 | steps: 59 | - uses: actions/checkout@master 60 | - uses: yokawasa/action-sqlcheck@v1.4.0 61 | with: 62 | post-comment: true 63 | risk-level: 2 64 | verbose: true 65 | token: ${{ secrets.GITHUB_TOKEN }} 66 | -------------------------------------------------------------------------------- /src/migrations/20220403185733-inital-setup.cjs: -------------------------------------------------------------------------------- 1 | const { Sequelize, DataTypes } = require('sequelize'); 2 | 3 | const tableNames = ['user', 'skill']; 4 | 5 | module.exports = { 6 | up: async (queryInterface) => { 7 | await queryInterface.sequelize.query(` 8 | CREATE TYPE enum_user_gender AS ENUM ('Male', 'Female', 'Other'); 9 | CREATE TYPE enum_skill_proficiency AS ENUM ('Beginer', 'Intermediate', 'Advanced'); 10 | 11 | CREATE TABLE IF NOT EXISTS public."user" 12 | ( 13 | id serial NOT NULL, 14 | first_name character varying(128) COLLATE pg_catalog."default", 15 | last_name character varying(128) COLLATE pg_catalog."default", 16 | gender enum_user_gender, 17 | created_at timestamp with time zone, 18 | updated_at timestamp with time zone, 19 | CONSTRAINT user_pkey PRIMARY KEY (id) 20 | ); 21 | 22 | CREATE TABLE IF NOT EXISTS public.skill 23 | ( 24 | id serial NOT NULL, 25 | user_id integer NOT NULL, 26 | name character varying(255) COLLATE pg_catalog."default" NOT NULL, 27 | proficiency enum_skill_proficiency, 28 | created_at timestamp with time zone, 29 | updated_at timestamp with time zone, 30 | CONSTRAINT skill_pkey PRIMARY KEY (id), 31 | CONSTRAINT "skill_user_id_fkey" FOREIGN KEY (user_id) 32 | REFERENCES public."user" (id) MATCH SIMPLE 33 | ON UPDATE NO ACTION 34 | ON DELETE NO ACTION 35 | ); 36 | `); 37 | }, 38 | 39 | down: async (queryInterface, Sequelize) => { 40 | await queryInterface.sequelize.transaction(async (transaction) => { 41 | const tablePromises = tableNames.map(async table => { 42 | await queryInterface.dropTable(table, { transaction, cascade: true }); 43 | }); 44 | 45 | await Promise.all(tablePromises); 46 | }); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/controllers/user.controller.js: -------------------------------------------------------------------------------- 1 | /** 2 | * User controller 3 | * 4 | * @author Chetan Patil 5 | */ 6 | import httpStatus from 'http-status'; 7 | 8 | import * as errors from '../utils/api-error.js'; 9 | import * as response from '../middlewares/response-handler.js'; 10 | import { findAll, findById, create } from '../services/user.service.js'; 11 | 12 | /** 13 | * @constant {function} responseHandler - function to form generic success response 14 | */ 15 | const responseHandler = response.default; 16 | /** 17 | * @constant {NotFoundError} NotFoundError - not found error object 18 | */ 19 | const { NotFoundError } = errors.default; 20 | 21 | /** 22 | * Function which provides functionality 23 | * to add/create new user in system 24 | * 25 | * @param {*} req - express HTTP request object 26 | * @param {*} res - express HTTP response object 27 | */ 28 | const addUser = async (req, res) => { 29 | const userDetails = await create(req.body); 30 | res.status(httpStatus.CREATED).send(responseHandler(userDetails)); 31 | }; 32 | 33 | /** 34 | * Function which provides functionality 35 | * to retrieve all users present in system 36 | * 37 | * @param {*} req - express HTTP request object 38 | * @param {*} res - express HTTP response object 39 | */ 40 | const getUsers = async (req, res) => { 41 | const users = await findAll(); 42 | res.status(httpStatus.OK).send(responseHandler(users)); 43 | }; 44 | 45 | /** 46 | * Function which provides functionality 47 | * to retrieve specific user based on provided userId 48 | * 49 | * @param {*} req - express HTTP request object 50 | * @param {*} res - express HTTP response object 51 | * 52 | * @throws {NotFoundError} - if no such user exists for provided userId 53 | */ 54 | const getUser = async (req, res) => { 55 | const user = await findById(req.params.userId); 56 | if (!user) { 57 | throw new NotFoundError(); 58 | } 59 | 60 | res.status(httpStatus.OK).send(responseHandler(user)); 61 | }; 62 | 63 | export { 64 | addUser, 65 | getUsers, 66 | getUser, 67 | }; 68 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import 'express-async-errors'; 3 | 4 | import helmet from 'helmet'; 5 | import cors from 'cors'; 6 | import morgan from 'morgan'; 7 | import { fileURLToPath } from 'url'; 8 | import swaggerUi from 'swagger-ui-express'; 9 | 10 | import { badJsonHandler, notFoundHandler, errorHandler } from './middlewares/index.js'; 11 | import { Logger } from './config/logger.js'; 12 | import { swaggerSpec } from './config/swagger-config.js'; 13 | import { generateSpecs } from './utils/generate-api-specs.js'; 14 | 15 | import healthRoute from './routes/health.route.js'; 16 | import v1Routes from './routes/v1/index.js'; 17 | 18 | const logger = Logger(fileURLToPath(import.meta.url)); 19 | 20 | const app = express(); 21 | 22 | // disable `X-Powered-By` header that reveals information about the server 23 | app.disable('x-powered-by'); 24 | 25 | // set security HTTP headers 26 | app.use(helmet()); 27 | 28 | // parse json request body 29 | app.use(express.json()); 30 | 31 | // parse urlencoded request body 32 | app.use(express.urlencoded({ extended: true })); 33 | 34 | // enable cors 35 | app.use(cors()); 36 | app.options('*', cors()); 37 | 38 | app.use(morgan( 39 | 'combined', 40 | { 41 | write(message) { 42 | logger.info(message.substring(0, message.lastIndexOf('\n'))); 43 | }, 44 | skip() { 45 | return process.env.NODE_ENV === 'test'; 46 | }, 47 | }, 48 | )); 49 | 50 | // handle bad json format 51 | app.use(badJsonHandler); 52 | 53 | app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { 54 | explorer: true, 55 | 56 | customCssUrl: 57 | 'https://cdn.jsdelivr.net/npm/swagger-ui-themes@3.0.1/themes/3.x/theme-material.css', 58 | })); 59 | 60 | app.use('/health', healthRoute); 61 | 62 | // v1 api routes 63 | app.use('/v1', v1Routes); 64 | 65 | // handle 404 not found error 66 | app.use(notFoundHandler); 67 | 68 | // catch all errors 69 | app.use(errorHandler); 70 | 71 | // generate swagger API specifications in yaml format 72 | generateSpecs(); 73 | 74 | export default app; 75 | -------------------------------------------------------------------------------- /src/utils/api-error.js: -------------------------------------------------------------------------------- 1 | /* eslint max-classes-per-file: ["error", 2] */ 2 | /* eslint no-multi-assign: ["error", { "ignoreNonDeclaration": true }] */ 3 | /* eslint no-param-reassign: ["error", { "props": false }] */ 4 | import httpStatus from 'http-status'; 5 | 6 | class APIError extends Error { 7 | /** 8 | * 9 | * @constructor 10 | * 11 | * @param {number} status - HTTP status code representing error type 12 | * @param {string} message - Error message describing cause of error 13 | */ 14 | constructor(status, message) { 15 | super(); 16 | this.status = status; 17 | this.message = message; 18 | } 19 | } 20 | 21 | const apiErrors = Object.entries({ 22 | BadRequest: { 23 | statusCode: httpStatus.BAD_REQUEST, 24 | message: httpStatus[httpStatus.BAD_REQUEST], 25 | }, 26 | Unauthorized: { 27 | statusCode: httpStatus.UNAUTHORIZED, 28 | message: httpStatus[httpStatus.UNAUTHORIZED], 29 | }, 30 | Forbidden: { 31 | statusCode: httpStatus.FORBIDDEN, 32 | message: httpStatus[httpStatus.FORBIDDEN], 33 | }, 34 | NotFound: { 35 | statusCode: httpStatus.NOT_FOUND, 36 | message: httpStatus[httpStatus.NOT_FOUND], 37 | }, 38 | Conflict: { 39 | statusCode: httpStatus.CONFLICT, 40 | message: httpStatus[httpStatus.CONFLICT], 41 | }, 42 | UnProcessableEntity: { 43 | statusCode: httpStatus.UNPROCESSABLE_ENTITY, 44 | message: httpStatus[httpStatus.UNPROCESSABLE_ENTITY], 45 | }, 46 | InternalServer: { 47 | statusCode: httpStatus.INTERNAL_SERVER_ERROR, 48 | message: httpStatus[httpStatus.INTERNAL_SERVER_ERROR], 49 | }, 50 | MethodNotAllowed: { 51 | statusCode: httpStatus.METHOD_NOT_ALLOWED, 52 | message: httpStatus[httpStatus.METHOD_NOT_ALLOWED], 53 | }, 54 | }).reduce((map, [name, data]) => { 55 | map[`${name}Error`] = map[name] = class extends APIError { 56 | constructor(message = data.message) { 57 | super(data.statusCode, message); 58 | } 59 | }; 60 | return map; 61 | }, {}); 62 | 63 | /** 64 | * API Error class which holds different kind of error types 65 | */ 66 | export default { 67 | ...apiErrors, 68 | APIError, 69 | }; 70 | -------------------------------------------------------------------------------- /tests/integration/routes/v1/user.route.test.js: -------------------------------------------------------------------------------- 1 | import { readFile } from 'fs/promises'; 2 | import { 3 | request, expect, httpStatus, server, 4 | } from '../../setup.test.js'; 5 | import { truncateTables, insertData } from '../../../db.test.js'; 6 | 7 | let data; 8 | 9 | describe('Users Endpoint', () => { 10 | before((async () => { 11 | data = JSON.parse(await readFile(new URL('../../../fixtures/input.json', import.meta.url))); 12 | })); 13 | 14 | describe('POST /v1/users', () => { 15 | before(async () => { 16 | await truncateTables(); 17 | }); 18 | 19 | it('should return 201 and successfully create new user', async () => { 20 | const res = await request(server) 21 | .post('/v1/users') 22 | .send(data.valid) 23 | .expect(httpStatus.CREATED); 24 | 25 | expect(res.body).to.have.property('body'); 26 | expect(res.body.body.id).to.not.be.undefined; 27 | }); 28 | 29 | it('should return error if input schema validation fails', async () => { 30 | const res = await request(server) 31 | .post('/v1/users') 32 | .send(data.schemaError) 33 | .expect(httpStatus.BAD_REQUEST); 34 | 35 | expect(res.body.id).to.be.undefined; 36 | expect(res.body.success).to.be.false; 37 | expect(res.body.error).to.have.property('body'); 38 | }); 39 | }); 40 | 41 | describe('GET /v1/users', () => { 42 | before(async () => { 43 | await truncateTables(); 44 | }); 45 | 46 | it('should return 200 and successfully fetched all avaialble users', async () => { 47 | await insertData(data.valid); 48 | 49 | const res = await request(server) 50 | .get('/v1/users') 51 | .expect(httpStatus.OK); 52 | 53 | expect(res.body).to.have.property('body'); 54 | expect(res.body.body).to.be.an('array'); 55 | expect(res.body.body).not.empty; 56 | 57 | expect(res.body.body[0].id).to.not.be.undefined; 58 | expect(res.body.body[0].firstName).to.equal(data.valid.firstName); 59 | expect(res.body.body[0].lastName).to.equal(data.valid.lastName); 60 | expect(res.body.body[0].gender).to.equal(data.valid.gender); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # .vscode 107 | .vscode/**/* 108 | !.vscode/launch.json 109 | 110 | # mocha-reports 111 | mochawesome-report/ 112 | 113 | # lock files 114 | yarn.lock 115 | 116 | doc/ -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pg-sequelize", 3 | "version": "1.0.0", 4 | "description": "Node PostgreSQL Sequelize boilerplate", 5 | "main": "./src/server.js", 6 | "exports": "./src/server.js", 7 | "scripts": { 8 | "start": "npm run migration && node src/server.js", 9 | "local": "npm run migration && nodemon src/server.js", 10 | "test": "npm run migration && NODE_ENV=test mocha tests/* -r dotenv/config --timeout 1000 --recursive --exit", 11 | "migration": "npx sequelize-cli db:migrate", 12 | "migration:undo": "npx sequelize-cli db:migrate:undo", 13 | "coverage": "c8 --reporter lcov --reporter text --reporter html npm test", 14 | "lint": "eslint .", 15 | "lint:fix": "eslint . --fix", 16 | "esdoc": "esdoc" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/Chetan07j/node-pg-sequelize.git" 21 | }, 22 | "author": "chetan0779@gmail.com", 23 | "license": "ISC", 24 | "dependencies": { 25 | "chalk": "^5.3.0", 26 | "cls-hooked": "^4.2.2", 27 | "cors": "^2.8.5", 28 | "dotenv": "^16.4.5", 29 | "esdoc": "^1.1.0", 30 | "esdoc-ecmascript-proposal-plugin": "^1.0.0", 31 | "esdoc-integrate-test-plugin": "^1.0.0", 32 | "esdoc-standard-plugin": "^1.0.0", 33 | "express": "^4.19.2", 34 | "express-async-errors": "^3.1.1", 35 | "express-json-validator-middleware": "^3.0.1", 36 | "helmet": "^7.1.0", 37 | "http-status": "^1.7.4", 38 | "js-yaml": "^4.1.0", 39 | "morgan": "^1.10.0", 40 | "pg": "^8.12.0", 41 | "pg-hstore": "^2.3.4", 42 | "sequelize": "^6.37.3", 43 | "stoppable": "^1.1.0", 44 | "swagger-jsdoc": "^6.2.8", 45 | "swagger-ui-express": "^5.0.0", 46 | "winston": "^3.13.0" 47 | }, 48 | "type": "module", 49 | "devDependencies": { 50 | "c8": "^9.1.0", 51 | "chai": "^5.1.0", 52 | "eslint": "^8.56.0", 53 | "eslint-config-airbnb-base": "^15.0.0", 54 | "eslint-config-google": "^0.14.0", 55 | "eslint-plugin-import": "^2.29.1", 56 | "mocha": "^10.4.0", 57 | "mochawesome": "^7.1.3", 58 | "supertest": "^6.3.4" 59 | }, 60 | "c8": { 61 | "all": true, 62 | "include": [ 63 | "src/*" 64 | ], 65 | "exclude": [ 66 | "src/config/*", 67 | "src/middlewares/*", 68 | "src/utils/*" 69 | ], 70 | "check-coverage": false, 71 | "branches": 80, 72 | "lines": 80, 73 | "functions": 80, 74 | "statements": 80 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/config/logger.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Logger: winston logger 3 | * 4 | * @author Chetan Patil 5 | * @version 1.0.0 6 | */ 7 | import { transports, createLogger, format } from 'winston'; 8 | 9 | const { 10 | combine, printf, errors, 11 | } = format; 12 | 13 | const { NODE_ENV, JSON_LOG = 'false' } = process.env; 14 | 15 | const severityLookup = { 16 | default: 'DEFAULT', 17 | silly: 'DEFAULT', 18 | verbose: 'DEBUG', 19 | debug: 'DEBUG', 20 | http: 'NOTICE', 21 | info: 'INFO', 22 | warn: 'WARNING', 23 | error: 'ERROR', 24 | }; 25 | 26 | const setTrasnsport = () => [new transports.Console()]; 27 | 28 | const getFilename = input => { 29 | const parts = input.split(/[\\/]/); 30 | return `${parts[parts.length - 2]}/${parts.pop()}`; 31 | }; 32 | 33 | /** 34 | * In this function custom log format is created 35 | * Here we have defined two formats one is JSON & other SingleLine 36 | * 37 | * If "JSON_LOG" from env is set to true then JSON log is returned 38 | * else single line 39 | * 40 | * @param {string} filename - filename in which log is added 41 | */ 42 | 43 | const loggerFormat = filename => printf(({ 44 | // eslint-disable-next-line no-shadow 45 | timestamp, level, message, stack, 46 | }) => { 47 | let fName = filename; 48 | let output = { 49 | processPID: process.pid, 50 | level: level.toUpperCase(), 51 | file: fName, 52 | message, 53 | ts: timestamp, 54 | severity: severityLookup[level] || severityLookup.default, 55 | }; 56 | 57 | if (stack) { 58 | const frame = (stack.split('\n')[1]).trim().split(' '); 59 | const stackTrace = { 60 | function: frame[1], 61 | line: frame[2].split(':')[2], 62 | stack, 63 | }; 64 | // eslint-disable-next-line prefer-destructuring 65 | fName = `${getFilename(frame[2]).split(':')[0]}-[Line: ${stackTrace.line}]`; 66 | output = { ...output, ...stackTrace }; 67 | } 68 | 69 | const lChar = level.charAt(0).toUpperCase(); 70 | let singleLineLog = `${timestamp}-[${process.pid}]-${lChar}-${fName}-${message}`; 71 | 72 | if (stack) { 73 | singleLineLog = `${singleLineLog}- {stack: ${stack}}`; 74 | } 75 | 76 | return JSON.parse(JSON_LOG.toLowerCase()) ? JSON.stringify(output) : singleLineLog; 77 | }); 78 | 79 | const Logger = filepath => createLogger({ 80 | format: combine( 81 | format.colorize({ all: true }), 82 | format.timestamp(), 83 | // format.align(), 84 | errors({ stack: true }), 85 | loggerFormat(getFilename(filepath)), 86 | ), 87 | silent: (NODE_ENV === 'test'), 88 | defaultMeta: { 89 | service: 'my-service', 90 | }, 91 | transports: setTrasnsport(), 92 | }); 93 | 94 | export { 95 | Logger, 96 | }; 97 | -------------------------------------------------------------------------------- /src/routes/v1/user.route.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { Validator } from 'express-json-validator-middleware'; 3 | 4 | import { addUser, getUsers, getUser } from '../../controllers/user.controller.js'; 5 | import { addUserSchema } from '../../validations/users-request.schema.js'; 6 | 7 | const router = express.Router(); 8 | const { validate } = new Validator(); 9 | 10 | /** 11 | * @openapi 12 | * components: 13 | * schemas: 14 | * Skills: 15 | * type: object 16 | * properties: 17 | * name: 18 | * type: string 19 | * description: Skill name. 20 | * example: JavaScript 21 | * proficiency: 22 | * type: string 23 | * description: Skill proficiency level. 24 | * enum: ['Beginer', 'Intermediate', 'Advanced'] 25 | * User: 26 | * type: object 27 | * properties: 28 | * firstName: 29 | * type: string 30 | * description: The user's first/given name. 31 | * example: Chetan 32 | * lastName: 33 | * type: string 34 | * description: The user's surname/family name. 35 | * example: Patil 36 | * gender: 37 | * type: string 38 | * enum: [Male, Female, Other] 39 | * 40 | * CreateUserRequest: 41 | * allOf: 42 | * - $ref: '#/components/schemas/User' 43 | * - type: object 44 | * properties: 45 | * skills: 46 | * type: array 47 | * items: 48 | * $ref: '#/components/schemas/Skills' 49 | * 50 | * CreateUserSuccess: 51 | * type: object 52 | * properties: 53 | * success: 54 | * type: boolean 55 | * description: Flag stating status of API call 56 | * example: true 57 | * body: 58 | * allOf: 59 | * - $ref: '#/components/schemas/User' 60 | * - type: object 61 | * properties: 62 | * id: 63 | * type: number 64 | * description: Id generated for created user. 65 | * example: 1 66 | * skills: 67 | * type: array 68 | * items: 69 | * allOf: 70 | * - type: object 71 | * properties: 72 | * id: 73 | * type: number 74 | * description: Id generated for created skill. 75 | * example: 1 76 | * userId: 77 | * type: number 78 | * description: Id generated for created user. 79 | * example: 1 80 | * - $ref: '#/components/schemas/Skills' 81 | */ 82 | 83 | /** 84 | * @openapi 85 | * /v1/users: 86 | * post: 87 | * tags: 88 | * - v1 89 | * description: Endpoint to create/add new user 90 | * requestBody: 91 | * required: true 92 | * content: 93 | * application/json: 94 | * schema: 95 | * $ref: '#/components/schemas/CreateUserRequest' 96 | * responses: 97 | * 200: 98 | * description: Application helath details. 99 | * content: 100 | * application/json: 101 | * schema: 102 | * $ref: '#/components/schemas/CreateUserSuccess' 103 | * 104 | */ 105 | router 106 | .route('/') 107 | .post(validate({ body: addUserSchema }), addUser) 108 | .get(getUsers); 109 | 110 | router 111 | .route('/:userId') 112 | .get(getUser); 113 | 114 | export default router; 115 | -------------------------------------------------------------------------------- /docs/api-specs.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | info: 3 | title: Express API for User Profile 4 | version: 1.0.0 5 | description: >- 6 | Simple boilerplate code base for creating APIs with `Node.js Express` 7 | framework using `Sequelize` with `PostgreSQL` database. 8 | contact: 9 | name: Chetan Patil 10 | url: https://github.com/Chetan07j 11 | servers: 12 | - url: http://localhost:3000 13 | description: Development server 14 | paths: 15 | /health: 16 | get: 17 | tags: 18 | - Health 19 | description: Health Endpoint 20 | responses: 21 | '200': 22 | description: Application helath details. 23 | content: 24 | application/json: 25 | schema: 26 | type: object 27 | properties: 28 | uptime: 29 | type: number 30 | format: float 31 | description: >- 32 | Time (in seconds) specifying apllication running from how 33 | long 34 | message: 35 | type: string 36 | description: Status message ok 37 | date: 38 | type: string 39 | format: date-time 40 | description: Current date in ISO format 41 | /v1/users: 42 | post: 43 | tags: 44 | - v1 45 | description: Endpoint to create/add new user 46 | requestBody: 47 | required: true 48 | content: 49 | application/json: 50 | schema: 51 | $ref: '#/components/schemas/CreateUserRequest' 52 | responses: 53 | '200': 54 | description: Application helath details. 55 | content: 56 | application/json: 57 | schema: 58 | $ref: '#/components/schemas/CreateUserSuccess' 59 | components: 60 | schemas: 61 | Skills: 62 | type: object 63 | properties: 64 | name: 65 | type: string 66 | description: Skill name. 67 | example: JavaScript 68 | proficiency: 69 | type: string 70 | description: Skill proficiency level. 71 | enum: 72 | - Beginer 73 | - Intermediate 74 | - Advanced 75 | User: 76 | type: object 77 | properties: 78 | firstName: 79 | type: string 80 | description: The user's first/given name. 81 | example: Chetan 82 | lastName: 83 | type: string 84 | description: The user's surname/family name. 85 | example: Patil 86 | gender: 87 | type: string 88 | enum: 89 | - Male 90 | - Female 91 | - Other 92 | CreateUserRequest: 93 | allOf: 94 | - $ref: '#/components/schemas/User' 95 | - type: object 96 | properties: 97 | skills: 98 | type: array 99 | items: 100 | $ref: '#/components/schemas/Skills' 101 | CreateUserSuccess: 102 | type: object 103 | properties: 104 | success: 105 | type: boolean 106 | description: Flag stating status of API call 107 | example: true 108 | body: 109 | allOf: 110 | - $ref: '#/components/schemas/User' 111 | - type: object 112 | properties: 113 | id: 114 | type: number 115 | description: Id generated for created user. 116 | example: 1 117 | skills: 118 | type: array 119 | items: 120 | allOf: 121 | - type: object 122 | properties: 123 | id: 124 | type: number 125 | description: Id generated for created skill. 126 | example: 1 127 | userId: 128 | type: number 129 | description: Id generated for created user. 130 | example: 1 131 | - $ref: '#/components/schemas/Skills' 132 | tags: [] 133 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node-Express-Sequelize-API Boilerplate 2 | 3 | Simple boilerplate code base for creating APIs with `Node.js Express` framework using `Sequelize` with `PostgreSQL` database. 4 | 5 | ## 📚 Contents 6 | 7 | - [Includes](#includes) 8 | - [Prerequisite](#prerequisite) 9 | - [Getting Started](#getting-started) 10 | - [Local Setup](#local-setup) 11 | - [Tests \& Coverage](#tests--coverage) 12 | - [Migrations](#migrations) 13 | - [Environment Variables](#environment-variables) 14 | - [`ESDoc`](#esdoc) 15 | - [Endpoints](#endpoints) 16 | - [References](#references) 17 | 18 | ## Includes 19 | 20 | - **ES6** `import/export` implemented with `type: "module"` in `package.json` 21 | - **Error handling** middlewares implemented 22 | - `Tests` added with `mocha` configuration 23 | - Code-coverage using Node.js' built in functionality `c8` 24 | - `Eslint` 25 | - `winston` logger 26 | 27 | ## 😊 Prerequisite 28 | 29 | - Install `nodemon` globally using below command if not installed already 30 | 31 | ```sh 32 | npm i -g nodemon 33 | ``` 34 | 35 | - **PostgreSQL** 36 | 37 | ## 🚀 Getting Started 38 | 39 | You can download or clone this repo using below command: 40 | 41 | ```sh 42 | git clone git@github.com:Chetan07j/node-pg-sequelize.git 43 | ``` 44 | 45 | ## ⚙️ Local Setup 46 | 47 | - After cloning enter into folder. 48 | - Install dependencies 49 | 50 | ```sh 51 | npm install 52 | ``` 53 | 54 | - Create file called `.env` 55 | - Copy `.env.example` file content `.env` file. 56 | 57 | - Run locally 58 | 59 | ```sh 60 | npm run local 61 | ``` 62 | 63 | ## 🧪 Tests & Coverage 64 | 65 | - Run tests *(unit/integration)* 66 | 67 | ```sh 68 | npm test 69 | ``` 70 | 71 | - Run tests with coverage 72 | 73 | ```sh 74 | npm run coverage 75 | ``` 76 | 77 | ## 🗃️ Migrations 78 | 79 | - Running Migrations 80 | 81 | ```sh 82 | npm run migration 83 | ``` 84 | 85 | - Undoing Migrations 86 | 87 | ```sh 88 | npm run migration:undo 89 | ``` 90 | 91 | ## ℹ️ Environment Variables 92 | 93 | | Variable | Description | Default Value | 94 | | -------- | ------------------------ | ------------- | 95 | | DB_HOST | Database connection host | `localhost` | 96 | | DB_PORT | Database port | `5432` | 97 | | DB_NAME | Database name | `postgres` | 98 | | DB_USER | Database username | `postgres` | 99 | | DB_PASS | Database password | `postgres` | 100 | 101 | > NOTE: These environment variables are already passed to `npm run local` and `npm test` scripts under `package.json` with their default values. You can update as per your need. 102 | 103 | ## 🗒️ `ESDoc` 104 | 105 | - Documention is created out of comments added for functions using `esdoc`. 106 | - That documentaion is avaialbe as GH Pages site, can be found [here](https://chetan07j.github.io/node-pg-sequelize-boilerplate/). 107 | 108 | ## ✴️ Endpoints 109 | 110 | 111 |
112 | Create User with Skills 113 | 114 | ```sh 115 | curl --location --request POST 'localhost:3000/v1/users' \ 116 | --header 'Content-Type: application/json' \ 117 | --data-raw '{ 118 | "firstName": "Chetan", 119 | "lastName": "Patil", 120 | "gender": "Male", 121 | "skills": [ 122 | { 123 | "name": "Node.js", 124 | "proficiency": "Advanced" 125 | } 126 | ] 127 | }' 128 | ``` 129 | 130 |
131 | 132 | 133 |
134 | Get all Users 135 | 136 | ```sh 137 | # Request 138 | 139 | curl --location --request GET 'localhost:3000/v1/users' 140 | 141 | #Response 142 | 143 | { 144 | "success": true, 145 | "body": [ 146 | { 147 | "id": 1, 148 | "firstName": "First1", 149 | "lastName": "Last", 150 | "gender": "Male", 151 | "createdAt": "2022-03-20T10:11:41.860Z", 152 | "updatedAt": "2022-03-20T10:11:41.860Z", 153 | "skills": [ 154 | { 155 | "id": 1, 156 | "userId": 1, 157 | "name": "Node.js", 158 | "proficiency": "Advanced", 159 | "createdAt": "2022-03-20T10:11:41.867Z", 160 | "updatedAt": "2022-03-20T10:11:41.867Z" 161 | } 162 | ] 163 | } 164 | ] 165 | } 166 | 167 | ``` 168 | 169 |
170 | 171 | 172 |
173 | Get specific User by userId 174 | 175 | ```sh 176 | # Request 177 | 178 | curl --location --request GET 'localhost:3000/v1/users/1' 179 | 180 | # Response 181 | 182 | { 183 | "success": true, 184 | "body": { 185 | "id": 1, 186 | "firstName": "Chetan", 187 | "lastName": "Patil", 188 | "gender": "Male", 189 | "createdAt": "2022-03-20T20:39:17.912Z", 190 | "updatedAt": "2022-03-20T20:39:17.912Z", 191 | "skills": [ 192 | { 193 | "id": 1, 194 | "userId": 1, 195 | "name": "Node.js", 196 | "proficiency": "Advanced", 197 | "createdAt": "2022-03-20T20:39:17.962Z", 198 | "updatedAt": "2022-03-20T20:39:17.962Z" 199 | } 200 | ] 201 | } 202 | } 203 | ``` 204 |
205 | 206 | ## 📚 References 207 | 208 | - [Sequelize ORM](https://sequelize.org/v6/) 209 | 210 | ## 🤗 Contributing 211 | 212 | Contributions are welcome! Feel free to open an issue or submit a pull request if you have a way to improve this project. 213 | 214 | Make sure your request is meaningful and you have tested the app locally before submitting a pull request. 215 | 216 | ## ♥️ Support 217 | 218 | 💙 If you like this project, give it a ⭐ 219 | --------------------------------------------------------------------------------