Construindo aplicação do zero com node.js: Parte 5

erandirjunior

Erandir Junior

Posted on November 17, 2022

Construindo aplicação do zero com node.js: Parte 5

Faaaaaala devs, chegamos ao nosso último artigo, se você chegou até aqui, meus sinceros parabéns e muito obrigado.

Mão na massa

Vamos começar configurando toda a nossa comunicação com o banco de dados, então dentro de src/infra/persistence, crie um arquivo chamado database.js, nele nós criaremos a nossa conexão com o banco:

import { Sequelize } from 'sequelize';

export default class Database {
    static async getConnection() {
        const database = process.env.DB_DATABASE;
        const user = process.env.DB_USER;
        const password = process.env.DB_PASSWORD;
        const host = process.env.DB_HOST;
        const dialect = process.env.DB_DIALECT;

        const sequelize = new Sequelize(database, user, password, {
            host,
            dialect,
            logging: false
        });

        try {
            await sequelize.authenticate();
            console.log('Connection has been established successfully.');
            return sequelize;
        } catch (error) {
            console.error('Unable to connect to the database:', error);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Percebam que todas as informações foram definidas em nosso .env. Neste código, utilizei um método estático, mas se quiserem utilizar uma função ou qualquer outra forma, fique à vontade.

Em seguida, vamos definir o nosso modelo, que seria basicamente a representação de uma tabela do banco, crie um arquivo chamado model.js, com o conteúdo abaixo:

import Database from './database.js';
import { DataTypes } from 'sequelize';

const loadModel = async () => {
    const connection = await Database.getConnection();
    const model = connection.define('User', {
        id: {
            type: DataTypes.INTEGER,
            required: true,
            primaryKey: true,
            autoIncrement: true
        },
        email: {
            type: DataTypes.STRING,
            required: true,
            allowNull: false
        },
        password: {
            type: DataTypes.STRING,
            required: true,
            allowNull: false
        },
        expired: {
            type: DataTypes.BOOLEAN,
            default: false
        },
        token: {
            type: DataTypes.STRING
        },
        email_token: {
            type: DataTypes.STRING
        }
    }, {
        tableName: 'users'
    });

    await model.sync();
    return model;
}

export default loadModel;
Enter fullscreen mode Exit fullscreen mode

Um detalhe interessante, é que o código acima também vai funcionar como uma migration, isso é, ele vai criar a tabela no banco, com as colunas e tipos definidos acima. Vejam também que não adicionei muitas informações a essa tabela, se forem adequar este projeto em algum sistema, provavelmente vocês vão precisar adicionar mais alguma informação, e para isso, basta modificar este arquivo.

Agora que temos a nossa conexão e nosso modelo, vamos implementar as dependências que nosso domínio precisa, nesse caso aqui eu joguei tudo em um arquivo chamado repository.js:

import IRepository from './../../domain/irepository.js';
import ITokenRepository from './../../domain/itoken-repository.js';
import User from './../../domain/user.js';

export class Repository extends IRepository {
    #model;

    constructor(model) {
        super();
        this.#model = model
    }

    async findByEmail(email) {
        const result = await this.#model.findOne({ where: {
                email
            }
        });

        if (!result) {
            return undefined;
        }

        return new User({
            id: result.dataValues.id,
            token: result.dataValues.token,
            emailToken: result.dataValues.email_token,
            email: result.dataValues.email,
            password: result.dataValues.password,
        })
    }

    async update(user) {
        const data = {
            token: user.token,
            email_token: user.emailToken,
            expired: user.expired
        }

        await this.#model.update(data, {
            where: {
                id: user.id
            }
        });
    }
}

export class TokenRepository extends ITokenRepository {
    #model;

    constructor(model) {
        super();
        this.#model = model
    }

    updateExpiredFieldToTrue(id) {
        throw Error('Must be implemented');
    }

    async findByToken(token) {
        const result = await this.#model.findOne({ where: {
                token: token.token,
                email_token: token.emailToken,
            }
        });

        if (!result) {
            return undefined;
        }

        return new User({
            id: result.dataValues.id,
            token: result.dataValues.token,
            emailToken: result.dataValues.email_token,
            email: result.dataValues.email,
            password: result.dataValues.password,
            expired: result.dataValues.expired
        })
    }

    async updateExpiredFieldToTrue(id) {
        const data = {
            expired: true
        }

        await this.#model.update(data, {
            where: {
                id
            }
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Pode ser que por algum motivo, o nosso database não tenha sido criado corretamente, para que tenhamos a certeza, acesse no navegador o endereço: http://localhost:8081, que é a interface do adminer, selecione o sistema como postgres, e preencha os dados do servidor, usuário e senha, no meu caso ficou assim:

Image description
Todas as informações preenchidas nessa configuração são as mesmas que estão no arquivo .env, definido no segundo artigo.

Clique em entrar, depois verifique se existe um database com o mesmo nome do campo DB_DATABASE definido em nosso .env, se não existir, clique em Sql Command e execute o comando sql abaixo:

create database two_factor;
Enter fullscreen mode Exit fullscreen mode

Só é necessário caso nosso database não tenha sido criado.

Agora que encerramos nossa parte de banco, vamos configurar nossas rotas. Dentro de src/infra/http/hapi, crie um arquivo chamado server.js, com o conteúdo abaixo:

import Hapi from '@hapi/hapi';
import Vision from '@hapi/vision';
import Inert from '@hapi/inert';
import HapiSwagger from 'hapi-swagger';

const init = async () => {
    const server = Hapi.server({
        port: process.env.APP_PORT,
    });

    const swaggerOptions = {
        info: {
            title: 'API Two-Factor Authentication',
            version: 'v1.0',
        }
    };

    await server.register([
        Inert,
        Vision,
        {
            plugin: HapiSwagger,
            options: swaggerOptions
        }
    ]);

    await server.start();
    console.log('Server running on %s', server.info.uri);
    return server;
};

export default init;
Enter fullscreen mode Exit fullscreen mode

Aqui adicionamos alguns plugins para o hapi, basicamente vai servir para gerarmos a documentação da nossa api, veremos isso com calma depois.

Agora vamos voltar a modificar o arquivo index.js, que está na raiz do nosso projeto, vamos importar nosso serviço de rotas:

import loadEnv from './src/infra/env/load-env.js';
import init from './src/infra/http/hapi/server.js';

async function run() {
    await loadEnv();
    await init();
}

run();
Enter fullscreen mode Exit fullscreen mode

Para saber se está tudo funcionando, basta ir no terminal do serviço node e executar o seguinte comando:

node index.js
Enter fullscreen mode Exit fullscreen mode

O servidor vai subir, agora acesse o endereço: http://localhost:8001/, provavelmente vamos ter um retorno 404.

Agora vamos começar a chamar nossas regras e passar para elas todos os serviços criado anteriormente, para isso, crie o diretório actions, dentro de src/infra, e crie os arquivos user-authetication-action.js e user-authetication-action.js, com o conteúdo abaixo:

// src/infra/action/token-authentication-action.js
import loadModel from '../persistence/model.js';
import { Repository } from '../persistence/repository.js';
import UserAuthentication from '../../domain/user-authentication.js';
import Email from '../email/email.js';
import PasswordHash from '../hash/password-hash.js';
import TokenService from '../token/token-service.js';
import LoginPayload from '../../domain/login-payload.js';

const createUserAuthentication = async (payload) => {
    const userModel = await loadModel();
    const repository = new Repository(userModel);
    const userAuthentication = new UserAuthentication(
        repository,
        new Email(),
        new PasswordHash(),
        new TokenService()
    );

    const loginPayload = new LoginPayload(payload.email, payload.password);
    return await userAuthentication.authenticate(loginPayload);
}

export default createUserAuthentication;

// src/infra/action/token-authentication-action.js
import loadModel from '../persistence/model.js';
import { TokenRepository } from '../persistence/repository.js';
import TokenAuthentication from '../../domain/token-authentication.js';
import Jwt from '../jwt/jwt.js';
import Token from '../../domain/token.js';

const createTokenAuthentication = async (payload) => {
    const userModel = await loadModel();
    const repository = new TokenRepository(userModel);
    const tokenAuthentication = new TokenAuthentication(
        repository,
        new Jwt()
    );

    const token = new Token(payload);
    return await tokenAuthentication.authenticate(token);
}

export default createTokenAuthentication;
Enter fullscreen mode Exit fullscreen mode

Estamos chamando nossas lógicas de domínio e passando para elas todas as dependências implementadas, fizemos isso nos testes, só que lá injetamos comportamentos que simulam as ações.

Voltemos para diretório src/infra/http/hapi, nele vamos criar um arquivo chamado routes.js, onde vamos definir nossas rotas e suas ações:

import Joi from 'joi';
import InvalidArgumentError from './../../../domain/invalid-argument-error.js';
import GatewayError from './../../../domain/gateway-error.js';
import Boom from '@hapi/boom';
import createUserAuthentication from '../../actions/user-authentication-action.js';
import createTokenAuthentication from '../../actions/token-authentication-action.js';

const handlerError = (error) => {
    if (error instanceof GatewayError) {
        return Boom.badGateway(error.message);
    }

    if (error instanceof InvalidArgumentError) {
        return Boom.badRequest(error.message);
    }

    return Boom.badImplementation(error);
}

const failAction = (request, h, err) => {
    throw err;
};

const routes = [
    {
        options: {
            tags: ['api'],
            description: 'Get temporary token',
            notes: 'Login with email and password',
            validate: {
                payload: Joi.object({
                    email: Joi.string().email().required(),
                    password: Joi.string().min(6).required()
                }),
                failAction
            }
        },
        method: 'POST',
        path: '/login',
        handler: async (request, h) => {
            try {
                const { payload } = request;
                const result = await createUserAuthentication(payload);
                return {
                    token: result
                };
            } catch (e) {
                return handlerError(e);
            }
        }
    },
    {
        options: {
            tags: ['api'],
            description: 'Login in the application',
            notes: 'Login with token and the token receive in e-mail',
            validate: {
                payload: Joi.object({
                    token: Joi.string().min(35).required(),
                    emailToken: Joi.string().min(22).required()
                }),
                failAction
            }
        },
        method: 'POST',
        path: '/token',
        handler: async (request, h) => {
            try {
                const { payload } = request;
                const result = await createTokenAuthentication(payload);
                return {
                    token: result
                };
            } catch (e) {
                return handlerError(e);
            }
        }
    }
]

export default routes;
Enter fullscreen mode Exit fullscreen mode

Detalhando o código acima: a função handlerError vai trabalhar em cima do tipo de erro para retornar uma resposta personalizada, utilizamos o Boom para nos auxiliar nessas respostas. A constante routes, recebe um array de objetos contendo as duas rotas do nosso sistemas, ambas são do tipo POST, definimos uma tag para elas, isso é útil para o swagger, que vai documentar nossa api. Fizemos uso do Joi para validar os dados enviados pela requisição e finalmente chamamos nossas ações passando os dados da requisição, acredito que tenha ficado fácil de entender o código acima.

Agora a gente precisa informar essa rotas ao hapi, para isso, altere o arquivo server.js, e deixe ele assim:

import Hapi from '@hapi/hapi';
import Vision from '@hapi/vision';
import Inert from '@hapi/inert';
import HapiSwagger from 'hapi-swagger';
import routes from './routes.js';

const init = async () => {
    const server = Hapi.server({
        port: process.env.APP_PORT,
    });

    const swaggerOptions = {
        info: {
            title: 'API Two-Factor Authentication',
            version: 'v1.0',
        }
    };

    await server.register([
        Inert,
        Vision,
        {
            plugin: HapiSwagger,
            options: swaggerOptions
        }
    ]);

    server.route(routes);

    await server.start();
    console.log('Server running on %s', server.info.uri);
    return server;
};

export default init;
Enter fullscreen mode Exit fullscreen mode

E pronto, se tudo estiver ok, a gente pode acessar o seguinte endereço: http://localhost:8001/documentation, que vai carregar a documentação da api, com os campos a serem enviados, o tipo da requisição, etc, lembre-se de parar e subir o servidor novamente.

Se precisarmos modificar algo no código acima, a reinicialização do servidor é necessária, se quiserem evitar isso, basta configurar o uso do nodemon, conforme explicado no artigo anterior, ele vai identificar uma mudança e fazer o recarregamento do servidor. Se quiserem utilizá-lo, alterem o arquivo package.json, e adicione um novo script. O exemplo abaixo mostra a adição do campo start e o uso do nodemon:

{
  "name": "two-factor-authentication",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
    "scripts": {
      "start": "node_modules/nodemon/bin/nodemon.js index.js",
      "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js ./tests/* --coverage --config='{ \"coverageReporters\": [\"html\"] }'\n"
    },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "module",
  "dependencies": {
    "@hapi/boom": "^10.0.0",
    "@hapi/hapi": "^20.2.2",
    "@hapi/inert": "^7.0.0",
    "@hapi/vision": "^7.0.0",
    "bcrypt": "^5.1.0",
    "dotenv": "^16.0.3",
    "hapi-swagger": "^14.5.5",
    "joi": "^17.6.3",
    "jsonwebtoken": "^8.5.1",
    "nodemailer": "^6.8.0",
    "pg": "^8.8.0",
    "pg-hstore": "^2.3.4",
    "sequelize": "^6.25.3",
    "short-uuid": "^4.2.0"
  },
  "devDependencies": {
    "jest": "^29.2.1",
    "nodemon": "^2.0.20"
  }
}
Enter fullscreen mode Exit fullscreen mode

Após essa alteração rode o seguinte comando:

npm start
Enter fullscreen mode Exit fullscreen mode

Esse comando vai subir o servidor e se qualquer modificação for realizada, o servidor vai ser reinicializado automaticamente.

E antes de irmos para um postman ou qualquer outra ferramenta, vamos criar um teste para saber se tudo está funcionando, crie um arquivo de teste chamado login.test.js, dentro de tests/feature, com o conteúdo abaixo:

import loadEnv from '../../src/infra/env/load-env.js';
import init from '../../src/infra/http/hapi/server.js';
let api = {};
let token = {};
let user = {
    email: 'email@email.com',
    password: '123456'
}
import Bcrypt from 'bcrypt';
import loadModel from '../../src/infra/persistence/model.js';
let model = {};
beforeAll(async () => {
    await loadEnv();
    api = await init();
    model = await loadModel();
    const pass = await Bcrypt.hash(user.password, 3);
    await model.destroy({ where : { email: user.email }});
    await model.create({
        email: user.email,
        password: pass
    });
});

test('Should get token', async () => {
    const result = await api.inject({
        method: 'POST',
        url: '/login',
        payload: user
    });
    const data = JSON.parse(result.payload);
    token = data.token;
    expect(token.length).toBeGreaterThan(35);
});

test('Should get jwt token', async () => {
    const user = await model.findOne({where: {token}, raw: true});
    const result = await api.inject({
        method: 'POST',
        url: '/token',
        payload: {
            token,
            emailToken: user.email_token
        }
    });
    const data = JSON.parse(result.payload);
    expect(data.token.length).toBeGreaterThan(40);
    expect(data.token).toBeTruthy();
    await model.destroy({ where : { email: user.email }});
});
Enter fullscreen mode Exit fullscreen mode

Aqui, primeiro criamos um usuário temporário e depois começamos a fazer as requisições, primeiro com login e senha, e depois a requisição passando os tokens. Observe o uso da lib Bcrypt para fazer um hash da nossa senha.

Antes de executarmos o teste, duplique o conteúdo do arquivo .env, para um arquivo chamado .env.test, isso é bem útil pois podemos realizar testes utilizando outro banco por exemplo.

Agora sim podemos executar nossos testes mais uma vez:

npm t
Enter fullscreen mode Exit fullscreen mode

Se você tiver seguido todos os passos, todos os testes vão passar corretamente. Minha dica é sempre olharem o relatório de cobertura.

Fim

Bem devs, chegamos ao fim do nosso projeto, espero que tenham aprendido algo, assim como aprendi. No primeiro artigo da série, eu deixei o link do repositório do código do projeto.

Novamente deixo meus agredecimentos por terem chegado até aqui. Nos vemos em outros artigos, até mais.

💖 💪 🙅 🚩
erandirjunior
Erandir Junior

Posted on November 17, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related