Case 2 - Site do EstudoApp (Backend)
Iniciar projeto dentro da pasta desejada com:
npm init -y
Instalar dependências do projeto
npm install nodemon --save-dev
npm install bcrypt cors express sqlite sqlite3
bcrypt
: Armazena e compara senhas de forma seguracors
: Permite que aplicativos em outros domínios acessem nosso servidorexpress
: Servidor HTTPnodemon
: Monitora os o projeto e reinicia o servidor quando salvamos um arquivo (hot reload)sqlite
: Biblioteca auxiliar para utilizar osqlite3
com Promisessqlite3
: Driver do banco de dados que iremos utilizar
Agora, atualize o nome do projeto no campo "name"
e crie o campo "type": "module"
no final do package.json
para usar a sintaxe de import no lugar de require.
Para finalizar, remova o script de "test"
, crie o script "start": "nodemon server.js"
. Seu package.json
deverá ficar parecido com este:
{
"name": "case2-back-teste",
"version": "1.0.0",
"description": "",
"main": "server.js",
"scripts": {
"start": "nodemon app.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"nodemon": "^2.0.20"
},
"dependencies": {
"bcrypt": "^5.1.0",
"cors": "^2.8.5",
"express": "^4.18.2",
"sqlite": "^4.1.2",
"sqlite3": "^5.1.4"
},
"type": "module"
}
Como configuramos acima, o nosso script para executar o projeto tem como ponto de entrada o server.js
. Vamos criar os nossos 2 primeiros arquivos em JavaScript:
src/app.js
: Cria o app express e configura suas funcionalidadesserver.js
: Inicia o servidor do express
Em src/app.js
, vamos criar um novo app, configuramos os pedidos de outros domínios com CORS e permitimos que o projeto receba dados em formato JSON (para usar em requisições POST, PUT e PATCH)
import cors from "cors";
import express from "express";
const app = express()
app.use(cors())
app.use(express.json())
export default app
No server.js
, importamos a aplicação, definimos uma porta de rede para trafegar dados e mandamos o aplicativo iniciar o servidor:
import app from "./src/app.js";
const port = 3000
app.listen(port, () => {
console.log(`Aplicação escutando na porta ${port}`)
})
Se você executar npm start
, você verá que o projeto estará funcionando e respondendo às atualizações de arquivos!
Precisamos separar no nosso projeto nos endereços nos quais vamos buscar os dados e como nós vamos lidar com esses pedidos. Para isso, vamos criar uma camada no nosso projeto: as controllers. Elas vão orquestrar as responsabilidades de outras camadas do nosso projeto: vão receber o pedido, repassar as informações para validadores, pedir informações para fábricas e modelos de dados e devolver as informações para o usuário.
Vamos criar os seguintes arquivos:
src/controller/PageController.js
: Vai cuidar dos pedidos de criação, leitura, atualização e exclusão de informações de uma página específicasrc/controller/ProductController.js
: Vai cuidar dos pedidos de criação, leitura, atualização e exclusão de informações de uma funcionalidade específicasrc/controller/UserController.js
: Vai cuidar dos pedidos de login na plataforma
Vamos começar pela src/controller/UserController.js
e montar um pseudocódigo para nos auxiliar a montar a estrutura do arquivo:
export default class UserController {
static routes(app) {
// Aqui informaremos qual método responderá à rota de login
}
static async login(req, res) {
// Recebemos os campos da requisição
// Se algum campo obrigatório não foi informado:
// - Devolvemos uma mensagem de erro e saímos da função
// Buscamos um usuário no banco de dados
// Se o usuário não existe:
// - Devolvemos uma mensagem de erro e saímos da função
// Se a senha informada não é a mesma senha armazenada:
// - Devolvemos uma mensagem de erro e saímos da função
// Criamos um novo token para o usuário e armazenamos no banco de dados
// Enviamos o token criado na resposta
}
}
Dica: Utilizaremos métodos
async
toda vez que trabalharmos com Promises para utilizarmosawait
em vez de encadear várias chamadas.then
seguidas umas das outras. Métodosasync
sempre aparecerão quando precisarmos mexer no banco de dados.
Com esse pseudocódigo podemos implementar um código que finge fazer a busca no banco de dados. Desta forma, podemos implementar todas as outras funcionalidades e não precisaremos nos preocupar por enquanto em como estamos buscando essas informações.
src/controller/UserController.js
:
export default class UserController {
static routes(app) {
// Aqui informaremos qual método responderá à rota de login
app.post('/login', UserController.login)
}
static async login(req, res) {
// Recebemos os campos da requisição
const { email, password } = req.body
// Se algum campo obrigatório não foi informado:
if (!email || !password) {
// Devolvemos uma mensagem de erro e saímos da função
return res.status(400).send({
message: 'Os campos "email" e "password" são obrigatórios'
})
}
// Buscamos um usuário no banco de dados
const user = {
authToken: 'abcdef',
password: '123'
}
// Se o usuário não existe:
if (!user) {
// Devolvemos uma mensagem de erro e saímos da função
return res.status(404).send({
message: 'Usuário não encontrado'
})
}
// Se a senha informada não é a mesma senha armazenada:
const passwordsMatch = password === user.password
if (!passwordsMatch) {
// Devolvemos uma mensagem de erro e saímos da função
return res.status(401).send({
message: 'Senha incorreta'
})
}
// Criamos um novo token para o usuário e armazenamos no banco de dados
user.authToken = 'fedcba'
// Enviamos o token criado na resposta
res.status(200).send({
token: user.authToken
})
}
}
Vamos remover os comentários desse arquivo já que estruturamos o nosso código! Agora vamos aplicar o mesmo processo para os outros dois arquivos: Montaremos um pseudocódigo nos comentários para entender qual a lógica seguir e depois escreveremos o código nos guiando pelos comentários! Os outros arquivos ficarão assim:
src/controller/PageController.js
:
export default class PageController {
static routes(app) {
app.get('/paginas/:id', PageController.listar)
app.patch('/paginas/:id', PageController.atualizar)
}
static async listar(req, res) {
const {id} = req.params
const page = {
title: `Página ${id}`,
text: 'Lorem ipsum dor sit amet'
}
if (!page) {
return res.status(404).send({
message: 'Página não encontrada'
})
}
res.status(200).send({
message: 'Sucesso ao buscar página',
data: page
})
}
static async atualizar(req, res) {
const {id} = req.params
const {title, text} = req.body
const page = {
title: 'Título antigo',
text: 'Texto antigo'
}
if (!page) {
return res.status(404).send({
message: 'Página não encontrada'
})
}
if (title) {
page.title = title
}
if (text) {
page.text = text
}
res.status(200).send({
message: 'Sucesso ao alterar dados da página',
data: page
})
}
}
src/controller/ProductController.js
:
export default class ProductController {
static routes(app) {
app.post('/produtos', ProductController.inserir)
app.get('/produtos', ProductController.listarTodos)
app.patch('/produtos/:id', ProductController.atualizar)
app.delete('/produtos/:id', ProductController.deletar)
}
static async inserir(req, res) {
const { title, description } = req.body
if (!title || !description) {
return res.status(400).send({
message: 'Os campos "title" e "description" são obrigatórios'
})
}
const product = { title, description }
res.status(200).send({
message: 'Produto criado com sucesso!',
data: product
})
}
static async listarTodos(req, res) {
const products = [
{
title: 'Produto 1',
description: 'Descrição produto 1'
},
{
title: 'Produto 2',
description: 'Descrição produto 2'
}
]
res.status(200).send({
message: 'Produtos listados com sucesso!',
data: products
})
}
static async atualizar(req, res) {
const {id} = req.params
const product = {
title: `Produto ${id}`,
description: `Descrição produto ${id}`
}
if (!product) {
return res.status(404).send({
message: `O produto de id ${id} não existe`
})
}
const {title, description} = req.body
if (title) {
product.title = title
}
if (description) {
product.description = description
}
res.status(200).send({
message: 'Produto alterado com sucesso!',
data: product
})
}
static async deletar(req, res) {
const {id} = req.params
const product = {
title: `Produto ${id}`,
description: `Descrição produto ${id}`
}
if (!product) {
return res.status(404).send({
message: `O produto de id ${id} não existe`
})
}
res.status(200).send({
message: 'Produto deletado com sucesso!'
})
}
}
Para finalizar, atualize o seu src/app.js
importando a lista de controllers. O seu arquivo ficará assim:
import cors from "cors";
import express from "express";
import UserController from './controller/UserController.js'
import ProductController from './controller/ProductController.js'
import PageController from './controller/PageController.js'
const app = express()
app.use(cors())
app.use(express.json())
UserController.rotas(app)
ProductController.rotas(app)
PageController.rotas(app)
export default app
Desta forma, todas as controllers conseguem configurar suas rotas! Faça o teste das rotas pelo Postman, Insomnia ou alguma outra ferramenta para testar APIs (não se esqueça de iniciar o projeto com npm start
). Exemplos:
O nosso próximo passo é conectar o nosso servidor a um banco de dados. Afinal, queremos que nossas informações sejam mantidas mesmo que a aplicação lance algum erro ou seja reiniciada.
Vamos trabalhar com uma nova camada, as models. Neste projeto elas fazem o trabalho dos DAOs (Data Access Objects) já que elas também vão acessar o banco de dados e nos devolver models criadas. Vamos trazer uma visão mais parecida com a de algumas bibliotecas de back-end que facilitam o relacionamento de modelos de dados com o banco em si. Essas bibliotecas usam a técnica ORM (Object Relational Mapping), que aproveita as vantagens da programação orientação a objetos para mapear objetos de uma determinada linguagem de programação para uma tabela no banco com suas respectivas colunas. A model nesse formato possui métodos de fabricação, busca, deleção e atualização de dados e envolve todas essas funcionalidades em suas classes. Um exemplo de biblioteca famosa de ORM para JavaScript é o Sequelize.
Até o final deste passo 4 você terá duas opções:
- Tomar a liberdade de instalar uma biblioteca e criar as models a partir de sua documentação, ou
- Reproduzirmos do zero um comportamento de ORM e entender como funciona por baixo dos panos algumas bibliotecas que implementam essa técnica
Caso escolha a opção 1, você deve ignorar o restante deste passo 4 todo e usar as models de acordo com a documentação da biblioteca escolhida. Atualize os métodos de criação, leitura, atualização e deleção nas controllers e pule para o passo 5.
Caso escolha a opção 2, continue seguindo este passo 4!
Uma das vantagens de usar ORMs é que eles deixam a maior parte da carga pesada em uma classe geral, a qual será herdada por outras classes que poderão usar seus métodos de forma customizada. O primeiro exemplo que vamos montar é de como encontrar, de acordo com a model que estamos usando, qual o nome da tabela em que guardaremos seus dados.
Dica: Utilizaremos tanto métodos estáticos quanto métodos de instância. Em ambos contextos a palavra chave this significará coisas diferentes. Revisaremos isso abaixo, mas é importante que você se atente a qual tipo de método estaremos usando e por quê.
Como métodos estáticos não pertencem a uma instância em específico, elas não dependem da existência de uma instância para serem executados. No fundo, elas são funções como quaisquer outras, mas organizadas em um contexto diferente. Geralmente métodos estáticos são usados para criar instâncias daquela classe (agem como uma função factory), fazem buscas ou processam algum tipo de dado relacionado àquela classe. Alguns exemplos de métodos estáticos:
const milliseconds = Date.now() // Devolve o número de milissegundos passados a partir do início dos relógios dos computadores (não precisa que uma data exista para ser chamado)
const letter = String.fromCharCode(65) // Cria uma string a partir de um código UTF8 (não precisa que uma string exista para ser chamado)
const number = Math.random() // Devolve um número aleatório entre 0 e 1. Não existem objetos do tipo Math, mas as funções matemáticas são organizadas dentro deste contexto
Alguns exemplos de métodos de instância:
const yelling = 'hello'.toUpperCase() // Devolve a string em letras maiúsculas (precisa que uma string exista para ser chamado)
const today = new Date()
const year = today.getFullYear() // Devolve o ano de uma data (precisa que um objeto do tipo Date exista para ser chamado)
Com essa revisão rápida de métodos estáticos, vamos criar nossa model genérica: Ela representará uma entidade (tabela) no nosso banco de dados. Crie o arquivo src/DAO/ApplicationModel.js
com o conteúdo abaixo:
export default class ApplicationModel {
static getTableName() {
return this.name.toLowerCase()
}
}
Dica: No exemplo acima, a palavra chave this referencia a classe construtora pois estamos em um método estático e não uma instância dessa classe. Desta forma, como classes são do tipo "function", elas possuem a propriedade "name" que permite acessar o nome da classe
Agora, crie as outras 3 models do nosso projeto (página, produto e usuário) nos seguintes arquivos:
src/DAO/Page.js
import ApplicationModel from "./ApplicationModel.js"
export default class Page extends ApplicationModel {
}
src/DAO/Product.js
import ApplicationModel from "./ApplicationModel.js"
export default class Product extends ApplicationModel {
}
src/DAO/User.js
import ApplicationModel from "./ApplicationModel.js"
export default class User extends ApplicationModel {
}
Desta forma, cada model terá um nome diferente para sua tabela!
Page.getTableName() // "page"
Product.getTableName() // "product"
User.getTableName() // "user"
Por que
User.getTableName()
retorna"user"
e não"applicationmodel"
já que o método foi declarado na classeApplicationModel
? É porque estamos tirando vantagem do polimorfismo: uma classe filha pode sobrescrever os comportamentos de uma classe mãe. No JavaScript isso também significa que se uma classe filha chama métodos de uma classe mãe, as chamadas para this vão referenciar a classe filha, pois é ela que está executando os métodos! Desta forma, o método.getTableName()
está sendo executado porUser
e o código acaba sendo traduzido parareturn User.name.toLowerCase()
naquela linha de código. Esse é a base fundamental para os comportamentos que montaremos na nossa model.
Como vimos anteriormente, ORM significa Object Relational Mapping. Isto significa que relacionaremos propriedades das nossas classes para colunas no banco de dados. Isso é muito importante porque às vezes os nomes das colunas nos bancos de dados são diferentes das propriedades na nossa linguagem de programação. Por isso, precisamos criar uma tabela de tradução para saber qual coluna do banco referencia qual propriedade da classe e vice versa. Por exemplo, imagine o seguinte cenário de uma tabela user
e uma classe User
e no passo a passo para traduzir os dados:
Nome da propriedade na classe | Nome da coluna no BD |
---|---|
id | ID |
encryptedPassword | ENCRYPTED_PASSWORD |
authToken | AUTH_TOKEN |
Ao fazer uma busca de um objeto no banco, informaremos qual o campo da classe gostaríamos de pesquisar. Queremos buscar um usuário pelo seu token de autorização, então o passo a passo seria:
- Informar que queremos buscá-lo pela propriedade
authToken
e fornecer seu valor - A classe realizará uma tradução propriedade -> coluna e essa propriedade será traduzida para
AUTH_TOKEN
para iniciar a busca no banco - Os dados serão devolvidos com a nomenclatura de colunas (
ID
,EMAIL
,ENCRYPTED_PASSWORD
eAUTH_TOKEN
) e cada propriedade precisará de uma tradução coluna -> propriedade - Uma instância vazia da model será criada e seus campos serão populados com as informações traduzidas
- A instância preenchida será devolvida para uso
Para poder realizar as traduções precisaremos guardar a tabela de tradução. Para isso, vou utilizar duas estruturas de dados do tipo Map
: uma para traduzir nomes de propriedades para colunas e o outro para guardar o sentido contrário da tradução. Além disso, criaremos um método para associar essas duas informações de uma vez só e um método obrigatório para configurar todas as models:
src/DAO/ApplicationModel.js
export default class ApplicationModel {
static _propertyToColumn = new Map()
static _columnToProperty = new Map()
static configurar() {
throw new Error('Você deve criar sua própria versão de SuaModel.configurar! Dentro dela chame o método "SuaModel.associar" para relacionar as propriedades da model com as colunas do banco!')
}
static associar( property, column ) {
this._propertyToColumn.set(property, column)
this._columnToProperty.set(column, property)
}
static getTableName() {
return this.name.toLowerCase()
}
}
Desta forma, podemos criar as propriedades nas nossas models e associar com as colunas do banco em cada uma das classes:
src/DAO/Page.js
import ApplicationModel from "./ApplicationModel.js"
export default class Page extends ApplicationModel {
id; title; text;
static configurar() {
Page.associar('id', 'ID')
Page.associar('title', 'TITLE')
Page.associar('text', 'TEXT')
}
}
src/DAO/Product.js
import ApplicationModel from "./ApplicationModel.js"
export default class Product extends ApplicationModel {
id; title; description;
static configurar() {
Product.associar('id', 'ID')
Product.associar('title', 'TITLE')
Product.associar('description', 'DESCRIPTION')
}
}
src/DAO/User.js
import ApplicationModel from "./ApplicationModel.js"
export default class User extends ApplicationModel {
id; email; encryptedPassword; authToken;
static configurar() {
User.associar('id', 'ID')
User.associar('email', 'EMAIL')
User.associar('encryptedPassword', 'ENCRYPTED_PASSWORD')
User.associar('authToken', 'AUTH_TOKEN')
}
}
Agora que temos uma tabela de tradução funcional, vamos criar dois métodos bem parecidos para nos auxiliar:
- Traduzir uma model para uma linha do banco de dados:
_toDatabase
static _toDatabase(model) { // Se o modelo não foi informado if (!model) { // Devolvemos nulo return null } // Buscamos todos os nomes de propriedades da model const properties = Object.keys(model) // Criamos uma linha vazia const row = {} // Passamos por cada nome de propriedade for (const property of properties) { // Traduzimos para o nome da coluna const column = this._propertyToColumn.get(property) // Armazenamos o dado da model caso ele exista, senão armazenamos nulo row[column] = model[property] ?? null } // Devolvemos a linha do banco return row }
- Traduzir resultado do banco de dados para uma model:
_toModel
static _toModel(dbResult) { // Se o resultado é vazio ou não informado if (!dbResult) { // Devolvemos nulo return null } // Buscamos todos os nomes de colunas do resultado const columns = Object.keys(dbResult) // Criamos uma instância vazia const instance = new this() // Passamos por cada nome de coluna for (const column of columns) { // Traduzimos para o nome da propriedade const property = this._columnToProperty.get(column) // Armazenamos o dado da coluna caso ele exista, senão armazenamos nulo instance[property] = dbResult[column] ?? null } // Devolvemos a instância preenchida return instance }
OBS: Percebeu que ali em cima executamos um
new this()
? Esse código pode parecer estranho, mas se lembra que a palavra this em um método estático referencia a classe construtora e não uma instância existente? Isso significa que se esse trecho de código for executado pela classeUser
, seria o equivalente a executar umnew User()
; se esse trecho de código for executado pela classePage
, seria o equivalente a executar umnew Page()
e assim por diante! Mas lembre-se que esse comportamento só acontece em métodos estáticos! Fazer isso em um método de instância geraria um erro:Uncaught TypeError: this is not a constructor
!
Nossa model genérica ficará assim:
export default class ApplicationModel {
static _propertyToColumn = new Map()
static _columnToProperty = new Map()
static configurar() {
throw new Error('Você deve criar sua própria versão de SuaModel.configurar! Dentro dela chame o método "SuaModel.associar" para relacionar as propriedades da model com as colunas do banco!')
}
static associar( property, column ) {
this._propertyToColumn.set(property, column)
this._columnToProperty.set(column, property)
}
static getTableName() {
return this.name.toLowerCase()
}
static _toModel(dbResult) {
if (!dbResult) {
return null
}
const columns = Object.keys(dbResult)
const instance = new this()
for (const column of columns) {
const property = this._columnToProperty.get(column)
instance[property] = dbResult[column] ?? null
}
return instance
}
static _toDatabase(model) {
if (!model) {
return null
}
const properties = Object.keys(model)
const row = {}
for (const property of properties) {
const column = this._propertyToColumn.get(property)
row[column] = model[property] ?? null
}
return row
}
}
Se você colocar temporariamente esse trecho de código no final do seu arquivo src/DAO/User.js
para testar as configurações, verá que nossa tradução está funcionando!
User.configurar()
const usr = new User()
usr.email = "salve@com.br"
console.log( User._toDatabase(usr) )
// { ID: null, EMAIL: 'salve@com.br', ENCRYPTED_PASSWORD: null, AUTH_TOKEN: null }
console.log( User._toModel({
ID: 3,
EMAIL: 'salve@com',
AUTH_TOKEN: 'eita',
ENCRYPTED_PASSWORD: 'jooj'
}) )
// User { id: 3, email: 'salve@com', encryptedPassword: 'jooj', authToken: 'eita' }
Por último, importe as models no seu src/app.js
e, para todas as models, execute o comando .configurar()
. Seu arquivo ficará assim:
import cors from "cors";
import express from "express";
import UserController from './controller/UserController.js'
import ProductController from './controller/ProductController.js'
import PageController from './controller/PageController.js'
import PageDAO from './DAO/Page.js'
import ProductDAO from './DAO/Product.js'
import UserDAO from './DAO/User.js'
const app = express()
app.use(cors())
app.use(express.json())
PageDAO.configurar()
ProductDAO.configurar()
UserDAO.configurar()
UserController.rotas(app)
ProductController.rotas(app)
PageController.rotas(app)
export default app
Até agora vimos como descobrir qual o nome da tabela da nossa model e qual a tradução dos seus campos para colunas do banco, mas ainda não fizemos nenhuma conexão com ele! Vamos criar um arquivo src/infra/connection.js
para poder criar conexões com o banco e realizar consultas:
import sqlite3 from "sqlite3"
import { open } from "sqlite"
export const getConnection = () => open({
filename: './db.sqlite',
driver: sqlite3.verbose().Database
})
Esse código abrirá uma conexão com um banco sqlite3 no arquivo raiz db.sqlite
. Não se preocupe, se o arquivo não existir ele será criado automaticamente.
Geralmente quando iniciamos um projeto queremos pelo menos alguns dados populados para a gente. Nem sempre queremos limpar as linhas de uma tabela ou apagar completamente o banco de dados. Por isso criaremos alguns scripts para executar alguns comandos auxiliares em momentos necessários:
clear
: Limpa os dados das tabelas mas as mantém. Útil antes de executar umseed
.drop
: Deleta todas as tabelas com todos os dados dentro. Útil quando a tabela mudou de formato (ganhou/perdeu colunas ou um tipo de dado foi alterado).migrate
: Cria todas as tabelas do banco de dados, sem nenhuma linha preenchida. Útil após umdrop
ou da primeira vez subindo seu banco de dados.seed
: Popula linhas de tabelas. Útil antes de executar o seu projeto.
Vamos aproveitar a nossa model genérica e criar os três primeiros métodos (o seed deixaremos para depois):
src/DAO/ApplicationModel.js
// Fora da classe...
import { getConnection } from "../database/connection.js"
// Dentro da classe...
// ...
static async _clear() {
const connection = await getConnection()
await connection.exec(`DELETE FROM ${this.getTableName()};`)
await connection.close()
}
static async _drop() {
const connection = await getConnection()
await connection.exec(`DROP TABLE IF EXISTS ${this.getTableName()};`)
await connection.close()
}
static async _migrate(columnsConfig) {
const connection = await getConnection()
await connection.exec(`CREATE TABLE IF NOT EXISTS ${this.getTableName()} (${columnsConfig.join(',')});`)
await connection.close()
}
// ...
Vamos criar três arquivos em uma nova pasta: scripts/clear.js
, scripts/drop.js
e scripts/migrate.js
:
scripts/clear.js
import Page from "../src/DAO/Page.js"
import Product from "../src/DAO/Product.js"
import User from "../src/DAO/User.js"
const models = [
Page, Product, User
]
const clear = async () => {
await Promise.all(models.map(model => model._clear()))
}
clear()
scripts/drop.js
import Page from "../src/DAO/Page.js"
import Product from "../src/DAO/Product.js"
import User from "../src/DAO/User.js"
const models = [
Page, Product, User
]
const drop = async () => {
await Promise.all(models.map(model => model._drop()))
}
drop()
scripts/migrate.js
import Page from "../src/DAO/Page.js"
import Product from "../src/DAO/Product.js"
import User from "../src/DAO/User.js"
const migrate = async () => {
await Page._migrate([
'"ID" INTEGER PRIMARY KEY NOT NULL',
'"TITLE" TEXT NOT NULL',
'"TEXT" TEXT NOT NULL'
])
await Product._migrate([
'"ID" INTEGER PRIMARY KEY NOT NULL',
'"TITLE" TEXT NOT NULL',
'"DESCRIPTION" TEXT NOT NULL'
])
await User._migrate([
'"ID" INTEGER PRIMARY KEY NOT NULL',
'"EMAIL" TEXT NOT NULL',
'"ENCRYPTED_PASSWORD" TEXT NOT NULL',
'"AUTH_TOKEN" TEXT'
])
}
migrate()
Agora, no seu package.json
adicione os seguintes dados dentro do campo "scripts"
:
"clear": "node scripts/clear.js",
"drop": "node scripts/drop.js",
"migrate": "node scripts/migrate.js",
Pronto! Agora é só rodar
npm run drop
para apagar tudo: quando a estrutura das suas tabelas mudaremnpm run migrate
primeira vez executando ou após um drop para criar as tabelasnpm run clear
para limpar os dados do banco e iniciar com o banco novinho
Recomendo instalar a extensão SQLite do VSCode para explorar as tabelas criadas. Após executar npm run migrate
, clique com o botão direito do mouse em cima do arquivo db.sqlite
e clique em Open Database
. O VSCode abrirá o SQLITE EXPLORER
e você poderá verificar que as suas tabelas estão com as colunas configuradas corretamente.
Já que nós temos uma estrutura de tabelas montadas e podemos visualizar esses dados nas tabelas, vamos aprender a criar, atualizar e deletar informações com as nossas models. Cada instância (objeto criado a partir de uma classe) de cada model representará uma e somente uma linha do banco de uma tabela.
A ideia é que a gente consiga realizar esse tipo de operação de uma forma simples:
const about = new Page()
about.title = 'Sobre'
about.text = 'Um site muito maneiro'
await about.save() // Salvaria no banco uma nova linha
const products = /* Busca no banco todos os produtos de algum jeito */
products[0].description = 'Descrição muito boa!'
await products[0].save() // Atualizaria uma linha do banco
await products[1].delete() // Removeria uma linha do banco
Perceba que o save
possui duas funcionalidades: criar um dado e atualizar um dado. Isso se dá porque quando criamos uma nova instância diretamente no nosso código (por exemplo, new Page()
), não a criamos diretamente no banco. No geral, ela não tem um identificador e precisa ser armazenada no banco para que ganhe um identificador único.
Já a atualização é feita quando essa model foi criada dentro de uma função de busca: Quando fazemos esse pedido para o banco ele traduz os dados das tabelas e cria uma ou mais instâncias na hora com a identificação e os dados das colunas (se lembra do new this()
lá em cima?) encontradas, depois ele devolve essas instâncias para utilizarmos seus dados.
De uma forma ou de outra, precisamos de pelo menos uma informação que vai diferenciar um dado de outro: uma chave primária! Para não aumentarmos mais ainda a complexidade, vamos assumir que todas as nossas models usam id como chave primária (no banco pode ser qualquer outra coisa, por exemplo pk_cpf
, desde que faça a associação com o id
na model depois). Vamos criar esse campo das instâncias e o método save
para as instâncias também:
src/DAO/ApplicationModel.js
//...
id;
async save() {
if (this.id) {
// Atualiza linha já que possui identificador único definido
} else {
// Cria linha no banco e atualiza o objeto no código com o novo identificador único criado na hora da inserção
}
}
//...
OBS: Não estamos mais trabalhando com métodos estáticos! Agora o this está se referindo a uma instância da classe criada com
new
!
Vamos implementar as funcionalidades do método save
e entender o que está acontecendo:
src/DAO/ApplicationModel.js
//...
id;
async save() {
// Busca o nome da tabela
const table = this.constructor.getTableName()
// Busca a tabela de tradução de propriedade para coluna
const propToCol = this.constructor._propertyToColumn
// Se transforma em um objeto traduzido para colunas do banco de dados
const dbObj = this.constructor._toDatabase(this)
// Guarda o nome das colunas do banco
const columns = Object.keys(dbObj)
// Guarda os valores que serão inseridos nas colunas
const values = Object.values(dbObj)
const connection = await getConnection()
// Possui id: atualizar
if (this.id) {
// Gera a query no formato do UPDATE
const updates = columns.map(column => `${column}=?`)
// Executa um update na tabela, informa quais colunas que serão modificadas, seus valores e qual linha será afetada
await connection.run(
`UPDATE ${table} SET ${updates} WHERE ${propToCol.get('id')} = ?;`,
...values,
this.id
)
// Não possui id: inserir
} else {
// Busca o último id da inserção executada informando o nome das colunas e os valores inseridos
const { lastID } = await connection.run(
`INSERT INTO ${table} (${columns}) VALUES (${values.map(_ => '?').join(',')});`,
...values
)
// Atualiza o objeto do código para refletir as alterações do banco de dados
this.id = lastID
}
// Finaliza a conexão
await connection.close()
}
//...
Perceba que para acessar o método estático
.getTableName()
useithis.constructor.getTableName()
. Fiz isso pois não estamos mais em um método estático e sim de instância! Para acessar um campo estático em uma instância deUser
, por exemplo, precisaríamos saber qual é a sua própria classe. A classe construtora está disponível em métodos de instância no campothis.constructor
de qualquer objeto do JavaScript.
Para testar que este método está funcionando, vamos criar o nosso último script auxiliar: seed
!
Na src/DAO/ApplicationModel.js
, adicione junto aos outros métodos auxiliares:
// ...
static async _seed(models) {
for ( const model of models ) {
await model.save()
}
}
// ...
Desta forma só precisamos informar um array de instâncias que todas elas serão criadas e inseridas no banco!
Crie um arquivo scripts/seed.js
e coloque o seguinte conteúdo:
import Page from "../src/DAO/Page.js"
import Product from "../src/DAO/Product.js"
import User from "../src/DAO/User.js"
const models = [
Page, Product, User
]
const seed = async () => {
// Precisamos configurar as models antes das inserções para ter acesso à tabela de tradução
models.forEach(model => model.configurar())
const page = new Page()
page.title = 'Sobre'
page.text = 'Lorem ipsum dolor sit amet.'
const pages = [page]
const products = []
for (let i=1; i<=10; i++) {
const prod = new Product()
prod.title = `Produto ${i}`
prod.description = `Descrição do produto ${i}`
products.push(prod)
}
const admin = new User()
admin.email = "admin@case2.com"
admin.encryptedPassword = '12345678'
const users = [admin]
await Page._seed(pages)
await Product._seed(products)
await User._seed(users)
}
seed()
Agora, adicione no campo "script"
do seu package.json
mais uma propriedade:
"seed": "node scripts/seed.js"
Pronto! Agora só executar npm run seed
e ver que os dados foram populados nas tabelas!
Não temos acesso a todas as letras do CRUD, por enquanto só temos o C (create): Não conseguimos realizar leituras porque ainda não temos nenhum método para listar dados, nem conseguimos atualizar ou deletar pois precisaríamos de informações de pesquisa (listagem). Porém, já conseguimos integrar uma rota completamente! Vamos criar e apagar alguns dados e ver as mudanças no explorer!
Vamos alterar o método inserir
do arquivo src/controller/ProductController.js
:
static async inserir(req, res) {
const { title, description } = req.body
if (!title || !description) {
return res.status(400).send({
message: 'Os campos "title" e "description" são obrigatórios'
})
}
const product = new Product()
product.title = title
product.description = description
await product.save()
res.status(200).send({
message: 'Produto criado com sucesso!',
data: product
})
}
Vamos executar um npm run clear
e um npm run seed
para garantirmos um ambiente inicial de 10 produtos. Depois disso, vamos executar um POST para a rota de criação de produtos:
E agora no explorer:
Maravilha! Agora vamos ver como podemos buscar os dados com as nossas models!
Uma das principais funcionalidades de busca é a listagem completa. Geralmente gostaríamos de devolver todos os dados, sem nenhum filtro. Às vezes, gostaríamos de encontrar somente uma linha de uma tabela em específico, buscando por valores exatos. Nós vamos implementar dois métodos, o findAll
e o findByProperty
que fazem exatamente o que foi citado acima.
//...
static async findAll() {
const connection = await getConnection()
const all = await connection.all(
`SELECT * FROM ${this.getTableName()}`
)
await connection.close()
// Importante traduzir os resultados do banco para as models que podemos usar
return all.map( result => this._toModel(result) )
}
static async findByProperty(property, value) {
const connection = await getConnection()
// Traduz o nome da propriedade para o nome da coluna
const column = this._propertyToColumn.get(property)
const result = await connection.get(
`SELECT * FROM ${this.getTableName()} WHERE ${column} = ?`,
value
)
await connection.close()
// Traduz de volta o resultado para uma model
return this._toModel(result)
}
//...
Com estes dois últimos métodos conseguimos implementar todos os métodos que faltavam!
src/controller/UserController.js
// Fora da classe...
import User from "../DAO/User.js"
// Dentro da classe...
//...
static async login(req, res) {
const { email, password } = req.body
if (!email || !password) {
return res.status(400).send({
message: 'Os campos "email" e "password" são obrigatórios'
})
}
const user = await User.findByProperty('email', email)
if (!user) {
return res.status(404).send({
message: 'Usuário não encontrado'
})
}
const passwordsMatch = password === user.encryptedPassword
if (!passwordsMatch) {
return res.status(401).send({
message: 'Senha incorreta'
})
}
user.authToken = 'fedcba'
await user.save()
res.status(200).send({
token: user.authToken
})
}
//...
src/controller/PageController.js
// Fora da classe...
import Page from "../DAO/Page.js"
// Dentro da classe...
//...
static async listar(req, res) {
const {id} = req.params
const page = await Page.findByProperty('id', id)
if (!page) {
return res.status(404).send({
message: 'Página não encontrada'
})
}
res.status(200).send({
message: 'Sucesso ao buscar página',
data: page
})
}
static async atualizar(req, res) {
const {id} = req.params
const {title, text} = req.body
const page = await Page.findByProperty('id', id)
if (!page) {
return res.status(404).send({
message: 'Página não encontrada'
})
}
if (title) {
page.title = title
}
if (text) {
page.text = text
}
await page.save()
res.status(200).send({
message: 'Sucesso ao alterar dados da página',
data: page
})
}
//...
src/controller/ProductController.js
// Fora da classe...
import Product from "../DAO/Product.js"
// Dentro da classe...
//...
static async listarTodos(req, res) {
const products = await Product.findAll()
res.status(200).send({
message: 'Produtos listados com sucesso!',
data: products
})
}
static async atualizar(req, res) {
const {id} = req.params
const product = await Product.findByProperty('id', id)
if (!product) {
return res.status(404).send({
message: `O produto de id ${id} não existe`
})
}
const {title, description} = req.body
if (title) {
product.title = title
}
if (description) {
product.description = description
}
await product.save()
res.status(200).send({
message: 'Produto alterado com sucesso!',
data: product
})
}
static async deletar(req, res) {
const {id} = req.params
const product = await Product.findByProperty('id', id)
if (!product) {
return res.status(404).send({
message: `O produto de id ${id} não existe`
})
}
await product.delete()
res.status(200).send({
message: 'Produto deletado com sucesso!'
})
}
//...
Teste todas as rotas! Agora todas elas funcionam!
Uma parte importantíssima na hora de montar as aplicações é a restrição de acesso. Nem sempre gostaríamos que todos tivessem acesso a todas as funcionalidades. Por exemplo, um desconhecido pode entrar no nosso site e apagar todos os produtos! Para isso, precisamos de um sistema de autenticação.
Este sistema será simples, pois teremos dois tipos de rotas:
- Abertas
- Protegidas
As rotas abertas aceitam o pedido de qualquer usuário. Neste exemplo, as nossas rotas abertas serão a de busca de informação de página, listagem de produtos e tentativa de login.
As rotas protegidas só serão liberadas se você possuir um "crachá" te identificando. Você só conseguirá obter este "crachá" se conseguir realizar um login com sucesso na plataforma. Em sistemas web chamamos este "crachá virtual" de token de autorização.
O token de autorização só será enviado para o servidor em uma parte específica do nosso pedido chamada cabeçalho (servirá como se fosse uma assinatura do usuário, ou um crachá virtual). Ao chegar no servidor, caso a rota seja protegida, o pedido irá procurar essa credencial e verificar no banco se ela existe. Caso ela não exista, não permitiremos o acesso à aplicação.
Para isso, vamos criar uma middleware. Uma middleware é uma função que executa antes ou depois do código da controller para tratar o pedido de alguma forma. Ela aceita a request atual, o objeto da response e um parâmetro extra: next! Este parâmetro será a próxima função que será executada para esta rota. Ou seja, se quisermos seguir o processamento do pedido na controller executaremos a função next()
e se quisermos rejeitar o pedido saímos da função e mandamos uma resposta de erro.
Crie o arquivo src/middleware/authorization.js
e coloque o seguinte:
import User from "../DAO/User.js"
export const verificarToken = async (req, res, next) => {
const token = req.headers['x-auth-token']
if (!token) {
res.status(401).send({
success: false,
message: 'Token não informado!'
})
return
}
const user = await User.findByProperty('authToken', token)
if (!user) {
res.status(401).send({
success: false,
message: 'Não autorizado!'
})
return
}
next()
}
O código acima faz exatamente o que falamos anteriormente: Se um token não for informado no cabeçalho ou se o token não pertencer a nenhum usuário, rejeitamos o pedido. Caso contrário, continuamos o processamento!
Agora atualize as controllers para usar essa middleware em rotas protegidas:
src/controller/PageController.js
// Fora da classe...
import { verificarToken } from "../middleware/authorization.js"
// Dentro da classe...
// ...
app.get('/paginas/:id', PageController.listar) // Aberta
app.patch('/paginas/:id', verificarToken, PageController.atualizar) // Protegida
// ...
src/controller/ProductController.js
// Fora da classe...
import { verificarToken } from "../middleware/authorization.js"
// Dentro da classe...
// ...
app.post('/produtos', verificarToken, ProductController.create) // Protegida
app.get('/produtos', ProductController.listarTodos) // Aberta
app.patch('/produtos/:id', verificarToken, ProductController.atualizar) // Protegida
app.delete('/produtos/:id', verificarToken, ProductController.deletar) // Protegida
// ...
Pronto! Agora você não conseguirá acessar essas rotas sem informar o token de acesso!
Outra coisa importantíssima quando pensamos na segurança da nossa aplicação, além de restringir acesso, é como armazenamos informações sensíveis. Uma delas é a senha, um dado que se for vazado pode gerar muitos problemas. Por isso vamos fazer um processo de hashing com a senha do usuário: Vamos jogá-la em um liquidificador e transformar em um dado que não pode ser revertido à senha original. Em compensação, se quisermos comparar duas senhas, precisaremos também jogar essa senha no liquidificador e ver se o resultado processado é o mesmo. Isso reduz bastante a chance de vazamento de senhas, pois quem tentar descobrir uma senha "liquidificada" precisaria tentar milhões de combinações sem chegar a nenhum resultado.
A versão de "liquidificador" (hashing) que vamos usar é a bcrypt
. Ele fornece funções de encriptação e de comparação de valores de forma segura.
No seed vamos armazenar a senha de usuário de forma processada:
scripts/seed.js
// Fora da função...
import { hashSync } from "bcrypt"
// Dentro da função...
// ...
admin.email = "admin@case2.com"
admin.encryptedPassword = hashSync('12345678', 10)
const users = [admin]
// ...
Isso fará com que a senha do usuário seja transformada de '12345678' para um valor liquidificado 10 vezes para garantir a irreversibilidade da informação.
Se você rodar npm run clear
e npm run seed
verá que a senha agora está ilegível
Agora, precisamos fazer essa comparação de senhas no login. Por sorte, a biblioteca fornece uma função para fazer exatamente isso:
src/controller/UserController.js
// Fora da classe...
import { compareSync } from "bcrypt"
// ...
// Dentro da classe...
// ...
const passwordsMatch = compareSync(password, user.encryptedPassword)
if (!passwordsMatch) {
// ...
Para finalizar o projeto, não podemos deixar um token tão simples como esse. A ideia do token é criar um crachá único para cada usuário e atualmente todos os usuários teriam o mesmo token cadastrado ('fedcba'). Vamos utilizar uma função do próprio JavaScript que gera um identificador único para o nosso token:
src/controller/UserController.js
// Fora da classe...
// ...
import { randomUUID } from "crypto"
// ...
// Dentro da classe...
// ...
user.authToken = randomUUID()
await user.save()
// ...
Maravilha, agora seu projeto está finalizado 😉! Espero que tenha aprendido algo novo!