Full-stack boilerplate on Express with MySQL database support and React for the user interface.
- Prerequisites
- Installation
- Version
- Async/await
- Modules
- Services
- Models
- Controllers
- Containers
- File upload
- Database schema
- Migrations
- Sending emails
- Webpack
- Device detection
- Language
- Translation
Dependencies:
If you are not familiar with Express their documentation will be a good starting point.
Clone the repository in folder codew
git clone git@github.com:Codenetz/codew.git codew
Running the project requires node >= 9.4.0
.
You can download NVM and use it like this:
$ nvm install 9.4.0
$ nvm use 9.4.0
or if you have already installed the required node
version you can continue to the next step.
Setup the environment copying dist.env
file to .env
.env
file is excluded in .gitignore
so that each environment will have it's own specific configurations.
$ cp dist.env .env
The test environment is used when running tests. For example you can have different database configurations if you don't want your tests to mess-up your application database.
$ cp dist.env.test .env.test
Read Version section
$ cp dist.version .version
Install all needed dependencies.
$ npm install
Run tests to make sure everything works correctly.
$ npm test
Fire it up
$ npm start
The application should now run with the configurations set in .env
This command will build front-end assets from ./src/client/
.
You can read more about it in Webpack section below.
You can skip this command if you don't need.
$ npm run webpack
The .version
file in the root directory of the project contains current application version using the following format {major}.{minor}.{patch}
.
Used when:
- Modifying the assets file names using hash representation of the version number to avoid caching them in the browser after editing the code.
Can also be used for:
- Automatic git tagging.
A deploy script can be created to automatically increase the current version using the command
node bin/version.js
and also create a tag in the git repository based on the.version
file. - Keeping track of current application version.
Updating the application version can be done using the command node bin/version.js
which takes one of the following arguments:
show
Prints current application version and it's hashmajor
Updates the major version by "1"minor
Updates the minor version by "1"patch
Updates the patch version by "1"
Current version can be accessed using app.get("VERSION")
, anywhere within your application.
In order to keep the code simple & readable everything is written with async
and await
Promise-based approach.
The application backend is built up from modules which gives those benefits:
- Reusable code
- Logic separation
- Better code organization
- Ability to enable modules easily
- Human readable source code
Example of such modules can be User
, Forum
, ShoppingCart
, Chat
etc.
Creating a module.
Each module has its own structure of controllers
, services
, models
, migrations
and routes
, so with its organized code it could easily be maintained and moved around different projects.
Basic module structure.
├── constants
│ └── tables.js
│
├── controller
│ └── itemController.js
│
├── migrations
│ ├── add_default_user.js
│ ├── add_field_name.js
│ └── create_user_table.js
│
├── model
│ └── itemModel.js
│
├── routing
│ └── routes.js
│
├── service
│ └── exampleService.js
│
├── models.js
├── example.js
└── services.js
/constants
- Keeps all your module constants in one place. For example it can containtable names
,endpoints
,payment methods
,error codes
and so on./controller
- Contains classes (controllers handling the client request and server response./migrations
- Database migration files./model
- Contains classes (models) handling part of the business logic and interactions with the database./routing
- Describes all module specific endpoints./service
- Contains classes (services) handling business logic.models.js
- Used to declare modules' models.services.js
- Used to declare modules' services.example.js
- Entry file that must be declared insrc/server/modules.json
in order to load the module.
A service is a useful object for example MailService
which can be used for sending emails or ImageService
for processing images.
A service registration can be made by using the SERVICE
container.
First a class instance must be made with all required arguments and then registered in the container.
app.get("SERVICE").set(new ImageService(app));
Service is accessed from the SERVICE
container.
app.get("SERVICE").get("ImageService");
This will return ImageService
object registered earlier.
No matter how much times a service is requested it will always return object from same reference.
app.get("SERVICE").get("ImageService"); //from ref 1
app.get("SERVICE").get("ImageService"); //from ref 1
app.get("SERVICE").get("ImageService"); //from ref 1
Benefits using services:
- No need to require additional modules in your files.
- Promotes good architecture.
- No need to instantiate a class everytime you need it. You already have it in the container ready for use.
- Easy access to your useful classes anywhere in the application.
They are responsible for the database access and the business logic with the help of services.
A model registration can be made by using the MODEL
container.
First a class instance must be made with all required arguments and then registered in the container.
app.get("MODEL").set(new UserModel(app));
Model is accessed from the MODEL
container.
app.get("MODEL").get("UserModel");
This will return UserModel
object registered earlier.
No matter how much times a service is requested it will always return object from same reference.
Notice:
- Each application model must extend the base model class
src/server/core/model
. - Use models from the
MODEL
container avoid doing model instantiation if not necessary, this must be done only on server boot time. - Never pass not validated data to the model.
Processing client request and returning appropriate response. A controller is composite from actions.
Each action takes 3 parameters.
- req. Request object.
- res. Response object.
- next. Function for calling next middleware.
Request
Commonly used properties:
- req.file/s - Client uploaded files. See files
- req.query - Query parameters.
?example=1
- req.body - Client POST/PUT data
- req.params - URL parameters.
/example/:id
Response
After extending the base controller class a method called response
will be available.
It is used for standardizing the response.
/**
* @var object res Action response object
* @var object data Data to be send back to client. Default {}
* @var integer status_code HTTP status code. Default 200
*/
response(res, data, status_code)
The usage of it will be like this:
return this.response(res, {
items: []
});
Error
The error response is standardized from a middleware located in src/server/middlewares/error.js
An error could be thrown by using the next()
and passing a Boom
error as an argument.
return next(Boom.forbidden());
Unhandled errors or errors thrown without Boom
will be processed from the middleware passing 400 Bad Request.
to client and response data.
"statusCode": 400
Validation
Client input data validation is done in the routes file as a middleware using Joi
.
let
Joi = require('joi'),
validation = require("./../../../middlewares/validation");
/** ... */
app.post(
"/sign-in",
validation.bind(
null,
Joi.object().keys({
username: Joi.string().alphanum().min(3).max(30).required(),
password: Joi.string().regex(/^[a-zA-Z0-9]{3,30}$/).required()
}),
"body"
),
itemController.listAction);
When adding validation you are passing two arguments.
- Joi schema
- Where to look for the client data that must be validated.
Possible values are:
body
,query
,params
Multiple validation
Different type (body
, query
, params
) validations can be set for a route.
let
Joi = require('joi'),
validation = require("./../../../middlewares/validation");
/** ... */
app.post(
"/sign-in",
validation.bind(
null,
Joi.object().keys({
sid: Joi.string().required()
}),
"query"
),
validation.bind(
null,
Joi.object().keys({
username: Joi.string().alphanum().min(3).max(30).required(),
password: Joi.string().regex(/^[a-zA-Z0-9]{3,30}$/).required()
}),
"body"
),
itemController.listAction);
so in order to enter listAction
first the request must have a query parameter sid
and post data that have username
and password
in it.
[POST] /sign-in?sid=xxx
username=someone
password=passw0rD
Container is a registry for objects from a certain type under one domain and by using it you don't need to import and instantiate any modules.
Usage
By default they are 2 containers.
MODEL
References of models.
Setting an object in MODEL
is done by:
app.get("MODEL").set(new ExampleModel(app);
And getting an object from MODEL
:
app.get("MODEL").get("ExampleModel");
SERVICE
References of services
Setting an object in SERVICE
is done by:
app.get("SERVICE").set(new ExampleService(app);
And getting an object from SERVICE
:
app.get("SERVICE").get("ExampleService");
Creating a container
Creating and setting up a container doesn't take much effort.
First you need to create your container class in /src/server/containers/
:
let container = require("./container");
class ExampleContainer extends container {
constructor(app) {
super(app);
this.container_name = "EXAMPLE";
}
}
module.exports = ExampleContainer;
Extend the base container class and set this.container_name
property with a name used later for accessing the container with app.get("EXAMPLE")
.
Additionally you can implement your own methods and use them like that app.get("EXAMPLE").myCustomMethod()
.
After the container is ready it is time to register it in /src/server/containers.json
.
The common way to use a container is in a module entry file.
Uploading is done using https://www.npmjs.com/package/multer
.
Configuration can be found in /boot/server.js
.
Enable file upload for specific route with multer middleware.
app.post('/example-image',
app.get("multer").single('image'),
itemController.uploadItemAction
);
Be aware that the send request must be multipart/form-data
.
After the request is send the newly uploaded files are saved in /public/uploads/
directory
and multer adds an object (file
/files
) to current request containing info data for the file.
See request
Managing database table structure from predefined schema using typeorm
Schema must be placed in entity
folder of a module.
Every schema could extend the base one which gives extra fields.
Example of a schema
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";
import { Base } from "../../../core/entity/Base";
import { USER_TABLE } from "../constants/tables";
@Entity({ name: 'user' })
export class User extends Base {
@Column()
username: string;
@Column()
email: string;
}
Synchronizing database
$ npm run typeorm schema:sync
For additional documentation about defining schema visit typeorm
Synchronize database changes between environments.
Writing a migration:
module.exports = {
up: "SQL COMMAND",
down: "SQL ROLLBACK COMMAND"
};
up
- SQL query for changing database
down
- SQL query for undoing changes made to database
The down
method is useful when after a broken deployment happen and you need to reverse the codebase and therefore the database to previous version.
Keeping track of the migrations
Registering a migration is done in /src/server/migrations.json
after the latest executed migration or at the bottom of the file.
If you place it for example before the latest
your new migration will be not executed automatically therefore you will need to run it manually.
Latest executed migration and history about all migration executions are kept in /var/migration.json
Migration script
Migrations are run through CLI.
-
Automatically run all migrations
up
actions after thelatest
one.$ node bin/migration.js
-
Run
up
action of the next migration and set it aslatest
.$ node bin/migration.js up
-
Run exact migration and save it only in migration history, it will be not set as
latest
.$ node bin/migration.js up "/path/to/migration"
-
Run previous executed migration and sets the next previous as
latest
.$ node bin/migration.js down
-
Run exact migration and save it only in migration history, it will be not set as
latest
.$ node bin/migration.js down "/path/to/migration"
Migration script on test environment
Tests are run on a test database so in order to keep it up to date you must tell the migration script which database to update.
This is done by passing test
as a flag.
$ node bin/migration.js --test
Emails are send through sendgrid
Example of sending an email
const sendgrid = this.app.get('sendgrid');
const { SYSTEM_EMAIL, SYSTEM_EMAIL_NAME } = env.vars;
await sendgrid.send(
{
name: SYSTEM_EMAIL_NAME,
email: SYSTEM_EMAIL
},
{
name: "someone name",
email: "someone@..."
},
'sign up',
await sendgrid.emailTemplate('signup', {
USERNAME: "someone"
})
);
Each email has it's own template located in src/client/views/emails/
and it is using the email_layout.ejs
.
Templates are written using ejs
Fully configured webpack for compiling the front-end.
Supports
- babel for supporting latest js & react
- stylus with nib support
- jsx
- minifications
- css class prefixes
- different environments
The configurations can be found in webpack.config.js
& .babelrc
React
As a front-end framework is used react and the source files can be found in /src/client/
.
They are two folders which represents the client environments desktop
and mobile
.
Compiling
CSS and JS files are compiled in /public/assets/dist/
.
The file names are generated from the (client environment folder name) + (version hash).
If compiling is started in production environment a minification
& optimization
of the assets are done.
Command
webpack can be start from the command
$ npm run webpack
Detecting client device is done by using the clientDevice
middleware on any route you want.
For example:
app.get("/",
clientDevice,
homeController.homeAction
);
to the request will be passed:
- device. Response from mobile-detect
- is_mobile. Boolean that tells if client device is mobile.
Notice that tablets are considered mobile too.
This rule could be changed from /src/server/middlewares/clientDevice.js
Language support is available on every route by using the language
middleware.
By using the middleware a property language
is set in the request.
Before setting up the middleware you must know that by default language support is not enabled.
Enable
-
Set
ENABLE_MULTILANGUAGE
totrue
in.env
file. -
Setting up the available languages is done in
/boot/language.js
. They could be dynamic too, for example if they are fetched from API. -
Set the
language
middleware on any route where multilanguage support is need it.
For example:
app.get("/",
language,
homeController.homeAction
);
Default language
You can set a default language by changing the is_default
property to true
for your specific language in /boot/language.js
.
Note: Only one language can be set as default.
When requesting the default language subdomain you will be redirected to the root domain.
Example: en.example
(301 Moved Permanently) -> example
Changing language
You can change the language by passing the query parameter lang
in the URL.
The value passed must be a language code (code
property) from the available language codes in /boot/language.js
.
Example: example?lang=en_GB
The language for new clients is determined by:
- accessing the root domain (
example
): The language is based on the ip geolocation of the client. - accessing a subdomain (
es.example
): The language is based on the subdomain and will be used from here onwards.
Geolocation & Nginx
If nginx is set in front of the node server then proxy_set_header X-Forwarded-For
must be set.
Language functionality must be enabled in order to use the translations.
By using the language middleware a property translation
is set in the request.
The value of the translation
property is an object holding all translations from current language.
Translation file
Translation files are key-value json objects located in /translations
directory.
To have a correct match between client language and translation file the file names must be same as the language codes in /boot/language.js
.