What we are going to build?
We will build a RESTful API in node.js aiming the following requirements:
- Handle CRUD (create, read, update, delete) on resources (posts, comments, users, roles, permissions)
- Access to the API will be secured with JWT auth + RBAC (Role-based access control)
- Data will be returned in JSON format
- All requests will be logged to the console
Our app dependencies
We are going to use the following tools/packages:
- node.js
- npm
- typescript
- express.js
- express validator
- express-jwt
- passport
- jest
- codecov
- ...and many more
RESTful API dictionary:
We will start with a few important terms, to make sure we speak the same language when we will be coding later on:
- Endpoint - an API URL which represents eiter resource or collection of resources (i.e. https://example.com/api/v1/users)
- Resource - a single instance of an object (i.e. user or comment)
- Collection of resources - a group of same types objects (i.e. users or comments)
- Client - computer program or a device that accesses a service (i.e. our API) provided by server
- Server - computer program or a device that provides functionality for other programs or devices, called clients
- Idempotent operations - operations that produce the same results without side effects, regardless whether happen once or many times (i.e. GET request should not produce any side effects if you send it once or thousand times)
Getting started
Let’s first specify the collection of resources we are going to include in our API:
- users
- roles
- permissions
- posts
- comments
Let's draw the relationoships between the models
We will be using the following relationships:
1. Users - Posts
- One user has many posts
- One post belongs to one user
2. Posts - Comments:
- One post has many comments
- One comment belongs to one post
3. Users - Reset Password Tokens:
- One user has many reset password tokens
- One token belongs to one user
4. Users - Roles:
- One user belongs to many roles (through role_user pivot table)
- One role belongs to many users (through role_user pivot table)
5. Roles - Permissions:
- One role belongs to many permissions (through role_permission pivot table)
- One permission belongs to many roles (through role_permission pivot table)
6. User - Comments:
- One user has many comments
- One comment belongs to one user
Schema planing
We will come back to this section later, after we set up a project, and we would talk about db structure in the context of migrations which basically mirror db structure.
API root URL
Chosing the root location is important part of designing an api.
The most commons ones are:
- https://your-website.com/api/v1/*
- https://api.your-website.com/v1/*
Which one to choose? If your application is just simple API, which presumably not going to be another facebook API than go with option 1. However when you think that your API might grow fast and to make it more scalable, putting it on subdomain might be a good choice (option 2).
API versioning
Why you should version your API? The reason is simple, your code is going to change in the future, stuff like models attributes and relationships might be added/removed or changed.
It is important to keep in mind that, API when published is contract between client and server.
If you make breaking changes, and clients would not work with your changed API, then you would eventually lose your customers.
Therefore, we would introduce some versioning to maintain the code which is currently released and when necessary introduce some new changes in new versions of our API.
There are two main schools of API versioning:
- Version specification in the headers, with other metadata, using the Accept header with a custom content type, i.e.
Accept: application/json; version=2
- Version specification as part of the root API URL segement i.e. https://your-website/api/v1/*
For the simplicity, we are going to stick to the second option in this tutorial.
Https everywhere
As you might notice all the previous url we mentioned already where proceed with SSL.
It is important for the production environment to secure the connections and data transmissions with SSL.
When you would be enabling ssl, make sure that when request would come from unsecure port 80 connection to throw 403 Forbidden error, to avoid any insecure data exchange.
For the simplitcity in this tutorial we won't be enabling SSL, however you must do it for your production environment.
REST verbs
We are going to perform basic CRUD operations on our resources (users, posts, comments and tokens)
It is important to understand the difference between the HTTP verbs:
HTTP verb | CRUD operation | Description |
---|---|---|
POST | CREATE | HTTP POST method is most often used to create new resources. On successful creation it should return status code 201 CREATED. POST requests are neither safe nor idempotent, means when you send to same POST requests it might create two resources contained same attributes. |
GET | READ | HTTP GET method is used to retrieve a reprentation of resource or collection of resources. If successful should return response 200. When resource cannot be found should return 404 or 400 when the the server cannot process request due to client error. According to HTTP spec, GET simiarily to HEAD requests should be only used to read data and not modify it (should be idempotent). |
PUT | UPDATE | HTTP PUT method is known for updating the entire resource resource (true PUT doesn't happen that often as you might be i.e. living updating the timestamps to the server - then is not a truly PUT request, but PATCH request). Even if it's possible to create a brand new request with specifying new resource ID, it is recommended to use POST instead. When request succeeded it should return response code 200 (or 204 when, not returning content in the body). Put is not safe operation, but is idempotent (regardless how many times you send it, resource state going to be the same as first call). |
PATCH | UPDATE | HTTP PATCH method is used for partially update the resource. Patch is neither safe nor idempotent. It should return 200 when sucessful, 404 when resource not found and 400 when request cannot be processed due to client error. |
DELETE | DELETE | HTTP DELETE method removes the resource identified by a URI. When request succeeded it should return 200 resonse code or 204 (when no content). Issuing again same request should return 404 NOT FOUND - because resource is gone. If you want to know more about delete here is some good article about HTTP DELETE in the context of soft-deleting. |
API endpoints
Auth routes
We are going to use AuthController for those routes:
Route | Permission required | Controller method | Description |
---|---|---|---|
POST /api/v1/auth/register | N/A | register | Allows unauthenticated user to register |
POST /api/v1/auth/login | N/A | login | Allows unauthenticated user to log in and receive JWT token for signing API requests |
POST /api/v1/auth/forgot-password | N/A | forgotPassword | Allows unauthenticated user to request an email with reset password token used for password reset |
POST /api/v1/auth/reset-password | N/A | resetPassword | Allows unauthenticated user to reset own password using token received via email |
Users routes
We are going to use UsersController for those routes:
Route | Permission required | Controller method | Description |
---|---|---|---|
POST /users | user.store | store | Allows to create a new user |
GET /users | users.index | index | Allows to get all users |
GET /users/:user_id | user.show | show | Allows to get user for a given id |
PUT /users/:user_id | user.update | update | Allows to update/replace entire user resource, contains complete resource |
PATCH /users/:user_id | user.update | update | Allows to modify particular user resource, only contains changes to the part of resource/td> |
DELETE /users/:user_id | user.delete | destroy | Soft deletes user by a given id |
UsersRoles routes
We are going to use UsersRolesController for those routes:
Route | Permission required | Controller method | Description |
---|---|---|---|
POST /users/:user_id/roles | role.assign | assign | Assign user's role |
DELETE /users/:user_id/roles/:role_id | role.remove | remove | Remove user's role |
Posts routes
We are going to use PostsController for those routes:
Route | Permission required | Controller method | Description |
---|---|---|---|
POST /posts | post.store | store | Allows to create a new post |
GET /posts | posts.index | index | Allows to get all posts |
GET /posts/:post_id | post.show | show | Allows to get post for a given id |
PUT /posts/:post_id | post.update | update | Allows to update/replace entire post resource, contains complete resource |
PATCH /posts/:post_id | post.update | update | Allows to modify particular post resource, only contains changes to the part of resource |
DELETE /posts/:post_id | post.delete | destroy | Soft deletes post by a given id |
Comments routes
We are going to use CommentsController for those routes:
Route | Permission required | Controller method | Description |
---|---|---|---|
POST /comments | comment.store | store | Allows to create a new comment |
GET /comments | comments.index | index | Allows to get all comments |
GET /comments/:comment_id | comment.show | show | Allows to get comment for a given id |
PUT /comments/:comment_id | comment.update | update | Allows to update/replace entire comment resource, contains complete resource |
PATCH /comments/:comment_id | comment.update | update | Allows to modify particular comment resource, only contains changes to the part of resource |
DELETE /comments/:comment_id | comment.delete | destroy | Soft deletes comment by a given id |
Roles routes
We are going to use RolesController for those routes:
Route | Permission required | Controller method | Description |
---|---|---|---|
POST /roles | role.store | store | Allows to create new role |
GET /roles | roles.index | index | Allows to get all roles |
GET /roles/:role_id | role.show | show | Allows to get role for a given id |
PUT /roles/:role_id | role.update | update | Allows to update/replace entire role resource, contains complete resource |
PATCH /roles/:role_id | role.update | update | Allows to modify particular role resource, only contains changes to the part of resource |
DELETE /roles/:role_id | role.delete | destroy | Deletes role |
Permissions routes
We are going to use PermissionsController for those routes:
Route | Permission required | Controller method | Description |
---|---|---|---|
POST /permissions | permission.store | store | Allows to assign new permission to the role |
GET /permissions | permissions.index | index | Allows to get all permissions |
GET /permissions/:permission_id | permission.show | show | Allows to get permission for a given id |
PUT /permissions/:permission_id | permission.update | update | Allows to update/replace entire permission resource, contains complete resource |
PATCH /permissions/:permission_id | permission.update | update | Allows to modify particular permission resource, only contains changes to the part of resource |
DELETE /permissions/:permission_id | permission.delete | destroy | Removes role's permission |
RolesPermissions routes
We are going to use RolesPermissionsController for those routes:
Route | Permission required | Controller method | Description |
---|---|---|---|
POST /roles/:role_id/permissions | permission.assign | assign | Assign role's permission |
DELETE /roles/:role_id/permissions/:permission_id | permission.remove | remove | Remove role's permission |
Why mainly flat routes?
There are following reasons behind it (based on this stackoverflow answer):
- Nested routes require redundant endpoints
- Nested endpoints are not future-proof, when you have those and i.e. add some search you will have to add another endpoint, with flat structure however, you will add more query params instead
- Nested endpoints kind of lock yourself with current resource descendants tree structure, however if you change something and decide i.e. that some child resource could have multiple parents, then you would have redundant endpoints for multiple parents resulting in returning the same resource
- Redundant points makes api harder to learn and docs harder to right
Hands on code
1. Create your github repo
2. Choose name and description, then tick add readme checkbox, choose .gitignore for node and MIT license (most popular)
3. Then clone the repo on your local machine
git clone https://github.com/mariocoski/rest-api-node-typescript.git
4. Then you are ready for app initialization
//cd into your cloned repo
cd rest-api-node-typescript
//initialize
yarn init
//or
npm init
//then choose package name (default is fine)
package name: (rest-api-node-typescript)
//choose version 0.1.0 (feature according to semantic versioning)
version: (1.0.0) 0.1.0
//choose package description
description: restful api build in node and typescript
//choose entry point to your program (default is fine for now)
entry point: (index.js) src/index.js
//set test command
test command: jest --coverage
//set git repo (default is fine)
git repository: (https://github.com/mariocoski/rest-api-node-typescript.git)
//choose keywords describing your package:
keywords: rest, api, node, express, typescript, sequelize, jwt
//specify license (MIT will do)
license: (ISC) MIT
Is this ok? (yes)
After that you should see newly created package.json in root dir
cat package.json
//it should look like this
{
"name": "rest-api-node-typescript",
"version": "0.1.0",
"description": "restful api build in node and typescript",
"main": "index.js",
"repository": "https://github.com/mariocoski/rest-api-node-typescript.git",
"keywords": [
"rest",
"api",
"express",
"typescript",
"sequelize",
"jwt"
],
"author": "Mariusz Rajczakowski <mariuszrajczakowski@gmail.com> (https://mariuszrajczakowski.me)",
"license": "MIT",
"bugs": {
"url": "https://github.com/mariocoski/rest-api-node-typescript/issues"
},
"homepage": "https://github.com/mariocoski/rest-api-node-typescript#readme"
}
5. Let's add engines property to package.json.
Npm treats engines.node as advisory. We will use version equal or higher than 4.8.2.
//package.json
{
//rest of config
"engines" : {
"node": ">=4.8.2"
}
}
Installing dependencies
To save up some time we will install most of our dependencies need upfront.
//installing dependencies (or using npm with npm install ... --save)
yarn add babel-polyfill bcrypt bluebird body-parser compression cors express express-validator iconv-lite express-jwt express-jwt-permissions ramda mailgun-js morgan mysql2 sqlite3 passport passport-jwt passport-local sequelize codecov --save
//installing devDependencies (or using npm with npm install ... --save-dev)
yarn add @types/node @types/bcrypt @types/body-parser @types/compression @types/ramda @types/cors @types/dotenv @types/es6-shim @types/express @types/express-serve-static-core @types/iconv-lite @types/jest @types/express-jwt @types/mime @types/morgan @types/passport @types/passport-jwt @types/passport-local @types/sequelize @types/supertest @types/superagent babel-cli babel-jest babel-plugin-transform-runtime babel-preset-env babel-preset-stage-0 dotenv typescript jest ts-jest supertest superagent --dev
//then we create a config file for typescript
tsc --init
//that will create a file with predefined settings (most of them are commented out)
//uncomment those which are necessary to match the following config:
//tsconfig.json
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"moduleResolution": "node",
"baseUrl": ".",
"allowJs": true,
"outDir": "build",
"sourceMap": true,
"strict" : true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}
//then we will create src dir
mkdir src
//and create few files app.ts, server.ts, router.ts and two env files
// (one for version control: .env.example and one for dev usage .env)
// we will be using dotenv package to load env variables from .env file
touch src/server.ts src/app.ts src/router.ts .env .env.example
Edit your app.ts file:
//src/app.ts
import * as express from 'express';
import * as bodyParser from 'body-parser';
import * as logger from 'morgan';
import * as passport from 'passport';
import * as cors from 'cors';
import * as compression from 'compression';
import * as fs from 'fs';
//we will import the module which will handle routing for our app
//we will populate this file in as sec
import httpRouter from './router';
//that will create an express app which we will
//exports and pass to http.createServer() function
const app: express.Application = express();
//body parser parses request bodies. Those could contain
//like json or url encoded form data.
//The form data will then appear in req.body
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
//in the meantime (as we don't have any gzip module on nginx yet -
//we will compress response bodies for all requests) using
//compression middleware
app.use(compression());
//we would you morgan for logging requests
//flags: 'a' opens the file in append mode.
app.use(logger('common', {
stream: fs.createWriteStream('./access.log', {flags: 'a'})
}));
//doing console.log
app.use(logger('dev'));
//we will use cors middleware for enabling cores and for all requests
//you can read more about cors here:
//https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
const corsMiddleware = cors({ origin: '*', preflightContinue: true });
app.use(corsMiddleware);
app.options('*', corsMiddleware);
httpRouter(app);
const myApp: express.Application = app;
export default myApp;
Then go to your src/router.ts and these lines:
import {Application, Router, Request, Response, NextFunction} from 'express';
const router = (app: Application) => {
//init your main express router
const apiRouter: Router = Router();
//handle GET request to /api/v1
apiRouter.get('/', (req: Request, res: Response) => {
res.status(200).json({message: "This is where the awesomeness happen..."});
});
app.use('/api/v1', apiRouter);
};
export default router;
Edit also your server.ts file
// src/server.ts
//this will emulate a full ES2015+ environment
//and is intended to be used in an application rather than a library/tool.
require('babel-polyfill');
//this will load all env variables for dev and test mode
if(process.env.NODE_ENV !== 'production'){
require('dotenv').config();
}
//load http module
import * as http from 'http';
import app from './app';
import * as iconvLite from 'iconv-lite';
//used for characted encoding conversion
iconvLite.encodingExists('foo');
//signal events are emitted when the Node.js process receives a signal
//SIGINT signal is with -C in most terminal programs
process.on('SIGINT', () => {
process.exit(0);
});
//this is when testing with jest - its set up
//process.env.NODE_ENV to be test
//in this case we will choose test port accordingly
const IS_TEST: boolean = process.env.NODE_ENV === 'test';
//we will replace those port number later on with env vars
const port: number = IS_TEST ? 3001 : 3000;
//create a server
const server: http.Server = new http.Server(app);
//listen on the provided port
server.listen(port, () => {
if(! IS_TEST){
console.log(`Listening at http://localhost:${port}/api/v1`);
}
});
//server error handler
server.on('error', (error: any, port: number) => {
if (error.syscall !== "listen") {
throw error;
}
switch (error.code) {
case 'EACCES':
if(process.env.NODE_ENV !== 'test'){
console.log(`${port} requires elevated privileges`);
}
process.exit(1);
case 'EADDRINUSE':
if(process.env.NODE_ENV !== 'test'){
console.log(`${port} is already in use`);
}
process.exit(1);
default:
throw error;
}
});
export default server;
Modify your package.json by adding additional scripts section:
//package.json
{
//...other config
"scripts" : {
"build": "rm -rf ./build && tsc",
"build:watch": "rm -rf ./build && tsc --watch",
"start": "NODE_ENV=development && node ./build/server.js"
}
}
//then run
yarn build
//or
npm build
//after that:
yarn start
//or npm start
//the you should see in your terminal
$ NODE_ENV=development && node ./build/server.js
Listening at http://localhost:3000
//go to your browser and type in the url bar:
http://localhost:3000/api/v1
You should see this:
Let's now populate our env files:
//env.example and .env (same content for now)
NODE_ENV=development
DEBUG=true
PORT=3000
TEST_PORT=3001
JWT_SECRET=your_jwt_secret
JWT_EXPIRATION_TIME=3600000
DEV_DB_USERNAME=root
DEV_DB_PASSWORD=root
DEV_DB_NAME=database_dev
DEV_DB_HOSTNAME=localhost
TEST_DB_USERNAME=root
TEST_DB_PASSWORD=root
TEST_DB_NAME=database_test
TEST_DB_HOSTNAME=localhost
PROD_DB_USERNAME=root
PROD_DB_PASSWORD=password
PROD_DB_NAME=database_prod
PROD_DB_HOSTNAME=localhost
MAILGUN_DOMAIN=
MAILGUN_API_KEY=
MAILGUN_TEST_RECIPIENT=
Then create new folder config and few more files
mkdir src/config src/database src/database/migrations src/database/seeds src/models
touch .sequelizerc src/config/database.js
//edit .sequelizerc and add:
const path = require('path');
module.exports = {
'config' : path.resolve(__dirname, 'src/config/database.js'),
'migrations-path' : path.resolve(__dirname, 'src/database/migrations'),
'seeders-path' : path.resolve(__dirname, 'src/database/seeds'),
'models-path' : path.resolve(__dirname, 'src/models'),
}
Edit config/database.js
//config/database.js
const path = require('path');
require('dotenv').config();
module.exports = {
development: {
username: process.env.DEV_DB_USERNAME,
password: process.env.DEV_DB_PASSWORD,
database: process.env.DEV_DB_NAME,
host: process.env.DEV_DB_HOSTNAME,
dialect: 'mysql',
operatorsAliases: false
},
test: {
username: process.env.TEST_DB_USERNAME,
password: process.env.TEST_DB_PASSWORD,
database: process.env.TEST_DB_NAME,
host: process.env.TEST_DB_HOSTNAME,
dialect: 'sqlite',
storage: ':memory:',
operatorsAliases: false
},
production: {
username: process.env.PROD_DB_USERNAME,
password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME,
host: process.env.PROD_DB_HOSTNAME,
operatorsAliases: false,
dialect: 'mysql'
}
}
Database structure
We will design our db structure, create migrations and models.
Prior to this I would recommend this post on how to set up db structure with Sequelize (the ORM we are using in this tutorial).
//let's start with installing sequelize-cli
//you can do it locally
yarn add sequelize-cli --save
//or globally
yarn global add sequelize-cli
//if you install it locally you would use it like so (unless you add it to the PATH)
node_modules/.bin/sequelize
//if installed globally then just:
sequelize
We wil start index.ts file which will load our models and connect to db.
We will then create a models and their interfaces manually, after that we will use sequelize-cli to create a missing migrations
//create an index.ts
touch src/models/index.ts
//edit it and paste those lines:
//src/models/index.ts
import * as fs from 'fs';
import * as path from 'path';
import * as SequelizeStatic from 'sequelize';
import {UserAttributes, UserInstance} from './interfaces/user';
import {RoleAttributes, RoleInstance} from './interfaces/role';
import {PermissionAttributes, PermissionInstance} from './interfaces/permission';
import {PostAttributes, PostInstance} from './interfaces/post';
import {CommentAttributes, CommentInstance} from './interfaces/comment';
import {ResetPasswordTokenAttributes, ResetPasswordTokenInstance} from './interfaces/reset_password_token';
import {RolePermissionAttributes, RolePermissionInstance} from './interfaces/role_permission';
import {UserRoleAttributes, UserRoleInstance} from './interfaces/user_role';
import {Sequelize} from 'sequelize';
export interface SequelizeModels {
User: SequelizeStatic.Model<UserInstance, UserAttributes>;
Role: SequelizeStatic.Model<RoleInstance, RoleAttributes>;
UserRole: SequelizeStatic.Model<UserRoleInstance, UserRoleAttributes>;
Permission: SequelizeStatic.Model<PermissionInstance, PermissionAttributes>;
RolePermission: SequelizeStatic.Model<RolePermissionInstance, RolePermissionAttributes>;
Post: SequelizeStatic.Model<PostInstance, PostInstance>;
Comment: SequelizeStatic.Model<UserInstance, CommentAttributes>;
ResetPasswordToken: SequelizeStatic.Model<ResetPasswordTokenInstance, ResetPasswordTokenAttributes>;
}
export interface DbEnvConfig {
database: string,
username: string,
password: string,
host: string,
operatorsAliases: boolean,
storage?: string
}
export interface DbConfig {
[key: string]: DbEnvConfig;
}
const dbConfig: DbConfig = require('../config/database');
const env: string = process.env.NODE_ENV || 'development';
const config: DbEnvConfig = dbConfig[env];
const basename: string = path.basename(module.filename);
const _sequelize: Sequelize = new SequelizeStatic(
config.database, config.username, config.password,
{...config, operatorsAliases: false, logging: false}
);
let _models: any = {};
//we dynamically load all the models for a given directory
const files: Array = fs.readdirSync(__dirname);
files.filter(file => {
return (file.indexOf('.') !== 0) && (file !== basename)
&& (file.slice(-3) === '.js' || file.slice(-3) === '.ts')
&& (file !== 'interfaces');
}).forEach(file => {
let model: any = _sequelize.import(path.join(__dirname, file));
_models[model.name] = model;
});
//we create the relationships for a models we will create shortly after
_models.Comment.belongsTo(_models.Post);
_models.Post.hasMany(_models.Comment, {as: 'comments', onDelete: 'CASCADE'});
_models.Post.belongsTo(_models.User);
_models.User.hasMany(_models.Post, {as: 'posts', onDelete: 'CASCADE'});
_models.User.hasMany(_models.ResetPasswordToken, { as: 'reset_password_tokens', onDelete: 'CASCADE'});
_models.Role.belongsToMany(_models.User, { through: _models.UserRole, as: 'users', onDelete: 'CASCADE',individualHooks: true});
_models.User.belongsToMany(_models.Role, { through: _models.UserRole, as: 'roles', onDelete: 'CASCADE',individualHooks: true});
_models.Role.belongsToMany(_models.Permission, { through: _models.RolePermission, as: 'permissions', onDelete: 'CASCADE',individualHooks: true});
_models.Permission.belongsToMany(_models.Role, { through: _models.RolePermission, as: 'roles', onDelete: 'CASCADE',individualHooks: true});
//we will export models and sequelize instance
export const models: SequelizeModels = _models;
export const sequelize: Sequelize = _sequelize;
Then edit your server.ts
//src/server.ts
//add additional import at the top:
import {sequelize} from './models';
//then before this line of code: server.listen(port, () => {
//add async function for intializing db and additional check
async function dbInit(){
await sequelize.sync();
}
//this db init will only run for dev and production as we will have our own init in tests
if(process.env.NODE_ENV !== 'test'){
dbInit();
}
We will manually create the models and interfaces and then one by one we are going to populate them.
//we create the models first
touch src/models/user.ts src/models/user_role.ts src/models/role.ts src/models/role_permission.ts src/models/permission.ts src/models/reset_password_token.ts src/models/post.ts src/models/comment.ts
//then we create interfaces folder and relevant files
mkdir src/models/interfaces
touch src/models/interfaces/user.ts src/models/interfaces/user_role.ts src/models/interfaces/role.ts src/models/interfaces/permission.ts src/models/interfaces/role_permission.ts src/models/interfaces/reset_password_token.ts src/models/interfaces/post.ts src/models/interfaces/comment.ts
Then we will edit models and corresponding with them interfaces.
User Model
//src/models/user.ts
import * as SequelizeStatic from "sequelize";
import {DataTypes, Sequelize, Instance} from "sequelize";
import {UserAttributes, UserInstance} from "./interfaces/user";
import {SequelizeModels} from './index';
export default (sequelize: Sequelize, dataTypes: DataTypes):
SequelizeStatic.Model<UserInstance, UserAttributes> => {
const User = sequelize.define<UserInstance, UserAttributes>("User", {
firstname: dataTypes.STRING,
lastname: dataTypes.STRING,
bio: dataTypes.TEXT,
email: {
type: dataTypes.STRING,
validate: {
isEmail: true
}
},
password: dataTypes.STRING,
deleted_at: dataTypes.DATE
}, {
tableName: 'users',
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [],
paranoid: true,
underscored: true,
});
User.beforeCreate(user: UserInstance, options: Object) => {
//@todo implement bcrypt
user.dataValues.password = 'hash';
});
User.afterDestroy((user: UserInstance, options: Object) => {
sequelize.models.Post.destroy({where: {user_id: user.dataValues.id},individualHooks: true});
sequelize.models.UserRole.destroy({where: {user_id: user.dataValues.id}, individualHooks: true});
sequelize.models.ResetPasswordToken.destroy({where: {user_id: user.dataValues.id}, individualHooks: true});
});
return User;
}
User Interface
//src/models/interfaces/user.ts
import {Instance} from "sequelize";
import {RoleInstance} from './role';
import {SequelizeModels} from '../index';
export interface UserAttributes {
id: number,
firstname: string,
lastname: string,
bio: string,
email: string,
password: string,
created_at: string,
updated_at: string,
deleted_at: string
}
export interface UserInstance extends Instance {
dataValues: UserAttributes;
}
Role Model
//src/models/role.ts
import * as SequelizeStatic from "sequelize";
import {DataTypes, Sequelize} from "sequelize";
import {RoleAttributes, RoleInstance} from "./interfaces/role";
import {SequelizeModels} from './index';
export default (sequelize: Sequelize, dataTypes: DataTypes):
SequelizeStatic.Model<RoleInstance, RoleAttributes> => {
const Role = sequelize.define<RoleInstance, RoleAttributes>("Role", {
name: dataTypes.STRING,
description: dataTypes.TEXT,
deleted_at: dataTypes.DATE
}, {
tableName: 'roles',
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [],
paranoid: true,
underscored: true
});
Role.afterDestroy((role: RoleInstance, options: Object) => {
sequelize.models.UserRole.destroy({where: {role_id: role.dataValues.id}, individualHooks: true});
sequelize.models.RolePermission.destroy({where: {role_id: role.dataValues.id}, individualHooks: true});
});
return Role;
}
Role Interface
//src/models/interfaces/role.ts
import {Instance} from "sequelize";
export interface RoleAttributes {
id: number,
name: string,
description: string,
created_at: string,
updated_at: string,
deleted_at: string
}
export interface RoleInstance extends Instance {
dataValues: RoleAttributes;
}
UserRole Model
//src/models/user_role.ts
import * as SequelizeStatic from "sequelize";
import {DataTypes, Sequelize} from "sequelize";
import {UserRoleAttributes, UserRoleInstance} from "./interfaces/user_role";
import {SequelizeModels} from './index';
export default (sequelize: Sequelize, dataTypes: DataTypes):
SequelizeStatic.Model<UserRoleInstance, UserRoleAttributes> => {
const UserRole = sequelize.define<UserRoleInstance, UserRoleAttributes>("UserRole", {
user_id: dataTypes.INTEGER,
role_id: dataTypes.INTEGER,
deleted_at: dataTypes.DATE
}, {
tableName: 'user_role',
createdAt: 'created_at',
updatedAt: 'updated_at',
paranoid: true,
underscored: true
});
return UserRole;
}
UserRole Interface
//src/models/interfaces/user_role.ts
import {Instance} from "sequelize";
export interface UserRoleAttributes {
id: number,
user_id: number,
role_id: number,
deleted_at: string
}
export interface UserRoleInstance extends Instance {
dataValues: UserRoleAttributes;
}
Permission Model
//src/models/permission.ts
import * as SequelizeStatic from "sequelize";
import {DataTypes, Sequelize} from "sequelize";
import {PermissionAttributes, PermissionInstance} from "./interfaces/permission";
import {SequelizeModels} from './index';
export default (sequelize: Sequelize, dataTypes: DataTypes):
SequelizeStatic.Model<PermissionInstance, PermissionAttributes> => {
const Permission = sequelize.define<PermissionInstance, PermissionAttributes>("Permission", {
name: dataTypes.STRING,
label: dataTypes.STRING,
description: dataTypes.TEXT,
deleted_at: dataTypes.DATE
}, {
tableName: 'permissions',
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [],
paranoid: true,
underscored: true
});
return Permission;
}
Permission Interface
//src/models/interfaces/permission.ts
import {Instance} from "sequelize";
export interface PermissionAttributes {
id: number,
name: string,
label: string,
description: string,
created_at: string,
updated_at: string,
deleted_at: string
}
export interface PermissionInstance extends Instance {
dataValues: PermissionAttributes;
}
RolePermission Model
//src/models/role_permission.ts
import * as SequelizeStatic from "sequelize";
import {DataTypes, Sequelize} from "sequelize";
import {RolePermissionAttributes, RolePermissionInstance} from "./interfaces/role_permission";
import {SequelizeModels} from './index';
export default (sequelize: Sequelize, dataTypes: DataTypes):
SequelizeStatic.Model<RolePermissionInstance, RolePermissionAttributes> => {
const RolePermission = sequelize.define<RolePermissionInstance, RolePermissionAttributes>("RolePermission", {
role_id: dataTypes.INTEGER,
permission_id: dataTypes.INTEGER,
deleted_at: dataTypes.DATE
}, {
tableName: 'role_permission',
createdAt: 'created_at',
updatedAt: 'updated_at',
paranoid: true,
underscored: true
});
return RolePermission;
}
Role Interface
//src/models/interfaces/role_permission.ts
import {Instance} from "sequelize";
export interface RolePermissionAttributes {
id: number,
role_id: number,
permission_id: number,
deleted_at: string
}
export interface RolePermissionInstance extends Instance {
dataValues: RolePermissionAttributes;
}
ResetPasswordToken Model
//src/models/reset_password_token.ts
import * as SequelizeStatic from "sequelize";
import {DataTypes, Sequelize} from "sequelize";
import {ResetPasswordTokenAttributes, ResetPasswordTokenInstance} from "./interfaces/reset_password_token";
import {SequelizeModels} from './index';
export default (sequelize: Sequelize, dataTypes: DataTypes):
SequelizeStatic.Model<ResetPasswordTokenInstance, ResetPasswordTokenAttributes> => {
const ResetPasswordToken = sequelize.define<ResetPasswordTokenInstance, ResetPasswordTokenAttributes>("ResetPasswordToken", {
user_id:dataTypes.INTEGER,
token: dataTypes.STRING,
deleted_at: dataTypes.DATE
}, {
tableName: 'reset_password_tokens',
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [],
paranoid: true,
underscored: true
});
return ResetPasswordToken;
}
ResetPasswordToken Interface
//src/models/interfaces/reset_password_token.ts
import {Instance} from "sequelize";
export interface ResetPasswordTokenAttributes {
id: number,
user_id: number,
token: string,
created_at: string,
updated_at: string,
deleted_at: string
}
export interface ResetPasswordTokenInstance extends Instance {
dataValues: ResetPasswordTokenAttributes;
}
Post Model
//src/models/post.ts
import * as SequelizeStatic from "sequelize";
import {DataTypes, Sequelize} from "sequelize";
import {PostAttributes, PostInstance} from "./interfaces/post";
import {SequelizeModels} from './index';
export default (sequelize: Sequelize, dataTypes: DataTypes):
SequelizeStatic.Model<PostInstance, PostAttributes> => {
const Post = sequelize.define<PostInstance, PostAttributes>("Post", {
post_id: dataTypes.INTEGER,
title: dataTypes.STRING,
body: dataTypes.TEXT,
deleted_at: dataTypes.DATE
}, {
tableName: 'posts',
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [],
paranoid: true,
underscored: true
});
Post.afterDestroy((post: PostInstance, options: Object) => {
sequelize.models.Comment.destroy({where: {post_id: post.dataValues.id}, individualHooks: true});
});
return Post;
}
Post Interface
//src/models/interfaces/post.ts
import {Instance} from "sequelize";
export interface PostAttributes {
id: number,
user_id: number,
title: string,
body: string,
created_at: string,
updated_at: string,
deleted_at: string
}
export interface PostInstance extends Instance {
dataValues: PostAttributes;
}
Comment Model
//src/models/comment.ts
import * as SequelizeStatic from "sequelize";
import {DataTypes, Sequelize} from "sequelize";
import {CommentAttributes, CommentInstance} from "./interfaces/comment";
import {SequelizeModels} from './index';
export default (sequelize: Sequelize, dataTypes: DataTypes):
SequelizeStatic.Model<CommentInstance, CommentAttributes> => {
const Comment = sequelize.define<CommentInstance, CommentAttributes>("Comment", {
post_id: dataTypes.INTEGER,
user_id: dataTypes.INTEGER,
body: dataTypes.STRING,
deleted_at: dataTypes.DATE
}, {
tableName: 'comments',
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [],
paranoid: true,
underscored: true
});
return Comment;
}
Comment Interface
//src/models/interfaces/comment.ts
import {Instance} from "sequelize";
export interface CommentAttributes {
id: number,
post_id: number,
user_id: number,
body: string,
created_at: string,
updated_at: string,
deleted_at: string
}
export interface CommentInstance extends Instance {
dataValues: CommentAttributes;
}
Migrations
We will start with migrations for each model. We will be using sequelize-cli installed globally (if you have installed it locally use it as: node_modules/.bin/sequelize)
sequelize migration:generate --name create_users_table
sequelize migration:generate --name create_roles_table
sequelize migration:generate --name create_user_role_table
sequelize migration:generate --name create_permissions_table
sequelize migration:generate --name create_role_permission_table
sequelize migration:generate --name create_reset_password_tokens_table
sequelize migration:generate --name create_posts_table
sequelize migration:generate --name create_comments_table
Users Migration
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('users', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
firstname: Sequelize.STRING,
lastname: Sequelize.STRING,
email: {
type: Sequelize.STRING,
unique: true
},
password: Sequelize.STRING,
bio: Sequelize.TEXT,
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
updated_at: {
allowNull: true,
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
deleted_at: {
allowNull: true,
type: Sequelize.DATE
}
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('users');
}
};
Roles Migration
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('roles', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
name: Sequelize.STRING,
description: Sequelize.TEXT,
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
updated_at: {
allowNull: true,
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
deleted_at: {
allowNull: true,
type: Sequelize.DATE
}
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('roles');
}
};
UserRole Migration
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('user_role', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
user_id: Sequelize.INTEGER,
role_id: Sequelize.INTEGER,
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
updated_at: {
allowNull: true,
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
deleted_at: {
allowNull: true,
type: Sequelize.DATE
}
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('user_role');
}
};
Permissions Migration
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('permissions', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
name: Sequelize.STRING,
label: Sequelize.STRING,
description: Sequelize.TEXT,
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
updated_at: {
allowNull: true,
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
deleted_at: {
allowNull: true,
type: Sequelize.DATE
}
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('permissions');
}
};
RolePermission Migration
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('role_permission', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
role_id: Sequelize.INTEGER,
permission_id: Sequelize.INTEGER,
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
updated_at: {
allowNull: true,
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
deleted_at: {
allowNull: true,
type: Sequelize.DATE
}
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('role_permission');
}
};
ResetPasswordTokens Migration
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('reset_password_tokens', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
user_id: Sequelize.INTEGER,
token: Sequelize.STRING,
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
updated_at: {
allowNull: true,
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
deleted_at: {
allowNull: true,
type: Sequelize.DATE
}
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('reset_password_tokens');
}
};
Posts Migration
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('posts', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
user_id: Sequelize.INTEGER,
title: Sequelize.STRING,
body: Sequelize.TEXT,
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
updated_at: {
allowNull: true,
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
deleted_at: {
allowNull: true,
type: Sequelize.DATE
}
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('posts');
}
};
Comments Migration
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('comments', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
post_id: Sequelize.INTEGER,
user_id: Sequelize.INTEGER,
body: Sequelize.TEXT,
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
updated_at: {
allowNull: true,
type: Sequelize.DATE,
defaultValue: Sequelize.NOW
},
deleted_at: {
allowNull: true,
type: Sequelize.DATE
}
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('comments');
}
};
Seeding our database
Sequelize-cli is equiped with commands to generate and execute seeds.
We are going to create a basic data, to play around. Then we switch to writing tests.
Users seeder
//when you use sequelize-cli locally you would use:
// node_modules/.bin/sequelize seed:generate --name user-seeder
sequelize seed:generate --name user-seeder
//then edit src/database/seeds/YOUR_TIMESTAMP-user-seeder.js
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.bulkInsert('users', [
{
firstname: 'Joe',
lastname: 'Admin',
bio: 'I have been admins for years...',
email: 'joe@test.com',
password: 'password',
created_at: new Date(),
updated_at: new Date()
},
{
firstname: 'Jane',
lastname: 'Editor',
bio: 'I have been editor for years...',
email: 'jane@test.com',
password: 'password',
created_at: new Date(),
updated_at: new Date()
}
],{ individualHooks: true });
},
down: (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('users', null, {});
}
};
Roles seeder
//when you use sequelize-cli locally you would use:
// node_modules/.bin/sequelize seed:generate --name role-seeder
sequelize seed:generate --name role-seeder
//then edit src/database/seeds/YOUR_TIMESTAMP-role-seeder.js
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.bulkInsert('roles', [{
name: 'admin',
description: 'Has all possible permissions across the app',
created_at: new Date(),
updated_at: new Date()
}],{ individualHooks: true });
},
down: (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('roles', null, {});
}
};
UserRole seeder
//when you use sequelize-cli locally you would use:
// node_modules/.bin/sequelize seed:generate --name user-role-seeder
sequelize seed:generate --name user-role-seeder
//then edit src/database/seeds/YOUR_TIMESTAMP-user-role-seeder.js
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.bulkInsert('user_role', [{
user_id: 1,
role_id: 1,
created_at: new Date(),
updated_at: new Date()
}],{ individualHooks: true });
},
down: (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('user_role', null, {});
}
};
Permissions seeder
//when you use sequelize-cli locally you would use:
// node_modules/.bin/sequelize seed:generate --name permissions-seeder
sequelize seed:generate --name permissions-seeder
//then edit src/database/seeds/YOUR_TIMESTAMP-permissions-seeder.js
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.bulkInsert('permissions', [
{
name: 'user.store',
label: 'Create user',
description: 'Allows to create a new user',
created_at: new Date(),
updated_at: new Date()
},
{
name: 'users.index',
label: 'Get all users',
description: 'Allows to get all users',
created_at: new Date(),
updated_at: new Date()
},
{
name: 'user.show',
label: 'Get user',
description: 'Allows to get user for a given id',
created_at: new Date(),
updated_at: new Date()
},
{
name: 'user.update',
label: 'Update user',
description: 'Allows to update/replace entire user resource, contains complete resource',
created_at: new Date(),
updated_at: new Date()
},
{
name: 'user.delete',
label: 'Delete user',
description: 'Soft deletes user by a given id',
created_at: new Date(),
updated_at: new Date()
}
],{ individualHooks: true });
},
down: (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('permissions', null, {});
}
};
RolePermission seeder
//when you use sequelize-cli locally you would use:
// node_modules/.bin/sequelize seed:generate --name role-permission-seeder
sequelize seed:generate --name role-permission-seeder
//then edit src/database/seeds/YOUR_TIMESTAMP-role-permission-seeder.js
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.bulkInsert('role_permission', [
{
role_id: 1,
permission_id: 1,
created_at: new Date(),
updated_at: new Date()
},
{
role_id: 1,
permission_id: 2,
created_at: new Date(),
updated_at: new Date()
},
{
role_id: 1,
permission_id: 3,
created_at: new Date(),
updated_at: new Date()
},
{
role_id: 1,
permission_id: 4,
created_at: new Date(),
updated_at: new Date()
},
{
role_id: 1,
permission_id: 5,
created_at: new Date(),
updated_at: new Date()
},
],{ individualHooks: true });
},
down: (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('role_permission', null, {});
}
};
Posts seeder
//when you use sequelize-cli locally you would use:
// node_modules/.bin/sequelize seed:generate --name posts-seeder
sequelize seed:generate --name posts-seeder
//then edit src/database/seeds/YOUR_TIMESTAMP-posts-seeder.js
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.bulkInsert('posts', [{
user_id: 1,
title: 'First article',
body: 'This is my first article.. Tell me your thoughts in comments..',
created_at: new Date(),
updated_at: new Date()
}],{ individualHooks: true });
},
down: (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('posts', null, {});
}
};
Comments seeder
//when you use sequelize-cli locally you would use:
// node_modules/.bin/sequelize seed:generate --name comments-seeder
sequelize seed:generate --name comments-seeder
//then edit src/database/seeds/YOUR_TIMESTAMP-comments-seeder.js
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.bulkInsert('comments', [
{
post_id: 1,
user_id: 2,
body: 'Your article is really long.. can you shorten it?',
created_at: new Date(),
updated_at: new Date()
},
{
post_id: 1,
user_id: 2,
body: 'Actually, can you split it into two articles?',
created_at: new Date(),
updated_at: new Date()
}
],{ individualHooks: true });
},
down: (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('comments', null, {});
}
};
Let's start TDD
We will start with a bit of scaffolding, then we will return to @todo on user model - we will create generateHash function.
//we create __tests__ (for storing tests)
//and __mocks__ (for stroring mocks)
//which is the name convention for jest framework
//if you want to know more about jest check the docs:
//https://facebook.github.io/jest
mkdir __tests__ __mocks__ __tests__/unit __tests__/feature
//we create our first unit test file for all the utils stuff
//including generateHash function
touch __tests__/unit/utils.test.ts
//then change your package.json and add:
{
//...other settings
"jest": {
"coverageDirectory": "./coverage/",
"collectCoverage": true,
"transform": {
".(ts|tsx|js|jsx)": "/node_modules/ts-jest/preprocessor.js"
},
"testEnvironment": "node",
"bail": true,
"moduleFileExtensions": [
"js",
"jsx",
"json",
"ts",
"tsx"
],
"mapCoverage": true,
"transformIgnorePatterns": [
"node_modules/(?!(express-validator)/)"
],
"testMatch": [
"/__tests__/**/*.test.(ts|js)"
]
}
}
//let's also add some test commands to package.json
//replace this
"scripts" : {
"build": "rm -rf ./build && tsc",
"build:watch": "rm -rf ./build && tsc --watch",
"start": "NODE_ENV=development && node ./build/server.js"
}
//with that:
"scripts" : {
"build": "rm -rf ./build && tsc",
"build:watch": "rm -rf ./build && tsc --watch",
"start": "NODE_ENV=development && node ./build/server.js",
"test": "jest --coverage --runInBand",
"test:watch": "jest --coverage --runInBand --watch"
}
//edit your__tests__/unit/utils.test.ts
//and add just to see how easy is testing with jest:
describe('UNIT: utils', () => {
it('can add two numbers',()=>{
const sum: number = 2 + 2;
expect(sum).toBe(4);
});
});
//we will want to pass mock instead of real bcrypt module
touch __mocks__/bcrypt.js
//edit __mocks__bcrypt.js
module.exports = {
hash: jest.fn((password)=>{
return Promise.resolve(`hased${password}`);
})
}
If you run your test with command: yarn test you should see:
We will now cover the following lines:
//src/models/user.ts
User.beforeCreate((user: UserInstance, options: Object) => {
//@todo implement bcrypt
user.dataValues.password = 'hash';
});
//we will add new folder called utils:
mkdir src/utils
//and make a new files
touch src/utils/index.ts
//then replace your dummy test in src/__tests__/unit/utils.test.ts
describe('UNIT: utils', () => {
});