2021-30
$ pwd
# to see what directory you are currently active in
$ ls
# list of names of files and directories
$ cd <name of directory>
# this will change your new current working directory to the directory you specified
$ cd ..
# you can specify .. to change to the directory one level up in your path
$ mkdir bar
# create new directories in our current working directory
$ rm -rf bar
# remove directory
$ cat baz
# this will print out the entire contents of baz to the terminal.
$ less baz
# with long files, this is impractical and unreadable. To paginate the output
$ touch foobar
# this creates an empty file with the name foobar in your current working directory
$ mv foobar fizzbuzz
# we can rename foobar to fizzbuzz
$ cp fizzbuzz foobar
# to copy a file to a new location
$ nano foobar
$ vim foobar
# to edit text into foobar
$ rm fizzbuzz
# to delete fizzbuzz
hint: The Filesystem Hierarchy Standard
$ man <command>
$ man chown
CHOWN(8) BSD System Manager's Manual CHOWN(8)
NAME
chown -- change file owner and group
SYNOPSIS
chown [-fhnv] [-R [-H | -L | -P]] owner[:group] file ...
chown [-fhnv] [-R [-H | -L | -P]] :group file ...
DESCRIPTION
The chown utility changes the user ID and/or the group ID of the specified files. Symbolic links named by arguments are silently left unchanged unless -h is used.
The options are as follows:
-f Don't report any failure to change file owner or group, nor modify the exit status to reflect such failures.
-H If the -R option is specified, symbolic links on the command line are followed. (Symbolic links encountered in the tree traversal are not followed.)
-h If the file is a symbolic link, change the user ID and/or the group ID of the link itself.
-L If the -R option is specified, all symbolic links are followed.
-P If the -R option is specified, no symbolic links are followed. Instead, the user and/or group ID of the link itself are modified. This is the default. Use -h to
change the user ID and/or the group of symbolic links.
-R Change the user ID and/or the group ID for the file hierarchies rooted in the files instead of just the files themselves.
-n Interpret user ID and group ID as numeric, avoiding name lookups.
-v Cause chown to be verbose, showing files as the owner is modified.
The -H, -L and -P options are ignored unless the -R option is specified. In addition, these options override each other and the command's actions are determined by the last
one specified.
The owner and group operands are both optional; however, at least one must be specified. If the group operand is specified, it must be preceded by a colon (``:'') character.
The owner may be either a numeric user ID or a user name. If a user name is also a numeric user ID, the operand is used as a user name. The group may be either a numeric
group ID or a group name. If a group name is also a numeric group ID, the operand is used as a group name.
For obvious security reasons, the ownership of a file may only be altered by a super-user. Similarly, only a member of a group can change a file's group ID to that group.
DIAGNOSTICS
The chown utility exits 0 on success, and >0 if an error occurs.
COMPATIBILITY
Previous versions of the chown utility used the dot (``.'') character to distinguish the group name. This has been changed to be a colon (``:'') character, so that user and
group names may contain the dot character.
1. Create a Ubuntu Server 20.04 LTS virtual machine
2. Make sure to select B1ms as VM size
3. Generate a SSH public key with the following username
4. Click on Review + create and Create
5. Set VM IP address as static
6. Open VSCode and create a new SSH target
7. Move public key
Host azure
HostName 52.255.169.172
User azureuser
IdentityFile /Users/sjdonado/.ssh/backend-202130_key
$ mv backend-202130_key ~/.ssh
$ chmod 400 ~/.ssh/backend-202130_key
8. Complete SSH target using the VM static IP address + VM public key
9. Open connection
10. Open folder
11. Setup node + docker
$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.38.0/install.sh | bash
$ source ~/.bashrc
$ nvm install --lts
$ sudo apt update
$ sudo apt install -y apt-transport-https ca-certificates curl software-properties-common
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
$ sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu focal stable"
$ sudo apt update
$ sudo apt install -y docker-ce
$ sudo usermod -aG docker ${USER}
$ sudo curl -L https://github.com/docker/compose/releases/download/1.29.2/docker-compose-`uname -s`-`uname -m` | sudo tee /usr/local/bin/docker-compose > /dev/null
$ sudo chmod +x /usr/local/bin/docker-compose
$ sudo ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose
12. Restart VM and install docker-compose
0. Setup
$ git config --global user.name "John Doe"
$ git config --global user.email johndoe@example.com
$ git config --global core.editor vim
1. Initializing a Repository in an Existing Directory
$ cd <folder>
$ git init
2. Recording Changes to the Repository
3. Tracking New Files
$ git add README
$ git status
On branch master
Your branch is up-to-date with 'origin/master'.
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
new file: README
4. Ignoring Files
$ cat .gitignore
build/
package-lock.json
5. Committing Your Changes
$ git commit
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
# On branch master
# Your branch is up-to-date with 'origin/master'.
#
# Changes to be committed:
# new file: README
# modified: CONTRIBUTING.md
#
~
~
~
".git/COMMIT_EDITMSG" 9L, 283C
$ git commit -m "Story 182: fix benchmarks for speed"
[master 463dc4f] Story 182: fix benchmarks for speed
2 files changed, 2 insertions(+)
create mode 100644 README
6. Viewing the Commit History
$ git log
commit ca82a6dff817ec66f44342007202690a93763949
Author: Scott Chacon <schacon@gee-mail.com>
Date: Mon Mar 17 21:52:11 2008 -0700
Change version number
commit 085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7
Author: Scott Chacon <schacon@gee-mail.com>
Date: Sat Mar 15 16:40:33 2008 -0700
Remove unnecessary test
commit a11bef06a3f659402fe7563abf99ad00de2209e6
Author: Scott Chacon <schacon@gee-mail.com>
Date: Sat Mar 15 10:31:28 2008 -0700
Initial commit
7. Basic Branching
$ git checkout -b iss53
Switched to a new branch "iss53"
$ vim index.html
$ git commit -a -m 'Create new footer [issue 53]'
7. Merging
$ git checkout master
$ git merge hotfix
Updating f42c576..3a0874c
Fast-forward
index.html | 2 ++
1 file changed, 2 insertions(+)
How to merge iss53 to master? 🤔
7. Working with Remotes
$ git clone https://github.com/schacon/ticgit
$ git remote -v
origin https://github.com/schacon/ticgit (fetch)
origin https://github.com/schacon/ticgit (push)
$ git fetch
$ touch testing.md
$ git commit -a -m "feat: testing.md"
$ git push origin master
Decentralized but centralized
Background: 10 years ago A successful Git branching model
Nowadays: Open Source
Fork
Conventional Commits == best practice ✅
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
exercise: fork (repo with README), create PR + commit (with conventions, add your name) + createPR
# Container image that runs your code
FROM debian:9.5-slim
# Copies your code file from your action repository to the filesystem path `/` of the container
ADD entrypoint.sh /entrypoint.sh
# Set execution permissions
RUN chmod +x /entrypoint.sh
# Code file to execute when the docker container starts up (`entrypoint.sh`)
ENTRYPOINT ["/entrypoint.sh"]
AuFS: advanced multi-layered unification filesystem
$ docker run -d -p 8080:80 docker/getting-started
Unable to find image 'docker/getting-started:latest' locally
latest: Pulling from docker/getting-started
595b0fe564bb: Pull complete
31e3f3692bd6: Pull complete
ba4d371dcb40: Pull complete
efb7a2f13438: Pull complete
3d4e2f872060: Pull complete
43416c5e3473: Pull complete
fd101b99d290: Pull complete
87904de4d38c: Pull complete
Digest: sha256:10555bb0c50e13fc4dd965ddb5f00e948ffa53c13ff15dcdc85b7ab65e1f240b
Status: Downloaded newer image for docker/getting-started:latest
dbdf88f723b8c83f753df9a32e0c2a33ca0c8c8bd7901c50c17dc6ce54d99808
1. Run from Docker Hub
$ docker build -t backend-un-testing .
Sending build context to Docker daemon 2.111MB
Step 1/4 : FROM debian:9.5-slim
9.5-slim: Pulling from library/debian
f17d81b4b692: Pull complete
Digest: sha256:ef6be890318a105f7401d0504c01c2888daa5d9e45b308cf0e45c7cb8e44634f
Status: Downloaded newer image for debian:9.5-slim
---> 4b4471f624dc
Step 2/4 : ADD entrypoint.sh /entrypoint.sh
---> 40458cabcda2
Step 3/4 : RUN chmod +x /entrypoint.sh
---> Running in cb7af692f32d
Removing intermediate container cb7af692f32d
---> 4605f094d20c
Step 4/4 : ENTRYPOINT ["/entrypoint.sh"]
---> Running in a108ef650d71
Removing intermediate container a108ef650d71
---> 3f03a369226c
Successfully built 3f03a369226c
Successfully tagged backend-un-testing:latest
2. Build image from Dockerfile
#!/bin/bash
echo "Hello world"
$ docker run backend-un-testing
Hello world
$ docker run -it debian:9.5-slim
root@80c6a69b7070:/#
$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
80c6a69b7070 debian:9.5-slim "bash" 23 seconds ago Exited (0) 18 seconds ago busy_bhabha
db57926f183f backend-un-testing "/entrypoint.sh" 37 seconds ago Exited (0) 36 seconds ago flamboyant_ritchie
3. Create and run container
How can I see the built images? 🤔
Compose is a tool for defining and running multi-container Docker applications
Source: Azure Marketplace
Define your app’s environment with a Dockerfile
Define the services that make up your app in docker-compose.yml so they can be run together in an isolated environment
Run docker-compose up
version: '3'
services:
db:
image: postgres:13.0-alpine
environment:
POSTGRES_DB: trinos
POSTGRES_USER: trinos_user
POSTGRES_PASSWORD: root_12345
ports:
- '5432:5432'
volumes:
- postgres:/var/lib/postgresql/data
volumes:
postgres: ~
Automated testing (CI)
Continuous delivery and deployment
Responding to workflow triggers using issues, @ mentions, labels, and more
Triggering code reviews
Managing branches
Triaging issues and pull requests
Workflow
Uses an action
Entrypoint
Dockerfile
Jobs
Steps
2. Add Dockerfile (backend-un-testing) to the folder
3. Update the entrypoint
#!/bin/bash
echo "Hello world $INPUT_MY_NAME"
1. Create a new folder
./hello-world
./hello-world/entrypoint.sh
3. Add the action metadata file
name: "Hello World Action"
description: "My first Github Action"
author: "sjdonado@uninorte.edu.co"
inputs:
MY_NAME:
description: "Who to greet"
required: true
default: "World"
runs:
using: "docker"
image: "Dockerfile"
./hello-world/action.yml
4. Run the action from your workflow file
name: A workflow for my Hello World action
on: push
jobs:
build:
name: Run the Hello World action
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: ./hello-world
with:
MY_NAME: "Juan"
.github/workflows/main.yml
HTTP is a protocol which allows the fetching of resources, such as HTML documents.
index.html
HTML = hypertext document
Just like Ajax? 🤔
What is DOM?
It All Began in the 90s
Scripting language for the web
Mocha != Java applets
In December 1995, Netscape Communications and Sun closed the deal:
Mocha/LiveScript would be renamed JavaScript
The first version of JScript was included with Internet Explorer 3.0, released in August 1996.
vs
ECMAScript: JavaScript as a standard
the new languages have seen rapid developer acceptance with more than 175,000 Java applets and more than 300,000 JavaScript-enabled pages on the Internet today according to www.hotbot.com -Netscape Press Release
> console.log("Hello World")
Hello World
undefined
Object
Func
Param
Implicit vs Explicit Conversion
> {
console.log(a, b, c)
a = 1
var b = 2
let c = 3
}
Uncaught ReferenceError: Cannot access 'c' before initialization
at <anonymous>:2:23
> 1 + 1
> 1 + "1"
> false + false
>"false" + false
var vs let
> let myVar = 3
> myVar = "Testing"
> myVar = false
Dynamic typing
Arrow functions
var name = 'Juan'
function greetings(name) {
console.log(this.name)
}
const greetingsArrowFunc = (name) => {
console.log(this.name)
}
function myFunc() {
var myVar = 1;
}
myFunc();
console.log(myVar);
function myFunc() {
myVar = 1;
}
myFunc();
console.log(myVar);
🤔
const person = {
name: null,
setName: function (name) {
this.name = name
},
};
person.setName('Pedro');
const person = {
name: null,
setName: function (name) {
this.name = name
},
};
window.setPersonName = person.setName;
setPersonName('Pedro');
🤔
> new Object()
{}
Prototype-based OOP
ES5 class-like semantics
function Rectangle(height, width) {
this.height = height;
this.width = width;
}
Rectangle.prototype.getArea = function () {
return this.height * this.width;
}
const rectangle = new Rectangle(5, 4);
console.log(rectangle.__proto__);
console.log(rectangle.getArea());
class RectangleES5 {
constructor(height, width) {
this.height = height;
this.width = width;
}
getArea() {
return this.height * this.width;
}
}
const rectangleES5 = new RectangleES5(5, 4);
console.log(rectangleES5.__proto__);
console.log(rectangleES5.getArea());
const myObj = { name: 'Juan' }
myObj.name = 'Pedro'
mutable variable? 🤔
> new Array()
[]
> let fruits = ['Apple', 'Banana']
> new Map()
Map(0) {}
> new Set()
Set(0) {}
let first = fruits[0]
// Apple
let last = fruits[fruits.length - 1]
// Banana
fruits.forEach(function(item, index, array) {
console.log(item, index)
})
// Apple 0
// Banana 1
let newLength = fruits.push('Orange')
// ["Apple", "Banana", "Orange"]
let last = fruits.pop() // remove Orange (from the end)
// ["Apple", "Banana"]
let first = fruits.shift() // remove Apple from the front
// ["Banana"]
let newLength = fruits.unshift('Mango') // add to the front
// ["Mango", "Banana"]
let pos = fruits.indexOf('Banana')
// 1
let removedItem = fruits.splice(pos, 1) // remove an item
// ["Strawberry", "Mango"]
const arr = ['a', 'b', 'c', 'd', 'e']
for (let i = 0; i < 5; i++) {
console.log(arr[i])
}
let i = 0
do {
console.log(arr[i])
i += 1
} while (i < 5)
let i = 0
while (i < 5) {
console.log(arr[i])
i += 1
}
for (let key in arr) {
console.log(key)
}
for (let val of arr) {
console.log(val)
}
function recurse(i = 0) {
if (i < arr.length) {
console.log(arr[i])
recurse(i + 1)
}
}
recurse()
Callback hell
Promises chain
Async/await
const TOTAL_SECONDS = 2000
function waitForWithCallback(seconds, callback) {
console.log('loading...')
setTimeout(() => callback(null, 'done!'), seconds)
}
waitForWithCallback(TOTAL_SECONDS, (err, res) => {
if (!err) {
console.log(res)
}
})
function waitFor(seconds) {
return new Promise((resolve, reject) => {
console.log('loading...')
return setTimeout(() => resolve('done!'), seconds)
})
}
waitFor(TOTAL_SECONDS)
.then((res) => console.log(res))
.catch((err) => console.error(err))
try {
const res = await waitFor(TOTAL_SECONDS)
console.log(res)
} catch (err) {
console.error(err)
}
how to execute many promises? 🤔
Pending: this, symbols, prototypes
exercise: prototypes -> https://docs.google.com/document/d/13w0QTiSvjm1Be_Ttahm6QatJ__oWsp-mtIFOjEh3eQU/edit
Node.js is an open-source, cross-platform, JavaScript runtime environment. It executes JavaScript code outside of a browser.
Built on Chrome's V8 JavaScript engine
More info: https://github.com/nodejs/node
More info: https://www.youtube.com/watch?v=voDhHPNMEzg
Node’s runtime architecture, Source: Medium
const bar = () => console.log('bar')
const baz = () => console.log('baz')
const foo = () => {
console.log('foo')
setTimeout(bar, 0)
new Promise((resolve, reject) =>
resolve('should be right after bar?')
).then(resolve => console.log(resolve))
baz()
}
foo()
🤔
Source: developpaper
Source: MDN
Callback Queue
$ nvm install --lts
Installing latest LTS version.
Downloading https://nodejs.org/dist/v14.17.5/node-v14.17.5-linux-x64.tar.xz...
####################################################################################################################################################### 100.0%
Now using node v14.17.5 (npm v6.14.14)
nvm_ensure_default_set: a version is required
More info: https://nodejs.org/en/about/releases/
Node.js REPL (aka Read Evaluate Print Loop )
const http = require('http');
const hostname = '0.0.0.0';
const port = 3000;
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Hello World');
});
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
const customServer = require('./customServer')
const hostname = '0.0.0.0';
const port = 3000;
customServer.start(port, hostname, () => {
console.log('Done!');
});
const http = require('http');
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Hello World');
});
const start = (port = 3000, hostname = '0.0.0.0', callback) => {
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
callback();
});
};
module.exports = {
start,
};
./customServer.js
./app.js
{
"key": "value",
"key": 0,
"array": [
"value",
{
"key": true
}
]
}
{
key: 'value',
key: 0,
array: [
'value',
{
key: true
},
],
[Symbol('a')]: 'testing'
}
!=
CLI == JS package manager
npm is the world's largest software registry
{
"name": "environment",
"version": "1.0.0",
"description": "",
"main": "index.js",
"directories": {
"test": "test"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {}
}
Web data extraction using a web browser
Ref: https://pptr.dev/
$ sudo apt-get install -y gconf-service libasound2 libatk1.0-0 libatk-bridge2.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget libgbm-dev
$ npm install puppeteer
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://google.com');
await page.screenshot({ path: 'google.png' });
await browser.close();
})();
definitions and protocols == contract
information provider
client
REST == architectural constraints (not a protocol or a standard)
Representation of the state of the resource via HTTP
JSON, XML, HTML, Plain text, ect
Performance, scalability, simplicity, modifiability, visibility, portability, and reliability
de facto standard web server framework for Node.js
More info: https://github.com/expressjs/express
More info: https://github.com/expressjs/express
More info: https://github.com/expressjs/express
mkdir hello-world-app
cd hello-world-app
touch app.js
npm init
npm install express
const express = require('express');
const app = express();
const port = 3000;
const hostname = '0.0.0.0';
app.get('/', (req, res) => {
res.send('Hello World!');
});
app.listen(port, hostname, () => {
console.log(`Example app listening at http://${hostname}:${port}`);
});
Setup
hello-world-app/app.js
architectural pattern for achieving a clean separation of concerns
Ref: https://eslint.org/
Static code analysis tool used to flag programming errors, bugs, stylistic errors and suspicious constructs
An open source JavaScript linting utility originally created by Nicholas C. Zakas in June 2013
Source: Medium
ESLint style guide == set of rules
More info: https://github.com/airbnb/javascript
{
"eslint.run": "onSave",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"search.exclude": {
"package-lock.json": true,
"coverage/**": true
}
}
.vscode/settings.json
ESLint auto fix on save
Source: Medium
detect differences between the expected output and the actual output
Jest is a delightful JavaScript Testing Framework with a focus on simplicity
Ref: https://jestjs.io/
Cypress example (e2e + chromium)
npm i --save-dev jest
npx jest --init
describe('block that groups together several related tests', () => {
it('method which runs a test', () => { // description: what is expected to happen in the test
const myVar = 2; // computation: executes a function/method (which invokes the method you will write to make your test pass)
expect(myVar).toBe(2); // assertion: verifies that the result of your computation is what you expect it to be.
});
});
Setup
Test structure
./tests/myFirsTest.test.js
Output
Mock functions allow you to test the links between code by erasing the actual implementation of a function
Capturing call/instances
Parameters
Real function
Response
jest.fn
// foo.js
module.exports = function () {
// some implementation;
};
// test.js
jest.mock('../foo');
const foo = require('../foo');
// foo is a mock function
foo.mockImplementation(() => 42);
foo();
// > 42
Mock implementations
const myMock = jest.fn();
console.log(myMock());
// > undefined
myMock.mockReturnValueOnce(10).mockReturnValueOnce('x').mockReturnValue(true);
console.log(myMock(), myMock(), myMock(), myMock());
// > 10, 'x', true, true
Mock Return Values
const request = require('supertest');
const app = require('../app');
describe('GET /user', () => {
it('Should response hello world', async () => {
const response = await request(app).get('/users');
expect(response.body.data).toBe('hello world');
});
});
More info: https://github.com/visionmedia/supertest
Super-agent + HTTP assertions
const express = require('express');
const router = express.Router();
/* GET users listing. */
router.get('/', (req, res, next) => {
res.json({ data: 'hello world' });
});
module.exports = router;
./src/routes/users.js
./tests/users.test.js
is a measure used to describe the degree to which the source code of a program is executed when a particular test suite runs
var x= 10; console.log(x); // one line and 2 statements
More info: https://jestjs.io/
coverage/lcov-report/index.html
think through your requirements or design before your write your functional code
More info: https://jestjs.io/
Relational databases provide a store of related data tables
(ACID-compliant)
NoSQL stores unstructured or semi-structured data, often in key-value pairs or JSON documents (BASE)
CAP theorem
ACID-compliant
BASE
Designed for managing data held in a relational database management system (RDBMS)
Source: c-sharpcorner
SELECT * FROM demo;
INSERT INTO demo (name, hint) VALUES ('DSLLO APLICACIONES WEB BACKEND', 'backend');
SELECT * FROM demo WHERE name = 'DSLLO APLICACIONES WEB BACKEND';
UPDATE demo SET name = 'NEW NAME' WHERE id = 22;
DELETE FROM demo WHERE id = 22;
open source object-relational database system that uses and extends the SQL language
Sequelize is a promise-based Node.js ORM for Postgres, MySQL, MariaDB, SQLite and Microsoft SQL Server.
Source: Medium
npm install --save sequelize
you can use migrations to keep track of changes to the database
npm install --save-dev sequelize-cli
npm install --save sequelize
$ npm install --save sqlite3 pg pg-hstore
$ mkdir ./src/database
$ cd ./src/database
$ npx sequelize init
1. Install dependencies and initialize sequelize
$ touch ./src/database/config/index.js
$ cd ./src
$ rm ./src/database/config/config.json
2. Refactor database config
module.exports = {
test: {
dialect: 'sqlite',
},
};
./src/database/config/index.js
3. Create .sequelizerc
const path = require('path');
module.exports = {
'config': path.resolve('config', 'index.js')
}
./src/database/.sequelizerc
4. Update ./src/database/models/index.js
/* eslint-disable global-require */
/* eslint-disable import/no-dynamic-require */
const fs = require('fs');
const path = require('path');
const Sequelize = require('sequelize');
const basename = path.basename(__filename);
const env = process.env.NODE_ENV || 'development';
const config = require(`${__dirname}/../config`)[env];
const db = {};
let sequelize;
if (config.use_env_variable) {
sequelize = new Sequelize(process.env[config.use_env_variable], config);
} else {
sequelize = new Sequelize(config.database, config.username, config.password, config);
}
fs
.readdirSync(__dirname)
.filter((file) => (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js'))
.forEach((file) => {
const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes);
db[model.name] = model;
});
Object.keys(db).forEach((modelName) => {
if (db[modelName].associate) {
db[modelName].associate(db);
}
});
db.sequelize = sequelize;
db.Sequelize = Sequelize;
module.exports = db;
$ touch ./src/database/index.js
5. Create database init method
const models = require('./models');
const init = async () => {
await models.sequelize.authenticate();
await models.sequelize.sync();
};
module.exports = {
init,
};
./src/database/index.js
6. Connect to database on start server
#!/usr/bin/env node
/**
* Module dependencies.
*/
const http = require('http');
const app = require('../app');
const database = require('../src/database');
/**
* Connect to database
*/
database.init();
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val) {
const portParsed = parseInt(val, 10);
if (Number.isNaN(portParsed)) {
// named pipe
return val;
}
if (portParsed >= 0) {
// port number
return portParsed;
}
return false;
}
/**
* Get port from environment and store in Express.
*/
const port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
/**
* Create HTTP server.
*/
const server = http.createServer(app);
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
const bind = typeof port === 'string'
? `Pipe ${port}`
: `Port ${port}`;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(`${bind} requires elevated privileges`);
process.exit(1);
break;
case 'EADDRINUSE':
console.error(`${bind} is already in use`);
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
const addr = server.address();
const bind = typeof addr === 'string'
? addr
: `http://localhost:${addr.port}`;
console.log(`Listening on ${bind}`);
}
./bin/www
7. Add new scripts to package.json
{
"name": "trinos-api",
"version": "1.0.0",
"description": "Twitter-like API",
"private": true,
"scripts": {
"start": "node ./bin/www",
"dev": "nodemon ./bin/www",
"test": "jest",
"db:migrate": "cd ./src/database && sequelize db:migrate",
"db:migration:generate": "cd ./src/database && sequelize migration:generate",
"db:model:generate": "cd ./src/database && sequelize model:generate",
"db:seed:generate": "cd ./src/database && npx sequelize-cli seed:generate"
},
"author": "sjdonado",
"license": "GPL-3.0",
package.json
8. Generate User model + migration
$ npm run db:model:generate -- --name User --attributes username:string,email:string,name:string,password:string,lastLoginDate:date
A model is an abstraction that represents a table in your database
9. Set the allowNull property to false in model and migration
const {
Model,
} = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class User extends Model {
/**
* Helper method for defining associations.
* This method is not a part of Sequelize lifecycle.
* The `models/index` file will call this method automatically.
*/
static associate(models) {
// define association here
}
}
User.init({
username: {
type: DataTypes.STRING,
allowNull: false,
},
email: {
type: DataTypes.STRING,
allowNull: false,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
password: {
type: DataTypes.STRING,
allowNull: false,
},
lastLoginDate: {
type: DataTypes.BOOLEAN,
defaultValue: null,
},
active: {
type: DataTypes.BOOLEAN,
defaultValue: true,
},
}, {
sequelize,
modelName: 'User',
});
return User;
};
./src/database/models/user.js
10. Remove old models folder
$ rm -rf ./src/models
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('Users', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER,
},
username: {
type: Sequelize.STRING,
allowNull: false,
},
email: {
type: Sequelize.STRING,
allowNull: false,
},
name: {
type: Sequelize.STRING,
allowNull: false,
},
password: {
type: Sequelize.STRING,
allowNull: false,
},
lastLoginDate: {
type: Sequelize.DATE,
},
active: {
type: Sequelize.BOOLEAN,
},
createdAt: {
allowNull: false,
type: Sequelize.DATE,
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE,
},
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('Users');
},
};
./src/database/migrations/20210825204340-create-user.js
11. Update Users controller
const ApiError = require('../utils/ApiError');
const { User } = require('../database/models');
const UserSerializer = require('../serializers/UserSerializer');
const createUser = async (req, res, next) => {
./src/controllers/users.js
12. Update UserSerializer
const BaseSerializer = require('./BaseSerializer');
class UserSerializer extends BaseSerializer {
constructor(model) {
const serializedModel = model ? model.toJSON() : null;
delete serializedModel.password;
delete serializedModel.active;
super('success', serializedModel);
}
}
module.exports = UserSerializer;
./src/serializers/UserSerializer.js
14. Update Jest config
/*
* For a detailed explanation regarding each configuration property, visit:
* https://jestjs.io/docs/configuration
*/
module.exports = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/gy/w7rnjkr12b376zsy3xs4hz340000gn/T/jest_dx",
// Automatically clear mock calls and instances between every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
collectCoverage: true,
// An array of glob patterns indicating a set of files for which coverage information should
// be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
coverageDirectory: 'coverage',
// An array of regexp pattern strings used to skip coverage collection
coveragePathIgnorePatterns: [
'/node_modules/',
'./src/database/*',
],
// Indicates which provider should be used to instrument code for coverage
coverageProvider: 'v8',
jest.config.js
13. Update users tests
const request = require('supertest');
const app = require('../app');
const database = require('../src/database');
const { User } = require('../src/database/models');
const USERS_PATH = '/users';
const FIRST_USER = {
username: 'user1',
name: 'User 1',
email: 'user1@test.com',
password: '12345',
};
const NEW_USER = {
username: 'myusername',
name: 'Tester user',
email: 'tester@test.com',
};
describe('Users routes', () => {
beforeAll(async () => {
await database.init();
await User.create(FIRST_USER);
await User.create(Object.assign(FIRST_USER, { active: false }));
});
it('Should create user', async () => {
const payload = {
password: '12345',
passwordConfirmation: '12345',
...NEW_USER,
};
const response = await request(app).post(USERS_PATH).send(payload);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.name).toBe(payload.name);
expect(response.body.data.username).toBe(payload.username);
expect(response.body.data.email).toBe(payload.email);
expect(response.body.data.createdAt).not.toBeNull();
expect(response.body.data.updatedAt).not.toBeNull();
expect(response.body.data.lastLoginDate).toBeNull();
expect(response.body.data.password).toBeUndefined();
expect(response.body.data.passwordConfirmation).toBeUndefined();
expect(response.body.data.active).toBeUndefined();
});
it('Should return bad request on create user with invalid payload', async () => {
const payload = {
password: '12345',
passwordConfirmation: '12345',
};
const response = await request(app).post(USERS_PATH).send(payload);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('Payload must contain name, username, email and password');
});
it('Should return bad request with missmatch passwords', async () => {
const payload = {
password: '12',
passwordConfirmation: '12345',
...NEW_USER,
};
const response = await request(app).post(USERS_PATH).send(payload);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('Passwords do not match');
});
it('Should get user by id', async () => {
const USER_ID = 1;
const response = await request(app).get(`${USERS_PATH}/${USER_ID}`);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.name).toBe(FIRST_USER.name);
expect(response.body.data.username).toBe(FIRST_USER.username);
expect(response.body.data.email).toBe(FIRST_USER.email);
expect(response.body.data.createdAt).not.toBeNull();
expect(response.body.data.updatedAt).not.toBeNull();
expect(response.body.data.lastLoginDate).toBeNull();
expect(response.body.data.password).toBeUndefined();
expect(response.body.data.active).toBeUndefined();
});
it('Should return bad request when user does not exist', async () => {
const USER_ID = 0;
const response = await request(app).get(`${USERS_PATH}/${USER_ID}`);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('User not found');
});
it('Should return bad request on get a deactivated user', async () => {
const USER_ID = 2;
const response = await request(app).get(`${USERS_PATH}/${USER_ID}`);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('User not found');
});
it('Should update user', async () => {
const USER_ID = 1;
const payload = {
username: 'new_username',
email: 'new_email@test.com',
name: 'New name',
};
const response = await request(app).put(`${USERS_PATH}/${USER_ID}`).send(payload);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.name).toBe(payload.name);
expect(response.body.data.username).toBe(payload.username);
expect(response.body.data.email).toBe(payload.email);
expect(response.body.data.createdAt).not.toBeNull();
expect(response.body.data.updatedAt).not.toBeNull();
expect(response.body.data.lastLoginDate).toBeNull();
expect(response.body.data.password).toBeUndefined();
expect(response.body.data.active).toBeUndefined();
});
it('Should return bad request on update deactivated user', async () => {
const USER_ID = 2;
const payload = {
username: 'new_username',
email: 'new_email@test.com',
name: 'New name',
};
const response = await request(app).put(`${USERS_PATH}/${USER_ID}`).send(payload);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('User not found');
});
it('Should return bad request on update user with invalid payload', async () => {
const USER_ID = 1;
const payload = {
password: '12345',
};
const response = await request(app).put(`${USERS_PATH}/${USER_ID}`).send(payload);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('Payload can only contain username, email or name');
});
it('Should deactivate user', async () => {
const USER_ID = 1;
const response = await request(app).delete(`${USERS_PATH}/${USER_ID}`);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data).toBeNull();
const totalUsers = await User.count({ where: { active: true } });
expect(totalUsers).toBe(1);
});
it('Should return bad request on deactivate user when does not exist', async () => {
const USER_ID = 0;
const response = await request(app).delete(`${USERS_PATH}/${USER_ID}`);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('User not found');
});
});
./tests/users.test.js
$ npm run test
15. Run tests
version: '3'
services:
api:
build:
context: .
dockerfile: Dockerfile.dev
restart: unless-stopped
volumes:
- /usr/src/app/node_modules
- .:/usr/src/app
environment:
DATABASE_URL: postgres://trinos_user:root_12345@db:5432/trinos
ports:
- 3000:3000
depends_on:
- db
db:
image: postgres:13.0-alpine
environment:
POSTGRES_DB: trinos
POSTGRES_USER: trinos_user
POSTGRES_PASSWORD: root_12345
ports:
- 5432:5432
volumes:
- postgres:/var/lib/postgresql/data
volumes:
postgres: ~
docker-compose.yml
1. Create Dockerfile + docker-compose.yml
2. Update database config
module.exports = {
development: {
dialect: 'postgres',
use_env_variable: 'DATABASE_URL',
},
test: {
dialect: 'sqlite',
logging: false,
},
};
./src/database/config/index.js
FROM node:fermium-alpine
WORKDIR /usr/src/app
EXPOSE 3000
RUN apk add --update python make g++\
&& rm -rf /var/cache/apk/*
COPY ./package*.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]
Dockerfile.dev
3. Run in development
$ docker-compose up
(optional) connect to database using psql
$ docker-compose exec db psql -U trinos_user trinos
(optional) create migration
$ npm run db:migration:generate -- --name add-column-role-to-users
(optional) run migrations
$ docker-compose run -rm api npm run db:migrate
are an open, industry standard RFC 7519 method for representing claims securely between two parties.
More info: https://jwt.io/
Where to store it? 🤔 XSS, CSRF
jsonwebtoken
authMiddleware
authPayload
1. Install dependencies
$ npm install --save jsonwebtoken
2. Re-build docker containers
$ docker-compose down
$ docker-compose up --build
3. Generate JWT_SECRET
$ node
> require('crypto').randomBytes(32).toString('hex')
'62220ffcacdde1f09b8600d3cc59f4225845ddd1caea5075f8fff4d1027128cb'
it('Should login with username and password', async () => {
const payload = {
username: 'myusername',
password: '12345',
};
const response = await request(app).post(`${USERS_PATH}/login`).send(payload);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.accessToken).not.toBeNull();
});
./tests/users.test.js
5. Add users/login test and run tests
4. Add JWT_SECRET to docker-compose.yml
volumes:
- /usr/src/app/node_modules
- .:/usr/src/app
environment:
DATABASE_URL: postgres://trinos_user:root_12345@db:5432/trinos
JWT_SECRET: 62220ffcacdde1f09b8600d3cc59f4225845ddd1caea5075f8fff4d1027128cb
ports:
- 3000:3000
docker-compose.yml
$ docker-compose exec api npm run test
const {
createUser,
getUserById,
updateUser,
deactivateUser,
loginUser,
} = require('../controllers/users');
router.post('/', createUser);
router.get('/:id', getUserById);
router.put('/:id', updateUser);
router.delete('/:id', deactivateUser);
router.post('/login', loginUser);
module.exports = router;
./src/routes/users.js
7. Add users/login route
6. Add config folder
module.exports = {
JWT_SECRET: process.env.JWT_SECRET,
};
./config/index.js
$ mkdir ./src/config
$ touch ./src/config/index.js
const ApiError = require('../utils/ApiError');
const { User } = require('../database/models');
const { generateAccessToken } = require('../services/jwt');
const UserSerializer = require('../serializers/UserSerializer');
const AuthSerializer = require('../serializers/AuthSerializer');
const findUser = async (where) => {
Object.assign(where, { active: true });
const user = await User.findOne({ where });
if (!user) {
throw new ApiError('User not found', 400);
}
return user;
};
const createUser = async (req, res, next) => {
try {
const { body } = req;
if (body.password !== body.passwordConfirmation) {
throw new ApiError('Passwords do not match', 400);
}
const userPayload = {
username: body.username,
email: body.email,
name: body.name,
password: body.password,
};
if (Object.values(userPayload).some((val) => val === undefined)) {
throw new ApiError('Payload must contain name, username, email and password', 400);
}
const user = await User.create(userPayload);
res.json(new UserSerializer(user));
} catch (err) {
next(err);
}
};
const getUserById = async (req, res, next) => {
try {
const { params } = req;
const user = await findUser({ id: Number(params.id) });
res.json(new UserSerializer(user));
} catch (err) {
next(err);
}
};
const updateUser = async (req, res, next) => {
try {
const { params, body } = req;
const userId = Number(params.id);
const user = await findUser({ id: userId });
const userPayload = {
username: body.username,
email: body.email,
name: body.name,
};
if (Object.values(userPayload).some((val) => val === undefined)) {
throw new ApiError('Payload can only contain username, email or name', 400);
}
Object.assign(user, userPayload);
await user.save();
res.json(new UserSerializer(user));
} catch (err) {
next(err);
}
};
const deactivateUser = async (req, res, next) => {
try {
const { params } = req;
const userId = Number(params.id);
const user = await findUser({ id: userId });
Object.assign(user, { active: false });
await user.save();
res.json(new UserSerializer(null));
} catch (err) {
next(err);
}
};
const loginUser = async (req, res, next) => {
try {
const { body } = req;
const user = await findUser({ username: body.username });
if (body.password !== user.password) {
throw new ApiError('User not found', 400);
}
const accessToken = generateAccessToken(user.id);
res.json(new AuthSerializer(accessToken));
} catch (err) {
next(err);
}
};
module.exports = {
createUser,
getUserById,
updateUser,
deactivateUser,
loginUser,
};
./src/controllers/users.js
8. Add loginUser method
const BaseSerializer = require('./BaseSerializer');
class AuthSerializer extends BaseSerializer {
constructor(accessToken) {
super('success', { accessToken });
}
}
module.exports = AuthSerializer;
./src/serializers/AuthSerializer.js
9. Create AuthSerializer
const jwt = require('jsonwebtoken');
const { JWT_SECRET } = require('../config');
/**
*
* @param {Number} id Userid
* @returns {String}
*/
function generateAccessToken(id) {
return jwt.sign({ id }, JWT_SECRET, { expiresIn: '1d' });
}
/**
*
* @param {String} token
* @returns {{ id: Number }}
*/
function verifyAccessToken(token) {
return jwt.verify(token, JWT_SECRET);
}
module.exports = {
generateAccessToken,
verifyAccessToken,
};
./src/services/jwt.js
10. Create jwt service
$ mkdir src/services
$ touch src/services/jwt.js
11. Fix imports in controllers/users.js and run tests
$ docker-compose exec api npm run test
12. Add Authorization header to user tests
const request = require('supertest');
const app = require('../app');
const database = require('../src/database');
const { User } = require('../src/database/models');
const { generateAccessToken } = require('../src/services/jwt');
const USERS_PATH = '/users';
const FIRST_USER = {
username: 'user1',
name: 'User 1',
email: 'user1@test.com',
password: '12345',
};
const NEW_USER = {
username: 'myusername',
name: 'Tester user',
email: 'tester@test.com',
};
describe('Users routes', () => {
let firstUserAccessToken;
let secondUserAccessToken;
beforeAll(async () => {
await database.init();
const firstUser = await User.create(FIRST_USER);
firstUserAccessToken = generateAccessToken(firstUser.id);
const secondUser = await User.create(Object.assign(FIRST_USER, { active: false }));
secondUserAccessToken = generateAccessToken(secondUser.id);
});
it('Should create user', async () => {
const payload = {
password: '12345',
passwordConfirmation: '12345',
...NEW_USER,
};
const response = await request(app).post(USERS_PATH).send(payload);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.name).toBe(payload.name);
expect(response.body.data.username).toBe(payload.username);
expect(response.body.data.email).toBe(payload.email);
expect(response.body.data.createdAt).not.toBeNull();
expect(response.body.data.updatedAt).not.toBeNull();
expect(response.body.data.lastLoginDate).toBeNull();
expect(response.body.data.password).toBeUndefined();
expect(response.body.data.passwordConfirmation).toBeUndefined();
expect(response.body.data.active).toBeUndefined();
});
it('Should return bad request on create user with invalid payload', async () => {
const payload = {
password: '12345',
passwordConfirmation: '12345',
};
const response = await request(app).post(USERS_PATH).send(payload);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('Payload must contain name, username, email and password');
});
it('Should return bad request with missmatch passwords', async () => {
const payload = {
password: '12',
passwordConfirmation: '12345',
...NEW_USER,
};
const response = await request(app).post(USERS_PATH).send(payload);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('Passwords do not match');
});
it('Should get user by id', async () => {
const USER_ID = 1;
const response = await request(app).get(`${USERS_PATH}/${USER_ID}`);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.name).toBe(FIRST_USER.name);
expect(response.body.data.username).toBe(FIRST_USER.username);
expect(response.body.data.email).toBe(FIRST_USER.email);
expect(response.body.data.createdAt).not.toBeNull();
expect(response.body.data.updatedAt).not.toBeNull();
expect(response.body.data.lastLoginDate).toBeNull();
expect(response.body.data.password).toBeUndefined();
expect(response.body.data.active).toBeUndefined();
});
it('Should return bad request when user does not exist', async () => {
const USER_ID = 0;
const response = await request(app).get(`${USERS_PATH}/${USER_ID}`);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('User not found');
});
it('Should return bad request on get a deactivated user', async () => {
const USER_ID = 2;
const response = await request(app).get(`${USERS_PATH}/${USER_ID}`);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('User not found');
});
it('Should update user', async () => {
const USER_ID = 1;
const payload = {
username: 'new_username',
email: 'new_email@test.com',
name: 'New name',
};
const response = await request(app)
.put(`${USERS_PATH}/${USER_ID}`)
.set('Authorization', `bearer ${firstUserAccessToken}`)
.send(payload);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.name).toBe(payload.name);
expect(response.body.data.username).toBe(payload.username);
expect(response.body.data.email).toBe(payload.email);
expect(response.body.data.createdAt).not.toBeNull();
expect(response.body.data.updatedAt).not.toBeNull();
expect(response.body.data.lastLoginDate).toBeNull();
expect(response.body.data.password).toBeUndefined();
expect(response.body.data.active).toBeUndefined();
});
it('Should return unauthorized on update deactivated user', async () => {
const USER_ID = 2;
const payload = {
username: 'new_username',
email: 'new_email@test.com',
name: 'New name',
};
const response = await request(app)
.put(`${USERS_PATH}/${USER_ID}`)
.set('Authorization', `bearer ${firstUserAccessToken}`)
.send(payload);
expect(response.statusCode).toBe(403);
expect(response.body.status).toBe('User not authorized');
});
it('Should return bad request on update user with invalid payload', async () => {
const USER_ID = 1;
const payload = {
password: '12345',
};
const response = await request(app)
.put(`${USERS_PATH}/${USER_ID}`)
.set('Authorization', `bearer ${firstUserAccessToken}`)
.send(payload);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('Payload can only contain username, email or name');
});
it('Should deactivate user', async () => {
const USER_ID = 1;
const response = await request(app)
.delete(`${USERS_PATH}/${USER_ID}`)
.set('Authorization', `bearer ${firstUserAccessToken}`);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data).toBeNull();
const totalUsers = await User.count({ where: { active: true } });
expect(totalUsers).toBe(1);
});
it('Should return unauthorized on deactivate user when does not exist', async () => {
const USER_ID = 0;
const response = await request(app)
.delete(`${USERS_PATH}/${USER_ID}`)
.set('Authorization', `bearer ${firstUserAccessToken}`);
expect(response.statusCode).toBe(403);
expect(response.body.status).toBe('User not authorized');
});
it('Should login with username and password', async () => {
const payload = {
username: 'myusername',
password: '12345',
};
const response = await request(app).post(`${USERS_PATH}/login`).send(payload);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.accessToken).not.toBeNull();
});
});
./tests/users.test.js
13. Create authMiddleware
const { verifyAccessToken } = require('../services/jwt');
const ApiError = require('../utils/ApiError');
function authMiddleware(req, res, next) {
const accessToken = req.headers.authorization?.split(' ')[1];
try {
if (accessToken == null) {
throw new ApiError('Access token required', 401);
}
const user = verifyAccessToken(accessToken);
req.user = user;
next();
} catch ({ message, statusCode }) {
next(new ApiError(message, statusCode || 400));
}
}
module.exports = {
authMiddleware,
};
./src/middlewares/authMiddleware.js
$ mkdir src/middlewares
$ touch src/middlewares/authMiddleware.js
14. Update user routes
const express = require('express');
const router = express.Router();
const {
createUser,
getUserById,
updateUser,
deactivateUser,
loginUser,
} = require('../controllers/users');
const { authMiddleware } = require('../middlewares/authMiddleware');
router.post('/', createUser);
router.get('/:id', getUserById);
router.put('/:id', authMiddleware, updateUser);
router.delete('/:id', authMiddleware, deactivateUser);
router.post('/login', loginUser);
module.exports = router;
./src/routes/users.js
assigning permissions to users based on their role within an organization. roles based on common responsibilities.
More info: https://auth0.com/docs/authorization/rbac/
15. Add roles constant
module.exports = {
ROLES: {
admin: 'ADMIN',
regular: 'REGULAR',
},
};
./src/config/constants.js
$ touch ./src/config/constants.js
16. Add getAllUsers test
const request = require('supertest');
const app = require('../app');
const { ROLES } = require('../src/config/constants');
const { generateAccessToken } = require('../src/services/jwt');
const database = require('../src/database');
const { User } = require('../src/database/models');
const USERS_PATH = '/users';
const FIRST_USER = {
username: 'user1',
name: 'User 1',
email: 'user1@test.com',
password: '12345',
};
const NEW_USER = {
username: 'myusername',
name: 'Tester user',
email: 'tester@test.com',
};
describe('Users routes', () => {
let firstUserAccessToken;
let secondUserAccessToken;
let adminUserAccessToken;
beforeAll(async () => {
await database.init();
const firstUser = await User.create(FIRST_USER);
firstUserAccessToken = generateAccessToken(firstUser.id, firstUser.role);
const secondUser = await User.create(Object.assign(FIRST_USER, { active: false }));
secondUserAccessToken = generateAccessToken(secondUser.id, secondUser.role);
const adminUser = await User.create(Object.assign(FIRST_USER, { role: ROLES.admin }));
adminUserAccessToken = generateAccessToken(adminUser.id, adminUser.role);
});
it('Should create user', async () => {
const payload = {
password: '12345',
passwordConfirmation: '12345',
...NEW_USER,
};
const response = await request(app).post(USERS_PATH).send(payload);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.name).toBe(payload.name);
expect(response.body.data.username).toBe(payload.username);
expect(response.body.data.email).toBe(payload.email);
expect(response.body.data.createdAt).not.toBeNull();
expect(response.body.data.updatedAt).not.toBeNull();
expect(response.body.data.lastLoginDate).toBeNull();
expect(response.body.data.password).toBeUndefined();
expect(response.body.data.passwordConfirmation).toBeUndefined();
expect(response.body.data.active).toBeUndefined();
});
it('Should return bad request on create user with invalid payload', async () => {
const payload = {
password: '12345',
passwordConfirmation: '12345',
};
const response = await request(app).post(USERS_PATH).send(payload);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('Payload must contain name, username, email and password');
});
it('Should return bad request with missmatch passwords', async () => {
const payload = {
password: '12',
passwordConfirmation: '12345',
...NEW_USER,
};
const response = await request(app).post(USERS_PATH).send(payload);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('Passwords do not match');
});
it('Should get user by id', async () => {
const USER_ID = 1;
const response = await request(app).get(`${USERS_PATH}/${USER_ID}`);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.name).toBe(FIRST_USER.name);
expect(response.body.data.username).toBe(FIRST_USER.username);
expect(response.body.data.email).toBe(FIRST_USER.email);
expect(response.body.data.createdAt).not.toBeNull();
expect(response.body.data.updatedAt).not.toBeNull();
expect(response.body.data.lastLoginDate).toBeNull();
expect(response.body.data.password).toBeUndefined();
expect(response.body.data.active).toBeUndefined();
});
it('Should return bad request when user does not exist', async () => {
const USER_ID = 0;
const response = await request(app).get(`${USERS_PATH}/${USER_ID}`);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('User not found');
});
it('Should return bad request on get a deactivated user', async () => {
const USER_ID = 2;
const response = await request(app).get(`${USERS_PATH}/${USER_ID}`);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('User not found');
});
it('Should update user', async () => {
const USER_ID = 1;
const payload = {
username: 'new_username',
email: 'new_email@test.com',
name: 'New name',
};
const response = await request(app)
.put(`${USERS_PATH}/${USER_ID}`)
.set('Authorization', `bearer ${firstUserAccessToken}`)
.send(payload);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.name).toBe(payload.name);
expect(response.body.data.username).toBe(payload.username);
expect(response.body.data.email).toBe(payload.email);
expect(response.body.data.createdAt).not.toBeNull();
expect(response.body.data.updatedAt).not.toBeNull();
expect(response.body.data.lastLoginDate).toBeNull();
expect(response.body.data.password).toBeUndefined();
expect(response.body.data.active).toBeUndefined();
});
it('Should return unauthorized on update deactivated user', async () => {
const USER_ID = 2;
const payload = {
username: 'new_username',
email: 'new_email@test.com',
name: 'New name',
};
const response = await request(app)
.put(`${USERS_PATH}/${USER_ID}`)
.set('Authorization', `bearer ${firstUserAccessToken}`)
.send(payload);
expect(response.statusCode).toBe(403);
expect(response.body.status).toBe('User not authorized');
});
it('Should return bad request on update user with invalid payload', async () => {
const USER_ID = 1;
const payload = {
password: '12345',
};
const response = await request(app)
.put(`${USERS_PATH}/${USER_ID}`)
.set('Authorization', `bearer ${firstUserAccessToken}`)
.send(payload);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('Payload can only contain username, email or name');
});
it('Should deactivate user', async () => {
const USER_ID = 1;
const response = await request(app)
.delete(`${USERS_PATH}/${USER_ID}`)
.set('Authorization', `bearer ${firstUserAccessToken}`);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data).toBeNull();
const totalUsers = await User.count({ where: { active: true } });
expect(totalUsers).toBe(1);
});
it('Should return unauthorized on deactivate user when does not exist', async () => {
const USER_ID = 0;
const response = await request(app)
.delete(`${USERS_PATH}/${USER_ID}`)
.set('Authorization', `bearer ${firstUserAccessToken}`);
expect(response.statusCode).toBe(403);
expect(response.body.status).toBe('User not authorized');
});
it('Should login with username and password', async () => {
const payload = {
username: 'myusername',
password: '12345',
};
const response = await request(app).post(`${USERS_PATH}/login`).send(payload);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.accessToken).not.toBeNull();
});
it('Should admin role get all users', async () => {
const response = await request(app)
.get(`${USERS_PATH}/all`)
.set('Authorization', `bearer ${adminUserAccessToken}`);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.length).toBe(4);
expect(response.body.data[0].createdAt).not.toBeNull();
expect(response.body.data[0].updatedAt).not.toBeNull();
expect(response.body.data[0].lastLoginDate).toBeNull();
expect(response.body.data[0].password).toBeUndefined();
expect(response.body.data[0].active).toBeUndefined();
});
it('Should return unauthorized on get all users with regular role', async () => {
const response = await request(app)
.get(`${USERS_PATH}/all`)
.set('Authorization', `bearer ${secondUserAccessToken}`);
expect(response.statusCode).toBe(403);
expect(response.body.status).toBe('Role not authorized');
});
});
./tests/users.test.js
17. Add migration
const { ROLES } = require('../../config/constants');
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('Users', 'role', {
allowNull: false,
type: Sequelize.ENUM(...Object.values(ROLES)),
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('Users', 'role');
await queryInterface.sequelize.query('DROP TYPE "enum_Users_role";');
},
};
./src/database/migrations/20211017181542-add-column-role-to-users.js
$ npm run db:migration:generate -- --name add-column-role-to-users
18. Update User model
const {
Model,
} = require('sequelize');
const { ROLES } = require('../../config/constants');
module.exports = (sequelize, DataTypes) => {
class User extends Model {
/**
* Helper method for defining associations.
* This method is not a part of Sequelize lifecycle.
* The `models/index` file will call this method automatically.
*/
static associate(models) {
// define association here
}
}
User.init({
username: {
type: DataTypes.STRING,
allowNull: false,
},
email: {
type: DataTypes.STRING,
allowNull: false,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
password: {
type: DataTypes.STRING,
allowNull: false,
},
lastLoginDate: {
type: DataTypes.BOOLEAN,
defaultValue: null,
},
active: {
type: DataTypes.BOOLEAN,
defaultValue: true,
},
role: {
type: DataTypes.ENUM,
values: Object.values(ROLES),
defaultValue: ROLES.regular,
},
}, {
sequelize,
modelName: 'User',
});
return User;
};
./src/database/models/user.js
19. Add getAllUsers test
const {
createUser,
getUserById,
updateUser,
deactivateUser,
loginUser,
getAllUsers,
} = require('../controllers/users');
const { authMiddleware } = require('../middlewares/authMiddleware');
router.get('/all', authMiddleware, getAllUsers);
router.post('/', createUser);
router.post('/login', loginUser);
router.get('/:id', getUserById);
router.put('/:id', authMiddleware, updateUser);
router.delete('/:id', authMiddleware, deactivateUser);
module.exports = router;
./src/routes/users.js
20. Add isRole and isUserAuthorized to authMiddleware
const { verifyAccessToken } = require('../services/jwt');
const ApiError = require('../utils/ApiError');
function authMiddleware(req, res, next) {
const accessToken = req.headers.authorization?.split(' ')[1];
try {
if (accessToken == null) {
throw new ApiError('Access token required', 401);
}
const user = verifyAccessToken(accessToken);
const isRole = (role) => {
if (user.role !== role) {
throw new ApiError('Role not authorized', 403);
}
};
const isUserAuthorized = (userId) => {
if (user.id !== userId) {
throw new ApiError('User not authorized', 403);
}
};
req.user = user;
req.isRole = isRole;
req.isUserAuthorized = isUserAuthorized;
next();
} catch ({ message, statusCode }) {
next(new ApiError(message, statusCode || 400));
}
}
module.exports = {
authMiddleware,
};
./src/middlewares/authMiddleware.js
21. Update jwt service
const jwt = require('jsonwebtoken');
const { JWT_SECRET } = require('../config');
/**
*
* @param {Number} id user.id
* @param {String} role user.role
* @returns {String}
*/
function generateAccessToken(id, role) {
return jwt.sign({ id, role }, JWT_SECRET, { expiresIn: '1d' });
}
/**
*
* @param {String} token
* @returns {{ id: Number, role: String }}
*/
function verifyAccessToken(token) {
return jwt.verify(token, JWT_SECRET);
}
module.exports = {
generateAccessToken,
verifyAccessToken,
};
./src/services/jwt.js
22. Create UsersSerializer (add new file)
const BaseSerializer = require('./BaseSerializer');
class UsersSerializer extends BaseSerializer {
constructor(models) {
const serializedModels = models.map((model) => {
const serializedModel = model.toJSON();
delete serializedModel?.password;
delete serializedModel?.active;
return serializedModel;
});
super('success', serializedModels);
}
}
module.exports = UsersSerializer;
./src/serializers/UsersSerializer.js
23. Add getAllUsers method
const ApiError = require('../utils/ApiError');
const { User } = require('../database/models');
const { generateAccessToken } = require('../services/jwt');
const UserSerializer = require('../serializers/UserSerializer');
const AuthSerializer = require('../serializers/AuthSerializer');
const UsersSerializer = require('../serializers/UsersSerializer');
const { ROLES } = require('../config/constants');
const findUser = async (where) => {
Object.assign(where, { active: true });
const user = await User.findOne({ where });
if (!user) {
throw new ApiError('User not found', 400);
}
return user;
};
const getAllUsers = async (req, res, next) => {
try {
req.isRole(ROLES.admin);
const users = await User.findAll();
res.json(new UsersSerializer(users));
} catch (err) {
next(err);
}
};
const createUser = async (req, res, next) => {
try {
const { body } = req;
if (body.password !== body.passwordConfirmation) {
throw new ApiError('Passwords do not match', 400);
}
const userPayload = {
username: body.username,
email: body.email,
name: body.name,
password: body.password,
};
if (Object.values(userPayload).some((val) => val === undefined)) {
throw new ApiError('Payload must contain name, username, email and password', 400);
}
const user = await User.create(userPayload);
res.json(new UserSerializer(user));
} catch (err) {
next(err);
}
};
const getUserById = async (req, res, next) => {
try {
const { params } = req;
const user = await findUser({ id: Number(params.id) });
res.json(new UserSerializer(user));
} catch (err) {
next(err);
}
};
const updateUser = async (req, res, next) => {
try {
const { params, body } = req;
const userId = Number(params.id);
req.isUserAuthorized(userId);
const user = await findUser({ id: userId });
const userPayload = {
username: body.username,
email: body.email,
name: body.name,
};
if (Object.values(userPayload).some((val) => val === undefined)) {
throw new ApiError('Payload can only contain username, email or name', 400);
}
Object.assign(user, userPayload);
await user.save();
res.json(new UserSerializer(user));
} catch (err) {
next(err);
}
};
const deactivateUser = async (req, res, next) => {
try {
const { params } = req;
const userId = Number(params.id);
req.isUserAuthorized(userId);
const user = await findUser({ id: userId });
Object.assign(user, { active: false });
await user.save();
res.json(new UserSerializer(null));
} catch (err) {
next(err);
}
};
const loginUser = async (req, res, next) => {
try {
const { body } = req;
const user = await findUser({ username: body.username });
if (body.password !== user.password) {
throw new ApiError('User not found', 400);
}
const accessToken = generateAccessToken(user.id, user.role);
res.json(new AuthSerializer(accessToken));
} catch (err) {
next(err);
}
};
module.exports = {
createUser,
getUserById,
updateUser,
deactivateUser,
loginUser,
getAllUsers,
};
./src/controllers/users.js
24. Install dependency
$ npm install --save bcrypt
module.exports = {
JWT_SECRET: process.env.JWT_SECRET,
SALT_ROUNDS: 10,
};
./src/config/index.js
25. Update config
const ApiError = require('../utils/ApiError');
const { User } = require('../database/models');
const { generateAccessToken } = require('../services/jwt');
const UserSerializer = require('../serializers/UserSerializer');
const AuthSerializer = require('../serializers/AuthSerializer');
const UsersSerializer = require('../serializers/UsersSerializer');
const { ROLES } = require('../config/constants');
const findUser = async (whereClause) => {
const user = await User.findOne({ where: Object.assign(whereClause, { active: true }) });
if (!user) {
throw new ApiError('User not found', 400);
}
return user;
};
const getAllUsers = async (req, res, next) => {
try {
req.isRole(ROLES.admin);
const users = await User.findAll();
res.json(new UsersSerializer(users));
} catch (err) {
next(err);
}
};
const createUser = async (req, res, next) => {
try {
const { body } = req;
if (body.password !== body.passwordConfirmation) {
throw new ApiError('Passwords do not match', 400);
}
const userPayload = {
username: body.username,
email: body.email,
name: body.name,
password: body.password,
};
if (Object.values(userPayload).some((val) => val === undefined)) {
throw new ApiError('Payload must contain name, username, email and password', 400);
}
const user = await User.create(userPayload);
res.json(new UserSerializer(user));
} catch (err) {
next(err);
}
};
const getUserById = async (req, res, next) => {
try {
const { params } = req;
const user = await findUser({ id: Number(params.id) });
res.json(new UserSerializer(user));
} catch (err) {
next(err);
}
};
const updateUser = async (req, res, next) => {
try {
const { params, body } = req;
const userId = Number(params.id);
req.isUserAuthorired(userId);
const user = await findUser({ id: userId });
const userPayload = {
username: body.username,
email: body.email,
name: body.name,
};
if (Object.values(userPayload).some((val) => val === undefined)) {
throw new ApiError('Payload can only contain username, email or name', 400);
}
Object.assign(user, userPayload);
await user.save();
res.json(new UserSerializer(user));
} catch (err) {
next(err);
}
};
const deactivateUser = async (req, res, next) => {
try {
const { params } = req;
const userId = Number(params.id);
req.isUserAuthorired(userId);
const user = await findUser({ id: userId });
Object.assign(user, { active: false });
await user.save();
res.json(new UserSerializer(null));
} catch (err) {
next(err);
}
};
const loginUser = async (req, res, next) => {
try {
const { body } = req;
const user = await findUser({ username: body.username });
if (!(await user.comparePassword(body.password))) {
throw new ApiError('User not found', 400);
}
const accessToken = generateAccessToken(user.id, user.role);
res.json(new AuthSerializer(accessToken));
} catch (err) {
next(err);
}
};
module.exports = {
createUser,
getUserById,
updateUser,
deactivateUser,
loginUser,
getAllUsers,
};
./src/database/models/user.js
26. Update User controller
/* eslint-disable no-param-reassign */
const {
Model,
} = require('sequelize');
const bcrypt = require('bcrypt');
const { SALT_ROUNDS } = require('../../config');
const { ROLES } = require('../../config/constants');
module.exports = (sequelize, DataTypes) => {
class User extends Model {
/**
* Helper method for defining associations.
* This method is not a part of Sequelize lifecycle.
* The `models/index` file will call this method automatically.
*/
static associate(models) {
// define association here
}
comparePassword(plainTextPassword) {
return bcrypt.compare(plainTextPassword, this.password);
}
}
User.init({
username: {
type: DataTypes.STRING,
allowNull: false,
},
email: {
type: DataTypes.STRING,
allowNull: false,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
password: {
type: DataTypes.STRING,
allowNull: false,
},
lastLoginDate: {
type: DataTypes.BOOLEAN,
defaultValue: null,
},
active: {
type: DataTypes.BOOLEAN,
defaultValue: true,
},
role: {
type: DataTypes.ENUM,
values: Object.values(ROLES),
defaultValue: ROLES.regular,
},
}, {
sequelize,
modelName: 'User',
});
const encryptPassword = async (user) => {
if (user.changed('password')) {
user.password = await bcrypt.hash(user.password, SALT_ROUNDS);
}
};
User.beforeCreate(encryptPassword);
User.beforeUpdate(encryptPassword);
return User;
};
./src/database/models/user.js
27. Update User model
28. Run tests
$ docker-compose exec api npm run test
Pagination is a solution to this problem that ensures that the server only sends data in small chunks.
1. Update users tests
const request = require('supertest');
const app = require('../app');
const { ROLES } = require('../src/config/constants');
const { generateAccessToken } = require('../src/services/jwt');
const database = require('../src/database');
const { User } = require('../src/database/models');
const USERS_PATH = '/users';
const FIRST_USER = {
username: 'user1',
name: 'User 1',
email: 'user1@test.com',
password: '12345',
};
const NEW_USER = {
username: 'myusername',
name: 'Tester user',
email: 'tester@test.com',
};
describe('Users routes', () => {
let firstUserAccessToken;
let secondUserAccessToken;
let adminUserAccessToken;
beforeAll(async () => {
await database.init();
const firstUser = await User.create(FIRST_USER);
firstUserAccessToken = generateAccessToken(firstUser.id, firstUser.role);
const secondUser = await User.create(Object.assign(FIRST_USER, { active: false }));
secondUserAccessToken = generateAccessToken(secondUser.id, secondUser.role);
const adminUser = await User.create(Object.assign(FIRST_USER, { role: ROLES.admin }));
adminUserAccessToken = generateAccessToken(adminUser.id, adminUser.role);
});
it('Should create user', async () => {
const payload = {
password: '12345',
passwordConfirmation: '12345',
...NEW_USER,
};
const response = await request(app).post(USERS_PATH).send(payload);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.name).toBe(payload.name);
expect(response.body.data.username).toBe(payload.username);
expect(response.body.data.email).toBe(payload.email);
expect(response.body.data.createdAt).not.toBeNull();
expect(response.body.data.updatedAt).not.toBeNull();
expect(response.body.data.lastLoginDate).toBeNull();
expect(response.body.data.password).toBeUndefined();
expect(response.body.data.passwordConfirmation).toBeUndefined();
expect(response.body.data.active).toBeUndefined();
expect(response.body.paginationInfo).toBeNull();
});
it('Should return bad request on create user with invalid payload', async () => {
const payload = {
password: '12345',
passwordConfirmation: '12345',
};
const response = await request(app).post(USERS_PATH).send(payload);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('Payload must contain name, username, email and password');
});
it('Should return bad request with missmatch passwords', async () => {
const payload = {
password: '12',
passwordConfirmation: '12345',
...NEW_USER,
};
const response = await request(app).post(USERS_PATH).send(payload);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('Passwords do not match');
});
it('Should get user by id', async () => {
const USER_ID = 1;
const response = await request(app).get(`${USERS_PATH}/${USER_ID}`);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.name).toBe(FIRST_USER.name);
expect(response.body.data.username).toBe(FIRST_USER.username);
expect(response.body.data.email).toBe(FIRST_USER.email);
expect(response.body.data.createdAt).not.toBeNull();
expect(response.body.data.updatedAt).not.toBeNull();
expect(response.body.data.lastLoginDate).toBeNull();
expect(response.body.data.password).toBeUndefined();
expect(response.body.data.active).toBeUndefined();
expect(response.body.paginationInfo).toBeNull();
});
it('Should return bad request when user does not exist', async () => {
const USER_ID = 0;
const response = await request(app).get(`${USERS_PATH}/${USER_ID}`);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('User not found');
});
it('Should return bad request on get a deactivated user', async () => {
const USER_ID = 2;
const response = await request(app).get(`${USERS_PATH}/${USER_ID}`);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('User not found');
});
it('Should update user', async () => {
const USER_ID = 1;
const payload = {
username: 'new_username',
email: 'new_email@test.com',
name: 'New name',
};
const response = await request(app)
.put(`${USERS_PATH}/${USER_ID}`)
.set('Authorization', `bearer ${firstUserAccessToken}`)
.send(payload);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.name).toBe(payload.name);
expect(response.body.data.username).toBe(payload.username);
expect(response.body.data.email).toBe(payload.email);
expect(response.body.data.createdAt).not.toBeNull();
expect(response.body.data.updatedAt).not.toBeNull();
expect(response.body.data.lastLoginDate).toBeNull();
expect(response.body.data.password).toBeUndefined();
expect(response.body.data.active).toBeUndefined();
expect(response.body.paginationInfo).toBeNull();
});
it('Should return unauthorized on update deactivated user', async () => {
const USER_ID = 2;
const payload = {
username: 'new_username',
email: 'new_email@test.com',
name: 'New name',
};
const response = await request(app)
.put(`${USERS_PATH}/${USER_ID}`)
.set('Authorization', `bearer ${firstUserAccessToken}`)
.send(payload);
expect(response.statusCode).toBe(403);
expect(response.body.status).toBe('User not authorized');
});
it('Should return bad request on update user with invalid payload', async () => {
const USER_ID = 1;
const payload = {
password: '12345',
};
const response = await request(app)
.put(`${USERS_PATH}/${USER_ID}`)
.set('Authorization', `bearer ${firstUserAccessToken}`)
.send(payload);
expect(response.statusCode).toBe(400);
expect(response.body.status).toBe('Payload can only contain username, email or name');
});
it('Should deactivate user', async () => {
const USER_ID = 1;
const response = await request(app)
.delete(`${USERS_PATH}/${USER_ID}`)
.set('Authorization', `bearer ${firstUserAccessToken}`);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data).toBeNull();
const totalUsers = await User.count({ where: { active: true } });
expect(totalUsers).toBe(1);
});
it('Should return unauthorized on deactivate user when does not exist', async () => {
const USER_ID = 0;
const response = await request(app)
.delete(`${USERS_PATH}/${USER_ID}`)
.set('Authorization', `bearer ${firstUserAccessToken}`);
expect(response.statusCode).toBe(403);
expect(response.body.status).toBe('User not authorized');
});
it('Should login with username and password', async () => {
const payload = {
username: 'myusername',
password: '12345',
};
const response = await request(app).post(`${USERS_PATH}/login`).send(payload);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.accessToken).not.toBeNull();
});
it('Should admin role get all users', async () => {
const response = await request(app)
.get(`${USERS_PATH}/all`)
.set('Authorization', `bearer ${adminUserAccessToken}`);
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('success');
expect(response.body.data.length).toBe(4);
expect(response.body.paginationInfo).not.toBeNull();
expect(response.body.paginationInfo.totalItems).toBe(4);
expect(response.body.paginationInfo.totalPages).toBe(1);
expect(response.body.paginationInfo.currentPage).toBe(1);
expect(response.body.data[0].createdAt).not.toBeNull();
expect(response.body.data[0].updatedAt).not.toBeNull();
expect(response.body.data[0].lastLoginDate).toBeNull();
expect(response.body.data[0].password).toBeUndefined();
expect(response.body.data[0].active).toBeUndefined();
});
it('Should return unauthorized on get all users with regular role', async () => {
const response = await request(app)
.get(`${USERS_PATH}/all`)
.set('Authorization', `bearer ${secondUserAccessToken}`);
expect(response.statusCode).toBe(403);
expect(response.body.status).toBe('Role not authorized');
});
});
./tests/users.test.js
2. Add paginationMiddleware
const { DEFAULT_PAGINATION_LIMIT } = require('../config');
function paginationMiddleware(req, res, next) {
const page = Number(req.query.page || 1) - 1;
const limit = Number(req.query.limit || DEFAULT_PAGINATION_LIMIT);
const getPaginationInfo = async (model) => {
const totalItems = await model.count();
const totalPages = Math.ceil(totalItems / limit);
const currentPage = page + 1;
return {
totalItems,
totalPages,
currentPage,
};
};
req.pagination = {
order: ['id'],
offset: page * limit,
limit,
};
req.getPaginationInfo = getPaginationInfo;
next();
}
module.exports = {
paginationMiddleware,
};
./src/middlewares/paginationMiddleware.js
3. Set default pagination limit
module.exports = {
JWT_SECRET: process.env.JWT_SECRET,
SALT_ROUNDS: 10,
DEFAULT_PAGINATION_LIMIT: 10,
};
./src/config/index.js
4. Update users route
const express = require('express');
const router = express.Router();
const {
createUser,
getUserById,
updateUser,
deactivateUser,
loginUser,
getAllUsers,
} = require('../controllers/users');
const { authMiddleware } = require('../middlewares/authMiddleware');
const { paginationMiddleware } = require('../middlewares/paginationMiddleware');
router.get('/all', authMiddleware, paginationMiddleware, getAllUsers);
router.post('/', createUser);
router.post('/login', loginUser);
router.get('/:id', getUserById);
router.put('/:id', authMiddleware, updateUser);
router.delete('/:id', authMiddleware, deactivateUser);
module.exports = router;
./src/routes/users.js
5. Update users controller
const ApiError = require('../utils/ApiError');
const { User } = require('../database/models');
const { generateAccessToken } = require('../services/jwt');
const UserSerializer = require('../serializers/UserSerializer');
const AuthSerializer = require('../serializers/AuthSerializer');
const UsersSerializer = require('../serializers/UsersSerializer');
const { ROLES } = require('../config/constants');
const findUser = async (whereClause) => {
const user = await User.findOne({ where: Object.assign(whereClause, { active: true }) });
if (!user) {
throw new ApiError('User not found', 400);
}
return user;
};
const getAllUsers = async (req, res, next) => {
try {
req.isRole(ROLES.admin);
const users = await User.findAll({ ...req.pagination });
res.json(new UsersSerializer(users, await req.getPaginationInfo(User)));
} catch (err) {
next(err);
}
};
const createUser = async (req, res, next) => {
try {
const { body } = req;
if (body.password !== body.passwordConfirmation) {
throw new ApiError('Passwords do not match', 400);
}
const userPayload = {
username: body.username,
email: body.email,
name: body.name,
password: body.password,
};
if (Object.values(userPayload).some((val) => val === undefined)) {
throw new ApiError('Payload must contain name, username, email and password', 400);
}
const user = await User.create(userPayload);
res.json(new UserSerializer(user));
} catch (err) {
next(err);
}
};
const getUserById = async (req, res, next) => {
try {
const { params } = req;
const user = await findUser({ id: Number(params.id) });
res.json(new UserSerializer(user));
} catch (err) {
next(err);
}
};
const updateUser = async (req, res, next) => {
try {
const { params, body } = req;
const userId = Number(params.id);
req.isUserAuthorired(userId);
const user = await findUser({ id: userId });
const userPayload = {
username: body.username,
email: body.email,
name: body.name,
};
if (Object.values(userPayload).some((val) => val === undefined)) {
throw new ApiError('Payload can only contain username, email or name', 400);
}
Object.assign(user, userPayload);
await user.save();
res.json(new UserSerializer(user));
} catch (err) {
next(err);
}
};
const deactivateUser = async (req, res, next) => {
try {
const { params } = req;
const userId = Number(params.id);
req.isUserAuthorired(userId);
const user = await findUser({ id: userId });
Object.assign(user, { active: false });
await user.save();
res.json(new UserSerializer(null));
} catch (err) {
next(err);
}
};
const loginUser = async (req, res, next) => {
try {
const { body } = req;
const user = await findUser({ username: body.username });
if (!(await user.comparePassword(body.password))) {
throw new ApiError('User not found', 400);
}
const accessToken = generateAccessToken(user.id, user.role);
res.json(new AuthSerializer(accessToken));
} catch (err) {
next(err);
}
};
module.exports = {
createUser,
getUserById,
updateUser,
deactivateUser,
loginUser,
getAllUsers,
};
./src/controllers/users.js
6. Update BaseSerializer
class BaseSerializer {
constructor(status, data, paginationInfo = null) {
this.status = status;
this.data = data;
this.paginationInfo = paginationInfo;
}
toJSON() {
return {
status: this.status,
data: this.data,
paginationInfo: this.paginationInfo,
};
}
}
module.exports = BaseSerializer;
./src/serializers/BaseSerializer.js
7. Update UsersSerializer
const BaseSerializer = require('./BaseSerializer');
class UsersSerializer extends BaseSerializer {
constructor(models, paginationInfo) {
const serializedModels = models.map((model) => {
const serializedModel = model.toJSON();
delete serializedModel?.password;
delete serializedModel?.active;
return serializedModel;
});
super('success', serializedModels, paginationInfo);
}
}
module.exports = UsersSerializer;
./src/serializers/UsersSerializer.js
8. Update package.json
{
"name": "trinos-api",
"version": "1.0.0",
"description": "Twitter-like API",
"private": true,
"scripts": {
"start": "node ./bin/www",
"dev": "nodemon ./bin/www",
"test": "jest",
"db:seed:all": "cd ./src/database && sequelize db:seed:all",
"db:seed:undo": "cd ./src/database && sequelize db:seed:undo",
"db:seed:generate": "cd ./src/database && npx sequelize-cli seed:generate",
"db:migrate": "cd ./src/database && sequelize db:migrate",
"db:migration:generate": "cd ./src/database && sequelize migration:generate",
"db:model:generate": "cd ./src/database && sequelize model:generate"
},
"author": "sjdonado",
"license": "GPL-3.0",
"devDependencies": {
"eslint": "^7.32.0",
"eslint-config-airbnb-base": "^14.2.1",
"eslint-plugin-import": "^2.24.1",
"jest": "^27.0.6",
"nodemon": "^2.0.12",
"sequelize-cli": "^6.2.0",
"supertest": "^6.1.6"
},
"dependencies": {
"bcrypt": "^5.0.1",
"express": "^4.17.1",
"jsonwebtoken": "^8.5.1",
"pg": "^8.7.1",
"pg-hstore": "^2.3.4",
"sequelize": "^6.6.5",
"sqlite3": "^5.0.2"
}
}
package.json
9. Generate new seed
$ npm run db:seed:generate -- --name demo-users
10. Add demo users to users seed
const bcrypt = require('bcrypt');
const { SALT_ROUNDS } = require('../../config');
const { ROLES } = require('../../config/constants');
const PASSWORD = bcrypt.hashSync('12345', SALT_ROUNDS);
const REGULAR_USER = (idx) => ({
username: `user${idx}`,
name: `User ${idx}`,
email: `user${idx}@test.com`,
password: PASSWORD,
active: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const DEMO_USERS = [
{
username: 'admin',
name: 'Admin user',
email: 'admin@test.com',
password: PASSWORD,
role: ROLES.admin,
active: true,
createdAt: new Date(),
updatedAt: new Date(),
},
{
username: 'userdeactivated',
name: 'User deactivated',
email: 'userdeactivated@test.com',
password: PASSWORD,
active: false,
createdAt: new Date(),
updatedAt: new Date(),
},
...Array.from(Array(50).keys()).map((idx) => REGULAR_USER(idx)),
];
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.bulkInsert('Users', DEMO_USERS);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.bulkDelete('Users', null, {});
},
};
./src/database/seeders/20211018064008-demo-users.js
11. Run seeds
$ docker-compose exec api npm run db:seed:all
1. Go to https://dashboard.heroku.com/new-app
IMPORTANT: The App name must be the same as the github repo
${UNINORTE_USERNAME}-trinos-api
E.g. sjdonado-trinos-api
3. Search and select Heroku Postgres
4. Click on Submit Order Form
5. Set ENV variables in The app settings
6. Create Dockerfile
FROM node:fermium-alpine
WORKDIR /usr/src/app
EXPOSE 3000
RUN apk add --update python make g++\
&& rm -rf /var/cache/apk/*
COPY ./package*.json ./
RUN npm install
COPY . .
CMD ["npm", "start"]
Dockerfile
7. Create main workflow
name: Build and Deploy
on:
push:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: akhileshns/heroku-deploy@v3.12.12
with:
heroku_api_key: ${{secrets.HEROKU_API_KEY}}
heroku_app_name: $(basename $GITHUB_REPOSITORY)
usedocker: true
docker_build_args: |
NODE_ENV
env:
NODE_ENV: production
./github/workflows/main.yml
8. Get HEROKU_API_KEY in Account settings
9. Add workflow keys to github repo as secret
10. Push changes to master🕺🏽
How to run the migrations? 🤔