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); } } }
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;
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 } }); } }
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:
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;
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;
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();
Para saber se está tudo funcionando, basta ir no terminal do serviço node e executar o seguinte comando:
node index.js
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;
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;
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;
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" } }
Após essa alteração rode o seguinte comando:
npm start
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 }}); });
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
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.
Top comments (0)