Express RESTful API Server Recipe

Table of Contents

Express RESTful API Server with Knex and PostgreSQL and deployed to PCF.

Using the Express Application Generator

You can scaffold a new Express project with the express application generator:

npx express-generator --no-view --git <app-name>
cd <app-name>
yarn                # or run `npm install`

Adding Additional Libraries

yarn add cfenv          # for reading PCF environment variables
yarn add dotenv         # for reading variables from a `.env` file
yarn add pg             # for connecting to a PostgreSQL database
yarn add morgan         # for enhanced logging to console
yarn add cors           # for configuring CORS support
yarn add jest           # test runner and assertion library
yarn add jest-extended  # additional assertions for jest
yarn add supertest      # for testing RESTful API endpoints

Example package.json

{
    "name": "my-server",
    "version": "0.0.0",
    "private": true,
    "scripts": {
        "start": "NODE_ENV=production node ./bin/www",
        "dev": "NODE_ENV=development PORT=3002 DEBUG=my-server:* nodemon --inspect ./bin/www",
        "migrate": "knex migrate:latest",
        "seed": "knex seed:run",
        "db-reset": "knex migrate:rollback all && yarn migrate && yarn seed && psql -d todos -f set-sequence.sql",
        "db-reset-prod": "knex --env production migrate:rollback all && knex --env production migrate:latest && knex --env production seed:run",
        "db-verify": "psql -d todos -f verify.sql",
        "db-verify-prod": "psql -h ld00228 -U todoapp -d todoapp -f verify.sql",
        "model-tester": "NODE_ENV=test jest --testPathPattern=\"models/.*\\.test\\.js\"",
        "route-tester": "NODE_ENV=test PORT=3004 jest --testPathPattern=\"routes/.*\\.test\\.js\"",
        "test": "yarn model-tester && yarn route-tester"
    },
    "dependencies": {
        "cfenv": "1.2.2",
        "cookie-parser": "~1.4.3",
        "cors": "2.8.5",
        "debug": "~2.6.9",
        "dotenv": "8.2.0",
        "express": "~4.16.0",
        "knex": "0.19.5",
        "morgan": "~1.9.0",
        "pg": "7.12.1"
    },
    "devDependencies": {
        "jest": "^24.9.0",
        "jest-extended": "^0.11.2",
        "supertest": "^4.0.2"
    }
}

Example app.js

const express = require('express')
const cors = require('cors')
const cookieParser = require('cookie-parser')
const bodyParser = require('body-parser')
const logger = require('morgan')

const indexRouter = require('./routes/index')
const usersRouter = require('./routes/users')
const todosRouter = require('./routes/todos')

const app = express()

app.use(
    cors({
        origin: /\.homedepot\.com$/,
        optionsSuccessStatus: 200 // some legacy browsers (IE11, various SmartTVs) choke on 204
    })
)

app.use(bodyParser.urlencoded())
app.use(bodyParser.json())

if (process.env.NODE_ENV !== 'test') {
    app.use(logger('dev'))
}

app.use(express.json())
app.use(express.urlencoded({ extended: false }))
app.use(cookieParser())

app.use('/', indexRouter)
app.use('/users', usersRouter)
app.use('/todos', todosRouter)

module.exports = app

Creating the knexfile.js configuration file.

knex init

Sample knexfile.js

const cfenv = require('cfenv')
const debug = require('debug')('my-server')

const appEnv = cfenv.getAppEnv()
const credentials = appEnv.getServiceCreds('my-server-db')

// console.log('credentials:', JSON.stringify(credentials, null, 4));

module.exports = {
    development: {
        client: 'postgresql',
        connection: {
            host: '127.0.0.1',
            database: 'todos',
            charset: 'utf8'
        }
    },

    production: {
        client: 'postgresql',
        connection: {
            host: credentials ? credentials.host : process.env.DB_HOST,
            database: credentials ? credentials.database : process.env.DB_NAME,
            user: credentials ? credentials.user : process.env.DB_USER,
            password: credentials
                ? credentials.password
                : process.env.DB_PASSWORD
        },
        pool: {
            min: 2,
            max: 10
        }
    }
}

Sample .env file

DB_HOST=<hostname>.homedepot.com
DB_NAME=<db_name>
DB_USER=<db_user_name>
DB_PASSWORD=<db_password>

An Example Knex Migration Script

knex migrate:make create_todos
// migrations/20191021101030_create_todos.js
exports.up = function(knex) {
    return knex.schema.hasTable('todos').then(function(exists) {
        if (!exists) {
            return knex.schema.createTable('todos', function(table) {
                table
                    .increments('id')
                    .primary()
                    .comment('Auto-generated id')
                table.string('title')
                table.boolean('completed')
                table.timestamps(true, true)
            })
        }
    })
}

exports.down = function(knex, Promise) {
    return knex.schema.dropTableIfExists('todos')
}

Example Knex Database Connecction File

'use strict'

const env = process.env.NODE_ENV || 'development'
const knexfile = require('../knexfile')
const knex = require('knex')(knexfile[env])

module.exports = knex

A Generic Knex Model Factory

// helpers/create-model.js
'use strict'

// The guts of a model that uses Knexjs to store and retrieve data from a
// database using the provided `knex` instance. Custom functionality can be
// composed on top of this set of common guts.
//
// The idea is that these are the most-used types of functions that most/all
// "models" will want to have. They can be overriden/modified/extended if
// needed by composing a new object out of the one returned by this function ;)
module.exports = ({
    knex,
    name,
    tableName,
    selectableProps,
    sortArray = [],
    timeout = 1000
}) => {
    const create = props => {
        delete props.id // not allowed to set `id`

        return knex
            .insert(props)
            .returning(selectableProps)
            .into(tableName)
            .timeout(timeout)
    }

    const findAll = () =>
        knex
            .select(selectableProps)
            .from(tableName)
            .modify(function(queryBuilder) {
                sortArray.forEach(({ field, direction }) =>
                    queryBuilder.orderBy(field, direction)
                )
            })
            .orderBy('created_at', 'asc') // TODO: make configurable
            .timeout(timeout)

    const find = filters =>
        knex
            .select(selectableProps)
            .from(tableName)
            .where(filters)
            .timeout(timeout)

    // Same as `find` but only returns the first match if >1 are found.
    const findOne = filters =>
        find(filters).then(results => {
            if (!Array.isArray(results)) return results

            return results[0]
        })

    const findById = id =>
        knex
            .select(selectableProps)
            .from(tableName)
            .where({ id })
            .timeout(timeout)

    const update = (id, props) => {
        delete props.id // not allowed to set `id`

        return knex
            .update(props)
            .from(tableName)
            .where({ id })
            .returning(selectableProps)
            .timeout(timeout)
    }

    const destroy = id =>
        knex
            .del()
            .from(tableName)
            .where({ id })
            .timeout(timeout)

    return {
        name,
        tableName,
        selectableProps,
        timeout,
        create,
        findAll,
        find,
        findOne,
        findById,
        update,
        destroy
    }
}

Example Model using create-model

// models/todo.js
'use strict'

const createModel = require('../helpers/create-model')

const name = 'Todo'
const tableName = 'todos'

const selectableProps = ['id', 'title', 'completed', 'updated_at', 'created_at']

module.exports = knex => {
    const model = createModel({
        knex,
        name,
        tableName,
        selectableProps,
        sortArray: [{ field: 'created_at', direction: 'asc' }]
    })

    return model
}

Exporting all Model Files Programmatically

// models/index.js
'use strict'

const fs = require('fs')
const path = require('path')
const knex = require('../config/database')

const getModelFiles = dir =>
    fs
        .readdirSync(dir)
        .filter(file => file.indexOf('.') !== 0 && file !== 'index.js')
        .map(file => path.join(dir, file))

// Gather up all model files (i.e., any file present in the current directory
// that is not this file) and export them as properties of an object such that
// they may be imported using destructuring like
// `const { MyModel } = require('./models')` where there is a model named
// `MyModel` present in the exported object of gathered models.
const files = getModelFiles(__dirname)

const models = files.reduce((modelsObj, filename) => {
    const initModel = require(filename)
    const model = initModel(knex)

    if (model) modelsObj[model.name] = model

    return modelsObj
}, {})

module.exports = models

Example Routes File

// routes/todos.js
var express = require('express')
const { Todo } = require('../models')
var router = express.Router()

router
    .route('/')
    .post(addTodo)
    .get(getTodos)

router
    .route('/:id')
    .get(getTodo)
    .put(updateTodo)
    .delete(deleteTodo)

function getTodos(req, res, next) {
    Todo.findAll()
        .then(todos => res.json(todos))
        .catch(next)
}

function getTodo(req, res, next) {
    const todoId = req.params.id

    Todo.findById(todoId)
        .then(todo => res.json(todo))
        .catch(next)
}

function addTodo(req, res, next) {
    const props = req.body

    Todo.create(props)
        .then(todos => res.json(todos[0]))
        .catch(next)
}

function updateTodo(req, res, next) {
    const todoId = req.params.id
    const props = req.body

    Todo.update(todoId, props)
        .then(todos => res.json(todos[0]))
        .catch(next)
}

function deleteTodo(req, res, next) {
    const todoId = req.params.id

    Todo.destroy(todoId)
        .then(deleteCount => res.json(deleteCount))
        .catch(next)
}

module.exports = router

Tests for Example Routes File

// routes/todos.test.js
require('jest-extended')
const supertest = require('supertest')
const TodosErrors = require('../models/TodosErrors')

const port = process.env.PORT || '3000'
const api = supertest(`localhost:${port}/api/todos`)

describe('Todos Routes', () => {
    let server
    beforeAll(next => {
        server = require('../bin/www')
        next()
    })
    afterAll(next => {
        server.close()
        next()
    })

    describe('INDEX ROUTE', () => {
        test('should return a 200 response and return 3 todos each having a title', done => {
            api.get('/')
                .set('Accept', 'application/json')
                .expect(200)
                .then(res => {
                    const todos = res.body
                    expect(todos).not.toBeNull()
                    expect(todos).toBeArray()
                    expect(todos.length).toEqual(3)
                    expect(todos[1]).toHaveProperty('title', 'Learn Redux')
                    todos.forEach(todo => {
                        expect(todo).toHaveProperty('title')
                    })
                    done()
                })
                .catch(err => {
                    done(err)
                })
        })
    })

    describe('SHOW ROUTE', () => {
        test('should return a 404 response when given an invalid id', done => {
            api.get('/123')
                .set('Accept', 'application/json')
                .expect(TodosErrors.notFound.code)
                .then(res => {
                    const error = res.body
                    expect(error).not.toBeNull()
                    expect(error).toHaveProperty(
                        'message',
                        TodosErrors.notFound.message
                    )
                    done()
                })
                .catch(err => {
                    done(err)
                })
        })
        test('should return a 200 response and return Learn Redux when given the proper id', done => {
            api.get('/2')
                .set('Accept', 'application/json')
                .expect(200)
                .then(res => {
                    const todo = res.body
                    expect(todo).not.toBeNull()
                    expect(todo).toHaveProperty('title', 'Learn Redux')
                    done()
                })
                .catch(err => {
                    done(err)
                })
        })
    })

    describe('CREATE ROUTE', () => {
        test('should return a 422 response and return an error message when given an empty title', done => {
            api.post('/')
                .set('Accept', 'application/json')
                .send({
                    title: ''
                })
                .expect(TodosErrors.invalidTitle.code)
                .then(res => {
                    const error = res.body
                    expect(error).toHaveProperty(
                        'message',
                        TodosErrors.invalidTitle.message
                    )
                    done()
                })
                .catch(err => {
                    done(err)
                })
        })
        test('should return the new todo when given a valid title and default the completed status to false', done => {
            api.post('/')
                .set('Accept', 'application/json')
                .send({
                    title: 'Learn TDD'
                })
                .expect(201)
                .then(res => {
                    const todo = res.body
                    expect(todo).not.toBeNull()
                    expect(todo).toHaveProperty('title', 'Learn TDD')
                    expect(todo).toHaveProperty('completed', false)
                    done()
                })
                .catch(err => {
                    done(err)
                })
        })
        test('should return the new todo when given a valid title and a completed status of true', done => {
            api.post('/')
                .set('Accept', 'application/json')
                .send({
                    title: 'Learn TDD',
                    completed: true
                })
                .expect(201)
                .then(res => {
                    const todo = res.body
                    expect(todo).not.toBeNull()
                    expect(todo).toHaveProperty('title', 'Learn TDD')
                    expect(todo).toHaveProperty('completed', true)
                    done()
                })
                .catch(err => {
                    done(err)
                })
        })
    })

    describe('UPDATE ROUTE', () => {
        test('should return a 404 response and when given an invalid id', done => {
            api.put('/123')
                .set('Accept', 'application/json')
                .send({
                    title: 'Learn go',
                    completed: false
                })
                .expect(TodosErrors.notFound.code)
                .then(res => {
                    const error = res.body
                    expect(error).not.toBeNull()
                    expect(error).toHaveProperty(
                        'message',
                        TodosErrors.notFound.message
                    )
                    done()
                })
                .catch(err => {
                    done(err)
                })
        })
        test('should return a 422 response and return an error message when given an empty title', done => {
            api.put('/2')
                .set('Accept', 'application/json')
                .send({
                    title: '',
                    completed: true
                })
                .expect(TodosErrors.invalidTitle.code)
                .then(res => {
                    const error = res.body
                    expect(error).toHaveProperty(
                        'message',
                        TodosErrors.invalidTitle.message
                    )
                    done()
                })
                .catch(err => {
                    done(err)
                })
        })
        test('should return a 200 response and return the updated todo when given a valid title', done => {
            api.put('/2')
                .set('Accept', 'application/json')
                .send({
                    title: 'Learn go',
                    completed: true
                })
                .expect(200)
                .then(res => {
                    const todo = res.body
                    expect(todo).not.toBeNull()
                    expect(todo).toHaveProperty('title', 'Learn go')
                    expect(todo).toHaveProperty('completed', true)
                    done()
                })
                .catch(err => {
                    done(err)
                })
        })
    })

    describe('destroyTodo', () => {
        test('should return a 404 response when given an invalid id', done => {
            api.delete('/123')
                .set('Accept', 'application/json')
                .expect(TodosErrors.notFound.code)
                .then(res => {
                    const error = res.body
                    expect(error).not.toBeNull()
                    expect(error).toHaveProperty(
                        'message',
                        TodosErrors.notFound.message
                    )
                    done()
                })
                .catch(err => {
                    done(err)
                })
        })
        test('should return the deleted todo when given a valid id', done => {
            api.delete('/3')
                .set('Accept', 'application/json')
                .expect(200)
                .then(res => {
                    const todo = res.body
                    expect(todo).not.toBeNull()
                    expect(todo).toHaveProperty('title', 'Learn GraphQL')
                    done()
                })
                .catch(err => {
                    done(err)
                })
        })
    })
})

PCF Deployment

Sample manifest.yml file

---
applications:
    - name: my-server
      command: npm start
      buildpack: https://github.com/cloudfoundry/nodejs-buildpack

Sample .cfignore file

node_modules

Login to PCF and push server

cf login -a https://api.run-np.homedepot.com -u <ldap-user-id> -o <org-name> -s <space-name>
cf push
open https://login.run-np.homedepot.com   # To open PCF Apps Manager - login with LDAP credentials
Dr. Mike Hopper avatar
Dr. Mike Hopper
Software Architect, Engineer, and Instructor