DSLLO APLICACIONES WEB BACKEND

2021-30

Introduce yourself

  • Name
  • Favorite color/serie/movie
  • What do you expect to learn in this course?

Syllabus

Setup

  1. Join Github
  2. Github Education pack
  3. Unix shell basics
  4. Azure + VSCode

Unix shell basics

  • Navigation
  • File manipulation
  • Permissions
  • Man

Navigation

$ 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

File manipulation

$ 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

Permissions

Man == A Culture of Learning

$ 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.

Azure + VSCode

download vscode + windows config

Azure + VSCode

2. Make sure to select B1ms as VM size

3. Generate a SSH public key with the following username

Azure + VSCode

4. Click on Review + create and Create

Azure + VSCode

5. Set VM IP address as static

6. Open VSCode and create a new SSH target

Azure + VSCode

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 

Azure + VSCode

9. Open connection

10. Open folder

Azure + VSCode

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

Git basics

Git basics

+

Git basics

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

Git basics

2. Recording Changes to the Repository

Git basics

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

Git basics

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

Git basics

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

Git basics

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]'

Git basics

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? 🤔

Git basics

7. Working with Remotes

  • Clone
  • Fetch / Pull
  • Commit
  • Push
$ 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

Git stash

How to deal with many branches? 🤔

Github flow

Github flow

Decentralized but centralized

Background: 10 years ago A successful Git branching model 

Github flow

  • Master
  • Staging
  • Develop
  • feat/hotfix branches

Github flow

Nowadays: Open Source

Fork

Github flow

Conventional Commits == best practice ✅

<type>[optional scope]: <description>

[optional body]

[optional footer(s)]

Github flow

Issues

PR Example

exercise: fork (repo with README), create PR + commit (with conventions, add your name) + createPR

Docker basics

  1. What is a container
  2. Docker vs Virtual Machines
  3. Setup
  4. Dockerfile
  5. CLI
  6. Docker compose

What is a container?

Docker vs Virtual Machines

Setup

Dockerfile

# 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

CLI

$ 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

CLI

$ 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"

CLI

$ 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? 🤔

Docker compose

Compose is a tool for defining and running multi-container Docker applications

Docker compose

  1. Define your app’s environment with a Dockerfile

  2. Define the services that make up your app in docker-compose.yml so they can be run together in an isolated environment

  3. 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: ~

Github actions

  1. What they are?
  2. How does it work?
  3. Hello world

What they are?

  • 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

How does it work?

Workflow

Uses an action

  • JavaScript actions
  • Docker container actions

Entrypoint

Dockerfile

+

Jobs

Steps

Hello world

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

Hello world

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

Hello world

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

Recap

Assessment 1

HTTP basics

  1. Definition
  2. How does it work?
  3. Messages
  4. XHR

Defintion

  • HTTP is simple
  • HTTP is extensible
  • HTTP is stateless, but not sessionless

HTTP is a protocol which allows the fetching of resources, such as HTML documents.

How does it work?

  1. Open a TCP connection
  2. Send an HTTP message
  3. Read the response sent by the server
  4. Close or reuse the connection for further requests

How does it work?

index.html

HTML = hypertext document

Messages

XHR == XMLHttpRequest

Just like Ajax? 🤔

What is DOM?

Javascripts basics

  1. JavaScript engine
  2. Hello world
  3. Data types
  4. Variables
  5. Functions
  6. Objects
  7. Collections
  8. Conditionals
  9. Loops
  10. Promises

JavaScript engine

It All Began in the 90s

Scripting language for the web

Mocha != Java applets

  • Simple
  • Dynamic
  • Accessible to non-developers.

JavaScript engine

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

JavaScript engine

Major Design Features

  • Java-like Syntax
  • Functions as First-Class Objects
  • Prototype-based Object Model
  • Primitives vs Objects

JavaScript engine

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

JavaScript engine

  1. ECMAScript 1 & 2: On The Road to Standardization (1997, 1998)
  2. ECMAScript 3: The First Big Changes (1999)
  3. ECMAScript 3.1 and 4: The Years of Struggle
  4. ECMAScript 5: The Rebirth Of JavaScript
  5. ECMAScript 6 (2015) & 7 (2016): a General Purpose Language

Hello world

> console.log("Hello World")
Hello World
undefined

Object

Func

Param

Data types

Primitives

  • undefined
  • boolean
  • number
  • string
  • bigint
  • symbol
  • null

Structural Types

  • object
  • function

Variables

Implicit vs Explicit Conversion

  • var: function-scoped or globally-scoped
  • let: block-scoped
  • const: block-scoped
> {
    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

Functions

Arrow functions

var name = 'Juan'

function greetings(name) {
  console.log(this.name)
}

const greetingsArrowFunc = (name) => {
  console.log(this.name)
}

First-class objects

  • Appear in an expression
  • Can be assigned to a variable
  • Can be used as an argument
  • Can be returned by a function call

Functions

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');

🤔

Objects

> 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? 🤔

Objects

Collections

> new Array()
[]

> let fruits = ['Apple', 'Banana']

> new Map()
Map(0) {}

> new Set()
Set(0) {}
  • Indexed
    • Array
    • Typed Arrays
  • Keyed
    • Set
    • Map
    • WeakSet
    • WeakMap

Collections

Array Common operations

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"]

Conditionals

  • Equality operators:
    • ==
    • !=
    • ===
    • !==
  • Binary logical operators
    • &&
    • ||
    • ??
  • Relational operators
    • in
    • instanceof
    • <
    • >
    • <=
    • >=
  • Conditional (ternary) operator
    • (condition ? ifTrue : ifFalse)
  • Optional Chaining operator
    • ?.

Loops

  • for statement
  • do...while statement
  • while statement
  • for...in statement
  • for...of statement
  • recursion
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()

Promises

Callback hell

Promises chain

Async/await

Promises

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 basics

  1. What is it?
  2. Under the hood
  3. NVM
  4. Hello world
  5. Modules
  6. JSON
  7. NPM
  8. Web scraping

What is it?

Node.js is an open-source, cross-platform, JavaScript runtime environment. It executes JavaScript code outside of a browser.

What is it?

Under the hood

Node’s runtime architecture, Source: Medium

Under the hood

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()

🤔

Under the hood

  • Call stack
  • Blocking the event loop
  • The Message Queue (aka Callback queue)
  • ES6 Job Queue (aka micro task queue)

Source: developpaper

Source: MDN

Callback Queue

NVM == node version manager

$ 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

Hello world

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}/`);
});

Modules == CommonJS == JS lib

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

JSON == JavaScript Object Notation

  • Lightweight data-interchange format
  • Easy for humans to read and write
  • Easy for machines to parse and generate
  • Based on ECMA-262 3rd Edition
{
  "key": "value",
  "key": 0,
  "array": [
    "value",
    {
      "key": true
    }
  ]
}
{
  key: 'value',
  key: 0,
  array: [
    'value',
    {
      key: true
    },
  ],
  [Symbol('a')]: 'testing'
}

!=

NPM

CLI == JS package manager

npm is the world's largest software registry

  • The website
  • The Command Line Interface (CLI)
  • The 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 scraping 

Web data extraction using a web browser

Web scraping 

$ 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
  1. Install dependencies
  2. Create package.json
  3. Install puppeteer
  4. Hello World
$ 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();
})();

Recap

Assessment 2

REST API + Express.js

  1. What is a REST API?
  2. Express.js
  3. Hello world
  4. MVC
  5. Linters
  6. Trinos API: first steps

REST API (aka RESTful API)

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

RESTful API Architectural constraints

  1. Client–server architecture
  2. Statelessness
  3. Cacheability
  4. Layered system
  5. Uniform interface

Performance, scalability, simplicity, modifiability, visibility, portability, and reliability

RESTful API Architectural constraints

  • Resource identification in requests: URIs
  • Resource manipulation through representations: response
  • Self-descriptive messages: how to process (ie: mediatype)
  • Hypermedia as the engine of application state: ie pagination

Uniform interface

Express.js

de facto standard web server framework for Node.js

Express.js

  • Routes: Write handlers for requests (with different HTTP verbs at different URL paths)
  • View system supporting 14+ template engines
  • HTTP helpers (redirection, caching, etc)
  • Add additional request processing "middleware" (at any point within the request handling pipeline)

Hello world

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

MVC

architectural pattern for achieving a clean separation of concerns

  • Model: State of the application and any business logic
  • View: Presenting content through the user interface
  • Controller: handle user interaction, work with the model, and ultimately select a view to render

MVC

Linters

Static code analysis tool used to flag programming errors, bugs, stylistic errors and suspicious constructs

ESLint

An open source JavaScript linting utility originally created by Nicholas C. Zakas in June 2013

Linters

Source: Medium

ESLint style guide == set of rules

Trinos API: first steps

  1. Create folder
  2. Add README + LICENSE
  3. Initial commit
  4. Express setup: npx express-generator --no-view --git
  5. ESLint setup: npx eslint --fix bin/www src/**/*.js app.js
  6. Dir structure (./src - MVC - .keep - ./tests)
  7. Add nodemon: npm i --save-dev nodemon
  8. Add .editorconfig
  9. Add ApiError
  10. Refactor: error middleware + serializers
  11. Add NPM scripts

Trinos API: first steps

{
  "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

Testing

  1. Unit vs Integration testing
  2. Jest
  3. Mock functions
  4. Supertest
  5. Coverage
  6. TDD
  7. Trinos API: Jest setup

Unit vs Integration testing

Source: Medium

detect differences between the expected output and the actual output

Jest (by facebook)

  • Zero config: Jest aims to work out of the box, config free, on most JavaScript projects
  • Snapshots: Make tests which keep track of large objects with ease
  • Isolated: Tests are parallelized by running them in their own processes to maximize performance
  • Great api: From it to expect - Jest has the entire toolkit in one place

Jest is a delightful JavaScript Testing Framework with a focus on simplicity

Jest: alternatives

  1. Mocha + chai (Node.js + browser)
  2. Cypress (browser)

Cypress example (e2e + chromium)

Jest: let's start

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

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

Mock functions

// 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

Supertest

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');
  });
});

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

Coverage

is a measure used to describe the degree to which the source code of a program is executed when a particular test suite runs

Criterias

  • Statements coverage
  • Branchs coverage
  • Functions coverage
  • Lines coverage
var x= 10; console.log(x); // one line and 2 statements

Coverage: HTML report

More info: https://jestjs.io/

coverage/lcov-report/index.html

TDD == Test Driven Development

think through your requirements or design before your write your functional code

  1. Write a Failing Test (Understand what is needed)
  2. Make the (failing) Test Pass
  3. Refactor the code you wrote ✅

Trinos API: Jest setup

More info: https://jestjs.io/

  1. Install supertest
  2. Add "jest": true to eslint env
  3. Add create user test to ./tests/users.test.js
  4. Add get user test to ./tests/users.test.js
  5. Create user controller
  6. Create GET + POST users routes
  7. Create fake User model
  8. Execute npm run test
  9. Check coverage (bonus)✅

Sequelize + PostgreSQL

  1. Relational vs NoSQL data
  2. SQL
  3. PostgreSQL
  4. Sequelize
  5. Sequelize + PostgreSQL

Relational vs NoSQL data

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)

  • Consistency: Every node in the cluster responds with the most recent data
  • Availability: Every node returns an immediate response
  • Partition Tolerance: Guarantees the system continues to operate even if a replicated data node fails (ie: SQL master slave)

CAP theorem

Relational vs NoSQL data

ACID-compliant

BASE

  • Atomicity means that transactions must complete or fail as a whole
  • Consistency constraints mean that all nodes in the cluster must be identical
  • Isolation is the main goal of concurrency control (same state as sequential)
  • Durability means that the write has been flushed to disk before returning to the client
  • Basically available indicates that the system does guarantee availability
  • Soft state indicates that the state of the system may change over time, even without an input
  • Eventual consistency indicates that the system will become consistent over time, given that the system doesn't receive input during that time

SQL == Structured Query Language

Designed for managing data held in a relational database management system (RDBMS)

  • Data Manipulation Language
  • Data Definition Language
  • Data Control Language
  • Transactional Control Language

Source: c-sharpcorner

SQL DML CRUD

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;

PostgreSQL

open source object-relational database system that uses and extends the SQL language

  • ACID-compliant since 2001
  • Powerful add-ons such as the popular PostGIS
  • Highly extensible (ie: define custom data types, build out custom functions)
  • Conforms to at least 170 of the 179 mandatory features for SQL:2016 Core conformance
  • Used by 138,180 companies

Sequelize

Sequelize is a promise-based Node.js ORM for Postgres, MySQL, MariaDB, SQLite and Microsoft SQL Server.

Source: Medium

  • Solid transaction support
  • Data relationships
  • Eager and lazy loading
  • Read replication
npm install --save sequelize

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

Sequelize

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;

Sequelize

$ 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

Sequelize

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

Sequelize

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

Sequelize

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

Sequelize

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

Sequelize + PostgreSQL

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

Sequelize + PostgreSQL

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

Recap

Assessment 3

Security

  1. JWT
  2. Trinos API: login
  3. Trinos API: authMiddleware
  4. RBAC
  5. Trinos API: roles
  6. Trinos API: bcrypt

JWT == JSON Web Tokens

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

Trinos API: authentication

jsonwebtoken

authMiddleware

authPayload

Trinos API: login

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'

Trinos API: login

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

Trinos API: login

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

Trinos API: login

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

Trinos API: login

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

Trinos API: AuthMiddleware

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

Trinos API: AuthMiddleware

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

Trinos API: AuthMiddleware

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

RBAC == Role Based Access Control

assigning permissions to users based on their role within an organization. roles based on common responsibilities.

Trinos API: roles

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

Trinos API: roles

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

Trinos API: roles

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

Trinos API: roles

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

Trinos API: roles

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

Trinos API: bcrypt

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

Trinos API: bcrypt

/* 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

  1. Offset vs Cursor pagination
  2. Trinos API: paginationMiddleware
  3. Trinos API: Update serializer
  4. Trinos API: Demo users seed

Offset vs Cursor pagination

Pagination is a solution to this problem that ensures that the server only sends data in small chunks.

Trinos API: pagination Middleware

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

Trinos API: pagination Middleware

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

Trinos API: pagination Middleware

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

Trinos API: Update serializer

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

Trinos API: demo users seed

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

Trinos API: demo users seed

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

CI deployment with Heroku

  1. Signup Heroku
  2. Create new app
  3. Add-ons: Heroku Postgres 
  4. Trinos API: Dockerfile
  5. Trinos API: Github Actions workflow

Create new app

IMPORTANT: The App name must be the same as the github repo

${UNINORTE_USERNAME}-trinos-api

E.g. sjdonado-trinos-api

Add-ons: Heroku Postgres 

3. Search and select Heroku Postgres

4. Click on Submit Order Form

Trinos API: Dockerfile

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

Trinos API: Github Actions workflow

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

Trinos API: Github Actions workflow

9. Add workflow keys to github repo as secret

10. Push changes to master🕺🏽

How to run the migrations? 🤔

Further work

  1. API documentation
  2. API versioning
  3. GraphQL