Skip to content

Latest commit

 

History

History
868 lines (611 loc) · 25.5 KB

ARCHITECTURE.md

File metadata and controls

868 lines (611 loc) · 25.5 KB

Architecture Guide

Joining in on an existing project such as this can feel daunting due to the learning curve and the sheer amount of code that there is. This document serves as a guide to get familiar with this project's architecture & codebase:

Reference for guides:

Introduction

This guide is long; we recommend a few things:

  • If you don't have much React/other framework or Node experience, start with learning either the front-end or the back-end. It can be a lot to tackle everything at once (even if you do have experience).
  • At the bottom we've gathered together some guides that we found were helpful. You may not need to dive into these right away, but these are here as a reference for when you face challenges during coding.
  • Have fun learning!

Technology Stack

Call to Code is built with the following technologies:

  • Front-end: React, HTML, SCSS
  • Back-end: Node, Express
  • Database: MongoDB w/ Mongoose

Project Structure

Here is a breakdown of the project's structure (links are to more information in the sections below):

  • .deploy/ - deployment files (scripts)
  • .setup/ - setup files (scripts, seed data)
  • client/ - front-end files
    • actions/ - redux types, actions, and action creators
    • api/ - handling client-side HTTP requests (connects us to our back-end REST API)
    • components/ - components used throughout the site's pages
    • middleware/ - redux middleware
    • reducers/ - redux reducers
    • test/ - client tests (excluding unit tests)
    • App.js - root component, where all other components are nested under
    • index.* - entry points
  • docs/ - documentation (setup, contributing, guides, etc.)
  • server/ - back-end files
    • config/ - configuration files for anything on the server
    • database/ - mongoose models and setup
    • lib/ - files used throughout the server
      • middleware/ - custom express middleware (user authentication, errorHandler)
    • routes/ - routing to the REST API
      • api/ - API endpoints (for example: POST /project)
      • controllers/ - API implementations (for example: createProject())
    • test/ - server tests (excluding unit tests)
    • app.js - express app setup (global middleware, error handling, routes, etc.)
    • index.js - entry point (server creation)

Client Architecture

Entry Point

File: index.js

Imports: App.js

Renders the root component:

<Provider store={store}>
  <ConnectedRouter history={browserHistory}>
    <MuiThemeProvider>
      <Switch>
        <Route path="/" component={App} />
      </Switch>
    </MuiThemeProvider>
  </ConnectedRouter>
</Provider>

File: App.js

Imports: components/*.js

Renders routes pointing to their associated components:

<Header />
<Version />
<Switch>
  <Route exact path='/' component={Home} />
  <Route path='/create-project' component={CreateProjectForm} />
  <Route path='/forgot-password' component={ForgotPasswordForm} />
  <Route path='/login' component={LoginForm} />
  <Route path='/profile' component={restricted(Profile)} />
  <Route path='/signup' component={SignupForm} />
</Switch>

API

Files: api/*.js

Imports: ./lib/apiRequest.js

These files handle our client-side HTTP requests and connect us to our back-end REST API. Imports an object where each key handles creating a request with its corresponding http method.

  • DELETE: del (url, apiOptions, query) { .. }
  • GET: get (url, apiOptions, query) { .. }
  • POST: post (url, apiOptions, body) { .. }
  • PUT: put (url, apiOptions, body) { .. }

For example, in api/projects.js:

const projectsApiClient = {
  getAllProjects (apiOptions) {
    return apiRequest.get('/projects', apiOptions)
  },

  getAppliedProjects (apiOptions, projectsAppliedFor) {
    const query = { projectsAppliedFor }
    return apiRequest.get('/projects', apiOptions, query)
  },

  getOrgProjects (apiOptions, id) {
    const query = { organization: id }
    return apiRequest.get('/projects', apiOptions, query)
  }
}

Redux

Store

File: index.js

Imports: reducers/*.js, middleware/*.js

Fairly simple store setup, applies promiseMiddleware, then thunkMiddleware, then localStorageMiddleware, in that order. The ordering is described below. Also applies a loggerMiddleware in development.

Actions

Files: actions/**/*.js

Actions

Actions are payloads of information that send data from the application to the redux store. They are the only source of information for the store. You dispatch() an action to send it to the store.

For example, in actions/projects/index.js:

export const fetching = { type: FETCHING_PROJECTS }

store.dispatch(fetching)

The action itself is the fetching object. Notice how it only has a type. Actions must always have at least a type property. As a best practice, it is good to limit the properties of an action to type, payload, error, and meta.

Types

Types are unique string constants used as identifiers for actions. Each type corresponds to exactly one action. Action types are used in redux reducers in order to identify the action and update the store appropriately.

For example, in actions/projects/types.js:

export const FETCHING_PROJECTS = 'FETCHING_PROJECTS'
export const RECEIVED_PROJECTS = 'RECEIVED_PROJECTS'
export const FAILED_PROJECTS = 'FAILED_PROJECTS'
Action Creators

Action creators return a function that can dispatch an action. This allows us to do asynchronous work such as call an API endpoint and wait for the results, and then dispatch an action when it finishes.

For example, in actions/projects/index.js:

static fetchAllProjects () {
  return (dispatch, getState) => {
    dispatch(ProjectActionCreator.fetching())

    try {
      const state = getState()
      const apiOptions = apiOptionsFromState(state)
      const projects = projectsApiClient.getAllProjects(apiOptions)
      dispatch(ProjectActionCreator.received(projects))
    } catch (e) {
      console.trace(e)
      dispatch(ProjectActionCreator.failed(e))
    }
  }
}

Middleware

Files: middleware/*.js

All dispatched actions are passed through all of the redux middleware.

promiseMiddleware

Intercepts all actions where action.payload is a Promise. In which case it extracts the value or error out of the promise and re-dispatches it as a non-promise:

return isPromise(action.payload)
  ? action.payload.then(
    result => store.dispatch({ ...action, payload: result }),
    error => store.dispatch({ ...action, payload: error, error: true })
  )
  : next(action)
thunkMiddleware

Intercepts all action creators and waits to dispatch an action until a certain condition is met.

For example, in actions/auth/index.js:

static signup ({ email, password, isOrganization }) {
  return async (dispatch, getState) => {
    try {
      const state = getState()
      const apiOptions = apiOptionsFromState(state)
      const usertype = isOrganization ? 'contact' : 'volunteer'

      const user = await usersApiClient.signup(apiOptions, { usertype, email, password })
      dispatch(AuthActionCreator.login(user))
    } catch (e) {
      console.trace(e)
      throw new SignupException()
    }
  }
}

This action creator returns a function that allows us to do asynchronous work such as await usersApiClient.signup(apiOptions, { usertype, email, password }), and then dispatch an action when it finishes receiving that data with dispatch(AuthActionCreator.login(user)).

localStorageMiddleware

Runs after promiseMiddleware and thunkMiddleware. This ordering is important because localStorageMiddleware we want to make sure that we parse action creators and promises into action objects.

Intercepts actions with type:

  • LOGIN: sets the user's token into localStorage with localStorage.setItem('jwt', action.payload.token)
  • LOGOUT: removes the user's token from localStorage with localStorage.removeItem('jwt')

Reducers

File: reducers/index.js

Imports: reducers/*.js

Exports all reducers in a single object so that combineReducers can be easily used to combine them in the store.

General Reducer Patterns
  • map payload into piece of state
  • toggle loading states by casing on FETCHING_* and RECEIVED_*:
case FETCHING_PROJECTS:
  return { ...state, fetching: true }

case RECEIVED_PROJECTS:
  return { ...state, fetching: false, projects: payload }
  • toggle error states by checking action.error if it is there (see promiseMiddleware)
const { payload, error } = action

...

case FAILED_PROJECTS:
  return { ...state, fetching: false, hasError: error, error: payload }

Components

Accessing store state in components:

function mapStateToProps (state) {
  return {
    authenticated: state.auth.authenticated,
    currentPage: state.routing.location.pathname,
    user: state.user
  }
}

Accessing action creators in components:

import AuthActionCreator from '../../actions/auth'

const mapDispatchToProps = {
  logout: AuthActionCreator.logout
}

This allows us to dispatch actions inside of components.

Notice how mapStateToProps and mapDispatchToProps end in ToProps. These properties get bound to the component's props, and are accessible with this.props.<state/dispatch property>.

Root Component - "/"

Mentioned above, but now we are building on our redux knowledge.

File: App.js

Imports: components/*.js, actions/auth/index.js

Renders routes pointing to their associated components:

<Header />
<Version />
<Switch>
  <Route exact path='/' component={Home} />
  <Route path='/create-project' component={CreateProjectForm} />
  <Route path='/forgot-password' component={ForgotPasswordForm} />
  <Route path='/login' component={LoginForm} />
  <Route path='/profile' component={restricted(Profile)} />
  <Route path='/signup' component={SignupForm} />
</Switch>
Mapping store to component props

Maps state to props to use in component:

function mapStateToProps (state) {
  return {
    appLoaded: state.common.appLoaded
  }
}

Maps action creators to props to use in component:

import AuthActionCreator from 'actions/auth'

const mapDispatchToProps = {
  appLoad: AuthActionCreator.appLoad
}
Lifecycle

We then trigger the appLoad action creator in the component's componentDidMount lifecycle. This triggers during component creation.

componentDidMount () {
  this.props.appLoad()
}

In this situation appLoad() updates the appLoaded state for us which can be seen in reducers/commonReducer.js:

case APP_LOAD:
  return { ...state, appLoaded: true }

This update is automatically passed to the component's bound appLoaded prop, which allows us to conditionally load the app's components based on that loaded state:

// Conditionally calls two different functions
render () {
  return this.props.appLoaded
    ? this.renderAppLoaded()
    : this.renderAppNotLoaded()
}

// When the app has been loaded:
renderAppLoaded () {
  return (
    <div>
      <Header />
      <Version />
      <Switch>
        <Route exact path='/' component={Home}/>
        <Route path='/create-project' component={CreateProjectForm}/>
        <Route path='/forgot-password' component={ForgotPasswordForm}/>
        <Route path='/login' component={LoginForm}/>
        <Route path='/profile' component={restricted(Profile)}/>
        <Route path='/signup' component={SignupForm}/>
      </Switch>
    </div>
  )
}

// When the app isn't loaded yet:
renderAppNotLoaded () {
  return (
    <Header />
  )
}

Home Component - "/"

File: components/Home/Home.js

Imports: actions/project/index.js, components/ListOfProjects/ListOfProjects.js

Render's the home page which is a list of projects:

render () {
  return (
    <ListOfProjects
      title={'Click To Apply'}
      projects={this.props.projects} />
  )
}
Mapping store to component props

Maps state to props to use in component:

function mapStateToProps (state) {
  return {
    projects: state.project.projects
  }
}

Maps action creators to props to use in component:

import ProjectActionCreator from 'actions/project'

const mapDispatchToProps = {
  onLoad: ProjectActionCreator.fetchAllProjects
}
Lifecycle

We then trigger the onLoad action creator in the component's componentDidMount lifecycle. This triggers during component creation.

componentDidMount () {
  this.props.onLoad()
}

In this situation onLoad() is bound to the fetchAllProjects action creator:

static fetchAllProjects () {
  return (dispatch, getState) => {
    dispatch(ProjectActionCreator.fetching())

    try {
      const state = getState()
      const apiOptions = apiOptionsFromState(state)
      const projects = projectsApiClient.getAllProjects(apiOptions)
      dispatch(ProjectActionCreator.received(projects))
    } catch (e) {
      console.trace(e)
      dispatch(ProjectActionCreator.failed(e))
    }
  }
}

This action creator starts by dispatching an action telling the store that it is starting to fetch projects. Then it asks the back-end for the projects, and dispatches another action (received()), telling the store that it received the projects. If anything fails along the way, the action (failed()) is dispatched instead.

Once the projects are received, the projectReducer updates the store appropriately:

case RECEIVED_PROJECTS:
  return { ...state, fetching: false, projects: payload }

And the projects prop on the Home component is updated, passing the new value to ListOfProjects, which renders the projects accordingly.

Other Components

Other components can be understood by following the patterns described above.

Server Architecture

Entry Point

File: index.js

Imports: babel-register, server.js

The entry point requires babel-register which allows us to use es6 import/export features in our files. It then requires our server entry point.

Server

File: server.js

Imports: app.js, database/index.js

Runs the server using the set up express app and connects to the database:

import app from './app'
import _database from './database'
import { appConfig, databaseConfig } from './config'

const database = _database._init(databaseConfig.url)

app.listen(appConfig.port, runServer)

async function runServer () {
  logger.log(`App listening on port ${this.address().port}`)

  try {
    await database.connect()
    logger.log('Database connected')
  } catch (error) {
    logger.error('Database connection error', error)
  }
}

export default app

Express App

File: app.js

Imports: express, middleware, routes/index.js

Sets up and exports the express app, adding middleware and routes:

import errorHandler from './lib/middleware/errorHandler'
import routes from './routes'

const app = express()

app.use(express.static(appConfig.publicDir))
app.use(bodyParser.json())
app.use(morgan('dev'))
app.use('/', routes)
app.use(errorHandler())

export default app

Database

File: database/index.js

Imports: mongoose

Exports an object which has an _init () {} for setting itself up, and a connect () {} for connecting to the MongoDB through mongoose:

export default {
  _init (url, client = mongoose) {
    this.url = url
    this.client = client
    return this
  },

  connect () {
    this.client.Promise = global.Promise
    this.client.connect(this.url)

    const db = this.client.connection
    return new Promise((resolve, reject) => {
      db.on('error', reject)
      db.once('open', resolve)
    })
  }
}

Files: database/models/*.js

Imports: mongoose, jsonwebtoken*, config/authConfig.js*

*: Only the User model

These files set up the mongoose models which tell us how to format the data we put in our MongoDB:

const UserSchema = mongoose.Schema({
  usertype: {
    type: String,
    enum: ['contact', 'volunteer'],
    required: true
  },
  email: {
    type: String,
    required: true,
    unique: true
  },
  // ...
}, { timestamps: true })

export default mongoose.model('User', UserSchema)

This model says that our users are going to have a required usertype whose value is required, can only be a string, and with value 'contact' or 'volunteer'. We also tell the model to automatically add timestamps, which gives our data auto-populated createdAt and updatedAt fields. We then register the model with mongoose.model().

We can also define functions on the model which can be called on the data that we retrieve/create using this model (more on this below):

import jwt from 'jsonwebtoken'
import { authConfig } from '../../config'

UserSchema.methods.generateSessionToken = function () {
  const today = new Date()
  const expiration = new Date(today)
  const expirationDays = 14
  expiration.setDate(today.getDate() + expirationDays)

  return jwt.sign({
    id: this._id,
    exp: parseInt(expiration.getTime() / 1000)
  }, authConfig.jwtSigningKey)
}

This function allows us to easily generate a token for a user when they login or signup. This token is used to authenticate them when they make requests to the server, and is signed with an id and exp property (more on this below).

Custom Middleware

Files: lib/middleware/*.js

Middleware are functions that we can pass server requests through. You can use this to set up logging (morgan does this), authenticate a user before you finish a request (auth.js) or add error handling to the end of the request (errorHandler.js).

Auth Middleware

File: lib/middleware/auth.js

Imports: express-jwt, config/authConfig.js

This middleware is something you can place in front of any api endpoint in order to check for the user's authentication token:

function getTokenFromHeader (req) {
  if (req.headers.authorization) {
    const [preamble, token] = req.headers.authorization.split(' ')
    if (preamble === 'Token' || preamble === 'Bearer') {
      return token
    }
  }

  return null
}

The authentication itself happens internally in the express-jwt module, but we define two types of user authentication:

export default {
  required: jwt({
    getToken: getTokenFromHeader,
    secret: authConfig.jwtSigningKey,
    userProperty: 'payload'
  }),
  optional: jwt({
    credentialsRequired: false,
    getToken: getTokenFromHeader,
    secret: authConfig.jwtSigningKey,
    userProperty: 'payload'
  })
}
  • auth.required will require the user to be authenticated (logged in) for the request to succeed. If a user does not pass this authentication, the request will throw an UnauthorizedError with a 401 status code.
  • auth.optional does not require the user to be authenticated (logged in) for the request to succeed, but is instead used to potentially enhance the data being returned by modifying the return data specific to the user.

Note: notice how auth.required and auth.optional both define a userProperty: 'payload'. This says that when the user is successfully authenticated, that the token will be parsed and any data that was signed into the token (user _id: id, and token expiration: exp in our case) will be added to the request object on its payload property. This will be explained more below.

Error Handler Middleware

File: lib/middleware/errorHandler.js

Imports: logger.js

This custom middleware is unique because it is specifically added to the very end of the app's middleware chain, after the routes. This is because we can forward any errors that occur in the route controllers (or anywhere else) to next() which is then ran through this middleware. An err parameter is added and the errors can be dealt with in a general way:

export default function () {
  return function (err, req, res, next) {
    logger.error(err)

    const error = { name: err.name, message: err.message, stack: err.stack }
    for (const prop in err) error[prop] = err[prop]

    return res.status(err.status || 500).json({ error })
  }
}

Routes

Files: routes/*.js

The server's routes are where the API endpoints are defined.

Entry Point

File: routes/index.js

Imports: express

This file uses express router to define our endpoint base (/api), which says that all of our endpoints begin with that base:

import api from './api'

const router = express.Router()

router.use('/api', api)

export default router

This points the request to:

File: routes/api/index.js

Imports: express, ./*.js

This file uses express router to further define our api endpoints:

const router = express.Router()

router.use('/applications', applicationsRoutes)
router.use('/projects', projectsRoutes)
router.use('/users', usersRoutes)

export default router

For example, the users endpoints are now accessible at /api/users, and are further defined in routes/api/users.js (example continues below).

API

Files: routes/api/*.js

Imports: expressPromiseRouter, ../controllers/*.js

These files use an express router wrapper (expressPromiseRouter) to once again further define our api endpoints and handle requests differently on each. For example in routes/api/users.js:

import auth from '../../lib/middleware/auth'
import _users from '../controllers/users'

const router = express.Router()
const users = _users._init()

router.route('/current')
  .get(auth.required, users.getCurrent)
  .put(auth.required, users.putCurrent)

export default router

These are the ends of these endpoint definitions:

  • A GET request on /api/users/current will end up here, pass through our auth.required token authentication, and if successful, use our users controller's getCurrent property to interact with the database.
  • A PUT request on /api/users/current will do the same thing, except use the users controller's putCurrent property.

These properties are functions.

Controllers

Files: routes/controllers/*.js

Imports: database/models/*.js

_init

You may have noticed that when we imported the users controller above, we also needed to call ._init() on it:

import _users from '../controllers/users'

const users = _users._init()

This function is used to bind the controller's needed model(s) and own functions to itself:

export default {
  _init (Users = UserModel) {
    bindFunctions(this)

    this.Users = Users
    return this
  },
  // ...
}

This pattern is repeated in all of the controllers and allows us to easily mock the model(s) in our controller unit tests.

Controller Implementations

The controller function implementations are where we interact with the database:

export default {
  // ...
  async getCurrent (req, res) {
    const id = req.payload.id
    const user = await this.Users.findById(id)

    if (!user) throw new NotFoundError()

    return res.status(200).json(user.toJSON())
  },
  // ...
}

Recall how the req.payload is populated with the parsing of the authentication token, explained in the Auth Middleware section above. We then use the parsed id to findById() the current user and return the data back to the client with return res.status(200).json(user.toJSON()).

Also recall how we are calling user.toJSON(). We can do this because the toJSON () function is defined on the mongoose User model in the same way that generateSessionToken () was.

Lastly recall how we are throwing a NotFoundError in the event that a user isn't found. The expressPromiseRouter mentioned earlier allows us to do this, as it handles the rejection and forwards it to next which passes to the error handler middleware. Any other errors thrown in the controller implementations (a failing query, etc.) are forwarded to the error handler in the same way.

Public files

Files: public/*.*

These files are the actual built and served files for the app. These are not meant to be touched or used for development.

Front-end Guides

The front-end consists of everything in the client folder.

Back-end Guides

The back-end consists of everything in the server folder.

Database Guides

This project uses MongoDB. On top of Mongo we use Mongoose, allowing us to define schemas/models for data. The relevant files are in the server/database/ folder. Making any changes to the database typically means making changes to the schemas located in server/database/models/.