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)