├── .appveyor.yml ├── .babelrc ├── .coveralls.yml ├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── .travis.yml ├── DIRECTORY.md ├── README.md ├── nodemon.json ├── package.json ├── src ├── app.js ├── bin │ └── www.js ├── controllers │ ├── home.js │ ├── index.js │ └── messages.js ├── middleware │ ├── index.js │ └── middleware.js ├── models │ ├── model.js │ └── pool.js ├── routes │ └── index.js ├── settings.js └── utils │ ├── queries.js │ ├── queryFunctions.js │ └── runQuery.js ├── test ├── hooks.js ├── index.test.js ├── messages.test.js └── setup.js └── yarn.lock /.appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - nodejs_version: "12" 4 | install: 5 | - yarn 6 | test_script: 7 | - yarn test 8 | build: off 9 | before_test: 10 | - SET PGUSER=postgres 11 | - SET PGPASSWORD=Password12! 12 | - PATH=C:\Program Files\PostgreSQL\10\bin\;%PATH% 13 | - createdb testdb 14 | services: 15 | - postgresql101 16 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": ["@babel/transform-runtime"] 4 | } 5 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | repo_token: pJA8b7BFCH4ibwwPFxRIYi95OFj0Ic5Xj 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = false 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true, 6 | "mocha": true 7 | }, 8 | "extends": ["airbnb-base"], 9 | "globals": { 10 | "Atomics": "readonly", 11 | "SharedArrayBuffer": "readonly" 12 | }, 13 | "parserOptions": { 14 | "ecmaVersion": 2018, 15 | "sourceType": "module" 16 | }, 17 | "rules": { 18 | "indent": ["warn", 2], 19 | "linebreak-style": ["error", "unix"], 20 | "quotes": ["error", "single"], 21 | "semi": ["error", "always"], 22 | "no-console": 1, 23 | "comma-dangle": [0], 24 | "arrow-parens": [0], 25 | "object-curly-spacing": ["warn", "always"], 26 | "array-bracket-spacing": ["warn", "always"], 27 | "import/prefer-default-export": [0] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | yarn-error.log 4 | .env 5 | .nyc_output 6 | coverage 7 | build/ 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | env: 3 | global: 4 | - CC_TEST_REPORTER_ID=d81e3aa75f9409a117901b091fc35106f0ebf9a147e3b8108f92c5a4913bfc0d 5 | matrix: 6 | include: 7 | - node_js: '12' 8 | cache: 9 | directories: 10 | - node_modules 11 | install: yarn 12 | after_success: yarn coverage 13 | before_script: 14 | - psql -c 'create database testdb;' -U postgres 15 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 16 | > ./cc-test-reporter 17 | - chmod +x ./cc-test-reporter 18 | - "./cc-test-reporter before-build" 19 | script: 20 | - yarn test 21 | after_script: 22 | - "./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT" 23 | services: 24 | - postgresql 25 | addons: 26 | postgresql: '10' 27 | apt: 28 | packages: 29 | - postgresql-10 30 | - postgresql-client-10 31 | before_install: 32 | - sudo cp /etc/postgresql/{9.6,10}/main/pg_hba.conf 33 | - sudo /etc/init.d/postgresql restart 34 | deploy: 35 | provider: heroku 36 | app: 37 | master: express-api-template 38 | api_key: 39 | secure: IZsokzJPsQ35yHoOZvsJrC0vfgN5Xso2uBRmWUDZWxOgGoCjiHQ1cpff/tyNOe6lgnNMw+IRHR146GOJWcxy380bWElF871Iy5sjf/P8Zwu9IQ+ltlJ0IOSjuJvW0+trhi8FBKKl8Cj0Rfi9yL+zU7azw6XfVhDFLjemHvfkMjDgGtL2cF9qBQPoNCm4b0I477NIk0pkKVl7pIFFLlOBgdy5bBCYIsmcgcWZ/lfnu7T86yQLT7QRsh2Ky6R5q62bwjxZqUn9qKP64iokfpdcZbi5LzUZ3TVCQBcWMVVLIG96nWJ6DUicL0mLdvMzwyz2kNj+WJCVVn4HaSLzkcEQis0RDbP2cr5I9xsHsoCtTQNRuJiBhYMtdE5Dv4ywNVJjbwGk7kZaq1bVoRzuX1gmEoofp2238q0OuHFnwuwjleZEq5vcwO80nQlHFHCDsciEoHmxcdG54pxMcmqTDXzGnULRkLRz3l1IhZsAPZmWuYQ+O2lVQVnnDSoaVT9qGwrff6SOu5ILJEj2oan3FB1F6I1FCx0AyRu2GdpqzcxsSAnUZzbcX9h4xSV5GcXh910qddys5iuuPs7WX6CfKEXfX033qNJC7hMVHjO509jvkZbPsw7BLdwqLAUejFwCL//gSL+NH0NegUVOrPkWNyskX0rPqPE1+lfKsSPMYwU2NQU= 40 | -------------------------------------------------------------------------------- /DIRECTORY.md: -------------------------------------------------------------------------------- 1 | # Directory structure 2 | 3 | Figure 01 4 | 5 | ```bash 6 | EXPRESS-API-TEMPLATE 7 | ├── .editorconfig 8 | ├── .gitignore 9 | ├── package.json 10 | └── README.md 11 | ``` 12 | 13 | Figure 02 14 | 15 | ```bash 16 | EXPRESS-API-TEMPLATE 17 | ├── node_modules 18 | ├── src 19 | | ├── bin 20 | │   │   ├── www.js 21 | │   ├── routes 22 | │   | ├── index.js 23 | │   └── app.js 24 | ├── .editorconfig 25 | ├── .gitignore 26 | ├── package.json 27 | ├── README.md 28 | └── yarn.lock 29 | ``` 30 | 31 | Figure 03 32 | 33 | ```bash 34 | EXPRESS-API-TEMPLATE 35 | ├── build 36 | ├── node_modules 37 | ├── src 38 | | ├── bin 39 | │   │   ├── www.js 40 | │   ├── routes 41 | │   | ├── index.js 42 | │   └── app.js 43 | ├── .babelrc 44 | ├── .editorconfig 45 | ├── .eslintrc.json 46 | ├── .gitignore 47 | ├── .prettierrc 48 | ├── nodemon.json 49 | ├── package.json 50 | ├── README.md 51 | └── yarn.lock 52 | ``` 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Express API template 2 | 3 | [![Build Status](https://travis-ci.com/chidimo/Express-API-Template.svg?token=vRPqNDsj84fjiYCWzphq&branch=master)](https://travis-ci.com/chidimo/Express-API-Template) 4 | [![Coverage Status](https://coveralls.io/repos/github/chidimo/Express-API-Template/badge.svg?branch=master)](https://coveralls.io/github/chidimo/Express-API-Template?branch=master) 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/b6cf857f9c2ff789743e/maintainability)](https://codeclimate.com/github/chidimo/Express-API-Template/maintainability) 6 | [![Test Coverage](https://api.codeclimate.com/v1/badges/b6cf857f9c2ff789743e/test_coverage)](https://codeclimate.com/github/chidimo/Express-API-Template/test_coverage) 7 | [![Build status](https://ci.appveyor.com/api/projects/status/h2uvmx9yft68k6b2?svg=true)](https://ci.appveyor.com/project/chidimo/express-api-template) 8 | 9 | Live API endpoint: 10 | 11 | Read the article here 12 | 13 | ## How to run the app 14 | 15 | 1. Clone the repo 16 | 1. Create a `.env` file at the project root and provide the following environment variables 17 | 18 | TEST_ENV_VARIABLE="some arbitrary string" 19 | CONNECTION_STRING="a url pointing to a PostgreSQL database" 20 | PORT="port number to serve the files. defaults to 3000" 21 | 22 | 1. Open a terminal in the project root and run `yarn install` to install the project dependencies. 23 | 1. Run `yarn test` to make sure everything is working correctly. 24 | 1. Run `yarn startdev` to start the development server 25 | 1. Open `http://localhost:3000` or use whatever port you supplied in your environment variable. 26 | 1. Remember to replace the badges with your custom badges 27 | 28 | ## How to test 29 | 30 | 1. Run `yarn install` to install project dependencies 31 | 1. Run `yarn test` 32 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | ".env", 4 | "package.json", 5 | "nodemon.json", 6 | ".eslintrc.json", 7 | ".babelrc", 8 | ".prettierrc", 9 | "src/" 10 | ], 11 | "verbose": true, 12 | "ignore": ["*.test.js", "*.spec.js"] 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-api-template", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "prestart": "babel ./src --out-dir build", 7 | "start": "node ./build/bin/www", 8 | "startdev": "nodemon --exec babel-node ./src/bin/www", 9 | "lint": "./node_modules/.bin/eslint ./src", 10 | "pretty": "prettier --write '**/*.{js,json}' '!node_modules/**'", 11 | "postpretty": "yarn lint --fix", 12 | "test": "nyc --reporter=html --reporter=text --reporter=lcov mocha -r @babel/register", 13 | "coverage": "nyc report --reporter=text-lcov | coveralls", 14 | "runQuery": "babel-node ./src/utils/runQuery" 15 | }, 16 | "dependencies": { 17 | "axios": "^0.19.2", 18 | "cookie-parser": "~1.4.4", 19 | "debug": "~2.6.9", 20 | "dotenv": "^8.2.0", 21 | "express": "~4.16.1", 22 | "http-errors": "~1.6.3", 23 | "morgan": "~1.9.1", 24 | "pg": "^7.18.2" 25 | }, 26 | "devDependencies": { 27 | "@babel/cli": "^7.8.4", 28 | "@babel/core": "^7.8.7", 29 | "@babel/node": "^7.8.7", 30 | "@babel/plugin-transform-runtime": "^7.8.3", 31 | "@babel/preset-env": "^7.8.7", 32 | "@babel/register": "^7.8.6", 33 | "@babel/runtime": "^7.8.7", 34 | "chai": "^4.2.0", 35 | "coveralls": "^3.0.9", 36 | "eslint": "^6.8.0", 37 | "eslint-config-airbnb-base": "^14.1.0", 38 | "eslint-plugin-import": "^2.20.1", 39 | "mocha": "^7.1.0", 40 | "nodemon": "^2.0.2", 41 | "nyc": "^15.0.0", 42 | "prettier": "^1.19.1", 43 | "sinon": "^9.0.1", 44 | "sinon-chai": "^3.5.0", 45 | "supertest": "^4.0.2" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import logger from 'morgan'; 2 | import express from 'express'; 3 | import cookieParser from 'cookie-parser'; 4 | import indexRouter from './routes/index'; 5 | 6 | const app = express(); 7 | 8 | app.use(logger('dev')); 9 | app.use(express.json()); 10 | app.use(express.urlencoded({ extended: true })); 11 | app.use(cookieParser()); 12 | app.use('/v1', indexRouter); 13 | 14 | app.use((err, req, res, next) => { 15 | res.status(400).json({ error: err.stack }); 16 | }); 17 | 18 | export default app; 19 | -------------------------------------------------------------------------------- /src/bin/www.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Module dependencies. 4 | */ 5 | // const debug = require('debug')('quick-credit:server'); 6 | import debug from 'debug'; 7 | import http from 'http'; 8 | import app from '../app'; 9 | /** 10 | * Normalize a port into a number, string, or false. 11 | */ 12 | const normalizePort = val => { 13 | const port = parseInt(val, 10); 14 | if (Number.isNaN(port)) { 15 | // named pipe 16 | return val; 17 | } 18 | if (port >= 0) { 19 | // port number 20 | return port; 21 | } 22 | return false; 23 | }; 24 | /** 25 | * Get port from environment and store in Express. 26 | */ 27 | const port = normalizePort(process.env.PORT || '3000'); 28 | app.set('port', port); 29 | /** 30 | * Create HTTP server. 31 | */ 32 | const server = http.createServer(app); 33 | /** 34 | * Event listener for HTTP server "error" event. 35 | */ 36 | const onError = error => { 37 | if (error.syscall !== 'listen') { 38 | throw error; 39 | } 40 | const bind = typeof port === 'string' ? `Pipe ${port}` : `Port ${port}`; 41 | // handle specific listen errors with friendly messages 42 | switch (error.code) { 43 | case 'EACCES': 44 | console.log(`${bind} requires elevated privileges`); 45 | process.exit(1); 46 | break; 47 | case 'EADDRINUSE': 48 | console.log(`${bind} is already in use`); 49 | process.exit(1); 50 | break; 51 | default: 52 | throw error; 53 | } 54 | }; 55 | /** 56 | * Event listener for HTTP server "listening" event. 57 | */ 58 | const onListening = () => { 59 | const addr = server.address(); 60 | const bind = typeof addr === 'string' ? `pipe ${addr}` : `port ${addr.port}`; 61 | debug(`Listening on ${bind}`); 62 | }; 63 | /** 64 | * Listen on provided port, on all network interfaces. 65 | */ 66 | server.listen(port); 67 | server.on('error', onError); 68 | server.on('listening', onListening); 69 | -------------------------------------------------------------------------------- /src/controllers/home.js: -------------------------------------------------------------------------------- 1 | import { testEnvironmentVariable } from '../settings'; 2 | 3 | export const indexPage = (req, res) => res.status(200).json({ message: testEnvironmentVariable }); 4 | -------------------------------------------------------------------------------- /src/controllers/index.js: -------------------------------------------------------------------------------- 1 | export * from './home'; 2 | export * from './messages'; 3 | -------------------------------------------------------------------------------- /src/controllers/messages.js: -------------------------------------------------------------------------------- 1 | import Model from '../models/model'; 2 | 3 | const messagesModel = new Model('messages'); 4 | 5 | export const messagesPage = async (req, res) => { 6 | try { 7 | const data = await messagesModel.select('name, message'); 8 | res.status(200).json({ messages: data.rows }); 9 | } catch (err) { 10 | res.status(200).json({ messages: err.stack }); 11 | } 12 | }; 13 | 14 | export const addMessage = async (req, res) => { 15 | const { name, message } = req.body; 16 | const columns = 'name, message'; 17 | const values = `'${name}', '${message}'`; 18 | try { 19 | const data = await messagesModel.insertWithReturn(columns, values); 20 | res.status(200).json({ messages: data.rows }); 21 | } catch (err) { 22 | res.status(200).json({ messages: err.stack }); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/middleware/index.js: -------------------------------------------------------------------------------- 1 | export * from './middleware'; 2 | -------------------------------------------------------------------------------- /src/middleware/middleware.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export const modifyMessage = (req, res, next) => { 4 | req.body.message = `SAYS: ${req.body.message}`; 5 | next(); 6 | }; 7 | 8 | export const performAsyncAction = async (req, res, next) => { 9 | try { 10 | await axios.get('https://picsum.photos/id/0/info'); 11 | next(); 12 | } catch (err) { 13 | next(err); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/models/model.js: -------------------------------------------------------------------------------- 1 | import { pool } from './pool'; 2 | 3 | class Model { 4 | constructor(table) { 5 | this.pool = pool; 6 | this.table = table; 7 | this.pool.on( 8 | 'error', 9 | (err, client) => `Error, ${err}, on idle client${client}` 10 | ); 11 | } 12 | 13 | async select(columns, clause) { 14 | let query = `SELECT ${columns} FROM ${this.table}`; 15 | if (clause) query += clause; 16 | return this.pool.query(query); 17 | } 18 | 19 | async insertWithReturn(columns, values) { 20 | const query = ` 21 | INSERT INTO ${this.table}(${columns}) 22 | VALUES (${values}) 23 | RETURNING id, ${columns} 24 | `; 25 | return this.pool.query(query); 26 | } 27 | } 28 | export default Model; 29 | -------------------------------------------------------------------------------- /src/models/pool.js: -------------------------------------------------------------------------------- 1 | import { Pool } from 'pg'; 2 | import dotenv from 'dotenv'; 3 | import { connectionString } from '../settings'; 4 | 5 | dotenv.config(); 6 | export const pool = new Pool({ connectionString }); 7 | -------------------------------------------------------------------------------- /src/routes/index.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { indexPage, messagesPage, addMessage } from '../controllers'; 3 | import { modifyMessage, performAsyncAction } from '../middleware'; 4 | 5 | const indexRouter = express.Router(); 6 | indexRouter.get('/', indexPage); 7 | indexRouter.get('/messages', messagesPage); 8 | indexRouter.post('/messages', modifyMessage, performAsyncAction, addMessage); 9 | 10 | export default indexRouter; 11 | -------------------------------------------------------------------------------- /src/settings.js: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | 3 | dotenv.config(); 4 | export const testEnvironmentVariable = process.env.TEST_ENV_VARIABLE; 5 | export const connectionString = process.env.CONNECTION_STRING; 6 | -------------------------------------------------------------------------------- /src/utils/queries.js: -------------------------------------------------------------------------------- 1 | export const createMessageTable = ` 2 | DROP TABLE IF EXISTS messages; 3 | CREATE TABLE IF NOT EXISTS messages ( 4 | id SERIAL PRIMARY KEY, 5 | name VARCHAR DEFAULT '', 6 | message VARCHAR NOT NULL 7 | ) 8 | `; 9 | 10 | export const insertMessages = ` 11 | INSERT INTO messages(name, message) 12 | VALUES ('chidimo', 'first message'), 13 | ('orji', 'second message') 14 | `; 15 | 16 | export const dropMessagesTable = 'DROP TABLE messages'; 17 | -------------------------------------------------------------------------------- /src/utils/queryFunctions.js: -------------------------------------------------------------------------------- 1 | import { pool } from '../models/pool'; 2 | import { 3 | insertMessages, 4 | dropMessagesTable, 5 | createMessageTable, 6 | } from './queries'; 7 | 8 | export const executeQueryArray = async arr => new Promise(resolve => { 9 | const stop = arr.length; 10 | arr.forEach(async (q, index) => { 11 | await pool.query(q); 12 | if (index + 1 === stop) resolve(); 13 | }); 14 | }); 15 | 16 | export const dropTables = () => executeQueryArray([ dropMessagesTable ]); 17 | export const createTables = () => executeQueryArray([ createMessageTable ]); 18 | export const insertIntoTables = () => executeQueryArray([ insertMessages ]); 19 | -------------------------------------------------------------------------------- /src/utils/runQuery.js: -------------------------------------------------------------------------------- 1 | import { createTables, insertIntoTables } from './queryFunctions'; 2 | 3 | (async () => { 4 | await createTables(); 5 | await insertIntoTables(); 6 | })(); 7 | -------------------------------------------------------------------------------- /test/hooks.js: -------------------------------------------------------------------------------- 1 | import { 2 | dropTables, 3 | createTables, 4 | insertIntoTables, 5 | } from '../src/utils/queryFunctions'; 6 | 7 | before(async () => { 8 | await createTables(); 9 | await insertIntoTables(); 10 | }); 11 | 12 | after(async () => { 13 | await dropTables(); 14 | }); 15 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | import { expect, server, BASE_URL } from './setup'; 2 | 3 | describe('Index page test', () => { 4 | it('get base url', done => { 5 | server 6 | .get(`${BASE_URL}/`) 7 | .expect(200) 8 | .end((err, res) => { 9 | expect(res.status).to.equal(200); 10 | expect(res.body.message).to.equal( 11 | 'Environment variable is coming across.' 12 | ); 13 | done(); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /test/messages.test.js: -------------------------------------------------------------------------------- 1 | import { expect, server, BASE_URL } from './setup'; 2 | 3 | describe('Messages', () => { 4 | it('get messages page', done => { 5 | server 6 | .get(`${BASE_URL}/messages`) 7 | .expect(200) 8 | .end((err, res) => { 9 | expect(res.status).to.equal(200); 10 | expect(res.body.messages).to.be.instanceOf(Array); 11 | res.body.messages.forEach(m => { 12 | expect(m).to.have.property('name'); 13 | expect(m).to.have.property('message'); 14 | }); 15 | done(); 16 | }); 17 | }); 18 | 19 | it('posts messages', done => { 20 | const data = { name: 'some name', message: 'new message' }; 21 | server 22 | .post(`${BASE_URL}/messages`) 23 | .send(data) 24 | .expect(200) 25 | .end((err, res) => { 26 | expect(res.status).to.equal(200); 27 | expect(res.body.messages).to.be.instanceOf(Array); 28 | res.body.messages.forEach(m => { 29 | expect(m).to.have.property('id'); 30 | expect(m).to.have.property('name', data.name); 31 | expect(m).to.have.property('message', `SAYS: ${data.message}`); 32 | }); 33 | done(); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import chai from 'chai'; 3 | import sinonChai from 'sinon-chai'; 4 | import app from '../src/app'; 5 | 6 | chai.use(sinonChai); 7 | export const { expect } = chai; 8 | export const server = supertest.agent(app); 9 | export const BASE_URL = '/v1'; 10 | --------------------------------------------------------------------------------