A modern JS app (III): backend architecture

A modern React / Node.js application

Part 3: Setting up our Backend… continued

NOTE: You’ll need to be running Node version >= 8.0 for the following. So if you don’t have that one installed, do so. Check out the nvm package if you’d like some assistance.

Concurrently

You might’ve been bothered by the fact that to run our application, we have to run two commands inside two different terminal windows: in client, we run npm start and in server, we run npm run watch.

We’ll use Concurrently to fix this by running everything with a single command.

In our top level directory, run:

npm init --yes && npm i --save-dev concurrently

This creates a package.json and installs the Concurrently tool. We will add the following line to the scripts key in package.json:

"start": "./node_modules/.bin/concurrently \"cd client && npm start\" \"cd server && npm run watch\""

Kill the server/client processes if necessary, and then run npm start in the top-level folder. This will start both the client and server applications with a single command. Much more convenient!

Real world example

Our calculator app is marvelous, but let’s build something a bit more realistic. We’ll create a simple booking application for B&Bs. The basic features we’ll build are:

Backend business logic / models

As stated earlier, we’ll use GraphQL to serve our API. GraphQL provides a language that describes our API and doubles as a way to automatically build the schema as well. Let’s define our models in a new file, src/schema.graphql:

type User {
  id: ID!
  email: String!
  firstName: String
  lastName: String
}

input UserInput {
  email: String
  firstName: String
  lastName: String
}

type LocalAuth {
  id: ID!
  user: User!
  password: String!
}

type Property {
  id: ID!
  owner: User!
  address: Address
  rooms: [Room]
}

type Address {
  street1: String!
  street2: String
  city: String!
  state: String!
}

type Room {
  id: ID!
  name: String!
  price: Float
  description: String
  image: File
}

type File {
  id: ID!
  url: String!
}

This is a very basic schema that will allow us to build our B&B Booking app. It simply creates some types that define the data we need to manage and book properties.

As far as schemas go, this is about as simple as you can get in GraphQL, which is capable of much more than what we’ll use for this simple application. Go ahead and read the GraphQL documentation to get a feel for the possibilities.

Now let’s install the GraphQL library:

npm i --save graphql

Creating Users

Let’s implement an API call to create users, and then some inventory. Add the following to our schema.graphql file:

type Query {
  fetchUser(id: ID!): User
}

type Mutation {
  createUser(input: UserInput): User
  updateUser(id: ID!, input: UserInput): User
  deleteUser(id: ID!): User
}

Create a gateway folder in src, and inside it, add a resolvers.js with contents:

import R from 'ramda'

const users = [
  { id: 0, email: 'toby@dundermifflin.com' },
  { id: 1, email: 'jim@dundermifflin.com' },
  { id: 2, email: 'pam@dundermifflin.com' },
  { id: 3, email: 'dwight@dundermifflin.com' },
  { id: 4, email: 'michael@dundermifflin.com' },
  { id: 5, email: 'andy@dundermifflin.com' },
]

export function fetchUser({ id }, context) {
  return users[id]
}

export function createUser({ input }, context) {
  users.push({ id: users.length, email: input.email, firstName: input.firstName, lastName: input.lastName })
  return R.last(users)
}

Finally, change our src/index.js file to look like the following:

// @flow
'use strict'
import express from 'express'
import bodyParser from 'body-parser'
import { graphql, buildSchema } from 'graphql'
import fs from 'fs'
import { promisify } from 'util'
import * as Root from './gateway/resolvers'

init()

async function init() {
  const app = express()
  app.use(bodyParser.json())
  app.use(bodyParser.urlencoded({ extended: true }))

  // read in the schema.graphql file, and build our schema with it
  const readFile : (string, string) => Promise<string> = promisify(fs.readFile)
  const gql : string = await readFile(`${__dirname}/schema.graphql`, 'utf8')
  const schema : Object = buildSchema(gql)
  app.post('/graphql', async (req, res) => {
    const { query, args } = req.body
    const result : Object = await graphql(schema, query, Root, { user: 'Bill' }, args)
    res.send(result)
  })

  app.listen(3001, () => {
    /* eslint-disable no-console */
    console.log('Listening on port 3001.')
    /* eslint-enable no-console */
  })
}

We’re now listening at /graphql for requests. Let’s test our endpoint. Run the following curl request:

curl -X POST localhost:3000/graphql -H "content-type: application/json" -d '{ "query": "query FetchUser($id: ID!) { fetchUser(id: $id) { id email } }", "args": { "id": "3" } }'

You should be getting the response:

{"data":{"getUser":{"id":"3","email":"dwight@dundermifflin.com"}}}

Woohoo! Let’s create a new user:

curl -X POST localhost:3000/graphql -H "content-type: application/json" -d '{ "query": "mutation CreateUser($input: UserInput) { createUser(input: $input) { id email } }", "args": { "input": { "email": "kevin@dundermifflin.com", "firstName": "Kevin", "lastName": "Malone" } } }'

This should return:

{"data":{"createUser":{"id":"6","email":"kevin@dundermifflin.com"}}}

It’s good to include Kevin. If you run the above GraphQL query (as opposed to the mutation), with the id changed to 6, we’ll also get Kevin back, proving he’s now in our “database.”

I used quotes around the word database because as I’m sure you’ve noticed, we’re simply using an in-memory array to store our users. Let’s change that.

Enter Mongo

If you haven’t already, install Mongo.

Done? Created your /data/db directory? Run the mongod command to make sure everything’s set. CTRL-C out of it and let’s add that to our top-level package.json’s start command:

"start": "./node_modules/.bin/concurrently \"cd client && npm start\" \"cd server && npm run watch\" \"mongod\""

Now when we run npm start in our base directory, we’ll kick off 3 processes: the client and server apps, and Mongo.

Alternatively, you may prefer just to maintain an extra (permanent) terminal window to run the Mongo server from, without adding the mongod command to our npm start, as it adds a bit of startup time.

Let’s now install the Node.js mongodb driver. In server/:

npm install mongodb --save

Let’s add a storage folder under server. It will house the files we use for persistence to our Mongo instance:

mkdir storage && touch storage/index.js

To start, we’ll use Mongo’s singular C.R.U.D. operations: insertOne, findOne, findOneAndUpdate, and findOneAndDelete. They map nicely to the initial mutations we’ll create for our GraphQL schema.

We’ll also use a library called shortid to generate URL-friendly IDs for our entities. Mongo generates a long alphanumeric ID that is fine, but it’s good practice not to expose internal IDs. Additionally, it allows us to not have to juggle between GraphQL expecting an id property and Mongo’s _id.

In storage/index.js, we’ll have the following:

// @flow
import 'babel-polyfill'
import { MongoClient } from 'mongodb'
import R from 'ramda'
import shortid from 'shortid'

//** URL where Mongo server is listening
const url = 'mongodb://localhost:27017/bnb-book'

/**
  Variable that holds the connection to Database.
*/
let db

export async function connectToStorage() {
  try {
    db = await MongoClient.connect(url)
    return db
  }
  catch (e) {
    console.log(e)
    throw e
  }
}

export async function disconnectFromStorage() {
  try {
    await db.close()
    db = null
  }
  catch (e) {
    console.log(e)
    throw e
  }
}

export async function insertOne(collection : string, item : Object) {
  try {
    const itemWithId = R.assoc('id', shortid.generate(), item)
    const result = await db.collection(collection).insert(itemWithId)
    return result.ops[0]
  }
  catch (e) {
    console.log(e)
    throw e
  }
}

export async function fetchOne(collection : string, id : string) : Object {
  try {
    return await db.collection(collection).findOne({ id })
  }
  catch (e) {
    console.log(e)
    throw e
  }
}

export async function updateOne(collection : string, id : string, input : Object) : Object {
  try {
    let result = await db.collection(collection).findOneAndUpdate(
      { id },
      { $set: input },
      { returnOriginal: false }
    )
    return result.value
  }
  catch (e) {
    console.log(e)
    throw e
  }
}

export async function deleteOne(collection : string, id : String) : Object {
  try {
    let result = await db.collection(collection).findOneAndDelete({ id })
    return result.value
  }
  catch (e) {
    console.log(e)
    throw e
  }
}

Note that we’re defining a const url to hold our Mongo server’s URL. Eventually, we’ll need to be more robust with this and use a proper configuration library. But for now, this is fine.

Most methods are self explanatory. connectToStorage simply creates a connection to the Mongo server and returns the context if successful. Collections are strings that reference the document-type we’re dealing with: Users, Rooms, Files, etc.

We’re using async/await here so that we can use a simple try/catch to handle any errors that popup when interacting with Mongo. For now we’ll just log and rethrow the Error object passed to the catch.

Let’s not forget our tests! We’ll write some simple tests in a new file, __tests__/mongo-tests.js:

import { connectToStorage, insert, select, remove, update } from '../src/storage'

beforeAll(async () => {
  let db = await connectToStorage()
})

// technically not exactly a "unit" test but will do for now
test('insertOne creates a document, updateOne updates it, fetchOne retrieves it, deleteOne removes it', async () => {
  let result = await insertOne('testDocuments', { test: 1 })
  expect(result).toHaveProperty('test', 1)

  let updated = await updateOne('testDocuments', result.id, { test2: 2 })
  expect(updated).toHaveProperty('test2', 2)

  let fetched = await fetchOne('testDocuments', result.id)
  expect(fetched.id).toBe(result.id)
  expect(fetched).toHaveProperty('test2', 2)

  let deleted = await deleteOne('testDocuments', fetched.id)
  expect(deleted.id).toBe(result.id)
  expect(deleted).toHaveProperty('test2', 2)
});

In addition to verifying that everything’s running smoothly, these tests provide a way to see how our storage service will work.

One more thing. We need to have our main script call connectToStorage when the app loads: we’ll just add these two lines after the app.use(bodyParser.urlencoded({ extended: true })) line in src/index.js:

// mongo setup
await connectToStorage()

We just wait for the connectToStorage function to finish, and discard the return value (we don’t need it).

Now that our Mongo server is creating/reading/updating/deleting, we can start to build our queries and mutations.

But wait, there’s more! We need to set up some basic data-integrity checks in our Mongo schema. For example, we don’t want two Users with the same email, or two LocalAuths with the same usernames, of course.

Let’s introduce another tool called db-migrate, which will help us in these situations where we need to tell our Mongo instance how to behave.

npm i --save-dev db-migrate db-migrate-mongodb

Now let’s create a scripts under our server directory to hold these database scripts. In server:

mkdir -p scripts/database/migrations && touch scripts/database/database.json

We also created a database.json file which we’ll populate with these contents:

{
  "dev": {
    "driver": "mongodb",
    "database": "bnb-book",
    "host": "localhost"
  }
}

To make working with db-migrate a bit more convenient, let’s add some commands to our server’s package.json’s scripts property:

"db-migrate-up": "./node_modules/.bin/db-migrate up --config scripts/database/database.json --migrations-dir scripts/database/migrations/",
"db-migrate-down": "./node_modules/.bin/db-migrate down --config scripts/database/database.json --migrations-dir scripts/database/migrations/",
"db-migrate-create": "./node_modules/.bin/db-migrate create --config scripts/database/database.json --migrations-dir scripts/database/migrations/",
"db-migrate-dropdb": "read -p \"*** Are you sure? Entering 'yes' or 'Y' will remove bnb-book DB from your local Mongo instance.\n> \" choice && case \"$choice\" in yes|Y ) ./node_modules/.bin/db-migrate db:drop bnb-book --config scripts/database/database.json;; * ) echo \"Nothing to do.\";; esac",
"db-migrate-createdb": "./node_modules/.bin/db-migrate db:create bnb-book --config scripts/database/database.json"

Let’s go over what these commands do:

Then, we’ll create our first migration, which will create the collections we need:

npm run db-migrate-create -- createCollections

This will create a new file in server/scripts/database/migrations that looks like 20170629172134-createCollections.js. Your numbers will be different; they just represent the current date. The file contains two exports called up and down. These functions will be used to perform a migration (up) and rollback a migration (down). Let’s complete both here:

exports.up = function(db) {
  // create collections
  return Promise.all([
    db.createCollection('User'),
    db.createCollection('LocalAuth'),
    db.createCollection('Property'),
    db.createCollection('Address'),
    db.createCollection('Room'),
    db.createCollection('File')
  ])
  // create unique indices
  .then(() => Promise.all([
    db.addIndex('User', 'idx_User_email', ['email'], true),
    db.addIndex('LocalAuth', 'idx_LocalAuth_username', ['username'], true)
  ]))
};

exports.down = function(db) {
  // drop all collections
  return Promise.all([
    db.dropCollection('User'),
    db.dropCollection('LocalAuth'),
    db.dropCollection('Property'),
    db.dropCollection('Address'),
    db.dropCollection('Room'),
    db.dropCollection('File')
  ])
};

We’re simply creating a collection for each of our entities, and adding a unique index on User.email and LocalAuth.username (technically, we’ll be using the email as the username – but with this schema, we have a choice of using emails or regular, non-email usernames).

To test our migration, execute npm run db-migrate-up. If you see the following, we’re good to go:

[INFO] Processed migration 20170629172134-createCollections
[INFO] Done

You can also test our db-migrate-down command.

Whew! Lots of DB boilerplate, but that’s all done. Let’s create some objects! Look at gateway/resolvers.js. It’s using an in-memory array to track our users. Let’s change this and use our shiny new Mongo instance instead. Change the contents of resolvers.js to:

import { select, insert } from '../storage'

export async function getUser({ id }, context) {
  try {
    const results = await select('User', { _id: id })
    return Object.assign(results[0], { id : results[0]._id })
  }
  catch (e) {
    console.log(e)
    throw e
  }
}

export async function createUser(user, context) {
  try {
    return await insert('User', user)
  }
  catch (e) {
    console.log(e)
    throw e
  }
}

Note that in the catch of createUser and getUser, we’re logging the exception and simply re-throwing. This will cause the API to return the exception message down the wire to the client. This is at best, user-unfriendly, and at worst, a security issue. Later, we’ll add logic to return safe and user-friendly errors back to the client.

Let’s test our changes. Run our curl command from above:

curl -X POST localhost:3000/graphql -H "content-type: application/json" -d '{ "query": "mutation CreateUser($input: UserInput) { createUser(input: $input) { id email } }", "args": { "input": { "email": "dwight@dundermifflin.com" } } }'

We should get back {"data":{"createUser":{"id":"5955fad170cc03b92a1c6199","email":"kevin@dundermifflin.com"}}} the first time it’s run (your ID will be different). If we run the same command a second time, we should get back:

{"errors":[{"message":"E11000 duplicate key error collection: bnb-book.User index: idx_User_email dup key: { : \"kevin@dundermifflin.com\" }","locations":[{"line":1,"column":40}],"path":["createUser"]}],"data":{"createUser":null}}

Excellent! Our response contains an errors array that contains a message describing the problem: we already added “kevin@dundermifflin.com” as a user.

Let’s try our getUser function (change the ID here to the one you received above!):

curl -X POST localhost:3000/graphql -H "content-type: application/json" -d '{ "query": "query GetUserById($id: ID!) { getUser(id: $id) { id email } }", "args": { "id": "5955fad170cc03b92a1c6199" } }'

This should return:

{"data":{"getUser":{"id":"5955fad170cc03b92a1c6199","email":"kevin@dundermifflin.com"}}}

Excellent. Two basic operations, but enough of a basis to start sending requests from the frontend.

Back to the front

While we’re not done fleshing out our API, we can return to the frontend a bit. Let’s send a GraphQL request. Change client/src/App.js to:

import React, { Component } from 'react'
import './App.css'
import * as api from './api'

class App extends Component {
  constructor() {
    super()
    this.state = {
      email: '',
      id: null
    }
  }

  render() {
    return (
      <div className="App">
        <div>Create user:</div>
        <div>
          <span>Email</span>
          <input type="text" defaultValue={this.state.email} onChange={e => this.setState({ email: e.target.value })} />
        </div>
        <div>
          <button onClick={() => this.handleClick(this.state.email)}>Create User</button>
        </div>
        <span>{this.state.id === null ? '' : `The user's ID is ${this.state.id}`}</span>
        <span style=>{this.state.error}</span>
      </div>
    )
  }

  async handleClick(email) {
    const result = await api.createUser(email)
    if (result.errors) {
      this.setState({ error: result.errors[0].message, id: null })
    }
    else {
      this.setState({ email: result.data.createUser.email, id: result.data.createUser.id, error: null })
    }
  }
}

export default App

And client/src/api.js to:

export function createUser(email) {
  return graphql(
    `mutation CreateUser($input: UserInput!) {
      createUser(input: $input) {
        id
        email
      }
    }`,
    { input: { email } }
  )
}

function graphql(query, args) {
  return send(`/graphql`, {
    method: 'POST',
    headers: {
      'content-type': 'application/json'
    },
    body: JSON.stringify({ query, args })
  })
}

async function send(url, options) {
  const res = await fetch(url, options)
  if (!res.ok) {
    const err = new Error(await res.json())
    return Promise.reject(err)
  }
  return res.json()
}

And play around with our new form that creates users. It displays the user’s ID if successful, and the error if not. Obviously we’ve got a long way to go for our app, but this is a start!

Read on, as we will fully defined our backend’s architecture in the next post!