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:
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!
Call to Code is built with the following technologies:
- Front-end: React, HTML, SCSS
- Back-end: Node, Express
- Database: MongoDB w/ Mongoose
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 filesactions/
- redux types, actions, and action creatorsapi/
- handling client-side HTTP requests (connects us to our back-end REST API)components/
- components used throughout the site's pagesmiddleware/
- redux middlewarereducers/
- redux reducerstest/
- client tests (excluding unit tests)App.js
- root component, where all other components are nested underindex.*
- entry points
docs/
- documentation (setup, contributing, guides, etc.)server/
- back-end filesconfig/
- configuration files for anything on the serverdatabase/
- mongoose models and setuplib/
- files used throughout the servermiddleware/
- custom express middleware (user authentication, errorHandler)
routes/
- routing to the REST APIapi/
- 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)
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>
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)
}
}
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.
Files: actions/**/*.js
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 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 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))
}
}
}
Files: middleware/*.js
All dispatched actions are passed through all of the redux middleware.
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)
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))
.
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 withlocalStorage.setItem('jwt', action.payload.token)
LOGOUT
: removes the user's token from localStorage withlocalStorage.removeItem('jwt')
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.
- map payload into piece of state
- toggle loading states by casing on
FETCHING_*
andRECEIVED_*
:
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 }
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>
.
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>
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
}
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 />
)
}
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} />
)
}
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
}
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 can be understood by following the patterns described above.
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.
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
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
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).
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
).
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.
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 })
}
}
Files: routes/*.js
The server's routes are where the API endpoints are defined.
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).
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 ourauth.required
token authentication, and if successful, use ourusers
controller'sgetCurrent
property to interact with the database. - A
PUT
request on/api/users/current
will do the same thing, except use theusers
controller'sputCurrent
property.
These properties are functions.
Files: routes/controllers/*.js
Imports: database/models/*.js
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.
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.
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.
The front-end consists of everything in the client
folder.
- Getting started with React
- Getting started with Redux
- React with Redux
- SCSS documentation
- SCSS guide
The back-end consists of everything in the server
folder.
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/
.