Apostila de arquivo único — Nível 3 — Frameworks Modernos. Uma aula por vez; use j/k ou o menu lateral. Voltar ao índice.
Aula 01 — Apresentação da disciplina e revisão de JavaScript
🎯 Objetivos de aprendizagem¶
Ao final desta aula você será capaz de:
- Descrever a estrutura da disciplina: unidades, cronograma, avaliações e regras de aprovação.
- Configurar o ambiente de desenvolvimento (Node.js 22 LTS, VS Code, Git) e validar a instalação.
- Explicar por que frameworks front-end existem, comparando manipulação manual do DOM com um paradigma declarativo.
- Escrever e ler código JavaScript moderno (ES2015+):
let/const, arrow functions, desestruturação, spread/rest, template literals, optional chaining e nullish coalescing. - Transformar coleções de dados com
map,filter,reduce,find,some,everyesortsem mutar o array original. - Organizar código em módulos ES (
import/export) e em classes. - Consumir uma API pública com
fetchusandoasync/awaite tratamento de erro comtry/catch. - Definir o tema do seu projeto autoral e modelar as entidades iniciais em um
README.md.
📋 Pré-requisitos desta aula¶
Esta é a primeira aula — não há pré-requisito de conteúdo da disciplina. Você precisa apenas de:
- Um notebook capaz de rodar Node.js 22 e o VS Code (Windows, Linux ou macOS).
- Conta no GitHub criada antes da aula.
- Conhecimento prévio de lógica de programação e alguma exposição a HTML/CSS/JS (pré-requisito formal: FACET-SNP-307).
⚠️ Atenção Se você nunca escreveu uma linha de JavaScript, não entre em pânico — a Seção 3 desta aula é uma revisão completa. Mas reserve um tempo extra para os laboratórios em casa.
🗺️ Roteiro¶
| Bloco | Tempo | Atividade |
|---|---|---|
| 1 | 50 min | Apresentação da disciplina, cronograma, avaliações, instalação do ambiente |
| 2 | 50 min | Por que frameworks existem: DOM manual vs. paradigma declarativo |
| 3 | 50 min | Revisão de JavaScript moderno (ES2015+) com foco no domínio de dados do curso |
1. Apresentação da disciplina¶
1.1 Quem sou eu e como essa disciplina funciona¶
Meu nome é Ivan Luiz Pedroso Pires, professor da FACET/UNEMAT — Campus Sinop. Esta disciplina, FACET-SNP-310 — Frameworks Modernos para Desenvolvimento de Sistemas, tem carga horária de 60 horas: 45h de aulas síncronas presenciais (15 encontros de 3 aulas de 50 minutos, às noites, horário 4N234) e 15h de atividades assíncronas — uma hora de trabalho fora de sala a cada encontro.
A ementa oficial do curso é:
"Desenvolvimento com uso de frameworks; padrões: criacionais, estruturais e comportamentais; aplicação conjunta das abordagens de frameworks e componentes no desenvolvimento de software."
Isso significa duas coisas na prática: (1) você vai aprender a construir aplicações reais com um framework front-end moderno e depois integrá-las a um back-end; (2) ao longo do caminho, vamos identificar padrões de projeto clássicos (GoF) escondidos dentro das ferramentas que usamos — Vue, Vuetify, Pinia, Express — porque a ementa exige isso explicitamente, e porque entender o padrão por trás da ferramenta é o que separa quem usa framework de quem entende framework.
1.2 As três unidades¶
| Unidade | Conteúdo | Aulas |
|---|---|---|
| U1 | Fundamentos de front-end com Vue.js | 01–04 |
| U2 | Vue.js avançado: Vuetify, Axios, Vue Router, Pinia | 04–06 |
| U3 | Integração front-end/back-end: Firebase, Supabase, Express, autenticação, banco de dados, deploy | 07–15 |
1.3 Cronograma completo¶
Guarde esta tabela — ela é o mapa do semestre inteiro.
| # | Data | Unidade | Tema |
|---|---|---|---|
| 01 | 12/08/2026 | U1 | Apresentação da disciplina e revisão de JavaScript |
| 02 | 19/08/2026 | U1 | Introdução ao Vue: lifecycle, instância, data e methods, diretivas básicas |
| 03 | 26/08/2026 | U1 | Vue: v-if, v-else, v-for, computed e onMounted |
| 04 | 02/09/2026 | U1 | Introdução a Vuetify e Vue Router — entrega da Avaliação 1 |
| 05 | 16/09/2026 | U2 | Componentes, Vue Router e Vuetify |
| 06 | 23/09/2026 | U2 | Axios e Pinia |
| 07 | 30/09/2026 | U3 | Firebase, Node.js e Express |
| 08 | 07/10/2026 | U3 | Endpoints e middlewares — entrega da Avaliação 2 |
| 09 | 21/10/2026 | U3 | Integração com MySQL |
| 10 | 28/10/2026 | U3 | Requisições autenticadas com Firebase |
| 11 | 11/11/2026 | U3 | CRUD front-end + back-end |
| 12 | 18/11/2026 | U3 | CRUD com banco de dados em nuvem (Supabase) |
| 13 | 25/11/2026 | U3 | Desenvolvimento do back-end em camadas |
| 14 | 09/12/2026 | U3 | Documentação com Swagger |
| 15 | 16/12/2026 | U3 | Apresentação dos resultados — entrega da Avaliação 3 |
Todas as aulas 01 a 15 constroem, em sala, uma aplicação de referência chamada UniEventos — uma plataforma de divulgação e inscrição em eventos acadêmicos. Você vai acompanhar essa construção passo a passo, mas seu projeto autoral (o que você entrega nas avaliações) terá a mesma arquitetura aplicada a um domínio diferente, escolhido por você. Falamos disso na Seção 1.6.
1.4 Avaliações¶
A nota final é a média aritmética simples das três avaliações.
| Avaliação | Escopo | Entrega |
|---|---|---|
| Avaliação 1 | Vue 3 com CLI: estrutura de projeto, componentes, diretivas | até 02/09/2026, 23h59 |
| Avaliação 2 | Vue avançado: Vuetify + Axios + Vue Router + Pinia | até 07/10/2026, 23h59 |
| Avaliação 3 | Back-end: Firebase/Express/Supabase, banco de dados, autenticação | até 16/12/2026, 23h59 |
Todas as entregas são feitas via SIGAA, dentro do prazo. As instruções detalhadas de cada avaliação (escopo exato, rubrica, formato de entrega) serão publicadas na aula correspondente — a Avaliação 1 será detalhada na Aula 04.
📌 Na prova Existe também um exame final: prova teórica, presencial e individual, cobrindo as três unidades. Ele é aplicado a quem não atinge a média mínima de aprovação direta pelas três avaliações, conforme o regimento da UNEMAT. Estude a teoria, não só a prática — o exame não é sobre "rodar o código", é sobre entender os conceitos.
1.5 Frequência e comunicação¶
A frequência é obrigatória e verificada em cada um dos 15 encontros presenciais — o mínimo de 75% de presença é exigência regimental para aprovação, independente da nota. Encontros faltados não podem ser compensados só com a atividade assíncrona: a atividade assíncrona é conteúdo adicional, não substituto de presença.
Canais oficiais de comunicação:
- SIGAA — avisos oficiais, entrega de avaliações, notas.
- E-mail institucional — dúvidas individuais e assuntos administrativos.
- GitHub — cada estudante mantém um repositório público do projeto autoral; é ali que o professor acompanha o progresso semana a semana.
💡 Dica Comite no seu repositório do projeto autoral toda semana, mesmo que pouco. Um histórico de commits ao longo do semestre vale mais, na avaliação de processo, do que um único commit gigante na véspera da entrega.
1.6 O projeto autoral¶
Cada estudante escolhe, ainda nesta primeira semana, um domínio de aplicação diferente do UniEventos (o projeto que construiremos em sala), mas segue exatamente a mesma arquitetura técnica: Vue 3 → Vuetify + Vue Router → Axios + Pinia → back-end Express → banco de dados → autenticação → deploy.
Exemplos de temas válidos:
- Catálogo de plantas do Pantanal, com filtro por bioma e época de floração.
- Agenda de quadras esportivas do bairro, com reserva de horário.
- Mural de estágios e vagas para estudantes da FACET.
- Brechó colaborativo, com peças, categorias e reserva.
- Controle de pescarias, com espécies, rio e datas.
- Cardápio digital de um restaurante, com categorias de prato e pedidos.
Regras para o tema:
- Precisa ter pelo menos duas entidades relacionadas (ex.: "Evento" e "Inscrição", "Quadra" e "Reserva") — um cadastro único sem relacionamento não sustenta as três unidades.
- Precisa ter uma tela de listagem com filtro, uma tela de detalhe e uma área que exija autenticação — isso espelha as telas do UniEventos (Home, Detalhe, Minhas inscrições, Login, Área administrativa).
- Não pode ser o próprio UniEventos copiado — o domínio precisa ser outro.
1.7 Ambiente de desenvolvimento¶
Vamos instalar, nesta aula, tudo que será usado até o fim do semestre. As versões abaixo foram testadas em 12/08/2026 no ambiente real da disciplina — use exatamente estas.
| Ferramenta | Versão usada na disciplina |
|---|---|
| Node.js | 22.22.2 LTS |
| npm | 10.9.7 (vem com o Node) |
| VS Code | versão estável mais recente |
| Git | versão estável mais recente |
Passo a passo:
- Node.js 22 LTS. Baixe em nodejs.org a versão "LTS" (não a "Current"). No Linux, você também pode usar o gerenciador de versões
nvm:
# instalar nvm (se ainda não tiver)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
# instalar e usar o Node 22 LTS
nvm install 22
nvm use 22
- Verifique a instalação:
node -v
# esperado: v22.22.2 (ou outra 22.x LTS)
npm -v
# esperado: 10.9.7 (ou próxima)
⚠️ Atenção Se
node -vmostrar uma versão 16, 18 ou 20, desinstale-a ou troque com onvmantes de continuar. Ferramentas que usaremos mais à frente, como ocreate-vue, exigem Node^22.18.0ou>=24.12.0— versões antigas simplesmente falham na instalação.
- VS Code. Baixe em code.visualstudio.com. Instale estas extensões (aba Extensions,
Ctrl+Shift+X):
- Vue - Official (antigo Volar) — suporte a arquivos
.vue, autocomplete, checagem de tipos no template. - ESLint — aponta erros e más práticas enquanto você digita.
- Prettier - Code formatter — formatação automática e consistente.
-
Navegador com DevTools. Use Chrome, Edge ou Firefox — qualquer um com um bom painel de DevTools (
F12). Vamos usar a aba Elements (inspecionar DOM), Console e Network (ver requisições) o semestre inteiro. -
Git e GitHub.
git --version
# se não tiver, instale: sudo apt install git (Linux) ou baixe em git-scm.com
git config --global user.name "Seu Nome"
git config --global user.email "seu-email@exemplo.com"
Crie uma conta em github.com se ainda não tiver. Vamos usar o GitHub para hospedar o código do projeto autoral e, mais adiante no semestre, para o deploy.
💡 Dica Depois de instalar tudo, rode
node -v,npm -vegit --versione tire um print. Cole no seu README como evidência de ambiente pronto — é o primeiro item do checkpoint desta aula.
2. Por que frameworks existem¶
2.1 Um problema concreto: lista de eventos com filtro¶
Imagine que você precisa mostrar uma lista de eventos acadêmicos na tela, com um campo de busca por texto e um filtro por categoria. Sem framework nenhum, em JavaScript puro manipulando o DOM diretamente, o código fica assim:
<!-- index.html -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<title>Eventos — DOM manual</title>
</head>
<body>
<input id="busca" type="text" placeholder="Buscar evento..." />
<select id="categoria">
<option value="">Todas as categorias</option>
<option value="palestra">Palestra</option>
<option value="minicurso">Minicurso</option>
<option value="workshop">Workshop</option>
</select>
<ul id="lista-eventos"></ul>
<script src="app-dom-manual.js"></script>
</body>
</html>
// app-dom-manual.js
const eventos = [
{ id: 1, titulo: 'Semana da Computação', categoria: 'palestra', vagas: 40 },
{ id: 2, titulo: 'Oficina de Vue.js', categoria: 'minicurso', vagas: 25 },
{ id: 3, titulo: 'Hackathon FACET', categoria: 'workshop', vagas: 60 },
{ id: 4, titulo: 'Introdução a IA', categoria: 'palestra', vagas: 80 },
]
const inputBusca = document.getElementById('busca')
const selectCategoria = document.getElementById('categoria')
const listaEl = document.getElementById('lista-eventos')
// Função que decide QUAIS eventos mostrar e depois
// PRECISA, na mão, apagar o DOM antigo e reconstruir tudo.
function renderizar() {
const termo = inputBusca.value.toLowerCase()
const categoria = selectCategoria.value
const filtrados = eventos.filter((evento) => {
const bateTexto = evento.titulo.toLowerCase().includes(termo)
const bateCategoria = categoria === '' || evento.categoria === categoria
return bateTexto && bateCategoria
})
// Passo manual 1: limpar o que já estava na tela.
listaEl.innerHTML = ''
// Passo manual 2: recriar cada item, um por um.
if (filtrados.length === 0) {
const li = document.createElement('li')
li.textContent = 'Nenhum evento encontrado.'
listaEl.appendChild(li)
return
}
filtrados.forEach((evento) => {
const li = document.createElement('li')
li.textContent = `${evento.titulo} (${evento.categoria}) — ${evento.vagas} vagas`
listaEl.appendChild(li)
})
}
// Passo manual 3: escutar cada evento de interação e chamar renderizar() de novo.
inputBusca.addEventListener('input', renderizar)
selectCategoria.addEventListener('change', renderizar)
renderizar()
Funciona. Mas repare no que você, programador, teve que fazer manualmente:
- Escutar cada evento de UI (
input,change) e lembrar de chamarrenderizar(). - Dentro de
renderizar(), apagar o HTML antigo (innerHTML = '') e reconstruir do zero. - Manter sincronizado, na sua cabeça, o estado (
eventos, o termo de busca, a categoria) com o que está na tela.
Em uma lista de 4 eventos isso é trivial. Em uma aplicação real — com dezenas de componentes, cada um reagindo a mudanças de estado de outros — esse sincronismo manual vira a maior fonte de bugs: tela desatualizada, elementos duplicados, listeners que vazam memória.
2.2 A mesma ideia, em paradigma declarativo¶
Compare com o que faremos a partir da Aula 02, em Vue:
<!-- Antecipação — ainda NÃO é o que vamos escrever hoje, é só para criar contraste -->
<template>
<input v-model="busca" type="text" placeholder="Buscar evento..." />
<select v-model="categoria">
<option value="">Todas as categorias</option>
<option value="palestra">Palestra</option>
<option value="minicurso">Minicurso</option>
<option value="workshop">Workshop</option>
</select>
<ul>
<li v-for="evento in eventosFiltrados" :key="evento.id">
{{ evento.titulo }} ({{ evento.categoria }}) — {{ evento.vagas }} vagas
</li>
<li v-if="eventosFiltrados.length === 0">Nenhum evento encontrado.</li>
</ul>
</template>
Note a diferença de raciocínio: no código Vue você descreve o resultado desejado ("a lista deve mostrar eventosFiltrados") e o framework decide, sozinho, quando e como atualizar o DOM. Você não escreve innerHTML = '', não escreve addEventListener, não gerencia manualmente qual elemento criar ou remover. Isso é programação declarativa: você declara o "o quê", o framework resolve o "como".
Essa é a promessa central de um framework front-end reativo como o Vue — e é o fio condutor do semestre inteiro. Ainda não vamos escrever Vue hoje (isso começa na Aula 02); hoje construímos a base de JavaScript que sustenta tudo isso.
2.3 Conceitos que você vai ouvir o semestre inteiro¶
- SPA (Single Page Application) vs. MPA (Multi Page Application). Uma MPA tradicional recarrega o HTML inteiro do servidor a cada navegação. Uma SPA carrega um único HTML inicial e, depois, troca apenas os pedaços de tela necessários via JavaScript — é o modelo que o Vue Router (Aula 04) implementa.
- Client-side rendering (CSR). O HTML final da página é montado no navegador do usuário, em JavaScript, a partir de dados — em vez de vir pronto do servidor. É o padrão que usaremos com Vue + Vite.
- Bundler. Ferramenta que pega seus arquivos-fonte (
.vue,.js,.css, módulos separados) e os empacota em arquivos otimizados para o navegador. Usaremos o Vite a partir da Aula 02. - Transpilação. Processo de converter uma sintaxe moderna (ES2022+, ou até TypeScript) em um JavaScript que rode em navegadores mais antigos ou que corresponda ao que o navegador entende nativamente. O Vite faz isso por baixo dos panos.
- npm e
package.json. Onpm(Node Package Manager) instala bibliotecas de terceiros. Opackage.jsoné o arquivo que lista essas dependências e os scripts do projeto (npm run dev, por exemplo). - SemVer (versionamento semântico). Versões no formato
MAIOR.MENOR.PATCH(ex.:3.5.41). Mudanças deMAIORpodem quebrar compatibilidade;MENORadiciona funcionalidade sem quebrar;PATCHcorrige bugs. É por isso que, nesta disciplina, fixamos versões exatas — evita que seu projeto quebre por uma atualização automática no meio do semestre.
3. Revisão de JavaScript moderno (ES2015+)¶
Esta é a espinha dorsal da aula de hoje. O Vue é, por baixo, "só" JavaScript — cada recurso que revisamos aqui reaparece dentro de um <script setup> já na próxima aula.
3.1 let, const e escopo de bloco¶
// Antes do ES2015 só existia `var`, com escopo de função (confuso).
// Hoje: use `const` por padrão, `let` só quando o valor precisa mudar.
const nomeEvento = 'Semana da Computação' // não pode ser reatribuída
let vagasDisponiveis = 40 // pode ser reatribuída
vagasDisponiveis = vagasDisponiveis - 1
console.log(vagasDisponiveis) // 39
// `let` e `const` respeitam escopo de bloco { }
if (vagasDisponiveis > 0) {
const mensagem = 'ainda há vagas'
console.log(mensagem)
}
// console.log(mensagem) aqui daria ReferenceError: mensagem não existe fora do bloco
⚠️ Atenção Nunca use
varem código novo.var"vaza" para fora de blocosif/for, o que gera bugs difíceis de rastrear. A disciplina inteira usa apenasleteconst.
3.2 Template literals¶
const evento = { titulo: 'Oficina de Vue.js', vagas: 25 }
// Antes: concatenação com +
const antigo = 'Evento: ' + evento.titulo + ' (' + evento.vagas + ' vagas)'
// Agora: template literal, com crase e ${}
const moderno = `Evento: ${evento.titulo} (${evento.vagas} vagas)`
// Suporta múltiplas linhas sem precisar de \n
const bloco = `
Título: ${evento.titulo}
Vagas: ${evento.vagas}
`
console.log(moderno)
3.3 Arrow functions e this¶
// Função tradicional
function dobrar(numero) {
return numero * 2
}
// Arrow function equivalente
const dobrarArrow = (numero) => numero * 2
// Com múltiplos parâmetros e corpo de bloco
const somarVagas = (a, b) => {
const total = a + b
return total
}
// Sem parâmetros, precisa dos parênteses vazios
const gerarId = () => Math.floor(Math.random() * 1000)
A diferença mais importante não é a sintaxe curta — é o comportamento do this.
const contador = {
vagas: 10,
// `function` tradicional: `this` é o objeto que CHAMA o método (contador).
reduzirComFunction: function () {
setTimeout(function () {
// aqui `this` NÃO é mais `contador` — em modo estrito, é `undefined`.
console.log(this?.vagas) // undefined
}, 100)
},
// arrow function: `this` é herdado do escopo onde a arrow foi DEFINIDA.
reduzirComArrow: function () {
setTimeout(() => {
// aqui `this` continua sendo `contador`, porque a arrow "pega emprestado"
// o `this` do método externo.
console.log(this.vagas) // 10
}, 100)
},
}
contador.reduzirComFunction()
contador.reduzirComArrow()
🔎 Por baixo do capô Arrow functions não têm seu próprio
this— em vez de criar um novo, elas capturam othisdo escopo léxico onde foram escritas. É exatamente por isso que, dentro de um<script setup>do Vue (Aula 02), quase sempre usamos arrow functions ou funções normais no nível do módulo: o comportamento dethisdeixa de ser um problema porque a Composition API não depende dele.
3.4 Desestruturação de objetos e arrays¶
const evento = {
id: 2,
titulo: 'Oficina de Vue.js',
categoria: 'minicurso',
vagas: 25,
local: 'Bloco B, sala 12',
}
// Desestruturação de objeto: extrai propriedades para variáveis
const { titulo, vagas } = evento
console.log(titulo, vagas) // Oficina de Vue.js 25
// Renomear ao desestruturar
const { titulo: nomeDoEvento } = evento
console.log(nomeDoEvento) // Oficina de Vue.js
// Valor default se a propriedade não existir
const { imagemUrl = '/img/padrao.png' } = evento
console.log(imagemUrl) // /img/padrao.png
// Desestruturação de array: extrai por posição
const coordenadas = [-16.0736, -57.6789]
const [latitude, longitude] = coordenadas
console.log(latitude, longitude) // -16.0736 -57.6789
// Desestruturação em parâmetros de função — muito comum no Vue com props
function exibirEvento({ titulo, vagas }) {
return `${titulo}: ${vagas} vagas`
}
console.log(exibirEvento(evento)) // Oficina de Vue.js: 25 vagas
3.5 Spread e rest¶
// Spread (...) em array: "espalha" os elementos
const categoriasBase = ['palestra', 'minicurso']
const todasCategorias = [...categoriasBase, 'workshop']
console.log(todasCategorias) // ['palestra', 'minicurso', 'workshop']
// Spread em objeto: cria uma CÓPIA com propriedades sobrescritas
// (importante: nunca mutar o objeto original em Vue)
const eventoOriginal = { id: 1, titulo: 'Semana da Computação', vagas: 40 }
const eventoAtualizado = { ...eventoOriginal, vagas: 39 }
console.log(eventoOriginal.vagas) // 40 — original intocado
console.log(eventoAtualizado.vagas) // 39 — cópia com a mudança
// Rest (...) em parâmetros: agrupa "o resto" dos argumentos em array
function somarTodasAsVagas(...quantidades) {
return quantidades.reduce((total, atual) => total + atual, 0)
}
console.log(somarTodasAsVagas(10, 20, 30)) // 60
// Rest em desestruturação: agrupa "o resto" das propriedades
const { id, ...detalhesDoEvento } = eventoOriginal
console.log(id) // 1
console.log(detalhesDoEvento) // { titulo: 'Semana da Computação', vagas: 40 }
3.6 Parâmetros default¶
function criarEvento(titulo, categoria = 'palestra', vagas = 30) {
return { titulo, categoria, vagas }
}
console.log(criarEvento('Minicurso de Git'))
// { titulo: 'Minicurso de Git', categoria: 'palestra', vagas: 30 }
console.log(criarEvento('Workshop de Testes', 'workshop', 15))
// { titulo: 'Workshop de Testes', categoria: 'workshop', vagas: 15 }
3.7 Optional chaining (?.) e nullish coalescing (??)¶
const evento = {
titulo: 'Semana da Computação',
local: {
predio: 'Bloco A',
// sala não foi informada
},
}
// Sem optional chaining, acessar uma propriedade aninhada ausente quebra:
// console.log(evento.organizador.nome) // TypeError: Cannot read properties of undefined
// Com optional chaining: retorna `undefined` em vez de lançar erro
console.log(evento.organizador?.nome) // undefined
console.log(evento.local?.sala) // undefined
console.log(evento.local?.predio) // Bloco A
// Funciona também para chamar métodos que podem não existir
const relatorio = null
console.log(relatorio?.gerar?.()) // undefined, sem quebrar
// Nullish coalescing (??): fornece um valor padrão SOMENTE quando o
// valor à esquerda é null ou undefined (diferente do || , que também
// cai no padrão para 0, '' ou false — o que costuma ser um bug).
const vagasInformadas = 0
console.log(vagasInformadas || 10) // 10 — ERRADO: 0 é um valor válido de vagas!
console.log(vagasInformadas ?? 10) // 0 — CORRETO: só usa o padrão se for null/undefined
const sala = evento.local?.sala ?? 'a definir'
console.log(sala) // a definir
⚠️ Atenção
||e??parecem intercambiáveis, mas não são. Use??sempre que0,''oufalseforem valores legítimos que você não quer substituir pelo padrão. É um erro comum em formulários (campo numérico zerado sendo tratado como "vazio").
3.8 Métodos de array: map, filter, reduce, find, some, every, sort¶
Vamos usar o mesmo array de eventos em todos os exemplos — é o dado que sustentará o UniEventos a partir da Aula 02.
const eventos = [
{ id: 1, titulo: 'Semana da Computação', categoria: 'palestra', dataHora: '2026-09-10T19:00:00', vagas: 40, inscritos: 12 },
{ id: 2, titulo: 'Oficina de Vue.js', categoria: 'minicurso', dataHora: '2026-08-20T14:00:00', vagas: 25, inscritos: 25 },
{ id: 3, titulo: 'Hackathon FACET', categoria: 'workshop', dataHora: '2026-10-05T08:00:00', vagas: 60, inscritos: 18 },
{ id: 4, titulo: 'Introdução a IA', categoria: 'palestra', dataHora: '2026-08-18T19:30:00', vagas: 80, inscritos: 55 },
]
map — transforma cada item em outra coisa, sem mudar o tamanho do array:
const titulos = eventos.map((evento) => evento.titulo)
console.log(titulos)
// ['Semana da Computação', 'Oficina de Vue.js', 'Hackathon FACET', 'Introdução a IA']
// map devolvendo objetos NOVOS (sem mutar os originais) — padrão que
// vamos repetir sempre que precisarmos "decorar" dados para a tela
const eventosComVagasRestantes = eventos.map((evento) => ({
...evento,
vagasRestantes: evento.vagas - evento.inscritos,
}))
console.log(eventosComVagasRestantes[0].vagasRestantes) // 28
filter — seleciona um subconjunto:
const palestras = eventos.filter((evento) => evento.categoria === 'palestra')
console.log(palestras.length) // 2
const comVagas = eventos.filter((evento) => evento.inscritos < evento.vagas)
console.log(comVagas.map((e) => e.titulo)) // exclui a "Oficina de Vue.js" (lotada)
reduce — acumula os itens em um único valor:
const totalDeVagas = eventos.reduce((acumulado, evento) => acumulado + evento.vagas, 0)
console.log(totalDeVagas) // 205
// reduce também serve para agrupar por categoria
const porCategoria = eventos.reduce((grupos, evento) => {
const chave = evento.categoria
if (!grupos[chave]) {
grupos[chave] = []
}
grupos[chave].push(evento)
return grupos
}, {})
console.log(Object.keys(porCategoria)) // ['palestra', 'minicurso', 'workshop']
find — retorna o primeiro item que bate na condição (ou undefined):
const eventoBuscado = eventos.find((evento) => evento.id === 3)
console.log(eventoBuscado.titulo) // Hackathon FACET
const inexistente = eventos.find((evento) => evento.id === 999)
console.log(inexistente) // undefined
some e every — testam a coleção e retornam boolean:
const existeEventoLotado = eventos.some((evento) => evento.inscritos >= evento.vagas)
console.log(existeEventoLotado) // true (a Oficina de Vue.js está lotada)
const todosTemVagas = eventos.every((evento) => evento.inscritos < evento.vagas)
console.log(todosTemVagas) // false
sort — ordena o array (⚠️ muta o array original):
// sort() muda o array ORIGINAL. Para não afetar `eventos`, copie antes com spread.
const eventosPorData = [...eventos].sort(
(a, b) => new Date(a.dataHora) - new Date(b.dataHora),
)
console.log(eventosPorData.map((e) => e.titulo))
// ['Oficina de Vue.js', 'Introdução a IA', 'Semana da Computação', 'Hackathon FACET']
console.log(eventos.map((e) => e.titulo))
// ainda na ordem original — porque ordenamos a CÓPIA, não `eventos`
Encadeando métodos — o padrão mais comum no dia a dia:
const resumoDePalestrasComVaga = eventos
.filter((evento) => evento.categoria === 'palestra')
.filter((evento) => evento.inscritos < evento.vagas)
.map((evento) => `${evento.titulo} (${evento.vagas - evento.inscritos} vagas livres)`)
console.log(resumoDePalestrasComVaga)
// ['Semana da Computação (28 vagas livres)', 'Introdução a IA (25 vagas livres)']
3.9 Objetos e shorthand¶
const titulo = 'Semana da Computação'
const vagas = 40
// Antes: repetir a chave e o valor
const eventoAntigo = { titulo: titulo, vagas: vagas }
// Shorthand: quando o nome da variável é igual ao nome da propriedade
const eventoModerno = { titulo, vagas }
console.log(eventoModerno) // { titulo: 'Semana da Computação', vagas: 40 }
// Shorthand também funciona para métodos
const gerenciadorDeEventos = {
eventos: [],
adicionar(evento) { // em vez de: adicionar: function (evento) { ... }
this.eventos.push(evento)
},
contar() {
return this.eventos.length
},
}
gerenciadorDeEventos.adicionar({ titulo: 'Novo evento' })
console.log(gerenciadorDeEventos.contar()) // 1
// Nomes de propriedade computados
const chave = 'categoria'
const filtro = { [chave]: 'palestra' }
console.log(filtro) // { categoria: 'palestra' }
3.10 Módulos ES: import e export¶
Organizar código em módulos é essencial — é assim que um projeto Vue inteiro é estruturado, um arquivo por responsabilidade.
// arquivo: eventos.js
// export nomeado: pode haver vários por arquivo
export const CATEGORIAS = ['palestra', 'minicurso', 'workshop']
export function filtrarPorCategoria(eventos, categoria) {
if (!categoria) return eventos
return eventos.filter((evento) => evento.categoria === categoria)
}
export function calcularVagasRestantes(evento) {
return evento.vagas - evento.inscritos
}
// export default: no máximo um por arquivo — geralmente a "coisa principal"
export default class GerenciadorDeEventos {
constructor(eventosIniciais = []) {
this.eventos = eventosIniciais
}
adicionar(evento) {
this.eventos.push(evento)
}
}
// arquivo: main.js
// import nomeado: usa chaves { } e o mesmo nome do export
import { CATEGORIAS, filtrarPorCategoria } from './eventos.js'
// import default: sem chaves, você escolhe o nome
import GerenciadorDeEventos from './eventos.js'
// import combinando os dois
import GerenciadorDeEventos2, { calcularVagasRestantes } from './eventos.js'
console.log(CATEGORIAS) // ['palestra', 'minicurso', 'workshop']
const gerenciador = new GerenciadorDeEventos()
gerenciador.adicionar({ titulo: 'Minicurso de Git', categoria: 'minicurso', vagas: 20, inscritos: 5 })
console.log(calcularVagasRestantes(gerenciador.eventos[0])) // 15
Para rodar módulos ES direto no navegador (sem bundler ainda), o HTML precisa declarar type="module":
<script type="module" src="main.js"></script>
📌 Na prova A partir da Aula 02, todo componente
.vueé, por baixo, um módulo ES: ele importa outros componentes comimporte é importado por quem o usa. Entenderimport/exportagora evita confusão depois.
3.11 Classes¶
class Evento {
// Campos de instância (sintaxe moderna, sem precisar declarar no constructor)
inscritos = 0
constructor(titulo, categoria, vagas) {
this.titulo = titulo
this.categoria = categoria
this.vagas = vagas
}
// Método de instância
inscrever() {
if (this.inscritos >= this.vagas) {
throw new Error('Evento lotado')
}
this.inscritos += 1
}
// Getter: parece uma propriedade, mas é calculado
get vagasRestantes() {
return this.vagas - this.inscritos
}
}
// Herança com extends
class Minicurso extends Evento {
constructor(titulo, vagas, cargaHoraria) {
super(titulo, 'minicurso', vagas) // chama o constructor da classe-mãe
this.cargaHoraria = cargaHoraria
}
}
const oficina = new Minicurso('Oficina de Vue.js', 25, 4)
oficina.inscrever()
oficina.inscrever()
console.log(oficina.vagasRestantes) // 23
console.log(oficina.cargaHoraria) // 4
console.log(oficina instanceof Evento) // true
3.12 JSON: parse e stringify¶
const evento = { id: 1, titulo: 'Semana da Computação', vagas: 40 }
// Objeto JavaScript → texto JSON (para enviar em uma requisição, por exemplo)
const textoJson = JSON.stringify(evento)
console.log(textoJson) // '{"id":1,"titulo":"Semana da Computação","vagas":40}'
// Com indentação, útil para debug/log
console.log(JSON.stringify(evento, null, 2))
// Texto JSON → objeto JavaScript (o inverso — comum ao ler resposta de API)
const textoRecebido = '{"id":2,"titulo":"Oficina de Vue.js","vagas":25}'
const objetoRecebido = JSON.parse(textoRecebido)
console.log(objetoRecebido.titulo) // Oficina de Vue.js
3.13 Assíncrono: de callback a async/await¶
O JavaScript é de thread única, então operações demoradas (rede, temporizadores) precisam de um jeito de "avisar quando terminar" sem travar tudo. A linguagem evoluiu em três estágios.
Estágio 1 — callback (o jeito antigo, difícil de encadear):
function buscarEventoComCallback(id, aoTerminar) {
setTimeout(() => {
aoTerminar({ id, titulo: 'Semana da Computação' })
}, 500)
}
buscarEventoComCallback(1, (evento) => {
console.log('recebido:', evento.titulo)
// se precisasse buscar outra coisa depois, teria que aninhar
// outro callback aqui dentro — o famoso "callback hell"
})
Estágio 2 — Promise (representa um valor que existirá no futuro):
function buscarEventoComPromise(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) {
resolve({ id, titulo: 'Semana da Computação' })
} else {
reject(new Error('id inválido'))
}
}, 500)
})
}
buscarEventoComPromise(1)
.then((evento) => console.log('recebido:', evento.titulo))
.catch((erro) => console.error('deu erro:', erro.message))
Estágio 3 — async/await (mesma Promise por baixo, sintaxe que lê como código síncrono):
async function carregarEvento() {
try {
const evento = await buscarEventoComPromise(1)
console.log('recebido:', evento.titulo)
} catch (erro) {
console.error('deu erro:', erro.message)
}
}
carregarEvento()
🔎 Por baixo do capô
async/awaitnão é uma tecnologia nova e diferente de Promise — é açúcar sintático sobre Promise.awaitpausa a execução da funçãoasyncaté a Promise resolver ou rejeitar, sem bloquear o restante do programa. Todoawaitprecisa estar dentro de uma função marcadaasync.
fetch com async/await e try/catch — o padrão que vamos usar o semestre inteiro:
async function buscarEventosDaApi() {
try {
const resposta = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5')
if (!resposta.ok) {
throw new Error(`Erro HTTP: ${resposta.status}`)
}
const dados = await resposta.json()
console.log('eventos recebidos:', dados.length)
return dados
} catch (erro) {
console.error('falha ao buscar eventos:', erro.message)
return []
}
}
buscarEventosDaApi()
Promise.all — disparar várias requisições em paralelo e esperar todas:
async function carregarDadosDaHome() {
try {
const [respostaEventos, respostaCategorias] = await Promise.all([
fetch('https://jsonplaceholder.typicode.com/posts?_limit=5'),
fetch('https://jsonplaceholder.typicode.com/users?_limit=3'),
])
const eventos = await respostaEventos.json()
const categorias = await respostaCategorias.json()
console.log('eventos:', eventos.length, 'categorias:', categorias.length)
} catch (erro) {
// Promise.all rejeita assim que QUALQUER uma das promises falhar
console.error('alguma requisição falhou:', erro.message)
}
}
carregarDadosDaHome()
⚠️ Atenção
Promise.allfalha rápido: se uma das promises rejeitar, todas as outras são "abandonadas" do ponto de vista do.catch/try-catch, mesmo que já estivessem resolvidas. Quando precisar do resultado de todas independentemente de falha, usePromise.allSettled(não obrigatório nesta disciplina, mas bom saber que existe).
🧩 Padrão de projeto em uso¶
🧩 Padrão de projeto em uso — Module / Revealing Module¶
O padrão Module organiza código relacionado (estado + comportamento) dentro de um único bloco, escondendo detalhes internos e expondo apenas uma interface pública. Antes dos módulos ES nativos, isso era feito com uma IIFE (função invocada imediatamente) que retornava um objeto com as partes públicas — o Revealing Module Pattern:
```js // Revealing Module Pattern — jeito pré-ES2015 de encapsular const GerenciadorDeEventos = (function () { // "privado": só existe dentro deste escopo de função let eventos = []
function adicionar(evento) { eventos.push(evento) }
function contarVagas() { return eventos.reduce((total, e) => total + e.vagas, 0) }
// "revela" (expõe) só o que deve ser público return { adicionar, contarVagas, } })()
GerenciadorDeEventos.adicionar({ titulo: 'Semana da Computação', vagas: 40 }) console.log(GerenciadorDeEventos.contarVagas()) // 40 // GerenciadorDeEventos.eventos não existe aqui fora — está encapsulado ```
Os módulos ES (
import/export, Seção 3.10) resolvem o mesmo problema de forma nativa e sem a necessidade da IIFE: tudo que não é exportado comexporté automaticamente privado ao arquivo. É o mesmo padrão de projeto, com sintaxe de linguagem em vez de truque de engenharia. Todo componente.vueque você vai escrever a partir da Aula 02 é, conceitualmente, um Module: estado interno + funções, expondo ao<template>só o que for necessário.
💻 Mão na massa — configurando o primeiro arquivo de revisão¶
Vamos consolidar tudo em um único exercício guiado, rodado no navegador.
Passo 1 — crie a pasta e os arquivos.
mkdir -p ~/fds-aula01 && cd ~/fds-aula01
touch index.html eventos.js main.js
Passo 2 — o HTML que carrega o módulo:
<!-- index.html -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<title>Aula 01 — Revisão de JavaScript</title>
</head>
<body>
<h1>Abra o Console do navegador (F12) para ver os resultados</h1>
<ul id="saida"></ul>
<script type="module" src="main.js"></script>
</body>
</html>
Passo 3 — o módulo com os dados e as funções:
// eventos.js
export const eventos = [
{ id: 1, titulo: 'Semana da Computação', categoria: 'palestra', dataHora: '2026-09-10T19:00:00', vagas: 40, inscritos: 12 },
{ id: 2, titulo: 'Oficina de Vue.js', categoria: 'minicurso', dataHora: '2026-08-20T14:00:00', vagas: 25, inscritos: 25 },
{ id: 3, titulo: 'Hackathon FACET', categoria: 'workshop', dataHora: '2026-10-05T08:00:00', vagas: 60, inscritos: 18 },
{ id: 4, titulo: 'Introdução a IA', categoria: 'palestra', dataHora: '2026-08-18T19:30:00', vagas: 80, inscritos: 55 },
]
export function filtrarPorCategoria(lista, categoria) {
if (!categoria) return lista
return lista.filter((evento) => evento.categoria === categoria)
}
export function ordenarPorData(lista) {
return [...lista].sort((a, b) => new Date(a.dataHora) - new Date(b.dataHora))
}
export function totalDeVagas(lista) {
return lista.reduce((total, evento) => total + evento.vagas, 0)
}
export function formatarData(dataIso) {
const data = new Date(dataIso)
return new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: 'long',
hour: '2-digit',
minute: '2-digit',
}).format(data)
}
Passo 4 — consumir o módulo, renderizar no DOM e buscar dados de uma API:
// main.js
import { eventos, filtrarPorCategoria, ordenarPorData, totalDeVagas, formatarData } from './eventos.js'
const listaEl = document.getElementById('saida')
function renderizarLista(lista) {
listaEl.innerHTML = ''
lista.forEach((evento) => {
const item = document.createElement('li')
item.textContent = `${evento.titulo} — ${formatarData(evento.dataHora)} (${evento.vagas} vagas)`
listaEl.appendChild(item)
})
}
const palestras = filtrarPorCategoria(eventos, 'palestra')
const ordenados = ordenarPorData(eventos)
console.log('total de palestras:', palestras.length)
console.log('total de vagas em todos os eventos:', totalDeVagas(eventos))
renderizarLista(ordenados)
// buscando dados externos de verdade
async function buscarPostsDeExemplo() {
try {
const resposta = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=3')
if (!resposta.ok) throw new Error(`HTTP ${resposta.status}`)
const posts = await resposta.json()
console.log('posts de exemplo recebidos da API:', posts)
} catch (erro) {
console.error('não foi possível buscar os posts:', erro.message)
}
}
buscarPostsDeExemplo()
Passo 5 — abra index.html com a extensão Live Server do VS Code (ou qualquer servidor local — módulos ES não funcionam abrindo o arquivo direto com file:// por causa de CORS). Abra o DevTools (F12) e confira o Console.
🧪 Laboratório¶
Use o array eventos do Passo 3 acima para os exercícios. Crie um arquivo lab.js, importe o que precisar de eventos.js e teste cada exercício no console.
1. Filtrar por categoria — escreva uma função apenasWorkshops(lista) que retorne só os eventos de categoria 'workshop', usando filter.
Resultado esperado: array com 1 item (Hackathon FACET).
Dica
lista.filter((evento) => evento.categoria === 'workshop')
2. Ordenar por vagas restantes — escreva ordenarPorVagasRestantes(lista) que devolva uma cópia do array ordenada da maior para a menor quantidade de vagas restantes (vagas - inscritos), sem mutar o array original.
Resultado esperado: o array original (eventos) mantém a mesma ordem depois de chamar a função.
Dica
Copie primeiro com [...lista], depois use .sort((a, b) => (b.vagas - b.inscritos) - (a.vagas - a.inscritos)).
3. Calcular vagas totais com reduce — escreva vagasRestantesTotais(lista) que retorne a soma de vagas - inscritos de todos os eventos.
Resultado esperado: 55 (para o array de exemplo: 28 + 0 + 42 + 25).
Dica
lista.reduce((total, evento) => total + (evento.vagas - evento.inscritos), 0)
4. Formatar datas em pt-BR — usando Intl.DateTimeFormat('pt-BR', { dateStyle: 'long' }), formate a dataHora de cada evento e monte um array de strings como "10 de setembro de 2026".
Resultado esperado: 4 strings de data em português.
Dica
const formatador = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'long' })
eventos.map((evento) => formatador.format(new Date(evento.dataHora)))
5. Buscar dados de uma API pública e renderizar — usando fetch e async/await, busque https://jsonplaceholder.typicode.com/users (sem parâmetro de limite), pegue apenas os 3 primeiros com .slice(0, 3), e renderize o name de cada um em uma lista <ul> no HTML.
Resultado esperado: 3 nomes de usuários aparecendo na página.
Dica
const usuarios = await (await fetch(url)).json(), depois usuarios.slice(0, 3).forEach(...) criando <li> como no Passo 4.
🐛 Erros comuns e como resolver¶
| Sintoma | Causa | Solução |
|---|---|---|
this é undefined dentro de um callback |
Usou function tradicional dentro de um método, perdendo o this do objeto |
Troque por arrow function, que herda o this do escopo externo |
| Array original mudou de ordem sem eu querer | .sort() muta o array original |
Copie antes: [...array].sort(...) |
if (vagas == '40') deu true mas os tipos são diferentes |
Uso de == faz coerção de tipo |
Use sempre === e !== |
TypeError: Cannot read properties of undefined |
Esqueceu de colocar await antes de uma Promise |
Confira se toda chamada assíncrona tem await dentro de uma função async |
fetch falha com erro de CORS no console |
A API não libera requisições vindas do seu domínio local | Use uma API que libere CORS publicamente (como JSONPlaceholder) ou rode via proxy/back-end nas aulas futuras |
🏠 Atividade assíncrona (1 h)¶
- Escolha o tema do seu projeto autoral seguindo as regras da Seção 1.6.
- Crie um repositório público no GitHub chamado
<seu-tema>-web(ex.:pantanal-plantas-web). - Escreva um
README.mdna raiz do repositório contendo: - Uma descrição de 3 a 5 linhas do problema que o projeto resolve. - As entidades do domínio (no mínimo duas relacionadas) com seus campos, no mesmo estilo da Seção 3 do plano de curso (compare comEvento/Inscricao/Usuariodo UniEventos). - As telas previstas: pelo menos listagem com filtro, detalhe, e uma área autenticada. - Envie o link do repositório pelo canal indicado no SIGAA.
Critério de pronto: repositório público criado, README.md com descrição, modelo de dados (entidades + campos) e lista de telas.
✅ Checkpoint do projeto autoral¶
Ao final desta aula, seu repositório deve ter:
- [ ] Repositório criado no GitHub, nomeado
<tema>-web, com visibilidade pública. - [ ]
README.mdcom descrição do projeto, entidades e campos, e telas previstas. - [ ] Ambiente instalado e verificado:
node -vmostrando Node 22 LTS, VS Code com as extensões Vue - Official, ESLint e Prettier. - [ ] Git configurado localmente (
git config --global user.name/user.email).
📚 Para aprofundar¶
- MDN Web Docs — JavaScript — referência oficial e gratuita, em português.
- MDN — Guia de gramática e tipos
- MDN —
Array.prototype— todos os métodos de array com exemplos. - MDN —
Intl.DateTimeFormat - javascript.info — curso completo e gratuito de JS moderno.
- Node.js — site oficial
Próxima aula (02, 19/08/2026): começamos o Vue de verdade — createApp, instância, data/methods (Options API) e Composition API com <script setup>, além das diretivas básicas (v-bind, v-on, v-model, v-if, v-for). Traga o ambiente instalado e o repositório do projeto autoral criado.
Aula 02 — Introdução ao Vue: instância, ciclo de vida e diretivas
Na Aula 01 revisamos JavaScript moderno e comparamos DOM manual com o estilo declarativo. Hoje esse estilo declarativo ganha nome: Vue 3.
🎯 Objetivos de aprendizagem¶
Ao final desta aula você será capaz de:
- Explicar o que é o Vue 3, o que significa "framework progressivo" e a diferença entre Options API e Composition API.
- Criar uma instância Vue via CDN com
createAppe entender o ciclo montar/renderizar. - Criar um projeto Vue com Vite usando
npm create vue@lateste explicar cada arquivo gerado. - Distinguir
ref()dereactive()e explicar por que.valueexiste. - Usar as diretivas
v-bind,v-on,v-model,v-if/v-else-if/v-else,v-show,v-for(com:key) ev-text/v-htmlcorretamente, cada uma com seus casos de uso e armadilhas. - Descrever as fases do ciclo de vida de um componente e usar os hooks
onMountedeonUnmounted. - Construir a primeira versão navegável do UniEventos: lista, busca, filtro e inscrição.
📋 Pré-requisitos desta aula¶
- Ambiente instalado na Aula 01: Node 22 LTS, VS Code com Vue - Official/ESLint/Prettier, Git.
- Conforto com
let/const, arrow functions, desestruturação,map/filter,import/export(Aula 01, Seção 3). - Repositório do projeto autoral criado com
README.md.
⚠️ Atenção Verifique agora, antes de começar:
node -vprecisa mostrar uma versão22.18.0ou superior (ou24.12.0+). Ocreate-vuedesta aula exige isso.
🗺️ Roteiro¶
| Bloco | Tempo | Atividade |
|---|---|---|
| 1 | 50 min | O que é Vue, Options API vs Composition API, primeiro contato via CDN |
| 2 | 50 min | Projeto Vite real, reatividade (ref/reactive), diretivas de vinculação e eventos |
| 3 | 50 min | Diretivas condicionais/lista, ciclo de vida, mão na massa no UniEventos |
1. O que é o Vue 3¶
Vue é um framework progressivo para construir interfaces de usuário. "Progressivo" significa que você pode adotá-lo aos poucos: usar só para uma parte reativa de uma página HTML existente (como faremos daqui a pouco, via CDN) ou para uma aplicação inteira, com build, roteamento e gerenciamento de estado (como faremos a partir de hoje mesmo, com Vite).
O ecossistema Vue que usaremos no semestre:
| Peça | Papel | Quando entra |
|---|---|---|
| Vue 3 (core) | Reatividade + renderização de componentes | Hoje |
| Vite | Servidor de desenvolvimento e bundler | Hoje |
| Vue Router | Navegação entre "páginas" da SPA | Aula 04 |
| Pinia | Estado compartilhado entre componentes | Aula 06 |
| Vuetify | Biblioteca de componentes visuais prontos (Material Design) | Aula 04 |
| Axios | Cliente HTTP para consumir APIs | Aula 06 |
Vamos usar a versão 3.5.41 do Vue, instalada via Vite 8.2.1 com o plugin @vitejs/plugin-vue 6.0.8 — as mesmas versões testadas e listadas no início do curso.
1.1 Options API vs. Composition API¶
O Vue 3 oferece duas formas de escrever a lógica de um componente. Elas produzem o mesmo resultado; mudam a organização do código.
Options API — organiza o componente em "opções" fixas: data() (estado), methods (funções), computed (Aula 03), mounted() (ciclo de vida) etc. É o estilo herdado do Vue 2, ainda muito usado e citado no plano de curso.
// Options API — cada preocupação vai em uma "caixa" pré-definida
export default {
data() {
return {
contador: 0,
}
},
methods: {
incrementar() {
this.contador++
},
},
mounted() {
console.log('componente montado, contador =', this.contador)
},
}
Composition API — organiza o componente por funções que você importa e compõe livremente (ref, reactive, onMounted...), agrupando por funcionalidade em vez de por tipo de opção. É o padrão do Vue 3 moderno e o que o create-vue gera por padrão, dentro da sintaxe açucarada <script setup>.
<script setup>
// Composition API com <script setup> — tudo neste bloco já é
// automaticamente exposto ao <template>, sem "return" manual
import { ref, onMounted } from 'vue'
const contador = ref(0)
function incrementar() {
contador.value++
}
onMounted(() => {
console.log('componente montado, contador =', contador.value)
})
</script>
📌 Na prova Esta disciplina usa Composition API com
<script setup>do início ao fim, porque é o padrão gerado pelocreate-vuee o que você vai encontrar em qualquer projeto Vue 3 novo. Nos primeiros exemplos de hoje mostramos o equivalente em Options API lado a lado — o plano de curso cita explicitamente "instância vue, data e methods" — mas a partir da Aula 03 falamos só Composition API.
2. Primeiro contato: Vue via CDN¶
Antes de qualquer ferramenta de build, vamos ver o Vue rodando com o mínimo possível: um único arquivo HTML.
<!-- cdn/index.html -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<title>Primeiro contato com Vue</title>
</head>
<body>
<div id="app">
<h1>{{ titulo }}</h1>
<p>Você clicou {{ contador }} vez(es).</p>
<button v-on:click="incrementar">Clicar</button>
</div>
<script src="https://unpkg.com/vue@3.5.41/dist/vue.global.js"></script>
<script>
const { createApp } = Vue
createApp({
data() {
return {
titulo: 'Olá, Vue!',
contador: 0,
}
},
methods: {
incrementar() {
this.contador++
},
},
}).mount('#app')
</script>
</body>
</html>
Abra este arquivo direto no navegador (funciona com file://, sem precisar de servidor, porque não há módulos ES aqui). Três ideias novas:
createApp({...})recebe um objeto de configuração — no estilo Options API — e devolve uma instância da aplicação Vue..mount('#app')diz ao Vue: "assuma o controle deste elemento do DOM e tudo dentro dele". A partir daqui, o Vue passa a gerenciar esse pedaço de página.{{ titulo }}é interpolação de texto: insere o valor da variável reativatitulono HTML. Sempre quetitulomuda, o texto na tela muda sozinho — seminnerHTML, semaddEventListenermanual.
🔎 Por baixo do capô
{{ }}só funciona dentro do elemento montado (#appe seus descendentes). Fora dele, o Vue nem olha para o HTML — por isso o<h1>do exemplo fica dentro de<div id="a02-app">.
O mesmo exemplo, agora em <script setup> (o estilo que usaremos a partir de agora), ainda via CDN mas em módulo ES:
<!-- cdn/index-composition.html -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<title>Primeiro contato — Composition API</title>
</head>
<body>
<div id="app"></div>
<script type="module">
import { createApp, ref } from 'https://unpkg.com/vue@3.5.41/dist/vue.esm-browser.js'
createApp({
setup() {
const titulo = ref('Olá, Vue!')
const contador = ref(0)
function incrementar() {
contador.value++
}
return { titulo, contador, incrementar }
},
template: `
<h1>{{ titulo }}</h1>
<p>Você clicou {{ contador }} vez(es).</p>
<button v-on:click="incrementar">Clicar</button>
`,
}).mount('#app')
</script>
</body>
</html>
Note que aqui, sem <script setup> (que só existe dentro de arquivos .vue compilados pelo Vite), precisamos declarar setup() manualmente e retornar o que o template usa. É exatamente esse return que o <script setup> elimina automaticamente — daí o nome "açúcar sintático".
3. Migrando para um projeto Vite de verdade¶
CDN é ótimo para aprender o conceito, mas nenhuma aplicação real do semestre vai ser um único HTML. A partir de agora usamos Vite com Single File Components (.vue).
3.1 Criando o projeto¶
npm create vue@latest
O create-vue (versão 3.23.0) pergunta interativamente o nome do projeto e quais recursos incluir. Para o UniEventos que construiremos em sala, as respostas são:
✔ Project name: … unieventos-web
✔ Add TypeScript? … No
✔ Add JSX Support? … No
✔ Add Vue Router for Single Page Application development? … Yes
✔ Add Pinia for state management? … Yes
✔ Add Vitest for Unit testing? … No
✔ Add an End-to-End Testing Solution? › No
✔ Add ESLint for code quality? … Yes
✔ Add Prettier for code formatting? … Yes
Ou, sem o modo interativo, direto com flags:
npx create-vue@latest unieventos-web --router --pinia --eslint --prettier
cd unieventos-web
npm install
npm run dev
💡 Dica Já habilitamos
--routere--piniamesmo sem usá-los ainda — eles só entram em cena nas Aulas 04 e 06, mas evita reconfigurar o projeto depois. Os arquivos que eles geram (src/router/index.js,src/stores/counter.js) ficam parados até lá.
3.2 Estrutura gerada¶
unieventos-web/
├─ .vscode/
├─ public/favicon.ico
├─ src/
│ ├─ App.vue
│ ├─ main.js
│ ├─ router/index.js
│ └─ stores/counter.js
├─ index.html
├─ jsconfig.json
├─ package.json
└─ vite.config.js
| Arquivo | Papel |
|---|---|
index.html |
HTML raiz — único ponto de entrada real da SPA, contém <div id="a02-app"> |
src/main.js |
Ponto de entrada JS: cria a aplicação, registra plugins, monta no DOM |
src/App.vue |
Componente raiz — tudo que renderizamos começa aqui |
src/router/index.js |
Configuração de rotas (usada a partir da Aula 04) |
src/stores/counter.js |
Exemplo de store Pinia gerado pelo scaffold (usado a partir da Aula 06) |
vite.config.js |
Configuração do Vite: plugins, aliases de importação |
package.json |
Dependências e scripts (npm run dev, npm run build) |
jsconfig.json |
Ajuda o VS Code a resolver imports como @/components/... |
src/main.js gerado:
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
Compare com o createApp(...).mount('#app') do exemplo CDN: é a mesma API. A diferença é que aqui App vem de um componente .vue importado, e app.use(...) registra plugins (Pinia, Router) que ainda não vamos usar hoje.
Rode o projeto:
npm run dev
O Vite sobe um servidor local (normalmente http://localhost:5173) com hot module replacement: você edita um arquivo .vue e a tela atualiza sozinha, sem recarregar a página inteira.
3.3 Anatomia de um Single File Component (SFC)¶
Abra src/App.vue gerado pelo scaffold — ele vem com bastante conteúdo de boas-vindas. Vamos substituí-lo por algo mínimo para entender a estrutura:
<!-- src/App.vue -->
<script setup>
import { ref } from 'vue'
const titulo = ref('UniEventos')
const contador = ref(0)
function incrementar() {
contador.value++
}
</script>
<template>
<main>
<h1>{{ titulo }}</h1>
<p>Cliques: {{ contador }}</p>
<button @click="incrementar">Clicar</button>
</main>
</template>
<style scoped>
main {
font-family: sans-serif;
padding: 2rem;
}
h1 {
color: #2c3e50;
}
</style>
Um .vue tem até três blocos:
<script setup>— lógica do componente em Composition API. Tudo declarado aqui (variáveis, funções) fica automaticamente disponível no<template>, semreturnexplícito — é isso que osetup: '<script setup>'do compilador do Vite faz por você.<template>— o HTML do componente, com as diretivas do Vue.<style scoped>— CSS que se aplica somente a este componente (o Vue adiciona um atributo único a cada elemento na hora do build, isolando o CSS). Semscoped, o estilo vaza para a aplicação inteira.
⚠️ Atenção
<script setup>só existe dentro de arquivos.vueprocessados pelo Vite — não existe fora desse contexto. Por isso o exemplo CDN da Seção 2 usousetup() { return {...} }explícito.
4. Reatividade: ref() e reactive()¶
4.1 Por que .value¶
<script setup>
import { ref } from 'vue'
// ref() envolve um valor primitivo (ou qualquer valor) em um objeto reativo
const contadorVagas = ref(40)
function reduzirVaga() {
// DENTRO do <script>, é preciso acessar/alterar via .value
contadorVagas.value--
console.log(contadorVagas.value)
}
</script>
<template>
<!-- NO <template>, o Vue "desembrulha" automaticamente — sem .value -->
<p>Vagas: {{ contadorVagas }}</p>
<button @click="reduzirVaga">Inscrever</button>
</template>
🔎 Por baixo do capô Um valor primitivo em JavaScript (
number,string,boolean) não tem como "avisar" ninguém quando muda — não há como interceptarcontador = contador + 1para uma variável solta.ref()resolve isso guardando o valor dentro de um objeto ({ value: 40 }) e tornando esse objeto reativo. É por isso que, no script, você sempre acessa.value. No<template>, o compilador do Vue já sabe que uma variável vinda deref()precisa ser desembrulhada e faz isso por você automaticamente.
4.2 reactive() — para objetos e arrays¶
<script setup>
import { reactive } from 'vue'
// reactive() torna um OBJETO inteiro reativo, sem precisar de .value
const evento = reactive({
titulo: 'Semana da Computação',
vagas: 40,
inscritos: 12,
})
function inscrever() {
// acesso direto às propriedades, sem .value
evento.inscritos++
}
</script>
<template>
<p>{{ evento.titulo }}: {{ evento.inscritos }}/{{ evento.vagas }}</p>
<button @click="inscrever">Inscrever</button>
</template>
🔎 Por baixo do capô
reactive()usa umProxydo JavaScript (recurso nativo do ES2015) para interceptar leituras e escritas nas propriedades do objeto. Toda vez que você lêevento.titulo, o Proxy registra "alguém depende disso"; toda vez que você escreveevento.inscritos = ..., o Proxy avisa "isso mudou, quem depende precisa atualizar". Vamos detalhar esse mecanismo na Aula 03, no box de padrão de projeto Proxy.
4.3 Quando usar cada um¶
| Situação | Use |
|---|---|
| Valor único (número, string, boolean) | ref() |
| Objeto ou array com várias propriedades relacionadas | reactive() (ou ref() também funciona para objetos — é uma escolha de estilo) |
Precisa substituir o valor inteiro depois (ex.: eventos = novaLista) |
ref() — reactive() não permite reatribuir a variável inteira sem perder a reatividade |
<script setup>
import { ref } from 'vue'
// Para uma LISTA que será recarregada inteira (ex.: vinda de uma API),
// ref() é mais seguro: dá para trocar o array inteiro sem perder reatividade.
const eventos = ref([])
async function carregarEventos() {
eventos.value = [
{ id: 1, titulo: 'Semana da Computação' },
{ id: 2, titulo: 'Oficina de Vue.js' },
]
}
</script>
⚠️ Atenção Se você usasse
reactive([])e depois tentasseeventos = [...](reatribuir a variável), perderia a conexão reativa — otemplatecontinuaria olhando para o array antigo. Comreactive, mude o conteúdo (eventos.push(...),eventos.splice(...)), nunca a referência.
Equivalência com Options API — o data() que você viu na Seção 1.1 usa reatividade automática em tudo que ele retorna, sem você escolher entre ref e reactive:
// Options API — Vue decide a reatividade por trás das cortinas
export default {
data() {
return {
contadorVagas: 40, // equivale a um ref
evento: { titulo: 'Semana da Computação', inscritos: 12 }, // equivale a um reactive
}
},
methods: {
inscrever() {
this.evento.inscritos++ // this.<propriedade>, sem .value
},
},
}
5. Diretivas¶
Diretivas são atributos especiais do Vue, prefixados com v-, que ligam o template ao estado reativo. Vamos ver cada uma com exemplo próprio.
5.1 v-bind — vincular atributos HTML¶
<script setup>
import { ref } from 'vue'
const evento = ref({
titulo: 'Oficina de Vue.js',
imagemUrl: '/img/oficina-vue.jpg',
linkInativo: true,
})
</script>
<template>
<!-- forma completa -->
<img v-bind:src="evento.imagemUrl" v-bind:alt="evento.titulo" />
<!-- atalho ":" — é o que se usa no dia a dia -->
<img :src="evento.imagemUrl" :alt="evento.titulo" />
<!-- vinculando um atributo booleano -->
<button :disabled="evento.linkInativo">Ver detalhes</button>
</template>
v-bind conecta um atributo do HTML a uma expressão JavaScript reativa. Sem ele, src="evento.imagemUrl" seria só o texto literal "evento.imagemUrl" — não avaliaria a expressão.
5.2 v-on — escutar eventos¶
<script setup>
import { ref } from 'vue'
const contador = ref(0)
function incrementar() {
contador.value++
}
function tratarEnvio() {
console.log('formulário enviado, sem recarregar a página')
}
</script>
<template>
<!-- forma completa -->
<button v-on:click="incrementar">+1</button>
<!-- atalho "@" — o que se usa no dia a dia -->
<button @click="incrementar">+1</button>
<!-- modificador .prevent: chama event.preventDefault() automaticamente -->
<form @submit.prevent="tratarEnvio">
<button type="submit">Enviar</button>
</form>
<!-- modificador .stop: chama event.stopPropagation() -->
<div @click="console.log('clique no pai')">
<button @click.stop="console.log('clique só no botão')">Não propaga</button>
</div>
<!-- modificador .once: o handler roda só na primeira vez -->
<button @click.once="console.log('só uma vez')">Clique único</button>
<!-- modificador de tecla: só dispara com Enter -->
<input @keyup.enter="incrementar" placeholder="Pressione Enter" />
</template>
5.3 v-model — vinculação bidirecional em formulários¶
v-model é açúcar sintático que combina v-bind (mostra o valor) com v-on (atualiza o valor a cada mudança), poupando você de escrever os dois manualmente.
<script setup>
import { ref } from 'vue'
const busca = ref('')
const observacoes = ref('')
const aceitaTermos = ref(false)
const categoriasEscolhidas = ref([])
const categoriaSelecionada = ref('palestra')
const email = ref('')
</script>
<template>
<!-- input de texto -->
<input v-model="busca" type="text" placeholder="Buscar evento..." />
<p>Buscando por: {{ busca }}</p>
<!-- textarea -->
<textarea v-model="observacoes" placeholder="Observações"></textarea>
<!-- checkbox único: liga a uma variável boolean -->
<label>
<input v-model="aceitaTermos" type="checkbox" />
Aceito os termos
</label>
<!-- vários checkboxes: liga a um array — cada "value" marcado entra no array -->
<label><input v-model="categoriasEscolhidas" type="checkbox" value="palestra" /> Palestra</label>
<label><input v-model="categoriasEscolhidas" type="checkbox" value="minicurso" /> Minicurso</label>
<p>Selecionadas: {{ categoriasEscolhidas }}</p>
<!-- radio: só um valor por grupo de "name" implícito pelo v-model -->
<label><input v-model="categoriaSelecionada" type="radio" value="palestra" /> Palestra</label>
<label><input v-model="categoriaSelecionada" type="radio" value="workshop" /> Workshop</label>
<!-- select -->
<select v-model="categoriaSelecionada">
<option value="palestra">Palestra</option>
<option value="minicurso">Minicurso</option>
<option value="workshop">Workshop</option>
</select>
<!-- modificadores -->
<!-- .trim: remove espaços das pontas automaticamente -->
<input v-model.trim="email" type="email" placeholder="seu@email.com" />
<!-- .number: converte o valor digitado para Number -->
<input v-model.number="categoriaSelecionada" type="number" />
<!-- .lazy: sincroniza no evento "change" (ao sair do campo), não a cada tecla -->
<input v-model.lazy="busca" type="text" />
</template>
💡 Dica
v-modelé o par perfeito para o formulário de inscrição do UniEventos que vamos montar hoje: o valor do campo de busca já fica disponível como variável reativa, sem escrever um únicoaddEventListener.
5.4 v-if, v-else-if, v-else¶
<script setup>
import { ref } from 'vue'
const vagasRestantes = ref(0)
</script>
<template>
<p v-if="vagasRestantes > 10">Vagas disponíveis</p>
<p v-else-if="vagasRestantes > 0">Últimas vagas!</p>
<p v-else>Evento lotado</p>
</template>
v-if (e seus complementos) adiciona ou remove o elemento do DOM conforme a condição — quando falso, o elemento simplesmente não existe na página.
5.5 v-show — a alternativa que só esconde¶
<script setup>
import { ref } from 'vue'
const mostrarDetalhes = ref(false)
</script>
<template>
<button @click="mostrarDetalhes = !mostrarDetalhes">Alternar detalhes</button>
<!-- o elemento SEMPRE existe no DOM; v-show só alterna display: none -->
<div v-show="mostrarDetalhes">
<p>Estes são os detalhes completos do evento.</p>
</div>
</template>
v-if |
v-show |
|
|---|---|---|
| Como funciona | Remove/insere o elemento no DOM | Alterna display: none via CSS |
| Custo de alternar | Mais caro (recria o elemento) | Mais barato (só troca CSS) |
| Custo inicial se falso | Mais barato (nem renderiza) | Mais caro (sempre renderiza) |
| Quando usar | Condição muda raramente | Condição alterna com frequência (ex.: abrir/fechar painel) |
5.6 v-for e a importância da :key¶
<script setup>
import { ref } from 'vue'
const eventos = ref([
{ id: 1, titulo: 'Semana da Computação', categoria: 'palestra' },
{ id: 2, titulo: 'Oficina de Vue.js', categoria: 'minicurso' },
{ id: 3, titulo: 'Hackathon FACET', categoria: 'workshop' },
])
</script>
<template>
<ul>
<!-- CORRETO: key única e estável (o id do dado, nunca o índice) -->
<li v-for="evento in eventos" :key="evento.id">
{{ evento.titulo }} — {{ evento.categoria }}
</li>
</ul>
<!-- v-for também expõe o índice, como segundo parâmetro -->
<ol>
<li v-for="(evento, indice) in eventos" :key="evento.id">
{{ indice + 1 }}. {{ evento.titulo }}
</li>
</ol>
</template>
O bug do índice como :key:
<script setup>
import { ref } from 'vue'
const eventos = ref([
{ id: 1, titulo: 'Semana da Computação' },
{ id: 2, titulo: 'Oficina de Vue.js' },
{ id: 3, titulo: 'Hackathon FACET' },
])
function removerPrimeiro() {
eventos.value.shift() // remove o item do início
}
</script>
<template>
<!-- ERRADO: usar o índice como key -->
<div v-for="(evento, indice) in eventos" :key="indice">
<input type="checkbox" /> {{ evento.titulo }}
</div>
<button @click="removerPrimeiro">Remover o primeiro</button>
</template>
Se cada <input type="checkbox"> tiver estado próprio (marcado por quem interage) e você remover o primeiro item da lista, o Vue reaproveitará os elementos DOM pelo índice: o segundo item (índice 0 agora) herda o checkbox que estava marcado no antigo primeiro item, mesmo sendo um dado diferente. O texto atualiza corretamente, mas o estado interno do elemento (checkbox marcado, valor de input, foco) fica errado, porque o Vue pensa que é "o mesmo elemento" da posição 0.
⚠️ Atenção Use sempre um identificador estável e único do dado (
evento.id) como:key, nunca o índice dov-for. O índice muda quando a lista é reordenada, filtrada ou tem itens removidos — e o Vue usa akeyexatamente para saber "isso é o mesmo item de antes ou é outro?".
5.7 v-text e v-html¶
<script setup>
import { ref } from 'vue'
const descricaoSimples = ref('Evento sobre Vue.js')
const descricaoComHtml = ref('<strong>Evento</strong> sobre Vue.js')
</script>
<template>
<!-- v-text é equivalente a {{ }}, mas substitui TODO o conteúdo do elemento -->
<p v-text="descricaoSimples"></p>
<!-- interpolação normal: sempre trata o conteúdo como TEXTO puro (escapa HTML) -->
<p>{{ descricaoComHtml }}</p>
<!-- renderiza literalmente: <strong>Evento</strong> sobre Vue.js -->
<!-- v-html: injeta HTML de verdade, interpretado pelo navegador -->
<p v-html="descricaoComHtml"></p>
<!-- renderiza: Evento (em negrito) sobre Vue.js -->
</template>
⚠️ Atenção — risco de XSS
v-htmlinjeta HTML bruto na página, sem escapar. Se o conteúdo vier de um usuário (comentário, campo de formulário, dado de API não confiável) e contiver<script>ou atributos comoonerror=, isso executa no navegador de quem visualiza a página — um ataque de Cross-Site Scripting (XSS). Usev-htmlsó com conteúdo que você controla (texto formatado vindo do seu próprio backend, sanitizado). Para exibir texto de usuário, use interpolação{{ }}normal, que sempre escapa.
6. Ciclo de vida do componente¶
Todo componente Vue passa por fases previsíveis, do momento em que é criado até ser destruído.
criação do componente
│
▼
onBeforeMount() ← ainda não existe no DOM real
│
▼
[Vue monta o componente no DOM]
│
▼
onMounted() ← já existe no DOM, pode acessar elementos, disparar fetch
│
▼
(o componente vive aqui — reage a mudanças de estado)
│
├──► dado reativo muda
│ │
│ ▼
│ onBeforeUpdate() ← estado já mudou, DOM ainda não
│ │
│ ▼
│ [Vue re-renderiza o DOM]
│ │
│ ▼
│ onUpdated() ← DOM já reflete o novo estado
│ │
│ └──► volta a "viver" aqui
│
▼
onBeforeUnmount() ← componente prestes a ser removido
│
▼
[Vue remove o componente do DOM]
│
▼
onUnmounted() ← já foi removido; hora de limpar recursos
<!-- src/components/DemoCicloDeVida.vue -->
<script setup>
import { ref, onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted } from 'vue'
const segundos = ref(0)
let intervaloId = null
onBeforeMount(() => {
console.log('[ciclo] onBeforeMount — ainda não está no DOM')
})
onMounted(() => {
console.log('[ciclo] onMounted — já está no DOM, iniciando o relógio')
// setInterval é um recurso "externo" ao Vue — precisa ser limpo manualmente
intervaloId = setInterval(() => {
segundos.value++
}, 1000)
})
onBeforeUpdate(() => {
console.log('[ciclo] onBeforeUpdate — segundos mudou para', segundos.value, 'mas o DOM ainda não')
})
onUpdated(() => {
console.log('[ciclo] onUpdated — DOM já mostra', segundos.value)
})
onBeforeUnmount(() => {
console.log('[ciclo] onBeforeUnmount — componente prestes a sumir')
})
onUnmounted(() => {
console.log('[ciclo] onUnmounted — limpando o setInterval')
// ESSENCIAL: sem isso, o timer continua rodando mesmo após o
// componente sumir da tela — um vazamento de memória clássico.
clearInterval(intervaloId)
})
</script>
<template>
<p>Segundos desde a montagem: {{ segundos }}</p>
</template>
Equivalência com Options API:
| Composition API | Options API |
|---|---|
onBeforeMount |
beforeMount() |
onMounted |
mounted() |
onBeforeUpdate |
beforeUpdate() |
onUpdated |
updated() |
onBeforeUnmount |
beforeUnmount() |
onUnmounted |
unmounted() |
// Options API — os mesmos hooks, como métodos especiais do objeto
export default {
data() {
return { segundos: 0, intervaloId: null }
},
mounted() {
console.log('mounted')
this.intervaloId = setInterval(() => { this.segundos++ }, 1000)
},
unmounted() {
clearInterval(this.intervaloId)
},
}
📌 Na prova
onMountedé, de longe, o hook mais usado na prática — é onde disparamos requisiçõesfetch(Aula 03) porque é o primeiro momento em que temos garantia de que o DOM existe.onUnmountedé onde limpamos qualquer recurso externo (setInterval,addEventListeneremwindow, conexões abertas) para não vazar memória quando o componente sai de cena.
🧩 Padrão de projeto em uso¶
🧩 Padrão de projeto em uso — Observer (comportamental)¶
O padrão Observer define uma relação um-para-muitos entre um objeto (o subject, que muda de estado) e vários observers, que são notificados automaticamente sempre que o subject muda — sem que o subject precise conhecer os observers individualmente.
Um Observer "na mão", em JavaScript puro:
```js // Um EventTarget simplificado — a base do Observer em JS puro class ContadorObservavel { constructor() { this.valor = 0 this.observadores = [] }
observar(funcaoCallback) { this.observadores.push(funcaoCallback) }
incrementar() { this.valor++ // notifica TODOS os observadores registrados this.observadores.forEach((callback) => callback(this.valor)) } }
const contador = new ContadorObservavel() contador.observar((valor) => console.log('UI A atualizada:', valor)) contador.observar((valor) => console.log('UI B atualizada:', valor)) contador.incrementar() // dispara os dois observadores ```
É exatamente isso que o sistema de reatividade do Vue faz por baixo dos panos. Quando você escreve
{{ contador }}no template, o Vue registra esse trecho do DOM como um "observador" da variávelcontador. Quando você escrevecontador.value++, o Vue percorre a lista de observadores daquela variável (os pedaços de template que a usam) e re-renderiza só eles — sem você escreverobservar()ounotificar()manualmente.refereactivesão, na essência, subjects observáveis; cada trecho do template que os lê vira, automaticamente, um observer. Vamos abrir esse mecanismo com mais detalhe na Aula 03, quando falarmos do padrão Proxy.
💻 Mão na massa — primeira versão do UniEventos¶
Vamos construir, dentro do projeto unieventos-web criado na Seção 3, a primeira tela funcional: lista de eventos com busca, filtro por categoria e inscrição.
Passo 1 — dados de exemplo. Crie um arquivo separado só com os dados, para manter o componente organizado (o mesmo raciocínio do módulo eventos.js da Aula 01).
// src/data/eventos.js
export const eventosIniciais = [
{
id: 1,
titulo: 'Semana da Computação',
categoria: 'palestra',
dataHora: '2026-09-10T19:00:00',
local: 'Auditório Central',
vagas: 40,
inscritos: 12,
},
{
id: 2,
titulo: 'Oficina de Vue.js',
categoria: 'minicurso',
dataHora: '2026-08-20T14:00:00',
local: 'Laboratório 3',
vagas: 25,
inscritos: 25,
},
{
id: 3,
titulo: 'Hackathon FACET',
categoria: 'workshop',
dataHora: '2026-10-05T08:00:00',
local: 'Bloco B',
vagas: 60,
inscritos: 18,
},
{
id: 4,
titulo: 'Introdução a IA',
categoria: 'palestra',
dataHora: '2026-08-18T19:30:00',
local: 'Auditório Central',
vagas: 80,
inscritos: 55,
},
]
Passo 2 — o componente principal. Ainda usamos filter "na mão" dentro de uma função (vamos trocar por computed, que faz cache, na Aula 03 — por hoje o objetivo é praticar diretivas).
<!-- src/App.vue -->
<script setup>
import { ref } from 'vue'
import { eventosIniciais } from './data/eventos.js'
const eventos = ref(eventosIniciais)
const busca = ref('')
const categoriaFiltro = ref('')
// Função comum (não computed ainda) — recalculada manualmente a cada uso.
// Repare que ela SEMPRE cria um array novo com filter, sem mutar `eventos`.
function obterEventosFiltrados() {
return eventos.value
.filter((evento) => evento.titulo.toLowerCase().includes(busca.value.toLowerCase()))
.filter((evento) => categoriaFiltro.value === '' || evento.categoria === categoriaFiltro.value)
}
function inscrever(eventoId) {
const evento = eventos.value.find((item) => item.id === eventoId)
if (!evento) return
if (evento.inscritos >= evento.vagas) {
alert('Este evento está lotado.')
return
}
evento.inscritos++
}
function vagasRestantes(evento) {
return evento.vagas - evento.inscritos
}
</script>
<template>
<main class="pagina">
<h1>UniEventos</h1>
<p>Encontre e inscreva-se em eventos acadêmicos.</p>
<div class="filtros">
<input
v-model.trim="busca"
type="text"
placeholder="Buscar por título..."
/>
<select v-model="categoriaFiltro">
<option value="">Todas as categorias</option>
<option value="palestra">Palestra</option>
<option value="minicurso">Minicurso</option>
<option value="workshop">Workshop</option>
</select>
</div>
<ul class="lista-eventos">
<li
v-for="evento in obterEventosFiltrados()"
:key="evento.id"
class="card-evento"
>
<h2>{{ evento.titulo }}</h2>
<p>Categoria: {{ evento.categoria }}</p>
<p>Local: {{ evento.local }}</p>
<p v-if="vagasRestantes(evento) > 0">
{{ vagasRestantes(evento) }} vaga(s) restante(s)
</p>
<p v-else class="lotado">Evento lotado</p>
<button
:disabled="vagasRestantes(evento) === 0"
@click="inscrever(evento.id)"
>
Inscrever-se
</button>
</li>
</ul>
<p v-if="obterEventosFiltrados().length === 0" class="vazio">
Nenhum evento encontrado com estes filtros.
</p>
</main>
</template>
<style scoped>
.pagina {
max-width: 720px;
margin: 0 auto;
padding: 2rem;
font-family: sans-serif;
}
.filtros {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.lista-eventos {
list-style: none;
padding: 0;
display: grid;
gap: 1rem;
}
.card-evento {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
}
.lotado {
color: #c0392b;
font-weight: bold;
}
.vazio {
text-align: center;
color: #666;
}
</style>
⚠️ Atenção Repare que
obterEventosFiltrados()é chamada três vezes no template (nav-for, e de novo para checar se está vazio). Cada chamada refaz ofilterduas vezes do zero — funciona, mas é desperdício de processamento e, pior, dificulta manter os resultados sincronizados. Vamos resolver isso comcomputed()já na próxima aula.
🧪 Laboratório¶
1. Contador de inscritos totais — adicione, logo abaixo do <h1>, um parágrafo mostrando quantas pessoas estão inscritas somando todos os eventos, usando reduce (Aula 01).
Resultado esperado: um número que aumenta a cada clique em "Inscrever-se".
Dica
eventos.value.reduce((total, evento) => total + evento.inscritos, 0) dentro de uma função chamada no template, ou direto em uma expressão de interpolação.
2. Botão de limpar filtros — adicione um botão que zera busca e categoriaFiltro de uma vez.
Resultado esperado: clicar no botão limpa o campo de texto e volta o select para "Todas as categorias".
Dica
function limparFiltros() {
busca.value = ''
categoriaFiltro.value = ''
}
3. Destacar evento quase lotado com v-show — mostre um aviso "Últimas vagas!" com v-show (não v-if) quando vagasRestantes(evento) <= 5 && vagasRestantes(evento) > 0.
Resultado esperado: o aviso aparece/some conforme inscrições, sem recriar o elemento no DOM (confira no DevTools, aba Elements).
Dica
<span v-show="vagasRestantes(evento) <= 5 && vagasRestantes(evento) > 0">Últimas vagas!</span>
4. Modificador .once em uma mensagem de boas-vindas — adicione um botão "Ver dica" que mostra um alerta apenas na primeira vez que for clicado, usando @click.once.
Resultado esperado: cliques seguintes não fazem nada.
Dica
<button @click.once="alert('Dica: use os filtros para encontrar eventos mais rápido!')">Ver dica</button>
5. Corrigir uma :key proposital — troque temporariamente :key="evento.id" por :key="indiceDoLoop" (usando a forma v-for="(evento, indiceDoLoop) in ..."), adicione um <input type="checkbox"> dentro de cada card, marque alguns, filtre por categoria e observe o comportamento estranho dos checkboxes. Depois desfaça a mudança.
Resultado esperado: você reproduz em sala o bug descrito na Seção 5.6 antes de corrigi-lo.
Dica
O bug aparece quando a lista filtrada muda de tamanho/ordem — os checkboxes "grudam" na posição, não no evento.
🐛 Erros comuns e como resolver¶
| Sintoma | Causa | Solução |
|---|---|---|
Tela em branco, console mostra erro sobre .vue |
Arquivo .vue com erro de sintaxe, ou <script setup> mal fechado |
Confira se os três blocos (script, template, style) estão bem formados e balanceados |
Alterei contador no script mas a tela não atualiza |
Esqueceu o .value ao alterar um ref |
Sempre variavel.value = novoValor dentro do <script setup> |
v-model não atualiza nada |
Esqueceu de declarar a variável com ref(), ou digitou o nome errado no template |
Confira se a variável usada no v-model existe e foi criada com ref() |
| Checkbox/input "gruda" em posição errada após filtrar/remover | :key usando o índice do v-for |
Use um identificador estável do dado, como evento.id |
v-html mostra texto cru com as tags <...> aparecendo |
Trocou v-html por interpolação {{ }} sem querer |
{{ }} sempre escapa HTML; use v-html só quando o objetivo é renderizar HTML de verdade |
🏠 Atividade assíncrona (1 h)¶
No repositório do seu projeto autoral:
- Rode
npm create vue@latest(ounpx create-vue@latest <seu-tema>-web --router --pinia --eslint --prettier) dentro da pasta do seu projeto. - Crie um arquivo
src/data/<entidade principal>.jscom um array de pelo menos 4 itens de exemplo do seu domínio (equivalente aoeventos.jsde hoje). - Em
src/App.vue, monte uma primeira listagem usandov-forcom:keycorreto, um campo de busca comv-modele pelo menos um filtro (v-modelem<select>). - Adicione uma ação (ex.: "reservar", "favoritar", "adicionar ao carrinho" — o verbo do seu domínio) usando
v-on/@click. - Faça commit e push.
Critério de pronto: npm run dev abre a aplicação, a lista aparece, busca e filtro funcionam, :key usa um identificador estável.
✅ Checkpoint do projeto autoral¶
- [ ] Projeto Vite criado com
create-vue(flags--router --pinia) no repositório do tema autoral. - [ ]
src/data/*.jscom dados de exemplo do domínio escolhido. - [ ] Listagem funcionando com
v-fore:keyestável (nunca o índice). - [ ] Busca com
v-modele pelo menos um filtro funcionando. - [ ] Uma ação de interação implementada com
v-on/@click. - [ ] Commit enviado ao GitHub.
📚 Para aprofundar¶
- Documentação oficial do Vue 3 — Introdução
- Vue 3 — Reactivity Fundamentals
- Vue 3 — Template Syntax (diretivas)
- Vue 3 — Lifecycle Hooks
- Vue 3 — List Rendering (
v-fore:key) - Documentação do Vite
Próxima aula (03, 26/08/2026): aprofundamos v-for, resolvemos o antipadrão v-for + v-if juntos, introduzimos computed() (com cache de verdade) e usamos onMounted() para carregar dados de uma fonte assíncrona, com estados de carregando/erro/vazio.
Aula 03 — Vue: listas, computed e ciclo de vida
Na Aula 02 criamos a primeira versão do UniEventos: lista, busca, filtro e inscrição, tudo com diretivas básicas e uma função de filtro chamada manualmente três vezes no template. Hoje resolvemos exatamente esse desperdício com computed(), aprofundamos v-for e passamos a carregar os eventos de forma assíncrona dentro de onMounted().
🎯 Objetivos de aprendizagem¶
Ao final desta aula você será capaz de:
- Usar
v-forsobre arrays, objetos e ranges, inclusive em<template>e listas aninhadas. - Explicar por que combinar
v-forcomv-ifno mesmo elemento é um antipadrão e resolvê-lo comcomputed. - Criar propriedades
computed(), entender seu cache e diferenciar computed de método e de watch. - Usar
watch()ewatchEffect()com as opçõesimmediateedeep, e parar um watcher. - Carregar dados assincronamente dentro de
onMounted(), implementando os três estados de tela: carregando, erro e vazio. - Formatar datas e números em pt-BR com
Intl, e aplicar classes/estilos condicionais com:classe:style. - Entregar uma versão do UniEventos com filtros combinados resolvidos por computed, carregamento assíncrono e destaque visual condicional.
📋 Pré-requisitos desta aula¶
- Projeto
unieventos-webfuncionando, com a listagem, busca e filtro da Aula 02. - Domínio de
ref(),v-model,v-for+:key,v-if/v-showe dos hooksonMounted/onUnmounted(Aula 02).
⚠️ Atenção Se seu
App.vueda Aula 02 ainda não estiver rodando comnpm run devsem erros, resolva isso antes de continuar — hoje vamos editar esse mesmo arquivo.
🗺️ Roteiro¶
| Bloco | Tempo | Atividade |
|---|---|---|
| 1 | 50 min | v-for avançado, o antipadrão v-for+v-if, introdução a computed() |
| 2 | 50 min | computed com getter/setter, watch/watchEffect, onMounted com os três estados |
| 3 | 50 min | Formatação Intl, :class/:style, mão na massa: UniEventos com filtros combinados |
1. v-for avançado¶
Na Aula 02 vimos v-for sobre um array simples. O Vue também itera sobre objetos, ranges numéricos, e permite estruturas mais ricas.
1.1 v-for sobre objetos¶
<script setup>
import { ref } from 'vue'
const evento = ref({
titulo: 'Semana da Computação',
categoria: 'palestra',
local: 'Auditório Central',
vagas: 40,
})
</script>
<template>
<!-- (valor, chave) — nessa ordem -->
<ul>
<li v-for="(valor, chave) in evento" :key="chave">
<strong>{{ chave }}:</strong> {{ valor }}
</li>
</ul>
<!-- também existe (valor, chave, indice) com o terceiro parâmetro opcional -->
<ul>
<li v-for="(valor, chave, indice) in evento" :key="chave">
{{ indice }}. {{ chave }} = {{ valor }}
</li>
</ul>
</template>
1.2 v-for sobre um range numérico¶
<template>
<!-- v-for="n in 5" gera n = 1, 2, 3, 4, 5 (começa em 1, não em 0) -->
<span v-for="n in 5" :key="n" class="estrela">⭐</span>
<!-- útil para paginação simples -->
<button v-for="pagina in 4" :key="pagina">{{ pagina }}</button>
</template>
1.3 v-for em <template> — repetir um grupo sem elemento extra¶
<script setup>
import { ref } from 'vue'
const eventos = ref([
{ id: 1, titulo: 'Semana da Computação', categoria: 'palestra' },
{ id: 2, titulo: 'Oficina de Vue.js', categoria: 'minicurso' },
])
</script>
<template>
<dl>
<!-- <template> com v-for não gera elemento HTML próprio no DOM final —
só repete o que está dentro dele. Útil quando você precisa de
MAIS de um elemento irmão por item, sem um <div> desnecessário. -->
<template v-for="evento in eventos" :key="evento.id">
<dt>{{ evento.titulo }}</dt>
<dd>{{ evento.categoria }}</dd>
</template>
</dl>
</template>
1.4 Listas aninhadas¶
<script setup>
import { ref } from 'vue'
const programacao = ref([
{
dia: '10/09/2026',
sessoes: [
{ horario: '19h', titulo: 'Abertura' },
{ horario: '20h', titulo: 'Palestra magna' },
],
},
{
dia: '11/09/2026',
sessoes: [
{ horario: '14h', titulo: 'Oficina de Vue.js' },
{ horario: '16h', titulo: 'Mesa redonda' },
],
},
])
</script>
<template>
<div v-for="dia in programacao" :key="dia.dia" class="dia-programacao">
<h3>{{ dia.dia }}</h3>
<!-- v-for aninhado: a key interna só precisa ser única DENTRO do
v-for externo, mas usar algo que combine as duas chaves evita
qualquer ambiguidade em listas grandes -->
<ul>
<li v-for="sessao in dia.sessoes" :key="`${dia.dia}-${sessao.horario}`">
{{ sessao.horario }} — {{ sessao.titulo }}
</li>
</ul>
</div>
</template>
2. O antipadrão v-for + v-if juntos¶
<script setup>
import { ref } from 'vue'
const eventos = ref([
{ id: 1, titulo: 'Semana da Computação', categoria: 'palestra', vagas: 40, inscritos: 40 },
{ id: 2, titulo: 'Oficina de Vue.js', categoria: 'minicurso', vagas: 25, inscritos: 10 },
])
</script>
<template>
<!-- ⚠️ EVITE: v-for e v-if no MESMO elemento -->
<li
v-for="evento in eventos"
v-if="evento.inscritos < evento.vagas"
:key="evento.id"
>
{{ evento.titulo }}
</li>
</template>
Por que isso é um problema:
- Precedência confusa. No Vue 3, quando
v-ifev-forestão no mesmo elemento,v-iftem prioridade mais alta na avaliação, mas isso significa que ele tenta avaliar a condição antes da variável dov-for(evento) estar disponível no escopo — um erro fácil de disparar sem perceber. - Desempenho. O Vue recria a checagem
v-ifa cada item, em todo re-render da lista, mesmo quando o critério do filtro não teve nenhuma relação com a mudança que disparou a atualização. - Legibilidade. Misturar "o que iterar" com "o que exibir" no mesmo atributo deixa o template difícil de ler.
A solução: filtre antes, com computed, e itere sobre o resultado já filtrado.
<script setup>
import { ref, computed } from 'vue'
const eventos = ref([
{ id: 1, titulo: 'Semana da Computação', categoria: 'palestra', vagas: 40, inscritos: 40 },
{ id: 2, titulo: 'Oficina de Vue.js', categoria: 'minicurso', vagas: 25, inscritos: 10 },
])
// computed: calcula UMA VEZ, o template só itera sobre o resultado
const eventosComVaga = computed(() =>
eventos.value.filter((evento) => evento.inscritos < evento.vagas),
)
</script>
<template>
<!-- correto: v-for sozinho, sobre uma lista já pronta -->
<li v-for="evento in eventosComVaga" :key="evento.id">
{{ evento.titulo }}
</li>
</template>
Isso nos leva ao assunto central da aula de hoje: computed().
3. computed(): cache de verdade¶
3.1 O problema que computed resolve¶
Na Aula 02, obterEventosFiltrados() era uma função comum, chamada manualmente no template. Toda chamada refaz o cálculo do zero — não importa se os dados mudaram ou não.
<script setup>
import { ref } from 'vue'
const eventos = ref([
{ id: 1, titulo: 'Semana da Computação', vagas: 40, inscritos: 12 },
{ id: 2, titulo: 'Oficina de Vue.js', vagas: 25, inscritos: 25 },
])
// MÉTODO: recalcula toda vez que é chamado, mesmo sem nada ter mudado
function totalDeVagasComMetodo() {
console.log('calculando total de vagas (método)...')
return eventos.value.reduce((total, evento) => total + evento.vagas, 0)
}
</script>
<template>
<!-- se este valor aparecer 3 vezes no template, o log acima roda 3 vezes -->
<p>{{ totalDeVagasComMetodo() }}</p>
<p>{{ totalDeVagasComMetodo() }}</p>
</template>
<script setup>
import { ref, computed } from 'vue'
const eventos = ref([
{ id: 1, titulo: 'Semana da Computação', vagas: 40, inscritos: 12 },
{ id: 2, titulo: 'Oficina de Vue.js', vagas: 25, inscritos: 25 },
])
// COMPUTED: calcula uma vez e GUARDA o resultado em cache.
// Só recalcula quando uma dependência reativa (eventos, neste caso) muda.
const totalDeVagas = computed(() => {
console.log('calculando total de vagas (computed)...')
return eventos.value.reduce((total, evento) => total + evento.vagas, 0)
})
</script>
<template>
<!-- mesmo aparecendo 2 vezes, o log acima roda 1 vez só,
porque o segundo acesso lê o valor já em cache -->
<p>{{ totalDeVagas }}</p>
<p>{{ totalDeVagas }}</p>
</template>
🔎 Por baixo do capô Um
computedsabe exatamente quais variáveis reativas ele lê durante sua execução (aqui,eventos) — o mesmo mecanismo de rastreamento de dependências do padrão Observer que vimos na Aula 02. Enquanto nenhuma dessas dependências mudar, o Vue devolve o valor guardado em cache, sem executar a função de novo. Isso é diferente de um método, que roda de novo a cada chamada, sempre, sem cache algum.
3.2 computed com getter e setter¶
Por padrão, um computed é somente leitura. Mas é possível criar um que também aceita escrita, definindo get e set:
<script setup>
import { ref, computed } from 'vue'
const nome = ref('Ivan')
const sobrenome = ref('Pires')
// forma somente leitura (a mais comum)
const nomeCompleto = computed(() => `${nome.value} ${sobrenome.value}`)
// forma com getter E setter
const nomeCompletoEditavel = computed({
get() {
return `${nome.value} ${sobrenome.value}`
},
set(novoValor) {
const partes = novoValor.split(' ')
nome.value = partes[0]
sobrenome.value = partes.slice(1).join(' ')
},
})
function renomear() {
// escrever em um computed com setter dispara o "set" acima,
// que por sua vez atualiza nome e sobrenome
nomeCompletoEditavel.value = 'Maria Silva'
}
</script>
<template>
<p>{{ nomeCompleto }}</p>
<button @click="renomear">Renomear</button>
</template>
3.3 Computed encadeadas¶
<script setup>
import { ref, computed } from 'vue'
const eventos = ref([
{ id: 1, titulo: 'Semana da Computação', categoria: 'palestra', vagas: 40, inscritos: 40 },
{ id: 2, titulo: 'Oficina de Vue.js', categoria: 'minicurso', vagas: 25, inscritos: 10 },
{ id: 3, titulo: 'Hackathon FACET', categoria: 'workshop', vagas: 60, inscritos: 18 },
])
// primeira computed: filtra por vaga disponível
const eventosComVaga = computed(() =>
eventos.value.filter((evento) => evento.inscritos < evento.vagas),
)
// segunda computed: DEPENDE da primeira — encadeamento
const totalDeVagasLivres = computed(() =>
eventosComVaga.value.reduce((total, evento) => total + (evento.vagas - evento.inscritos), 0),
)
</script>
<template>
<p>Eventos com vaga: {{ eventosComVaga.length }}</p>
<p>Total de vagas livres: {{ totalDeVagasLivres }}</p>
</template>
3.4 Computed × método × watch¶
computed |
método | watch |
|
|---|---|---|---|
| Tem cache? | Sim | Não | Não se aplica (não retorna valor) |
| Quando roda | Só quando uma dependência muda | Toda vez que é chamado | Quando a fonte observada muda |
| Uso típico | Derivar um valor a partir de outro estado | Reagir a um evento de UI (clique) | Executar um efeito colateral (chamar API, logar, sincronizar localStorage) |
| Retorna valor usável no template? | Sim | Sim (se chamado) | Não diretamente |
4. watch() e watchEffect()¶
computed deriva um valor. watch/watchEffect executam um efeito colateral (algo que não é "calcular e devolver", como fazer uma requisição, gravar em localStorage, exibir um alerta) em reação a uma mudança.
4.1 watch() — observa uma fonte específica¶
<script setup>
import { ref, watch } from 'vue'
const busca = ref('')
const totalDeBuscas = ref(0)
// watch(fonte, callback) — só roda quando "busca" muda
watch(busca, (valorNovo, valorAntigo) => {
console.log(`busca mudou de "${valorAntigo}" para "${valorNovo}"`)
totalDeBuscas.value++
})
// watch com { immediate: true } — roda também na primeira vez,
// mesmo sem a fonte ainda ter mudado
const categoriaFiltro = ref('')
watch(
categoriaFiltro,
(valor) => {
console.log('categoria selecionada:', valor || '(nenhuma)')
},
{ immediate: true },
)
</script>
4.2 watch com { deep: true } — observar objetos/arrays por dentro¶
<script setup>
import { reactive, watch } from 'vue'
const filtros = reactive({
busca: '',
categoria: '',
apenasComVaga: false,
})
// por padrão, watch em um objeto reactive só dispara se a REFERÊNCIA mudar.
// como alterações em filtros.busca são mudanças INTERNAS ao objeto,
// precisamos de { deep: true } para o watch perceber.
watch(
filtros,
(valorNovo) => {
console.log('algum filtro mudou:', valorNovo)
},
{ deep: true },
)
</script>
4.3 watchEffect() — roda de novo automaticamente, sem declarar a fonte¶
<script setup>
import { ref, watchEffect } from 'vue'
const busca = ref('')
const categoriaFiltro = ref('')
// watchEffect executa a função IMEDIATAMENTE (não precisa de immediate: true)
// e registra sozinho, ao rodar, quais variáveis reativas ela leu —
// depois reexecuta sempre que qualquer uma delas mudar.
watchEffect(() => {
console.log(`filtro atual → busca: "${busca.value}", categoria: "${categoriaFiltro.value}"`)
})
</script>
4.4 Parando um watcher¶
<script setup>
import { ref, watch } from 'vue'
const contador = ref(0)
// watch() e watchEffect() retornam uma função para PARAR de observar
const pararDeObservar = watch(contador, (valor) => {
console.log('contador:', valor)
})
function pararObservacao() {
pararDeObservar() // a partir daqui, mudanças em `contador` não disparam mais o log
}
</script>
watch |
watchEffect |
|
|---|---|---|
| Declara a fonte explicitamente? | Sim — watch(fonte, callback) |
Não — descobre sozinho lendo o corpo da função |
| Roda na criação, por padrão? | Não (a menos que immediate: true) |
Sim, sempre |
| Acesso ao valor antigo? | Sim ((novo, antigo) => ...) |
Não |
| Quando usar | Precisa saber o valor anterior, ou observar só uma fonte específica | Quer reagir a "qualquer coisa que a função usa", de forma mais enxuta |
📌 Na prova Regra prática: se você precisa de um valor derivado para usar no template, use
computed. Se precisa fazer algo (chamar API, gravar em disco, mostrar um alerta) quando um dado muda, usewatchouwatchEffect.
5. onMounted() e carregamento de dados¶
Até agora, eventos nasce pronto, direto de um array local. Na prática, dados vêm de uma API ou arquivo remoto — e isso é assíncrono. O lugar certo para disparar essa busca é o hook onMounted (Aula 02), porque é aí que temos garantia de que o componente já existe.
5.1 O padrão dos três estados de tela¶
Toda tela que depende de dados assíncronos deveria tratar três situações:
- Carregando — a requisição está em andamento.
- Erro — a requisição falhou (rede caiu, servidor retornou erro).
- Vazio — a requisição funcionou, mas não há dados para mostrar.
<!-- src/components/ListaEventosAssincrona.vue -->
<script setup>
import { ref, onMounted } from 'vue'
const eventos = ref([])
const carregando = ref(true)
const erro = ref(null)
async function carregarEventos() {
carregando.value = true
erro.value = null
try {
const resposta = await fetch('/eventos.json')
if (!resposta.ok) {
throw new Error(`Erro HTTP: ${resposta.status}`)
}
const dados = await resposta.json()
eventos.value = dados
} catch (erroCapturado) {
erro.value = 'Não foi possível carregar os eventos. Tente novamente mais tarde.'
console.error(erroCapturado)
} finally {
carregando.value = false
}
}
onMounted(() => {
carregarEventos()
})
</script>
<template>
<div class="lista-eventos">
<!-- estado 1: carregando -->
<p v-if="carregando">Carregando eventos...</p>
<!-- estado 2: erro -->
<div v-else-if="erro" class="erro">
<p>{{ erro }}</p>
<button @click="carregarEventos">Tentar novamente</button>
</div>
<!-- estado 3: vazio (sem erro, sem carregar, mas sem itens) -->
<p v-else-if="eventos.length === 0">Nenhum evento cadastrado no momento.</p>
<!-- estado 4 (implícito): sucesso com dados -->
<ul v-else>
<li v-for="evento in eventos" :key="evento.id">{{ evento.titulo }}</li>
</ul>
</div>
</template>
Crie o arquivo de dados simulando uma API, em public/eventos.json (a pasta public/ do Vite é servida como está, sem processamento):
[
{
"id": 1,
"titulo": "Semana da Computação",
"categoria": "palestra",
"dataHora": "2026-09-10T19:00:00",
"local": "Auditório Central",
"vagas": 40,
"inscritos": 12
},
{
"id": 2,
"titulo": "Oficina de Vue.js",
"categoria": "minicurso",
"dataHora": "2026-08-20T14:00:00",
"local": "Laboratório 3",
"vagas": 25,
"inscritos": 25
},
{
"id": 3,
"titulo": "Hackathon FACET",
"categoria": "workshop",
"dataHora": "2026-10-05T08:00:00",
"local": "Bloco B",
"vagas": 60,
"inscritos": 18
},
{
"id": 4,
"titulo": "Introdução a IA",
"categoria": "palestra",
"dataHora": "2026-08-18T19:30:00",
"local": "Auditório Central",
"vagas": 80,
"inscritos": 55
}
]
💡 Dica Para testar o estado de erro de propósito, troque a URL do
fetchpara algo que não existe (/eventos-inexistente.json) e veja a tela de erro com o botão "Tentar novamente" funcionando.
6. Formatação com Intl e ligação de classes/estilos¶
6.1 Intl.DateTimeFormat e Intl.NumberFormat¶
<script setup>
const dataEvento = new Date('2026-09-10T19:00:00')
const valorInscricao = 45.9
const dataFormatada = new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(dataEvento)
const valorFormatado = new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
}).format(valorInscricao)
</script>
<template>
<p>Data: {{ dataFormatada }}</p>
<!-- 10 de setembro de 2026 19:00 -->
<p>Valor: {{ valorFormatado }}</p>
<!-- R$ 45,90 -->
</template>
💡 Dica Em vez de instanciar
Intl.DateTimeFormat/Intl.NumberFormatde novo a cada uso, crie uma função utilitária reaproveitável (src/utils/formatadores.js) — é o que faremos na seção de "Mão na massa" a seguir.
6.2 :class — objeto e array¶
<script setup>
import { ref, computed } from 'vue'
const evento = ref({ titulo: 'Oficina de Vue.js', vagas: 25, inscritos: 25 })
const estaLotado = computed(() => evento.value.inscritos >= evento.value.vagas)
</script>
<template>
<!-- :class com OBJETO: cada chave é uma classe, o valor decide se ela é aplicada -->
<div :class="{ 'card-lotado': estaLotado, 'card-disponivel': !estaLotado }">
{{ evento.titulo }}
</div>
<!-- :class com ARRAY: combina classes fixas e condicionais -->
<div :class="['card', estaLotado ? 'card-lotado' : 'card-disponivel']">
{{ evento.titulo }}
</div>
<!-- misturando classe estática (sem :) com classe dinâmica (com :) -->
<div class="card" :class="{ 'card-lotado': estaLotado }">
{{ evento.titulo }}
</div>
</template>
<style scoped>
.card {
border: 1px solid #ccc;
padding: 1rem;
}
.card-lotado {
border-color: #c0392b;
background-color: #fdecea;
}
.card-disponivel {
border-color: #27ae60;
}
</style>
6.3 :style¶
<script setup>
import { ref, computed } from 'vue'
const vagas = ref(40)
const inscritos = ref(30)
const percentualOcupado = computed(() => Math.round((inscritos.value / vagas.value) * 100))
</script>
<template>
<!-- :style com objeto -->
<div
class="barra-progresso"
:style="{ width: percentualOcupado + '%', backgroundColor: percentualOcupado > 80 ? '#c0392b' : '#27ae60' }"
></div>
<!-- :style com array de objetos: combina múltiplos conjuntos de estilo -->
<p :style="[{ fontWeight: 'bold' }, { color: percentualOcupado > 80 ? 'red' : 'black' }]">
{{ percentualOcupado }}% ocupado
</p>
</template>
<style scoped>
.barra-progresso {
height: 8px;
border-radius: 4px;
transition: width 0.3s ease;
}
</style>
🧩 Padrão de projeto em uso¶
🧩 Padrão de projeto em uso — Proxy (estrutural)¶
O padrão Proxy cria um objeto substituto que controla o acesso a outro objeto — interceptando leituras, escritas ou chamadas, e adicionando comportamento extra sem que quem usa o objeto perceba a diferença.
Um Proxy simplificado, em JavaScript puro, para logar todo acesso a um objeto:
```js const evento = { titulo: 'Semana da Computação', vagas: 40 }
const eventoComLog = new Proxy(evento, { get(alvo, propriedade) { console.log(
[leitura] alguém acessou "${propriedade}") return alvo[propriedade] }, set(alvo, propriedade, novoValor) { console.log([escrita] "${propriedade}" mudou de "${alvo[propriedade]}" para "${novoValor}") alvo[propriedade] = novoValor return true }, })console.log(eventoComLog.titulo) // dispara o "get" -> loga e retorna o valor eventoComLog.vagas = 39 // dispara o "set" -> loga e altera o valor real ```
É exatamente este mecanismo que
reactive()usa por dentro. Quando você chamareactive(objeto), o Vue devolve umProxyque envolve o objeto original. Toda leitura de propriedade (evento.titulo) passa pelogetdo Proxy, que registra "este trecho de template/computed depende detitulo" (o rastreamento de dependências que sustenta o padrão Observer da Aula 02). Toda escrita (evento.vagas = 39) passa peloset, que dispara a notificação para quem depende daquele valor, disparando a re-renderização.ref()usa uma técnica um pouco diferente por baixo (um objeto com getter/setter na propriedade.value, sem precisar de umProxycompleto, já que só precisa interceptar uma única propriedade), mas o princípio — interceptar acesso para adicionar comportamento reativo — é o mesmo padrão Proxy.
💻 Mão na massa — UniEventos com filtros combinados¶
Vamos consolidar tudo em uma versão mais completa: busca por texto + categoria + "apenas com vagas", ordenação, contadores derivados, carregamento assíncrono no onMounted e destaque visual para eventos lotados ou que acontecem nos próximos 7 dias.
Passo 1 — utilitário de formatação, reaproveitável em todo o projeto.
// src/utils/formatadores.js
export function formatarDataHora(dataIso) {
return new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(dataIso))
}
export function formatarPercentual(valor) {
return new Intl.NumberFormat('pt-BR', {
style: 'percent',
maximumFractionDigits: 0,
}).format(valor / 100)
}
export function ocorreNosProximosDias(dataIso, dias) {
const agora = new Date()
const dataDoEvento = new Date(dataIso)
const diferencaEmMs = dataDoEvento.getTime() - agora.getTime()
const diferencaEmDias = diferencaEmMs / (1000 * 60 * 60 * 24)
return diferencaEmDias >= 0 && diferencaEmDias <= dias
}
Passo 2 — mantenha public/eventos.json da Seção 5.1 (ou ajuste as datas para ficarem próximas da data atual, se quiser testar o destaque de "próximos 7 dias").
Passo 3 — o componente completo.
<!-- src/App.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { formatarDataHora, formatarPercentual, ocorreNosProximosDias } from './utils/formatadores.js'
const eventos = ref([])
const carregando = ref(true)
const erro = ref(null)
const busca = ref('')
const categoriaFiltro = ref('')
const apenasComVaga = ref(false)
const criterioOrdenacao = ref('data') // 'data' | 'vagas'
async function carregarEventos() {
carregando.value = true
erro.value = null
try {
const resposta = await fetch('/eventos.json')
if (!resposta.ok) {
throw new Error(`Erro HTTP: ${resposta.status}`)
}
eventos.value = await resposta.json()
} catch (erroCapturado) {
erro.value = 'Não foi possível carregar os eventos. Tente novamente mais tarde.'
console.error(erroCapturado)
} finally {
carregando.value = false
}
}
onMounted(() => {
carregarEventos()
})
// computed principal: aplica os TRÊS filtros de uma vez, em cadeia
const eventosFiltrados = computed(() => {
return eventos.value
.filter((evento) => evento.titulo.toLowerCase().includes(busca.value.trim().toLowerCase()))
.filter((evento) => categoriaFiltro.value === '' || evento.categoria === categoriaFiltro.value)
.filter((evento) => !apenasComVaga.value || evento.inscritos < evento.vagas)
})
// computed encadeada: ordena o resultado já filtrado
const eventosOrdenados = computed(() => {
const copia = [...eventosFiltrados.value]
if (criterioOrdenacao.value === 'data') {
return copia.sort((a, b) => new Date(a.dataHora) - new Date(b.dataHora))
}
// ordenar por vagas restantes, da maior para a menor
return copia.sort((a, b) => (b.vagas - b.inscritos) - (a.vagas - a.inscritos))
})
// contadores derivados — cada um é barato de calcular porque
// eventosFiltrados já está em cache
const totalFiltrado = computed(() => eventosFiltrados.value.length)
const totalVagasLivres = computed(() =>
eventosFiltrados.value.reduce((total, evento) => total + (evento.vagas - evento.inscritos), 0),
)
function vagasRestantes(evento) {
return evento.vagas - evento.inscritos
}
function percentualOcupacao(evento) {
return Math.round((evento.inscritos / evento.vagas) * 100)
}
function inscrever(eventoId) {
const evento = eventos.value.find((item) => item.id === eventoId)
if (!evento || evento.inscritos >= evento.vagas) return
evento.inscritos++
}
function limparFiltros() {
busca.value = ''
categoriaFiltro.value = ''
apenasComVaga.value = false
}
</script>
<template>
<main class="pagina">
<h1>UniEventos</h1>
<p>Encontre e inscreva-se em eventos acadêmicos.</p>
<!-- estado: carregando -->
<p v-if="carregando">Carregando eventos...</p>
<!-- estado: erro -->
<div v-else-if="erro" class="erro">
<p>{{ erro }}</p>
<button @click="carregarEventos">Tentar novamente</button>
</div>
<!-- estado: sucesso -->
<template v-else>
<div class="filtros">
<input v-model.trim="busca" type="text" placeholder="Buscar por título..." />
<select v-model="categoriaFiltro">
<option value="">Todas as categorias</option>
<option value="palestra">Palestra</option>
<option value="minicurso">Minicurso</option>
<option value="workshop">Workshop</option>
</select>
<label>
<input v-model="apenasComVaga" type="checkbox" />
Apenas com vagas
</label>
<select v-model="criterioOrdenacao">
<option value="data">Ordenar por data</option>
<option value="vagas">Ordenar por vagas livres</option>
</select>
<button @click="limparFiltros">Limpar filtros</button>
</div>
<p class="resumo">
{{ totalFiltrado }} evento(s) encontrado(s) — {{ totalVagasLivres }} vaga(s) livre(s) no total
</p>
<!-- estado: vazio (sucesso, mas sem itens após o filtro) -->
<p v-if="eventosOrdenados.length === 0" class="vazio">
Nenhum evento encontrado com estes filtros.
</p>
<ul v-else class="lista-eventos">
<li
v-for="evento in eventosOrdenados"
:key="evento.id"
class="card-evento"
:class="{
'card-lotado': vagasRestantes(evento) === 0,
'card-em-breve': ocorreNosProximosDias(evento.dataHora, 7),
}"
>
<h2>{{ evento.titulo }}</h2>
<p>{{ formatarDataHora(evento.dataHora) }} — {{ evento.local }}</p>
<p>Categoria: {{ evento.categoria }}</p>
<p v-if="ocorreNosProximosDias(evento.dataHora, 7)" class="selo-em-breve">
Acontece em breve!
</p>
<div
class="barra-progresso-fundo"
:style="{ '--percentual': percentualOcupacao(evento) + '%' }"
>
<div
class="barra-progresso"
:style="{
width: percentualOcupacao(evento) + '%',
backgroundColor: vagasRestantes(evento) === 0 ? '#c0392b' : '#27ae60',
}"
></div>
</div>
<p class="texto-ocupacao">{{ formatarPercentual(percentualOcupacao(evento)) }} ocupado</p>
<p v-if="vagasRestantes(evento) > 0">{{ vagasRestantes(evento) }} vaga(s) restante(s)</p>
<p v-else class="lotado">Evento lotado</p>
<button :disabled="vagasRestantes(evento) === 0" @click="inscrever(evento.id)">
Inscrever-se
</button>
</li>
</ul>
</template>
</main>
</template>
<style scoped>
.pagina {
max-width: 780px;
margin: 0 auto;
padding: 2rem;
font-family: sans-serif;
}
.filtros {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
}
.resumo {
color: #555;
margin-bottom: 1.5rem;
}
.lista-eventos {
list-style: none;
padding: 0;
display: grid;
gap: 1rem;
}
.card-evento {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
}
.card-lotado {
border-color: #c0392b;
background-color: #fdecea;
}
.card-em-breve {
border-left: 4px solid #f39c12;
}
.selo-em-breve {
color: #d68910;
font-weight: bold;
}
.barra-progresso-fundo {
background-color: #eee;
border-radius: 4px;
height: 8px;
overflow: hidden;
margin-top: 0.5rem;
}
.barra-progresso {
height: 100%;
transition: width 0.3s ease;
}
.texto-ocupacao {
font-size: 0.85rem;
color: #666;
}
.lotado {
color: #c0392b;
font-weight: bold;
}
.erro,
.vazio {
text-align: center;
color: #666;
}
</style>
📌 Na prova Observe que
eventosFiltradoseeventosOrdenadossão duas computed encadeadas, etotalFiltrado/totalVagasLivresdependem deeventosFiltrados. Se você mudarbusca, o Vue recalculaeventosFiltrados(porque ela lêbusca), o que por sua vez invalida o cache deeventosOrdenados,totalFiltradoetotalVagasLivres— tudo automático, seguindo a cadeia de dependências. Você não escreve nenhuma chamada manual de "atualizar".
🧪 Laboratório¶
1. Refatorar método em computed — pegue esta função e transforme-a em computed:
function eventosPalestrasComVaga() {
return eventos.value.filter((e) => e.categoria === 'palestra' && e.inscritos < e.vagas)
}
Resultado esperado: uma constante eventosPalestrasComVaga criada com computed(() => ...), usada no template sem parênteses (v-for="evento in eventosPalestrasComVaga", não eventosPalestrasComVaga()).
Dica
const eventosPalestrasComVaga = computed(() => eventos.value.filter((e) => e.categoria === 'palestra' && e.inscritos < e.vagas))
2. Corrigir uma lista sem :key — dado este trecho com um bug proposital, corrija-o:
<li v-for="evento in eventosOrdenados">{{ evento.titulo }}</li>
Resultado esperado: :key="evento.id" adicionado, e o console do navegador sem o aviso Elements in iteration expect to have 'v-bind:key'.
Dica
Abra o DevTools (Console) — o Vue avisa explicitamente quando falta :key em um v-for.
3. watch para persistir o filtro — use watch sobre categoriaFiltro para gravar a categoria escolhida em localStorage.setItem('ultimaCategoria', valor), e leia esse valor com localStorage.getItem para definir o valor inicial de categoriaFiltro.
Resultado esperado: recarregar a página mantém a última categoria filtrada.
Dica
const categoriaFiltro = ref(localStorage.getItem('ultimaCategoria') || '')
watch(categoriaFiltro, (valor) => localStorage.setItem('ultimaCategoria', valor))
4. Computed com getter e setter — crie uma computed buscaEmMaiusculas que exiba busca sempre em maiúsculas ao ler, mas ao escrever converta para minúsculas antes de gravar em busca.
Resultado esperado: digitar "VUE" em um campo ligado a buscaEmMaiusculas faz busca.value valer "vue".
Dica
const buscaEmMaiusculas = computed({
get: () => busca.value.toUpperCase(),
set: (valor) => { busca.value = valor.toLowerCase() },
})
5. Estado de erro proposital — troque a URL do fetch em carregarEventos para /eventos-inexistente.json, confirme que a tela de erro aparece com o botão "Tentar novamente", depois desfaça a mudança.
Resultado esperado: você reproduz e depois corrige o estado de erro descrito na Seção 5.
Dica
O catch do try/catch precisa capturar tanto falha de rede quanto resposta.ok === false.
🐛 Erros comuns e como resolver¶
| Sintoma | Causa | Solução |
|---|---|---|
computed não atualiza quando eu esperava |
A função do computed não lê nenhuma variável reativa (ex.: lê uma variável comum, não um ref/reactive) |
Garanta que tudo que o computed depende é reativo |
v-for com v-if no mesmo elemento gera erro ou resultado estranho |
Antipadrão descrito na Seção 2 | Mova o filtro para um computed e itere sobre o resultado |
watch não dispara ao mudar uma propriedade interna de um objeto |
Faltou { deep: true } ao observar um reactive/objeto |
Adicione a opção deep: true |
| Tela fica presa em "Carregando eventos..." para sempre | Esqueceu de setar carregando.value = false no finally, ou uma exceção interrompeu antes de chegar lá |
Sempre use try/catch/finally, com carregando.value = false no finally |
Data aparece como Invalid Date |
dataIso no formato errado, ou new Date() recebendo undefined |
Confira o formato ISO (YYYY-MM-DDTHH:mm:ss) vindo do JSON |
🏠 Atividade assíncrona (1 h)¶
No repositório do seu projeto autoral:
- Substitua a listagem estática do seu domínio por dados carregados via
fetchdentro deonMounted, a partir de um arquivo empublic/<entidade>.json(siga o modelo da Seção 5.1). - Implemente os três estados de tela: carregando, erro (com botão de tentar novamente) e vazio.
- Transforme pelo menos uma função de filtro em
computed, e combine dois ou mais critérios de filtro na mesma computed (texto + categoria/tipo, como fizemos hoje). - Adicione formatação de datas e/ou valores com
Intlpara os campos do seu domínio. - Adicione um destaque visual condicional (
:class) para algum estado relevante do seu domínio (ex.: "esgotado", "últimas unidades", "encerra em breve"). - Faça commit e push.
Critério de pronto: os três estados de tela funcionam (teste forçando um erro), os filtros combinados funcionam via computed, e existe pelo menos um destaque visual condicional.
✅ Checkpoint do projeto autoral¶
- [ ] Dados carregados via
fetchdentro deonMounted, a partir de um JSON empublic/. - [ ] Estados de carregando, erro e vazio implementados e testados.
- [ ] Pelo menos um
computedcombinando dois ou mais critérios de filtro. - [ ] Formatação de data e/ou valor com
Intlaplicada em pelo menos um campo. - [ ] Destaque visual condicional com
:classem pelo menos um cenário do domínio. - [ ] Todo
v-fordo projeto usa:keycom um identificador estável (nunca o índice). - [ ] Commit enviado ao GitHub.
Na próxima aula você vai reestruturar seu projeto com Vuetify e Vue Router — os filtros e listas que você já tem hoje continuam valendo, só ganham um visual pronto e navegação entre telas.
📝 Aviso — Avaliação 1 se aproxima¶
A Avaliação 1 (implementação introdutória de Vue 3 com CLI: estrutura, componentes, diretivas) tem entrega até 02/09/2026, 23h59, e as instruções completas — escopo, rubrica e formato de entrega — serão publicadas na Aula 04 (02/09/2026), junto com Vuetify e Vue Router. Use esta semana assíncrona também para revisar: estrutura do projeto Vite, diretivas da Aula 02 e computed/onMounted de hoje são a base de tudo que será cobrado.
📚 Para aprofundar¶
- Vue 3 — Computed Properties
- Vue 3 — Watchers
- Vue 3 — List Rendering, seção "v-for with v-if"
- Vue 3 — Class and Style Bindings
- MDN —
Intl.DateTimeFormat - MDN —
Intl.NumberFormat - MDN —
Proxy
Próxima aula (04, 02/09/2026): introdução a Vuetify e Vue Router, transformando o UniEventos em uma SPA navegável com componentes visuais prontos — e publicação das instruções completas da Avaliação 1.
Aula 04 — Introdução a Vuetify e Vue Router
Nas Aulas 02 e 03 você construiu o UniEventos em Vue puro: listagem com v-for, filtros com computed(), carregamento assíncrono em onMounted() e HTML/CSS escritos à mão. Hoje ele ganha interface profissional com Vuetify e navegação real com Vue Router, virando uma SPA de verdade — e você recebe as instruções completas da Avaliação 1.
🎯 Objetivos de aprendizagem¶
Ao final desta aula você será capaz de:
- Explicar o que é um design system e por que um framework de UI acelera (e padroniza) o desenvolvimento de interfaces.
- Instalar e configurar o Vuetify 4 em um projeto Vite/Vue 3 seguindo exatamente os passos testados nesta disciplina.
- Estruturar uma aplicação com
v-app,v-app-bar,v-navigation-drawer,v-maine o sistema de gridv-container/v-row/v-col. - Usar os componentes essenciais do Vuetify (
v-card,v-btn,v-chip,v-icon,v-list,v-alert,v-dialog,v-snackbar,v-text-field,v-select,v-img,v-progress-circular) para montar telas reais. - Configurar um tema institucional customizado e implementar um alternador de tema claro/escuro.
- Criar rotas com Vue Router 5 (
createRouter,createWebHistory), navegar com<RouterLink>/<RouterView>, ler parâmetros de rota e tratar rota 404. - Transformar o UniEventos de página única em uma SPA navegável com múltiplas views.
📋 Pré-requisitos desta aula¶
Antes de começar, confirme que você tem:
- [ ] O projeto UniEventos das aulas 02–03 rodando localmente com
npm run dev(lista de eventos com filtro,v-for,v-if,computed,onMounted, carregamento assíncrono comfetch). - [ ] Node.js 22.22.2 LTS instalado (
node -v). Ocreate-vueexige^22.18.0 || >=24.12.0. - [ ] Git configurado e uma conta no GitHub — hoje você vai precisar de um repositório público para a Avaliação 1.
- [ ] Revisão rápida:
<script setup>,ref,computed,v-bind,v-on,v-model,v-if/v-forcom:key,onMounted.
📌 Na prova: hoje marca o fim da Unidade 1. Tudo que vier depois — Vuetify avançado, Axios, Pinia — pressupõe que você sabe montar uma SPA com rotas. Não pule esta aula.
🗺️ Roteiro¶
| Bloco | Tempo | Atividade |
|---|---|---|
| 1 | 50 min | Design system, Material Design 3, instalação do Vuetify 4, estrutura de aplicação e grid |
| 2 | 50 min | Componentes essenciais do Vuetify, tema customizado, Vue Router 5 (rotas, parâmetros, navegação) |
| 3 | 50 min | Mão na massa: migração do UniEventos para SPA com layout, views e tema institucional + instruções da Avaliação 1 |
1. Por que usar um framework de UI¶
Nas aulas 02 e 03 você escreveu HTML e CSS à mão para estilizar os cards de evento. Funciona, mas em um projeto real isso significa reinventar, para cada tela nova, decisões que já foram tomadas mil vezes por outras equipes: como um botão deve reagir ao toque, quanto de espaçamento um card precisa, que cor de texto garante contraste suficiente sobre um fundo azul.
Um framework de UI (ou biblioteca de componentes) resolve isso entregando componentes prontos — botões, cartões, campos de formulário, tabelas, diálogos — que já implementam essas decisões de forma consistente. Isso é diferente de um framework como o Vue, que resolve como a interface reage a dados; um framework de UI resolve como a interface se parece e se comporta visualmente.
A vantagem central é o design system: um conjunto de regras (cores, tipografia, espaçamento, elevação, animação) aplicado uniformemente em toda a aplicação. Sem um design system, cada componente vira uma ilha visual, e a interface fica com "cara de colcha de retalhos". Com um, o card de evento, o formulário de inscrição e o painel administrativo compartilham a mesma linguagem visual — mesmo que tenham sido escritos em dias diferentes por pessoas diferentes.
Material Design 3 em uma página¶
O Vuetify 4 implementa o Material Design 3 (MD3), o design system do Google usado no Android e em produtos como Gmail e YouTube. Os pilares que importam para o dia a dia:
- Color roles — em vez de "azul" e "cinza", você pensa em papéis:
primary(ação principal),secondary(ação de apoio),error,success,warning,info,surface(fundo de cartões) ebackground. Trocar o tema não exige trocar cada componente — só redefinir os papéis. - Elevação — sombras indicam hierarquia (o que está "mais perto" do usuário). O MD3 no Vuetify 4 trabalha com uma escala reduzida de 6 níveis (0 a 5), mais sutil que a escala antiga de 0 a 24 do Material Design 2.
- Tipografia em escala — títulos, corpo e rótulos seguem uma escala tipográfica nomeada (
display,headline,title,body,label), cada uma em tamanhoslarge/medium/small. - Forma e espaçamento — cantos arredondados e um sistema de espaçamento em múltiplos de 4px, aplicado por classes utilitárias.
⚠️ Atenção: o Vuetify 4 migrou a tipografia de MD2 para MD3. As classes antigas
text-h1…text-h6continuam existindo, mas mudaram de tamanho e semântica. Os equivalentes MD3 são nomes comotext-display-large,text-headline-medium,text-title-large,text-body-medium,text-label-large. Nesta disciplina, prefira citar explicitamente a classe MD3 ou definir sua própria tipografia — não assuma quetext-h4de um tutorial antigo vai parecer do jeito que você viu em vídeo.
2. Instalando o Vuetify 4¶
Vamos instalar o Vuetify no projeto UniEventos que você já tem. Os comandos abaixo são os mesmos testados no ambiente da disciplina — siga exatamente esta ordem.
npm install vuetify @mdi/font
npm install -D vite-plugin-vuetify
O primeiro comando instala o Vuetify em si e a fonte de ícones Material Design Icons (MDI), que usaremos em botões, listas e menus. O segundo instala o plugin do Vite que faz o autoimport dos componentes — sem ele, você teria que importar manualmente cada v-card, v-btn etc. em cada arquivo .vue, o que é inviável em um projeto com dezenas de telas.
Configure o vite.config.js:
// vite.config.js
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
export default defineConfig({
plugins: [
vue({ template: { transformAssetUrls } }),
vuetify({ autoImport: true }),
],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
})
Repare em dois detalhes:
transformAssetUrlsé passado ao plugin do Vue para que caminhos de imagem usados dentro de props do Vuetify (comosrcdev-img) sejam resolvidos corretamente pelo Vite.vuetify({ autoImport: true })é o que permite usar<v-card>,<v-btn>e qualquer outro componente do Vuetify sem importar nada no<script setup>. O plugin varre seus templates em tempo de build, detecta quais componentes e diretivas você usou, e injeta o registro automaticamente. Isso substitui o padrão antigo de fazerimport * as components from 'vuetify/components'e registrar tudo manualmente (ou, pior, registrar tudo globalmente e inflar o bundle).
Agora o src/main.js:
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
import { createVuetify } from 'vuetify'
const vuetify = createVuetify({
theme: { defaultTheme: 'light' }, // v4: o padrão virou 'system'
})
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(vuetify)
app.mount('#app')
Três importações merecem atenção:
'@mdi/font/css/materialdesignicons.css'— carrega a fonte de ícones. Sem isso,<v-icon>mdi-account</v-icon>aparece como um quadrado vazio.'vuetify/styles'— o CSS base do Vuetify (grid, tipografia, reset parcial).createVuetify(...)— cria a instância do Vuetify, análoga aocreatePinia()oucreateRouter(): você a registra na aplicação comapp.use(vuetify).
⚠️ Atenção: no Vuetify 4, o tema padrão passou a ser
'system'— ou seja, se você não configurar nada, a aplicação vai seguir a preferência de tema (claro/escuro) do sistema operacional do usuário. Isso é ótimo em produção, mas péssimo para dar aula: metade da turma veria uma tela clara e a outra metade, escura, sem que ninguém tivesse mudado nada. Por isso declaramosdefaultTheme: 'light'explicitamente — mais adiante, na seção de tema, vamos configurar isso de verdade com cores institucionais.
🔎 Por baixo do capô:
app.use(vuetify)funciona exatamente comoapp.use(router)ouapp.use(pinia)— é o mecanismo de plugin do Vue. Um plugin é um objeto com um métodoinstall(app, options)que o Vue chama internamente. Isso é o mesmo padrão que você vai usar para instalar qualquer biblioteca de terceiros no ecossistema Vue.
Depois de configurar os dois arquivos, rode:
npm run dev
Se a tela carregar sem erros no console, o Vuetify está funcionando. Um teste rápido: coloque <v-btn color="primary">Teste</v-btn> em qualquer template e veja se aparece um botão estilizado (não um <button> cru do navegador).
3. Estrutura de aplicação Vuetify¶
Toda aplicação Vuetify é envolvida por um componente raiz obrigatório: <v-app>. Ele injeta o contexto de tema, o sistema de layout responsivo e o container onde diálogos e menus são renderizados (via teleport). Sem v-app, nada no Vuetify funciona direito — nem cores de tema, nem posicionamento de v-dialog.
Dentro de v-app, os blocos estruturais mais comuns são:
| Componente | Papel |
|---|---|
v-app-bar |
barra superior fixa — logotipo, título, ações, botão de menu |
v-navigation-drawer |
menu lateral (fixo ou retrátil) |
v-main |
área de conteúdo principal — se ajusta automaticamente ao espaço ocupado por app-bar e drawer |
v-footer |
rodapé |
<!-- src/App.vue (esqueleto conceitual — vamos completar na seção Mão na massa) -->
<template>
<v-app>
<v-app-bar color="primary">
<v-app-bar-title>UniEventos</v-app-bar-title>
</v-app-bar>
<v-navigation-drawer>
<!-- itens de menu aqui -->
</v-navigation-drawer>
<v-main>
<v-container>
<!-- conteúdo da página aqui -->
</v-container>
</v-main>
<v-footer app color="primary">
<span>UNEMAT · FACET · 2026</span>
</v-footer>
</v-app>
</template>
Note que v-main já "sabe" que existe um v-app-bar acima dele e um v-navigation-drawer ao lado — o Vuetify calcula o espaçamento automaticamente. Você não precisa (e não deve) definir margin-top manualmente para compensar a barra fixa.
Grid: v-container / v-row / v-col¶
O Vuetify usa um grid de 12 colunas, parecido com o Bootstrap, mas com props reativas a breakpoints:
<v-container>
<v-row>
<v-col cols="12" sm="6" md="4">
<!-- ocupa 12/12 no celular, 6/12 em tablet, 4/12 em desktop -->
</v-col>
</v-row>
</v-container>
Os breakpoints do Vuetify 4 mudaram de valor em relação a versões anteriores — use os números abaixo, não os de tutoriais antigos:
| Breakpoint | Largura mínima |
|---|---|
sm |
600px |
md |
840px |
lg |
1145px |
xl |
1545px |
xxl |
2138px |
Um grid de cards de evento responsivo típico:
<v-row>
<v-col v-for="evento in eventos" :key="evento.id" cols="12" sm="6" md="4">
<!-- v-card do evento -->
</v-col>
</v-row>
Em telas pequenas (cols="12"), um card por linha. A partir de 600px, dois por linha (sm="6"). A partir de 840px, três por linha (md="4"). Essa é a técnica que você vai usar no Mão na massa desta aula.
⚠️ Atenção — duas armadilhas comuns do grid no Vuetify 4: 1.
<v-container fill-height>não centraliza mais verticalmente como fazia antes. Se você precisa centralizar conteúdo na tela (por exemplo, uma tela de erro 404), use classes utilitárias:<v-container class="d-flex align-center justify-center" style="min-height: 100vh">. 2. As propsalign,justifyedensedo<v-row>foram removidas. No lugar delas, use classes utilitárias de flexbox (class="justify-space-between",class="align-center") ou a propdensity="compact"para reduzir o espaçamento entre colunas. Código copiado de tutoriais do Vuetify 3 que usa<v-row align="center">vai quebrar silenciosamente — a prop simplesmente é ignorada.
4. Componentes essenciais¶
Vamos conhecer os componentes que você vai usar em praticamente toda tela do UniEventos.
v-card¶
O cartão é a unidade básica de conteúdo agrupado — um evento, um resultado de busca, um formulário curto. Ele é composto por subcomponentes:
<v-card>
<v-img src="/img/evento.jpg" height="180" cover />
<v-card-title>Semana Acadêmica de Computação</v-card-title>
<v-card-subtitle>29/09/2026 · Auditório Central</v-card-subtitle>
<v-card-text>
Palestras, minicursos e apresentação de projetos dos estudantes.
</v-card-text>
<v-card-actions>
<v-btn color="primary" variant="text">Ver detalhes</v-btn>
<v-spacer />
<v-chip color="success" size="small">32 vagas</v-chip>
</v-card-actions>
</v-card>
v-card-title, v-card-subtitle, v-card-text e v-card-actions existem para dar estrutura semântica e espaçamento correto — evite substituí-los por <div> com classes manuais.
v-btn e suas variantes¶
O v-btn tem seis variantes visuais (prop variant), cada uma com um uso recomendado:
| Variant | Quando usar |
|---|---|
elevated |
ação de destaque, com sombra (padrão visual antigo do Material) |
flat |
ação primária sem sombra — a mais comum em toolbars |
tonal |
ação secundária, fundo suave na cor do tema |
outlined |
ação secundária, apenas borda |
text |
ação terciária, sem fundo — links de ação dentro de cards |
plain |
mínimo destaque visual, quase texto puro |
<v-btn color="primary" variant="elevated">Inscrever-se</v-btn>
<v-btn color="primary" variant="tonal">Ver mais</v-btn>
<v-btn color="error" variant="outlined">Cancelar inscrição</v-btn>
<v-btn variant="text">Voltar</v-btn>
⚠️ Atenção: no Vuetify 4,
v-btnnão transforma mais o texto em UPPERCASE automaticamente (era o comportamento padrão em versões antigas do Material Design). Se você escreverInscrever-se, o texto aparece exatamente assim — nãoINSCREVER-SE. Isso é intencional: o MD3 abandonou a caixa alta como padrão de botão.
v-chip, v-icon, v-list¶
v-chip é uma etiqueta compacta — categoria do evento, status, tag:
<v-chip color="primary" size="small" prepend-icon="mdi-tag">Minicurso</v-chip>
v-icon renderiza um ícone MDI (Material Design Icons) — o nome sempre começa com o prefixo mdi-:
<v-icon icon="mdi-calendar" color="primary" />
<v-icon>mdi-map-marker</v-icon>
v-list organiza itens verticais — menu de navegação, lista de eventos inscritos:
<v-list>
<v-list-item
v-for="evento in eventos"
:key="evento.id"
:title="evento.titulo"
:subtitle="evento.local"
prepend-icon="mdi-calendar-star"
/>
</v-list>
v-alert, v-dialog, v-snackbar¶
Três componentes de feedback com propósitos distintos:
v-alert— mensagem persistente embutida no fluxo da página (ex.: "nenhum evento encontrado com esse filtro").v-dialog— janela modal que bloqueia a interação até ser fechada (ex.: confirmar exclusão de um evento).v-snackbar— notificação temporária no rodapé da tela, some sozinha (ex.: "inscrição realizada com sucesso").
<v-alert type="info" variant="tonal" title="Nenhum evento encontrado">
Tente ajustar os filtros de categoria ou data.
</v-alert>
<script setup>
import { ref } from 'vue'
const mostrarDialogo = ref(false)
</script>
<template>
<v-btn color="error" @click="mostrarDialogo = true">Excluir</v-btn>
<v-dialog v-model="mostrarDialogo" max-width="400">
<v-card title="Confirmar exclusão">
<v-card-text>Esta ação não pode ser desfeita.</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="mostrarDialogo = false">Cancelar</v-btn>
<v-btn color="error" variant="flat" @click="mostrarDialogo = false">Excluir</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
Repare que v-dialog usa v-model para controlar se está aberto ou fechado — o mesmo padrão de two-way binding que você já usa em v-text-field. Vamos usar v-snackbar de verdade na Aula 06, quando tivermos ações assíncronas (salvar, excluir) que precisam de feedback.
v-text-field, v-select, v-img, v-progress-circular¶
<v-text-field
v-model="busca"
label="Buscar evento"
prepend-inner-icon="mdi-magnify"
variant="outlined"
clearable
/>
<v-select
v-model="categoriaFiltro"
:items="['Todas', 'Palestra', 'Minicurso', 'Workshop']"
label="Categoria"
variant="outlined"
/>
<v-img src="/img/banner.jpg" height="200" cover />
<v-progress-circular indeterminate color="primary" />
v-progress-circular com indeterminate é o spinner de carregamento — você já usou um estado de "carregando" na Aula 03 com uma condição simples; agora vamos trocar o texto "Carregando..." por esse componente visual.
5. Tema: cores institucionais e alternador claro/escuro¶
Um tema no Vuetify é declarado em createVuetify, com um conjunto de cores nomeadas por papel:
// src/main.js (trecho — configuração de tema)
const vuetify = createVuetify({
theme: {
defaultTheme: 'light',
themes: {
light: {
dark: false,
colors: {
primary: '#1B5E20', // verde institucional
secondary: '#F9A825', // amarelo de destaque
error: '#B00020',
success: '#2E7D32',
warning: '#F57F17',
info: '#0277BD',
background: '#F5F5F5',
surface: '#FFFFFF',
},
},
dark: {
dark: true,
colors: {
primary: '#66BB6A',
secondary: '#FFCA28',
error: '#CF6679',
success: '#66BB6A',
warning: '#FFB300',
info: '#4FC3F7',
background: '#121212',
surface: '#1E1E1E',
},
},
},
},
})
Depois de declarado, qualquer componente usa color="primary" e recebe automaticamente a cor certa, seja no tema claro ou escuro — você nunca escreve um valor hexadecimal direto no template.
Para alternar entre os temas em tempo de execução, o Vuetify expõe o composable useTheme():
<!-- src/components/AlternadorTema.vue -->
<script setup>
import { computed } from 'vue'
import { useTheme } from 'vuetify'
const tema = useTheme()
const ehEscuro = computed(() => tema.global.name.value === 'dark')
function alternarTema() {
tema.global.name.value = ehEscuro.value ? 'light' : 'dark'
}
</script>
<template>
<v-btn
:icon="ehEscuro ? 'mdi-weather-sunny' : 'mdi-weather-night'"
variant="text"
@click="alternarTema"
/>
</template>
useTheme() é um composable — mesma ideia dos composables useRoute()/useRouter() que veremos já já, e dos que você vai escrever na Aula 05. Ele te dá acesso reativo ao estado global de tema: ler tema.global.name.value e escrever nele muda o tema da aplicação inteira instantaneamente.
💡 Dica: guarde a preferência de tema do usuário em
localStoragepara que ela persista entre visitas. Vamos formalizar esse padrão de persistência com Pinia na Aula 06 — por hoje, é suficiente saber alternar o tema em memória.
6. Classes utilitárias de espaçamento e layout¶
O Vuetify gera classes utilitárias para espaçamento e flexbox, seguindo a convenção {propriedade}{direção}-{tamanho}:
pa-4— padding em todos os lados, tamanho 4 (múltiplo de 4px → 16px)ma-2— margin em todos os lados, tamanho 2 (8px)mt-4,mb-2,mx-auto,py-6— direções específicas (t=top, b=bottom, x=horizontal, y=vertical)d-flex—display: flexalign-center—align-items: center(funciona em contêiner flex)justify-space-between—justify-content: space-between
<div class="d-flex align-center justify-space-between pa-4">
<span class="text-h6">Eventos disponíveis</span>
<v-btn color="primary" variant="tonal">Novo evento</v-btn>
</div>
Essas classes evitam CSS customizado para casos simples de espaçamento e alinhamento — e, por serem previsíveis, tornam o código mais fácil de ler entre desenvolvedores diferentes.
7. Vue Router 5: transformando páginas em rotas¶
Até agora o UniEventos era uma única página com tudo dentro de App.vue. Uma aplicação real precisa de navegação: uma URL para a lista de eventos, outra para o detalhe de um evento específico, outra para "sobre". Isso é o papel do Vue Router.
O UniEventos, se você criou o projeto com --router (como recomenda a §4 da especificação), já vem com Vue Router 5.2.0 instalado e configurado. Vamos entender e expandir essa configuração.
Estrutura básica¶
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
],
})
export default router
createRoutermonta a instância do roteador — assim comocreateVuetifyecreatePinia, ela é registrada comapp.use(router)nomain.js(isso já vem pronto no scaffold).createWebHistoryusa a API de histórico do navegador (pushState) para gerar URLs "limpas" (/eventos/12) em vez de usar#(hash). Isso exige que o servidor de produção redirecione todas as rotas paraindex.html— trataremos disso na Unidade 3, ao falar de deploy.routesé um array de objetos{ path, name, component }.namepermite navegar por nome em vez de string de URL, o que evita erros de digitação espalhados pelo código.
<RouterLink> e <RouterView>¶
Dois componentes globais, já registrados automaticamente pelo app.use(router):
<template>
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink :to="{ name: 'sobre' }">Sobre</RouterLink>
</nav>
<RouterView />
</template>
<RouterLink>renderiza um<a>de verdade (importante para acessibilidade e SEO), mas intercepta o clique para trocar de rota sem recarregar a página inteira.<RouterView>é o "buraco" onde o componente da rota ativa é renderizado. EmApp.vue, ele normalmente fica dentro dev-main.
Rotas com parâmetros¶
A tela de detalhe de um evento precisa saber qual evento mostrar. Isso é feito com um segmento dinâmico na URL:
// src/router/index.js (trecho)
{
path: '/eventos/:id',
name: 'evento-detalhe',
component: () => import('../views/EventoDetalheView.vue'),
}
Dentro do componente, o parâmetro é lido com o composable useRoute():
<!-- src/views/EventoDetalheView.vue (trecho) -->
<script setup>
import { useRoute } from 'vue-router'
const rota = useRoute()
console.log(rota.params.id) // string com o valor de :id na URL atual
</script>
⚠️ Atenção:
rota.params.idsempre vem como string, mesmo que o ID no seu array de dados seja um número. Se você comparar com===, compare string com string ou converta comNumber(rota.params.id).
Rota 404 (catch-all)¶
Toda SPA precisa de uma rota que capture qualquer caminho não mapeado:
// src/router/index.js (trecho — sempre por último no array de routes)
{
path: '/:pathMatch(.*)*',
name: 'nao-encontrado',
component: () => import('../views/NaoEncontradoView.vue'),
}
O padrão /:pathMatch(.*)* é a sintaxe do Vue Router para "qualquer caminho, com qualquer profundidade de segmentos". Ele precisa ficar por último na lista de rotas — o roteador testa as rotas na ordem declarada, e uma rota catch-all no início bloquearia todas as outras.
Navegação programática¶
Além de <RouterLink>, você pode navegar via código — por exemplo, depois de confirmar uma inscrição:
import { useRouter } from 'vue-router'
const router = useRouter()
function confirmarInscricao() {
// ... lógica de inscrição
router.push({ name: 'home' })
}
useRouter() (com R maiúsculo de Router) dá acesso ao roteador inteiro — inclusive ao método push, que navega para uma nova rota, empilhando-a no histórico do navegador (o botão "voltar" funciona). Note a diferença: useRoute() (singular, sem "r" no fim de Route) dá acesso somente à rota atual; useRouter() dá acesso ao roteador, que permite navegar.
Lazy loading de rotas¶
Repare que, no exemplo de /eventos/:id acima, o componente foi importado como () => import('../views/EventoDetalheView.vue') em vez de um import estático no topo do arquivo. Essa é a técnica de lazy loading (carregamento tardio): o Vite gera um arquivo JavaScript separado para essa view, que só é baixado pelo navegador quando o usuário navega até ela.
Em uma aplicação pequena isso não faz diferença perceptível, mas é o padrão recomendado desde já — conforme o UniEventos cresce (área administrativa, formulários, tabelas), o bundle inicial permanece pequeno porque cada view só é carregada quando necessária.
// src/router/index.js — versão completa recomendada, com lazy loading em tudo
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', name: 'home', component: () => import('../views/HomeView.vue') },
{ path: '/eventos/:id', name: 'evento-detalhe', component: () => import('../views/EventoDetalheView.vue') },
{ path: '/sobre', name: 'sobre', component: () => import('../views/SobreView.vue') },
{ path: '/:pathMatch(.*)*', name: 'nao-encontrado', component: () => import('../views/NaoEncontradoView.vue') },
],
})
export default router
🧩 Padrão de projeto em uso¶
🧩 Padrão de projeto em uso — Composite (estrutural)¶
A árvore de componentes do Vue é um exemplo direto do padrão Composite: um componente pode conter outros componentes, que por sua vez podem conter outros, formando uma hierarquia onde o "todo" e a "parte" são tratados de forma uniforme.
v-appcontémv-app-bar,v-mainev-navigation-drawer;v-maincontémRouterView;RouterViewrenderiza uma view, que contémv-container>v-row>v-col>v-card. Em cada nível, você trabalha com a mesma interface (props, slots, eventos) sem precisar saber o que está por dentro.O Vue Router aplica a mesma lógica na dimensão de navegação: rotas podem ter rotas-filhas (
children), formando uma árvore de rotas que espelha uma árvore deRouterViews aninhados. Vamos explorar isso a fundo na Aula 05, quando construirmos a área administrativa com rotas aninhadas.
💻 Mão na massa — migrando o UniEventos para uma SPA de verdade¶
Vamos transformar o projeto de página única em uma aplicação navegável com layout persistente, tema institucional e quatro views.
Passo 1 — instalar o Vuetify no projeto¶
Se você ainda não instalou (parte da §2 desta aula), rode dentro da pasta do projeto:
npm install vuetify @mdi/font
npm install -D vite-plugin-vuetify
Passo 2 — configurar vite.config.js¶
// vite.config.js
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
export default defineConfig({
plugins: [
vue({ template: { transformAssetUrls } }),
vuetify({ autoImport: true }),
],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
})
Passo 3 — criar o plugin do Vuetify com tema institucional¶
// src/plugins/vuetify.js
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
import { createVuetify } from 'vuetify'
export default createVuetify({
theme: {
defaultTheme: 'light',
themes: {
light: {
dark: false,
colors: {
primary: '#1B5E20',
secondary: '#F9A825',
error: '#B00020',
success: '#2E7D32',
warning: '#F57F17',
info: '#0277BD',
background: '#F5F5F5',
surface: '#FFFFFF',
},
},
dark: {
dark: true,
colors: {
primary: '#66BB6A',
secondary: '#FFCA28',
error: '#CF6679',
success: '#66BB6A',
warning: '#FFB300',
info: '#4FC3F7',
background: '#121212',
surface: '#1E1E1E',
},
},
},
},
})
Separar a configuração do Vuetify em src/plugins/vuetify.js (em vez de deixar tudo dentro de main.js) mantém o ponto de entrada da aplicação enxuto — uma prática que vamos repetir com o Axios na Aula 06.
Passo 4 — atualizar src/main.js¶
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import vuetify from './plugins/vuetify'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(vuetify)
app.mount('#app')
Passo 5 — criar o modelo de dados de eventos¶
// src/data/eventos.js
export const eventos = [
{ id: 1, titulo: 'Semana Acadêmica de Computação', descricao: 'Palestras e minicursos sobre tendências em tecnologia.', categoria: 'palestra', dataHora: '2026-09-29T19:00:00', local: 'Auditório Central', vagas: 40, imagemUrl: 'https://picsum.photos/seed/evento1/600/300' },
{ id: 2, titulo: 'Minicurso de Vue.js Avançado', descricao: 'Componentização, roteamento e gerenciamento de estado.', categoria: 'minicurso', dataHora: '2026-09-15T18:30:00', local: 'Laboratório 3', vagas: 25, imagemUrl: 'https://picsum.photos/seed/evento2/600/300' },
{ id: 3, titulo: 'Workshop de Prototipação em Figma', descricao: 'Fundamentos de design de interfaces para desenvolvedores.', categoria: 'workshop', dataHora: '2026-09-20T14:00:00', local: 'Sala 12', vagas: 30, imagemUrl: 'https://picsum.photos/seed/evento3/600/300' },
{ id: 4, titulo: 'Palestra: Carreira em Dados', descricao: 'Trilhas profissionais em ciência e engenharia de dados.', categoria: 'palestra', dataHora: '2026-10-02T19:30:00', local: 'Auditório Central', vagas: 50, imagemUrl: 'https://picsum.photos/seed/evento4/600/300' },
{ id: 5, titulo: 'Minicurso de Banco de Dados NoSQL', descricao: 'Modelagem de dados com MongoDB na prática.', categoria: 'minicurso', dataHora: '2026-09-22T18:30:00', local: 'Laboratório 2', vagas: 20, imagemUrl: 'https://picsum.photos/seed/evento5/600/300' },
{ id: 6, titulo: 'Workshop de Testes Automatizados', descricao: 'Testes unitários e de integração em aplicações web.', categoria: 'workshop', dataHora: '2026-10-05T14:00:00', local: 'Sala 12', vagas: 25, imagemUrl: 'https://picsum.photos/seed/evento6/600/300' },
{ id: 7, titulo: 'Palestra: Ética em Inteligência Artificial', descricao: 'Discussão sobre vieses e responsabilidade em sistemas de IA.', categoria: 'palestra', dataHora: '2026-10-10T19:00:00', local: 'Auditório Central', vagas: 60, imagemUrl: 'https://picsum.photos/seed/evento7/600/300' },
{ id: 8, titulo: 'Minicurso de Node.js e Express', descricao: 'Construindo APIs REST do zero.', categoria: 'minicurso', dataHora: '2026-09-25T18:30:00', local: 'Laboratório 1', vagas: 25, imagemUrl: 'https://picsum.photos/seed/evento8/600/300' },
]
Estes são os mesmos oito eventos-base que continuam evoluindo a partir de agora até a Unidade 3, quando virão de uma API de verdade.
Passo 6 — atualizar src/router/index.js¶
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', name: 'home', component: () => import('../views/HomeView.vue') },
{ path: '/eventos/:id', name: 'evento-detalhe', component: () => import('../views/EventoDetalheView.vue') },
{ path: '/sobre', name: 'sobre', component: () => import('../views/SobreView.vue') },
{ path: '/:pathMatch(.*)*', name: 'nao-encontrado', component: () => import('../views/NaoEncontradoView.vue') },
],
})
export default router
Passo 7 — criar o layout em App.vue¶
<!-- src/App.vue -->
<script setup>
import { ref, computed } from 'vue'
import { useTheme } from 'vuetify'
import { RouterLink, RouterView } from 'vue-router'
const drawerAberto = ref(false)
const tema = useTheme()
const ehEscuro = computed(() => tema.global.name.value === 'dark')
function alternarTema() {
tema.global.name.value = ehEscuro.value ? 'light' : 'dark'
}
const linksMenu = [
{ titulo: 'Início', rota: 'home', icone: 'mdi-home' },
{ titulo: 'Sobre', rota: 'sobre', icone: 'mdi-information' },
]
</script>
<template>
<v-app>
<v-app-bar color="primary">
<v-app-bar-nav-icon @click="drawerAberto = !drawerAberto" />
<v-app-bar-title>
<RouterLink to="/" class="text-white text-decoration-none">UniEventos</RouterLink>
</v-app-bar-title>
<v-spacer />
<v-btn
:icon="ehEscuro ? 'mdi-weather-sunny' : 'mdi-weather-night'"
variant="text"
@click="alternarTema"
/>
</v-app-bar>
<v-navigation-drawer v-model="drawerAberto" temporary>
<v-list>
<v-list-item
v-for="link in linksMenu"
:key="link.rota"
:to="{ name: link.rota }"
:title="link.titulo"
:prepend-icon="link.icone"
/>
</v-list>
</v-navigation-drawer>
<v-main>
<RouterView />
</v-main>
<v-footer color="primary" class="d-flex justify-center pa-4">
<span class="text-white">UNEMAT · FACET · FACET-SNP-310 · 2026.2</span>
</v-footer>
</v-app>
</template>
Repare que v-list-item aceita a prop to, exatamente como RouterLink — internamente, o Vuetify integra os dois. Isso evita ter que envolver cada item de menu em um <RouterLink> manualmente.
Passo 8 — criar HomeView.vue¶
<!-- src/views/HomeView.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { eventos } from '../data/eventos'
const carregando = ref(true)
const listaEventos = ref([])
const categoriaFiltro = ref('Todas')
const busca = ref('')
onMounted(() => {
// simula uma chamada assíncrona, como fizemos na Aula 03 com fetch
setTimeout(() => {
listaEventos.value = eventos
carregando.value = false
}, 400)
})
const categorias = ['Todas', 'Palestra', 'Minicurso', 'Workshop']
const eventosFiltrados = computed(() => {
return listaEventos.value.filter((evento) => {
const bateCategoria =
categoriaFiltro.value === 'Todas' ||
evento.categoria === categoriaFiltro.value.toLowerCase()
const bateBusca = evento.titulo
.toLowerCase()
.includes(busca.value.toLowerCase())
return bateCategoria && bateBusca
})
})
function formatarData(dataIso) {
return new Date(dataIso).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
</script>
<template>
<v-container>
<h1 class="text-h4 mb-4">Eventos disponíveis</h1>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-text-field
v-model="busca"
label="Buscar evento"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
clearable
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="categoriaFiltro"
:items="categorias"
label="Categoria"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<div v-if="carregando" class="d-flex justify-center pa-8">
<v-progress-circular indeterminate color="primary" size="48" />
</div>
<v-alert
v-else-if="eventosFiltrados.length === 0"
type="info"
variant="tonal"
title="Nenhum evento encontrado"
>
Tente ajustar os filtros de categoria ou o termo de busca.
</v-alert>
<v-row v-else>
<v-col
v-for="evento in eventosFiltrados"
:key="evento.id"
cols="12"
sm="6"
md="4"
>
<v-card :to="{ name: 'evento-detalhe', params: { id: evento.id } }">
<v-img :src="evento.imagemUrl" height="160" cover />
<v-card-title>{{ evento.titulo }}</v-card-title>
<v-card-subtitle>
{{ formatarData(evento.dataHora) }} · {{ evento.local }}
</v-card-subtitle>
<v-card-actions>
<v-chip color="secondary" size="small">{{ evento.categoria }}</v-chip>
<v-spacer />
<v-chip color="success" size="small">{{ evento.vagas }} vagas</v-chip>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
Assim como v-list-item, o v-card aceita a prop to — o card inteiro vira clicável e navega para o detalhe do evento, sem precisar de um @click manual com router.push.
Passo 9 — criar EventoDetalheView.vue¶
<!-- src/views/EventoDetalheView.vue -->
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { eventos } from '../data/eventos'
const rota = useRoute()
const router = useRouter()
const evento = computed(() =>
eventos.find((e) => e.id === Number(rota.params.id))
)
function formatarDataHora(dataIso) {
return new Date(dataIso).toLocaleString('pt-BR', {
dateStyle: 'long',
timeStyle: 'short',
})
}
function voltar() {
router.push({ name: 'home' })
}
</script>
<template>
<v-container>
<v-btn variant="text" prepend-icon="mdi-arrow-left" class="mb-4" @click="voltar">
Voltar para eventos
</v-btn>
<v-alert v-if="!evento" type="error" variant="tonal" title="Evento não encontrado">
Não existe evento com este identificador. Confira o link acessado.
</v-alert>
<v-card v-else>
<v-img :src="evento.imagemUrl" height="280" cover />
<v-card-title class="text-h5">{{ evento.titulo }}</v-card-title>
<v-card-subtitle>
<v-icon icon="mdi-calendar" size="small" class="mr-1" />
{{ formatarDataHora(evento.dataHora) }}
</v-card-subtitle>
<v-card-text>
<p class="mb-4">{{ evento.descricao }}</p>
<div class="d-flex align-center mb-2">
<v-icon icon="mdi-map-marker" class="mr-2" />
<span>{{ evento.local }}</span>
</div>
<div class="d-flex align-center">
<v-icon icon="mdi-account-group" class="mr-2" />
<span>{{ evento.vagas }} vagas disponíveis</span>
</div>
</v-card-text>
<v-card-actions>
<v-chip color="secondary">{{ evento.categoria }}</v-chip>
<v-spacer />
<v-btn color="primary" variant="flat">Inscrever-se</v-btn>
</v-card-actions>
</v-card>
</v-container>
</template>
Note o uso de Number(rota.params.id) — como discutido na §7, o parâmetro de rota sempre chega como string, e nossos IDs no array eventos são números.
Passo 10 — criar SobreView.vue e NaoEncontradoView.vue¶
<!-- src/views/SobreView.vue -->
<script setup>
</script>
<template>
<v-container>
<v-card class="pa-4">
<v-card-title class="text-h5">Sobre o UniEventos</v-card-title>
<v-card-text>
<p class="mb-2">
O UniEventos é uma plataforma para divulgação e inscrição em eventos
acadêmicos — palestras, minicursos e workshops.
</p>
<p>
Projeto desenvolvido na disciplina FACET-SNP-310 — Frameworks Modernos
para Desenvolvimento de Sistemas, UNEMAT/Sinop, 2026.2.
</p>
</v-card-text>
</v-card>
</v-container>
</template>
<!-- src/views/NaoEncontradoView.vue -->
<script setup>
import { RouterLink } from 'vue-router'
</script>
<template>
<v-container class="d-flex flex-column align-center justify-center" style="min-height: 60vh">
<v-icon icon="mdi-alert-circle-outline" size="80" color="error" class="mb-4" />
<h1 class="text-h4 mb-2">Página não encontrada</h1>
<p class="mb-6">O endereço acessado não existe no UniEventos.</p>
<v-btn color="primary" variant="flat" :to="{ name: 'home' }">Voltar para o início</v-btn>
</v-container>
</template>
Repare que usamos class="d-flex flex-column align-center justify-center" em vez de fill-height — exatamente o alerta da §6 sobre a mudança de comportamento no Vuetify 4.
Passo 11 — testar a navegação¶
npm run dev
Confira: a home lista os eventos com filtro funcionando; clicar em um card navega para o detalhe com a URL /eventos/3; o menu lateral abre com o ícone de hambúrguer; o botão de sol/lua alterna o tema; acessar uma URL inexistente (/qualquer-coisa) mostra a tela 404.
🧪 Laboratório¶
1. Chip de vagas esgotadas
No HomeView.vue, altere o chip de vagas para mostrar "Esgotado" em vermelho (color="error") quando evento.vagas === 0. Adicione um evento de teste com vagas: 0 no array de dados.
Dica
Use um v-if/v-else dentro do v-card-actions, ou um computed que retorna a cor e o texto do chip com base em evento.vagas.
2. Rota /eventos (lista) separada da rota /eventos/:id (detalhe)
Hoje a home (/) já mostra a lista. Crie também uma rota nomeada eventos-lista no caminho /eventos que renderiza o mesmo componente que a home usa para a listagem. Use <RouterLink :to="{ name: 'eventos-lista' }"> em algum lugar do menu.
Dica
Você pode apontar duas entradas de routes para o mesmo component, com path e name diferentes.
3. Contador de eventos no app-bar
No App.vue, mostre no v-app-bar (ao lado do título) um v-chip com o total de eventos cadastrados. Você vai precisar importar o array eventos também no App.vue.
Dica
import { eventos } from './data/eventos' e depois {{ eventos.length }} dentro de um v-chip.
4. Tema alternativo com terceira paleta
Adicione um terceiro tema chamado contraste, com cores de alto contraste (preto/amarelo), e um botão que cicla entre light → dark → contraste → light.
Dica
themes: { light: {...}, dark: {...}, contraste: {...} } no createVuetify, e uma função que usa um array ['light', 'dark', 'contraste'] com indexOf para descobrir o próximo tema.
5. Rota protegida por parâmetro inválido
No EventoDetalheView.vue, se rota.params.id não for um número válido (ex.: /eventos/abc), redirecione automaticamente para a rota nao-encontrado usando router.push.
Dica
Number.isNaN(Number(rota.params.id)) dentro de um onMounted ou de um watch sobre rota.params.id.
🐛 Erros comuns e como resolver¶
| Sintoma | Causa | Solução |
|---|---|---|
v-card, v-btn etc. aparecem como tags desconhecidas (sem estilo) |
vuetify({ autoImport: true }) não foi adicionado ao vite.config.js, ou o servidor não foi reiniciado após editar a config |
Confira o vite.config.js e reinicie npm run dev |
| Ícones MDI aparecem como quadrado vazio | Faltou importar @mdi/font/css/materialdesignicons.css |
Adicione o import no arquivo onde createVuetify é chamado |
| Tela muda de clara para escura sozinha, sem clique | defaultTheme não foi definido — Vuetify 4 usa 'system' por padrão |
Defina theme: { defaultTheme: 'light' } explicitamente |
v-row align="center" não centraliza nada |
Prop removida no Vuetify 4 | Troque por class="align-center" no v-row, ou d-flex align-center num <div> |
RouterLink/RouterView não reconhecidos no template |
Componente não importado (fora do padrão de autoimport do Vuetify, que não cobre o Vue Router) | Adicione import { RouterLink, RouterView } from 'vue-router' no <script setup> |
rota.params.id comparado com === a um número nunca bate |
Parâmetro de rota sempre é string | Converta com Number(rota.params.id) antes de comparar |
| Rota 404 nunca é acionada, mesmo em URL inválida | Rota catch-all /:pathMatch(.*)* não está por último no array routes |
Mova a rota catch-all para o final da lista |
🏠 Atividade assíncrona (1 h)¶
No seu projeto autoral (definido na Aula 01), aplique exatamente a mesma migração feita hoje no UniEventos:
- Instale o Vuetify seguindo os passos da §2.
- Crie um layout com
v-app-bar,v-navigation-drawer(ou menu simples) ev-main. - Configure um tema com pelo menos
primaryesecondarycustomizados, coerente com o domínio do seu projeto. - Crie pelo menos três rotas: uma lista, um detalhe com parâmetro (
/:id) e uma rota 404. - Migre seus dados (mínimo 8 registros, já existentes desde a Aula 01/02) para os cards em grid responsivo.
Critério de pronto: npm run dev roda sem erros no console; navegar entre as três rotas funciona; o card de detalhe mostra os dados corretos ao clicar em um item da lista; acessar uma URL inexistente mostra a tela 404. Suba o commit no repositório do projeto autoral.
✅ Checkpoint do projeto autoral¶
Ao final desta aula, seu repositório deve ter:
- [ ] Vuetify 4 instalado e funcionando (
v-card,v-btnrenderizando estilizados). - [ ] Tema customizado com
defaultTheme: 'light'declarado explicitamente. - [ ] Layout com
v-app-bar+v-main(ev-navigation-drawerse aplicável). - [ ] Vue Router configurado com no mínimo: rota de lista, rota de detalhe com parâmetro
:id, rota 404. - [ ] Navegação funcionando via
<RouterLink>e/ou:toemv-card/v-list-item. - [ ] Dados de pelo menos 8 registros do domínio autoral, exibidos em grid responsivo (
v-row/v-colcom breakpoints). - [ ] README atualizado com instruções de instalação e execução (
npm install,npm run dev). - [ ] Código versionado e enviado ao repositório GitHub público.
📝 Avaliação 1 — instruções de entrega¶
Escopo¶
A Avaliação 1 cobre a Unidade 1 inteira: estrutura de um projeto Vue 3 criado com CLI, componentes, diretivas, reatividade, ciclo de vida e — a partir de hoje — Vuetify e Vue Router básico. Você vai entregar o projeto autoral que vem evoluindo desde a Aula 01.
O projeto deve:
- Ter sido criado com
npm create vue@latest(ounpx create-vue@latest), com as flags--routerno mínimo. - Ser uma SPA de página única transformada em múltiplas views navegáveis (o que fizemos hoje).
- Usar Vuetify para toda a interface visual (não é permitido CSS puro substituindo os componentes do Vuetify nas telas principais).
- Ter um domínio de dados diferente do UniEventos construído em sala (ex.: catálogo de plantas do Pantanal, agenda de quadras esportivas, mural de estágios, brechó, controle de pescarias, cardápio de restaurante — ou outro tema aprovado na Aula 01).
Requisitos obrigatórios¶
- Mínimo de 6 componentes
.vuepróprios (views + componentes reutilizáveis), além doApp.vue. - Uso comprovado — em código, não só em teoria — de:
v-if/v-else,v-forcom:key,v-model,v-bind(ou o atalho:),v-on(ou o atalho@),computedeonMounted. - Dados de pelo menos 8 registros do domínio escolhido, em um arquivo separado (
src/data/*.js) ou vindos defetcha uma API pública/mock. - Roteamento com Vue Router: no mínimo 3 rotas, sendo uma delas com parâmetro dinâmico e uma delas a rota 404.
- README.md no repositório, com: nome do projeto, descrição de uma linha, instruções de instalação (
npm install) e execução (npm run dev), e print de tela (opcional, mas recomendado). - Repositório GitHub público, com histórico de commits que mostre evolução incremental (não um único commit "projeto final").
Formato e prazo de entrega¶
Entregue o link do repositório GitHub público via SIGAA, na atividade "Avaliação 1", até 02/09/2026, 23h59. Cole o link diretamente no campo de texto da atividade — não anexe .zip.
Rubrica (10,0 pontos)¶
| Critério | Peso |
|---|---|
| Estrutura e organização do projeto (pastas, nomes, componentização mínima) | 2,0 |
Uso correto de diretivas e reatividade (v-if, v-for, v-model, v-bind, v-on) |
3,0 |
Uso de computed e ciclo de vida (onMounted) de forma coerente com o domínio |
2,0 |
| Interface visual com Vuetify e navegação com Vue Router funcionando | 1,5 |
| README, versionamento e histórico de commits | 1,5 |
Política de atraso¶
Entregas após 02/09/2026 23h59 perdem 1,0 ponto por dia corrido de atraso, até o limite de 5 dias. Após esse prazo, a atividade recebe nota zero, salvo justificativa formal protocolada junto à coordenação do curso.
Política de plágio e uso de IA¶
É permitido usar ferramentas de IA como apoio (explicar erros, sugerir sintaxe, revisar código) — assim como é permitido consultar documentação e tutoriais. Não é permitido entregar um projeto gerado quase integralmente por IA sem compreensão do próprio código: na correção, qualquer estudante pode ser chamado para explicar oralmente uma parte do seu projeto, e a nota é ajustada conforme a clareza da explicação. Cópia integral do projeto de outro colega (mesmo com o domínio "trocado") é considerada plágio e resulta em nota zero para ambos os envolvidos, com encaminhamento ao regimento acadêmico da UNEMAT.
📚 Para aprofundar¶
- Documentação oficial do Vuetify 4: https://vuetifyjs.com/
- Guia de instalação com Vite: https://vuetifyjs.com/en/getting-started/installation/
- Material Design 3 — cores e temas: https://m3.material.io/styles/color/overview
- Vue Router — guia oficial: https://router.vuejs.org/
- Vue Router — rotas dinâmicas e parâmetros: https://router.vuejs.org/guide/essentials/dynamic-matching.html
- Referências básicas do plano de curso: capítulos sobre componentização e roteamento client-side.
Na Aula 05 vamos aprofundar componentização — defineProps, defineEmits, slots, composables — e o Vue Router avançado: rotas aninhadas, guards de navegação e query strings sincronizadas com filtros. É também quando o Vuetify ganha formulários com validação e v-data-table.
Aula 05 — Componentes, Vue Router e Vuetify avançado
🎯 Objetivos de aprendizagem¶
Ao final desta aula você será capaz de:
- Decidir quando e como quebrar uma tela em componentes menores, distinguindo componentes "burros" (apresentação) de "inteligentes" (com lógica).
- Definir contratos de componente com
defineProps(tipado, comrequired/default/validator) edefineEmits, incluindov-modelcustomizado comdefineModel()e a forma clássica. - Usar
provide/injectpara dados compartilhados em profundidade e controlar atributos fallthrough comdefineOptions({ inheritAttrs: false }). - Aplicar slots (padrão, nomeados e com escopo) para criar componentes de layout reutilizáveis.
- Extrair lógica reativa reutilizável em composables (
use*) e explicar por que isso substitui mixins. - Configurar rotas aninhadas, rotas nomeadas,
meta, navigation guards e sincronizar filtros com query strings na URL. - Construir formulários validados com
v-form, listar dados comv-data-tablee usar diálogos de confirmação, tabs, menus e skeleton loaders do Vuetify.
📋 Pré-requisitos desta aula¶
- [ ] UniEventos da Aula 04 rodando: Vuetify instalado, tema configurado, rotas
home,evento-detalhe,sobre,nao-encontradofuncionando. - [ ] Avaliação 1 entregue (ou em fase final de entrega).
- [ ] Domínio confortável de
<script setup>,defineProps/defineEmitsbásicos (vistos rapidamente na Aula 02),computed,onMounted.
Na Aula 04 você transformou o UniEventos em uma SPA navegável. Hoje ele fica modular: em vez de views monolíticas com tudo dentro, cada pedaço de interface vira um componente com contrato próprio — e a área administrativa ganha rotas aninhadas e formulários validados.
🗺️ Roteiro¶
| Bloco | Tempo | Atividade |
|---|---|---|
| 1 | 50 min | Componentização a sério: props, emits, v-model customizado, provide/inject, slots |
| 2 | 50 min | Composables, Vue Router avançado (rotas aninhadas, guards, query strings), formulários e v-data-table |
| 3 | 50 min | Mão na massa: refatoração do UniEventos em componentes + área administrativa |
1. Componentização a sério¶
Até aqui, cada view do UniEventos (HomeView, EventoDetalheView) concentrava template, lógica e estilo em um único arquivo. Isso funciona em uma tela pequena, mas cresce mal: a HomeView já mistura busca, filtro, grid de cards e lógica de carregamento — daqui a duas aulas, com formulário de cadastro e tabela administrativa, o arquivo viraria ilegível.
Componentizar é dividir a interface em peças menores, cada uma com uma responsabilidade única e um contrato explícito de entrada (props) e saída (emits). O benefício não é só organização de arquivo — é reuso (o mesmo EventoCard aparece na home, na busca e na área administrativa) e testabilidade (um componente pequeno é mais fácil de entender isoladamente).
Granularidade: quando quebrar em componente¶
Não existe regra rígida, mas alguns sinais indicam que é hora de extrair um componente:
- O mesmo trecho de template se repete em duas ou mais telas (ex.: o card de evento).
- Um bloco do template tem lógica própria que não interessa ao componente pai (ex.: a lógica de validação de um campo de formulário).
- O arquivo passou de ~150–200 linhas e virou difícil de escanear visualmente.
- Você consegue nomear o pedaço com um substantivo claro (
EventoCard,FiltroEventos,CabecalhoApp) — se não consegue nomear, talvez não seja um componente coerente ainda.
⚠️ Atenção: granularidade excessiva também é problema. Um projeto com componentes de 5 linhas para cada
<span>cria uma "sopa de componentes" difícil de navegar. Componentize quando há repetição ou responsabilidade clara — não por dogma.
Componente burro vs. componente inteligente¶
Uma distinção útil (não uma regra do Vue, mas um padrão de arquitetura comum em SPAs):
- Componente burro (presentational / dumb) — só recebe dados via props e emite eventos. Não sabe de onde vêm os dados nem o que acontece depois do evento. Fácil de reutilizar e testar. Exemplo:
EventoCard, que recebe um objetoeventoe emite@inscrever. - Componente inteligente (container / smart) — busca dados, decide o que fazer com eventos emitidos pelos filhos, conversa com store/API. Exemplo:
HomeView, que carrega a lista de eventos e passa cada um para umEventoCard.
Essa separação evita que a lógica de negócio (como buscar dados, como filtrar) fique espalhada em componentes visuais pequenos — o que dificultaria trocar, por exemplo, a fonte de dados sem tocar em uma dezena de arquivos.
defineProps com contrato completo¶
Você já usou defineProps de forma simples nas aulas anteriores. Um contrato robusto de props declara tipo, obrigatoriedade, valor padrão e validação:
<script setup>
const props = defineProps({
evento: {
type: Object,
required: true,
},
destaque: {
type: Boolean,
default: false,
},
tamanhoImagem: {
type: Number,
default: 160,
validator: (valor) => valor >= 100 && valor <= 400,
},
})
</script>
typehabilita checagem em tempo de desenvolvimento — o Vue avisa no console se você passar um tipo errado.required: truefaz o Vue emitir um aviso se a prop não for passada.defaultdefine um valor quando a prop não é informada (obrigatório para props opcionais que não sãorequired).validatoré uma função que recebe o valor e retornatrue/false— útil para restringir um número a uma faixa, ou uma string a um conjunto de valores permitidos (enuminformal).
💡 Dica: props são somente leitura dentro do componente filho — nunca faça
props.evento = outraCoisa. Se o filho precisa "mudar" algo que veio do pai, ele deve emitir um evento pedindo a mudança, e é o pai quem decide se atende.
defineEmits e comunicação filho → pai¶
<!-- src/components/EventoCard.vue (trecho) -->
<script setup>
const props = defineProps({
evento: { type: Object, required: true },
})
const emit = defineEmits({
inscrever: (idEvento) => typeof idEvento === 'number',
favoritar: null, // sem validação
})
function aoClicarInscrever() {
emit('inscrever', props.evento.id)
}
</script>
<template>
<v-card>
<!-- ... -->
<v-btn color="primary" @click="aoClicarInscrever">Inscrever-se</v-btn>
</v-card>
</template>
defineEmits declarado como objeto (em vez de array de strings) permite validar o payload de cada evento — assim como defineProps valida entradas, isso valida saídas. O componente pai escuta o evento normalmente:
<EventoCard :evento="evento" @inscrever="tratarInscricao" />
v-model em componente customizado¶
Você já usa v-model em v-text-field e v-dialog — isso é possível porque esses componentes implementam o contrato de v-model. Você pode implementar o mesmo contrato nos seus próprios componentes, de duas formas.
Forma moderna — defineModel() (Vue 3.4+):
<!-- src/components/CampoBusca.vue -->
<script setup>
const modelo = defineModel({ type: String, default: '' })
</script>
<template>
<v-text-field
v-model="modelo"
label="Buscar"
prepend-inner-icon="mdi-magnify"
variant="outlined"
clearable
/>
</template>
<CampoBusca v-model="termoBusca" />
defineModel() cria automaticamente uma prop modelValue e um evento update:modelValue por baixo dos panos, expondo tudo como uma única variável reativa (modelo) que você lê e escreve como se fosse um ref comum. É a forma recomendada para código novo.
Forma clássica — modelValue / update:modelValue:
<!-- src/components/CampoBusca.vue (equivalente, forma clássica) -->
<script setup>
const props = defineProps({
modelValue: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<v-text-field
:model-value="modelValue"
label="Buscar"
prepend-inner-icon="mdi-magnify"
variant="outlined"
clearable
@update:model-value="emit('update:modelValue', $event)"
/>
</template>
As duas formas produzem exatamente o mesmo comportamento externo — <CampoBusca v-model="termoBusca" /> funciona igual nos dois casos. defineModel() é mais curto e é o padrão desta disciplina daqui em diante, mas você vai encontrar a forma clássica em muito código existente (inclusive em bibliotecas), então precisa reconhecê-la.
🔎 Por baixo do capô:
v-model="x"em um componente é açúcar sintático para:model-value="x" @update:model-value="x = $event". É exatamente o mesmo mecanismo de prop + evento que você já usa manualmente — só que com uma sintaxe mais curta, reconhecida pelo compilador do Vue.
provide/inject para dados profundos¶
Passar props por 3 ou 4 níveis de componentes só para chegar a um neto profundo (prop drilling) é doloroso de manter. Para dados amplamente compartilhados — tema, usuário logado, configuração global —, o Vue oferece provide/inject:
<!-- src/App.vue (trecho) -->
<script setup>
import { provide, ref } from 'vue'
const usuarioLogado = ref({ nome: 'Convidado' })
provide('usuarioLogado', usuarioLogado)
</script>
<!-- src/components/PainelPerfil.vue (qualquer nível abaixo de App.vue) -->
<script setup>
import { inject } from 'vue'
const usuarioLogado = inject('usuarioLogado')
</script>
<template>
<span>Olá, {{ usuarioLogado.nome }}</span>
</template>
inject encontra o valor mais próximo fornecido por um ancestral, não importa quantos níveis de componentes existam entre eles. Não é um substituto para comunicação local (props/emits continuam sendo a opção certa entre pai e filho diretos) — é uma ferramenta específica para dados "ambientais". Na Aula 06, o Pinia vai resolver a maior parte desses casos de forma mais estruturada; provide/inject ainda é útil para configuração de componentes de biblioteca (é assim, inclusive, que o próprio Vuetify propaga o tema).
Atributos fallthrough e inheritAttrs¶
Quando você passa um atributo a um componente que não está declarado como prop, o Vue aplica automaticamente esse atributo à raiz do template do componente — isso se chama fallthrough:
<EventoCard :evento="evento" class="destaque" data-testid="card-evento" />
Se EventoCard não declara class nem data-testid como props, o Vue aplica os dois diretamente no elemento raiz do template de EventoCard (por exemplo, no <v-card>). Isso é conveniente na maioria dos casos — mas quando o componente tem múltiplos elementos raiz, ou quando você quer redirecionar o atributo para um elemento interno específico (não o raiz), use:
<script setup>
defineOptions({ inheritAttrs: false })
</script>
<template>
<div class="wrapper">
<v-card v-bind="$attrs">
<!-- conteúdo -->
</v-card>
</div>
</template>
defineOptions({ inheritAttrs: false }) desliga o comportamento automático; v-bind="$attrs" aplica manualmente todos os atributos não declarados como props no elemento que você escolher.
2. Slots: componentes de layout reutilizáveis¶
Props resolvem "que dados entram". Slots resolvem "que conteúdo/template entra" — permitem que um componente pai injete HTML/componentes dentro de um "buraco" definido pelo componente filho.
Slot padrão¶
<!-- src/components/CartaoBase.vue -->
<template>
<v-card class="pa-4">
<slot />
</v-card>
</template>
<CartaoBase>
<h3>Qualquer conteúdo aqui</h3>
<p>O CartaoBase não sabe nem precisa saber o que vai dentro.</p>
</CartaoBase>
Slots nomeados¶
Um componente pode ter vários "buracos" com papéis diferentes:
<!-- src/components/CartaoBase.vue -->
<template>
<v-card>
<v-card-title>
<slot name="titulo">Sem título</slot>
</v-card-title>
<v-card-text>
<slot />
</v-card-text>
<v-card-actions>
<slot name="acoes" />
</v-card-actions>
</v-card>
</template>
<CartaoBase>
<template #titulo>Semana Acadêmica</template>
Conteúdo do corpo do card, vai para o slot padrão.
<template #acoes>
<v-btn color="primary">Inscrever-se</v-btn>
</template>
</CartaoBase>
#titulo é o atalho para v-slot:titulo. Um slot sem name é chamado de slot padrão (default), e recebe qualquer conteúdo que não esteja explicitamente marcado com <template #algumNome>.
Slots com escopo (scoped slots)¶
Às vezes o componente filho tem dados que o pai precisa usar dentro do conteúdo injetado. Um slot com escopo passa dados do filho para o template do pai:
<!-- src/components/EventoLista.vue (trecho) -->
<template>
<div v-for="evento in eventos" :key="evento.id">
<slot name="item" :evento="evento" :formatarData="formatarData" />
</div>
</template>
<EventoLista :eventos="listaEventos">
<template #item="{ evento, formatarData }">
<v-card>
<v-card-title>{{ evento.titulo }}</v-card-title>
<v-card-subtitle>{{ formatarData(evento.dataHora) }}</v-card-subtitle>
</v-card>
</template>
</EventoLista>
O componente EventoLista controla a iteração (v-for) e a lógica auxiliar (formatarData), mas delega ao componente pai como cada item é desenhado. Isso é poderoso: o mesmo EventoLista pode ser reaproveitado em uma tela que mostra cards e em outra que mostra uma tabela — só o slot #item muda.
3. Composables: extraindo lógica reutilizável¶
Um composable é uma função que usa a Composition API (ref, computed, watch, onMounted etc.) para encapsular um pedaço de lógica reativa reutilizável, seguindo a convenção de nome use*.
// src/composables/useEventos.js
import { ref, computed, onMounted } from 'vue'
import { eventos as eventosBase } from '../data/eventos'
export function useEventos() {
const carregando = ref(true)
const eventos = ref([])
const categoriaFiltro = ref('Todas')
const busca = ref('')
onMounted(() => {
setTimeout(() => {
eventos.value = eventosBase
carregando.value = false
}, 300)
})
const eventosFiltrados = computed(() => {
return eventos.value.filter((evento) => {
const bateCategoria =
categoriaFiltro.value === 'Todas' ||
evento.categoria === categoriaFiltro.value.toLowerCase()
const bateBusca = evento.titulo
.toLowerCase()
.includes(busca.value.toLowerCase())
return bateCategoria && bateBusca
})
})
return {
carregando,
eventos,
categoriaFiltro,
busca,
eventosFiltrados,
}
}
Qualquer componente que precise dessa lógica simplesmente chama a função:
<script setup>
import { useEventos } from '../composables/useEventos'
const { carregando, categoriaFiltro, busca, eventosFiltrados } = useEventos()
</script>
Cada chamada de useEventos() cria seu próprio estado isolado (as variáveis ref são criadas de novo a cada chamada) — diferente de uma store Pinia, que é compartilhada globalmente (veremos essa distinção com clareza na Aula 06).
Por que composables substituem mixins¶
Antes da Composition API, o Vue 2 usava mixins para reutilizar lógica entre componentes: um objeto com data, methods, computed que era "misturado" ao componente. O problema era que, ao usar dois ou mais mixins no mesmo componente, não dava para saber de onde vinha cada propriedade — se data.carregando veio do mixin A ou do mixin B era invisível no template, e colisões de nome se sobrescreviam silenciosamente.
Composables resolvem isso porque tudo é explícito: você importa a função, chama, e desestrutura exatamente o que quer usar, sob o nome que quiser:
const { eventosFiltrados: eventosDaHome } = useEventos()
Não há mágica de mesclagem por trás — é só uma função JavaScript comum retornando um objeto. Essa clareza de origem é a razão pela qual a comunidade Vue abandonou mixins como padrão recomendado.
4. Vue Router avançado¶
Rotas aninhadas (children)¶
Uma área administrativa tem uma URL-base (/admin) com sub-telas (/admin/eventos, /admin/eventos/novo). Em vez de repetir /admin em cada rota, use children:
// src/router/index.js (trecho)
{
path: '/admin',
component: () => import('../views/admin/AdminLayoutView.vue'),
children: [
{ path: '', name: 'admin-home', component: () => import('../views/admin/AdminHomeView.vue') },
{ path: 'eventos', name: 'admin-eventos', component: () => import('../views/admin/AdminEventosView.vue') },
{ path: 'eventos/novo', name: 'admin-evento-novo', component: () => import('../views/admin/AdminEventoFormView.vue') },
{ path: 'eventos/:id/editar', name: 'admin-evento-editar', component: () => import('../views/admin/AdminEventoFormView.vue') },
],
}
O componente pai da rota (AdminLayoutView.vue) precisa ter seu próprio <RouterView /> — é onde as rotas-filhas serão renderizadas:
<!-- src/views/admin/AdminLayoutView.vue -->
<template>
<v-container>
<v-tabs>
<v-tab :to="{ name: 'admin-home' }">Painel</v-tab>
<v-tab :to="{ name: 'admin-eventos' }">Eventos</v-tab>
</v-tabs>
<RouterView />
</v-container>
</template>
Esse aninhamento de RouterView dentro de RouterView é o mesmo padrão Composite que vimos na Aula 04 aplicado à navegação: cada nível de rota tem seu próprio "slot" de renderização.
meta em rotas¶
Cada rota pode carregar metadados arbitrários, usados por guards ou pela própria interface (ex.: título da página, exigência de autenticação):
{
path: 'eventos/novo',
name: 'admin-evento-novo',
component: () => import('../views/admin/AdminEventoFormView.vue'),
meta: { requerAutenticacao: true, titulo: 'Novo evento' },
}
Navigation guards¶
Guards são funções que rodam antes (ou depois) de uma navegação, podendo permitir, bloquear ou redirecionar.
beforeEach — guard global, roda em toda navegação:
// src/router/index.js (trecho, após criar o router)
router.beforeEach((to, from) => {
document.title = to.meta.titulo
? `${to.meta.titulo} · UniEventos`
: 'UniEventos'
const autenticado = false // substituiremos por estado real com Pinia na Aula 06
if (to.meta.requerAutenticacao && !autenticado) {
return { name: 'home' } // redireciona
}
// retornar undefined/true permite a navegação
})
beforeEnter — guard por rota, só roda ao entrar naquela rota específica:
{
path: 'eventos/:id/editar',
name: 'admin-evento-editar',
component: () => import('../views/admin/AdminEventoFormView.vue'),
beforeEnter: (to) => {
if (Number.isNaN(Number(to.params.id))) {
return { name: 'nao-encontrado' }
}
},
}
onBeforeRouteLeave — guard dentro do componente, útil para confirmar saída de um formulário com alterações não salvas:
<script setup>
import { ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
const formularioAlterado = ref(true)
onBeforeRouteLeave(() => {
if (formularioAlterado.value) {
const confirmar = window.confirm('Existem alterações não salvas. Sair mesmo assim?')
if (!confirmar) return false
}
})
</script>
Query strings sincronizadas com filtros¶
Uma prática comum e muito útil: refletir o estado dos filtros de busca na URL, para que o usuário possa compartilhar/recarregar a página sem perder o filtro aplicado.
<script setup>
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const rota = useRoute()
const router = useRouter()
// inicializa o filtro a partir da query string, se existir
const categoriaFiltro = ref(rota.query.categoria ?? 'Todas')
// sempre que o filtro mudar, atualiza a URL (sem recarregar a página)
watch(categoriaFiltro, (novoValor) => {
router.push({ query: { ...rota.query, categoria: novoValor } })
})
</script>
Com isso, /eventos?categoria=Minicurso carrega a tela já filtrada — útil para compartilhar um link de busca específica, e para o botão "voltar" do navegador restaurar o filtro anterior.
Scroll behavior¶
Por padrão, ao navegar entre rotas o Vue Router mantém a posição de rolagem atual. Para voltar ao topo em cada navegação (comportamento mais comum em SPAs de conteúdo):
// src/router/index.js (trecho, dentro de createRouter)
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition // navegação por botão voltar/avançar
return { top: 0 }
},
routes: [ /* ... */ ],
})
Layouts diferentes por rota¶
Nem toda rota deve usar o mesmo App.vue. A área administrativa, por exemplo, pode ter um layout próprio (sem o app-bar público). Uma forma simples é usar rotas aninhadas com um componente de layout diferente para cada seção — exatamente a estrutura de AdminLayoutView.vue que criamos acima. Cada "família" de rotas aponta para seu próprio layout, e cada layout tem seu próprio <RouterView /> interno.
5. Vuetify aprofundado¶
v-form com validação por regras¶
<script setup>
import { ref } from 'vue'
const formRef = ref(null)
const titulo = ref('')
const vagas = ref(null)
const regrasTitulo = [
(v) => !!v || 'O título é obrigatório',
(v) => (v && v.length >= 5) || 'O título precisa ter ao menos 5 caracteres',
]
const regrasVagas = [
(v) => !!v || 'Informe o número de vagas',
(v) => (v > 0) || 'O número de vagas deve ser positivo',
]
async function salvar() {
const { valid } = await formRef.value.validate()
if (!valid) return
// ... enviar dados
}
</script>
<template>
<v-form ref="formRef" @submit.prevent="salvar">
<v-text-field v-model="titulo" label="Título do evento" :rules="regrasTitulo" />
<v-text-field v-model.number="vagas" label="Vagas" type="number" :rules="regrasVagas" />
<v-btn type="submit" color="primary">Salvar</v-btn>
</v-form>
</template>
rules é um array de funções que recebem o valor atual do campo e retornam true (válido) ou uma string (mensagem de erro exibida abaixo do campo). Chamar formRef.value.validate() executa todas as regras de todos os campos do formulário de uma vez e retorna { valid, errors }.
⚠️ Atenção: no Vuetify 4, se você usa o slot com escopo do
v-form(<v-form v-slot="{ isValid }">) para acessar o estado de validação diretamente no template, essas variáveis de slot não são mais refs — não use.valuenelas dentro do template. Compare:```vue
Salvar
``` Salvar Se você copiar um exemplo antigo com
.valuedentro do template dov-form, o botão nunca habilita —isValiddeixou de ser um objeto ref e passou a ser o valor puro.
v-data-table¶
<script setup>
import { ref } from 'vue'
import { eventos } from '../data/eventos'
const cabecalhos = [
{ title: 'Título', key: 'titulo' },
{ title: 'Categoria', key: 'categoria' },
{ title: 'Data', key: 'dataHora' },
{ title: 'Vagas', key: 'vagas' },
{ title: 'Ações', key: 'acoes', sortable: false },
]
const busca = ref('')
</script>
<template>
<v-text-field v-model="busca" label="Buscar" prepend-inner-icon="mdi-magnify" class="mb-4" />
<v-data-table
:headers="cabecalhos"
:items="eventos"
:search="busca"
items-per-page="5"
>
<template #item.acoes="{ item }">
<v-btn icon="mdi-pencil" size="small" variant="text" />
<v-btn icon="mdi-delete" size="small" variant="text" color="error" />
</template>
</v-data-table>
</template>
v-data-table já traz ordenação por coluna (clicando no cabeçalho), paginação e busca (via prop search, cruzada contra todos os campos dos itens) prontos, sem código adicional. O slot nomeado #item.acoes — repare no padrão item.<chave-da-coluna> — permite customizar completamente o conteúdo de uma coluna, exatamente com a técnica de slot com escopo que vimos na §2.
v-dialog de confirmação, v-tabs, v-menu, v-skeleton-loader, v-pagination¶
<!-- diálogo de confirmação reutilizável -->
<v-dialog v-model="dialogoAberto" max-width="400" persistent>
<v-card title="Confirmar exclusão" text="Esta ação não pode ser desfeita.">
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialogoAberto = false">Cancelar</v-btn>
<v-btn color="error" variant="flat" @click="confirmarExclusao">Excluir</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- abas -->
<v-tabs v-model="abaAtiva">
<v-tab value="proximos">Próximos</v-tab>
<v-tab value="encerrados">Encerrados</v-tab>
</v-tabs>
<v-window v-model="abaAtiva">
<v-window-item value="proximos">...</v-window-item>
<v-window-item value="encerrados">...</v-window-item>
</v-window>
<!-- menu de contexto -->
<v-menu>
<template #activator="{ props: propsAtivador }">
<v-btn icon="mdi-dots-vertical" variant="text" v-bind="propsAtivador" />
</template>
<v-list>
<v-list-item title="Editar" prepend-icon="mdi-pencil" />
<v-list-item title="Excluir" prepend-icon="mdi-delete" />
</v-list>
</v-menu>
O slot #activator do v-menu é outro exemplo de slot com escopo: ele entrega propsAtivador, um conjunto de listeners/atributos que você precisa espalhar (v-bind) no elemento que deve abrir o menu ao ser clicado.
<!-- esqueleto de carregamento -->
<v-skeleton-loader v-if="carregando" type="card" />
<EventoCard v-else :evento="evento" />
v-skeleton-loader substitui o v-progress-circular genérico quando você quer que o "estado de carregando" já sugira a forma do conteúdo final (cards cinza pulsando no lugar dos cards reais) — uma técnica de percepção de performance bastante usada em produção.
<!-- paginação manual (fora do v-data-table) -->
<v-pagination v-model="paginaAtual" :length="totalPaginas" />
🧩 Padrão de projeto em uso¶
🧩 Padrão de projeto em uso — Composite e Template Method¶
Composite aparece de novo hoje, agora na composição de componentes de layout:
CartaoBasenão sabe o que vai dentro dele — apenas define a "moldura" (v-cardcom título, corpo e ações), e quem usa o componente decide o conteúdo via slots. Isso é o mesmo princípio da árvore de componentes da Aula 04, aplicado deliberadamente ao design de um componente reutilizável.Template Method é um padrão comportamental em que uma classe (ou, aqui, um componente) define o esqueleto de um algoritmo ou de uma estrutura, deixando etapas específicas para serem preenchidas por quem o usa. Um slot com escopo — como o
#itemdeEventoLista— é exatamente isso: o componente controla o "algoritmo" (iterar sobre a lista, aplicar filtro), mas delega ao chamador a etapa de "como desenhar cada item". A estrutura geral é fixa; o passo variável é injetado de fora.
💻 Mão na massa — refatorando o UniEventos em componentes¶
Passo 1 — criar EventoCard.vue¶
<!-- src/components/EventoCard.vue -->
<script setup>
const props = defineProps({
evento: { type: Object, required: true },
})
const emit = defineEmits({
inscrever: (idEvento) => typeof idEvento === 'number',
})
function formatarData(dataIso) {
return new Date(dataIso).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
</script>
<template>
<v-card :to="{ name: 'evento-detalhe', params: { id: evento.id } }">
<v-img :src="evento.imagemUrl" height="160" cover />
<v-card-title>{{ evento.titulo }}</v-card-title>
<v-card-subtitle>
{{ formatarData(evento.dataHora) }} · {{ evento.local }}
</v-card-subtitle>
<v-card-actions>
<v-chip color="secondary" size="small">{{ evento.categoria }}</v-chip>
<v-spacer />
<v-chip :color="evento.vagas > 0 ? 'success' : 'error'" size="small">
{{ evento.vagas > 0 ? `${evento.vagas} vagas` : 'Esgotado' }}
</v-chip>
</v-card-actions>
</v-card>
</template>
Passo 2 — criar EventoLista.vue¶
<!-- src/components/EventoLista.vue -->
<script setup>
defineProps({
eventos: { type: Array, required: true },
})
</script>
<template>
<v-row>
<v-col
v-for="evento in eventos"
:key="evento.id"
cols="12"
sm="6"
md="4"
>
<EventoCard :evento="evento" />
</v-col>
</v-row>
</template>
Graças ao autoImport do Vuetify e ao registro automático de componentes de mesmo diretório do Vite/Vue no scaffold, EventoCard é usado aqui sem import explícito — mas em projetos com muitos componentes é comum importar explicitamente por clareza. Vamos manter import explícito nas próximas telas para deixar as dependências óbvias.
<!-- src/components/EventoLista.vue — versão com import explícito -->
<script setup>
import EventoCard from './EventoCard.vue'
defineProps({
eventos: { type: Array, required: true },
})
</script>
<template>
<v-row>
<v-col
v-for="evento in eventos"
:key="evento.id"
cols="12"
sm="6"
md="4"
>
<EventoCard :evento="evento" />
</v-col>
</v-row>
</template>
Passo 3 — criar FiltroEventos.vue com v-model duplo¶
<!-- src/components/FiltroEventos.vue -->
<script setup>
const busca = defineModel('busca', { type: String, default: '' })
const categoria = defineModel('categoria', { type: String, default: 'Todas' })
const categorias = ['Todas', 'Palestra', 'Minicurso', 'Workshop']
</script>
<template>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-text-field
v-model="busca"
label="Buscar evento"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
clearable
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="categoria"
:items="categorias"
label="Categoria"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
</template>
defineModel('busca', ...) e defineModel('categoria', ...) são a forma de defineModel() para múltiplos v-models no mesmo componente — cada nome vira um par prop/evento independente:
<FiltroEventos v-model:busca="busca" v-model:categoria="categoriaFiltro" />
Passo 4 — criar CabecalhoApp.vue e RodapeApp.vue¶
<!-- src/components/CabecalhoApp.vue -->
<script setup>
import { computed } from 'vue'
import { useTheme } from 'vuetify'
import { RouterLink } from 'vue-router'
defineProps({
totalEventos: { type: Number, default: 0 },
})
const emit = defineEmits(['abrir-menu'])
const tema = useTheme()
const ehEscuro = computed(() => tema.global.name.value === 'dark')
function alternarTema() {
tema.global.name.value = ehEscuro.value ? 'light' : 'dark'
}
</script>
<template>
<v-app-bar color="primary">
<v-app-bar-nav-icon @click="emit('abrir-menu')" />
<v-app-bar-title>
<RouterLink to="/" class="text-white text-decoration-none">UniEventos</RouterLink>
</v-app-bar-title>
<v-chip color="secondary" size="small" class="mr-2">{{ totalEventos }} eventos</v-chip>
<v-spacer />
<v-btn
:icon="ehEscuro ? 'mdi-weather-sunny' : 'mdi-weather-night'"
variant="text"
@click="alternarTema"
/>
</v-app-bar>
</template>
<!-- src/components/RodapeApp.vue -->
<script setup>
</script>
<template>
<v-footer color="primary" class="d-flex justify-center pa-4">
<span class="text-white">UNEMAT · FACET · FACET-SNP-310 · 2026.2</span>
</v-footer>
</template>
Passo 5 — criar DialogoConfirmacao.vue¶
<!-- src/components/DialogoConfirmacao.vue -->
<script setup>
const aberto = defineModel({ type: Boolean, default: false })
defineProps({
titulo: { type: String, default: 'Confirmar ação' },
mensagem: { type: String, default: 'Esta ação não pode ser desfeita.' },
})
const emit = defineEmits(['confirmar'])
function confirmar() {
emit('confirmar')
aberto.value = false
}
</script>
<template>
<v-dialog v-model="aberto" max-width="400" persistent>
<v-card :title="titulo" :text="mensagem">
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="aberto = false">Cancelar</v-btn>
<v-btn color="error" variant="flat" @click="confirmar">Confirmar</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
Passo 6 — atualizar App.vue para usar CabecalhoApp e RodapeApp¶
<!-- src/App.vue -->
<script setup>
import { ref, computed } from 'vue'
import { RouterView } from 'vue-router'
import CabecalhoApp from './components/CabecalhoApp.vue'
import RodapeApp from './components/RodapeApp.vue'
import { eventos } from './data/eventos'
const drawerAberto = ref(false)
const totalEventos = computed(() => eventos.length)
const linksMenu = [
{ titulo: 'Início', rota: 'home', icone: 'mdi-home' },
{ titulo: 'Sobre', rota: 'sobre', icone: 'mdi-information' },
{ titulo: 'Administração', rota: 'admin-home', icone: 'mdi-cog' },
]
</script>
<template>
<v-app>
<CabecalhoApp :total-eventos="totalEventos" @abrir-menu="drawerAberto = !drawerAberto" />
<v-navigation-drawer v-model="drawerAberto" temporary>
<v-list>
<v-list-item
v-for="link in linksMenu"
:key="link.rota"
:to="{ name: link.rota }"
:title="link.titulo"
:prepend-icon="link.icone"
/>
</v-list>
</v-navigation-drawer>
<v-main>
<RouterView />
</v-main>
<RodapeApp />
</v-app>
</template>
Passo 7 — reescrever HomeView.vue usando os componentes e o composable¶
<!-- src/views/HomeView.vue -->
<script setup>
import { useEventos } from '../composables/useEventos'
import FiltroEventos from '../components/FiltroEventos.vue'
import EventoLista from '../components/EventoLista.vue'
const { carregando, categoriaFiltro, busca, eventosFiltrados } = useEventos()
</script>
<template>
<v-container>
<h1 class="text-h4 mb-4">Eventos disponíveis</h1>
<FiltroEventos v-model:busca="busca" v-model:categoria="categoriaFiltro" />
<div v-if="carregando" class="d-flex justify-center pa-8">
<v-skeleton-loader type="card" v-for="n in 3" :key="n" class="mb-4" />
</div>
<v-alert
v-else-if="eventosFiltrados.length === 0"
type="info"
variant="tonal"
title="Nenhum evento encontrado"
>
Tente ajustar os filtros de categoria ou o termo de busca.
</v-alert>
<EventoLista v-else :eventos="eventosFiltrados" />
</v-container>
</template>
Compare este arquivo com o HomeView.vue da Aula 04: a lógica de busca/filtro/carregamento saiu para o composable useEventos, o grid de cards virou EventoLista, e os campos de filtro viraram FiltroEventos. A view agora só orquestra — é um bom exemplo de componente "inteligente" fino, delegando apresentação aos filhos.
Passo 8 — criar rotas administrativas aninhadas¶
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition
return { top: 0 }
},
routes: [
{ path: '/', name: 'home', component: () => import('../views/HomeView.vue') },
{ path: '/eventos/:id', name: 'evento-detalhe', component: () => import('../views/EventoDetalheView.vue') },
{ path: '/sobre', name: 'sobre', component: () => import('../views/SobreView.vue'), meta: { titulo: 'Sobre' } },
{
path: '/admin',
component: () => import('../views/admin/AdminLayoutView.vue'),
children: [
{ path: '', name: 'admin-home', component: () => import('../views/admin/AdminHomeView.vue'), meta: { titulo: 'Painel administrativo' } },
{ path: 'eventos', name: 'admin-eventos', component: () => import('../views/admin/AdminEventosView.vue'), meta: { titulo: 'Gerenciar eventos' } },
{ path: 'eventos/novo', name: 'admin-evento-novo', component: () => import('../views/admin/AdminEventoFormView.vue'), meta: { titulo: 'Novo evento' } },
{
path: 'eventos/:id/editar',
name: 'admin-evento-editar',
component: () => import('../views/admin/AdminEventoFormView.vue'),
meta: { titulo: 'Editar evento' },
beforeEnter: (to) => {
if (Number.isNaN(Number(to.params.id))) {
return { name: 'nao-encontrado' }
}
},
},
],
},
{ path: '/:pathMatch(.*)*', name: 'nao-encontrado', component: () => import('../views/NaoEncontradoView.vue') },
],
})
router.beforeEach((to) => {
document.title = to.meta.titulo ? `${to.meta.titulo} · UniEventos` : 'UniEventos'
})
export default router
Passo 9 — criar AdminLayoutView.vue¶
<!-- src/views/admin/AdminLayoutView.vue -->
<script setup>
import { ref } from 'vue'
import { RouterView } from 'vue-router'
const abaAtiva = ref('admin-eventos')
</script>
<template>
<v-container>
<h1 class="text-h4 mb-4">Administração</h1>
<v-tabs v-model="abaAtiva" class="mb-4">
<v-tab value="admin-home" :to="{ name: 'admin-home' }">Painel</v-tab>
<v-tab value="admin-eventos" :to="{ name: 'admin-eventos' }">Eventos</v-tab>
</v-tabs>
<RouterView />
</v-container>
</template>
Passo 10 — criar AdminHomeView.vue e AdminEventosView.vue¶
<!-- src/views/admin/AdminHomeView.vue -->
<script setup>
import { eventos } from '../../data/eventos'
</script>
<template>
<v-row>
<v-col cols="12" sm="4">
<v-card class="pa-4 text-center">
<div class="text-h3 text-primary">{{ eventos.length }}</div>
<div>eventos cadastrados</div>
</v-card>
</v-col>
</v-row>
</template>
<!-- src/views/admin/AdminEventosView.vue -->
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { eventos } from '../../data/eventos'
import DialogoConfirmacao from '../../components/DialogoConfirmacao.vue'
const router = useRouter()
const dialogoAberto = ref(false)
const eventoParaExcluir = ref(null)
const cabecalhos = [
{ title: 'Título', key: 'titulo' },
{ title: 'Categoria', key: 'categoria' },
{ title: 'Vagas', key: 'vagas' },
{ title: 'Ações', key: 'acoes', sortable: false },
]
const busca = ref('')
function pedirExclusao(evento) {
eventoParaExcluir.value = evento
dialogoAberto.value = true
}
function confirmarExclusao() {
const indice = eventos.findIndex((e) => e.id === eventoParaExcluir.value.id)
if (indice !== -1) eventos.splice(indice, 1)
eventoParaExcluir.value = null
}
</script>
<template>
<div>
<div class="d-flex justify-space-between align-center mb-4">
<v-text-field
v-model="busca"
label="Buscar"
prepend-inner-icon="mdi-magnify"
density="compact"
style="max-width: 300px"
/>
<v-btn color="primary" prepend-icon="mdi-plus" :to="{ name: 'admin-evento-novo' }">
Novo evento
</v-btn>
</div>
<v-data-table :headers="cabecalhos" :items="eventos" :search="busca" items-per-page="5">
<template #item.acoes="{ item }">
<v-btn
icon="mdi-pencil"
size="small"
variant="text"
:to="{ name: 'admin-evento-editar', params: { id: item.id } }"
/>
<v-btn
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="pedirExclusao(item)"
/>
</template>
</v-data-table>
<DialogoConfirmacao
v-model="dialogoAberto"
titulo="Excluir evento"
:mensagem="`Excluir '${eventoParaExcluir?.titulo}'? Esta ação não pode ser desfeita.`"
@confirmar="confirmarExclusao"
/>
</div>
</template>
Passo 11 — criar AdminEventoFormView.vue com validação¶
<!-- src/views/admin/AdminEventoFormView.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { eventos } from '../../data/eventos'
const rota = useRoute()
const router = useRouter()
const modoEdicao = computed(() => rota.name === 'admin-evento-editar')
const formRef = ref(null)
const formularioAlterado = ref(false)
const titulo = ref('')
const descricao = ref('')
const categoria = ref('palestra')
const local = ref('')
const vagas = ref(null)
const categorias = ['palestra', 'minicurso', 'workshop']
const regrasTitulo = [
(v) => !!v || 'O título é obrigatório',
(v) => (v && v.length >= 5) || 'Mínimo de 5 caracteres',
]
const regrasLocal = [(v) => !!v || 'O local é obrigatório']
const regrasVagas = [
(v) => !!v || 'Informe o número de vagas',
(v) => v > 0 || 'Deve ser maior que zero',
]
onMounted(() => {
if (modoEdicao.value) {
const evento = eventos.find((e) => e.id === Number(rota.params.id))
if (evento) {
titulo.value = evento.titulo
descricao.value = evento.descricao
categoria.value = evento.categoria
local.value = evento.local
vagas.value = evento.vagas
}
}
})
onBeforeRouteLeave(() => {
if (formularioAlterado.value) {
const confirmar = window.confirm('Existem alterações não salvas. Sair mesmo assim?')
if (!confirmar) return false
}
})
async function salvar() {
const { valid } = await formRef.value.validate()
if (!valid) return
if (modoEdicao.value) {
const evento = eventos.find((e) => e.id === Number(rota.params.id))
Object.assign(evento, {
titulo: titulo.value,
descricao: descricao.value,
categoria: categoria.value,
local: local.value,
vagas: vagas.value,
})
} else {
const novoId = Math.max(...eventos.map((e) => e.id)) + 1
eventos.push({
id: novoId,
titulo: titulo.value,
descricao: descricao.value,
categoria: categoria.value,
local: local.value,
vagas: vagas.value,
dataHora: new Date().toISOString(),
imagemUrl: `https://picsum.photos/seed/evento${novoId}/600/300`,
})
}
formularioAlterado.value = false
router.push({ name: 'admin-eventos' })
}
</script>
<template>
<v-card class="pa-4">
<v-card-title>{{ modoEdicao ? 'Editar evento' : 'Novo evento' }}</v-card-title>
<v-card-text>
<v-form ref="formRef" @submit.prevent="salvar" @update:model-value="formularioAlterado = true">
<v-text-field v-model="titulo" label="Título" :rules="regrasTitulo" class="mb-2" />
<v-textarea v-model="descricao" label="Descrição" rows="3" class="mb-2" />
<v-select v-model="categoria" :items="categorias" label="Categoria" class="mb-2" />
<v-text-field v-model="local" label="Local" :rules="regrasLocal" class="mb-2" />
<v-text-field v-model.number="vagas" label="Vagas" type="number" :rules="regrasVagas" class="mb-4" />
<v-btn type="submit" color="primary" variant="flat">Salvar</v-btn>
<v-btn variant="text" class="ml-2" :to="{ name: 'admin-eventos' }">Cancelar</v-btn>
</v-form>
</v-card-text>
</v-card>
</template>
🧪 Laboratório¶
1. CartaoBase com slots nomeados
Crie o componente src/components/CartaoBase.vue com slots titulo, padrão e acoes (como na §2), e use-o para reescrever a tela SobreView.vue.
Dica
<template #titulo>, conteúdo solto (sem <template>) cai no slot padrão, <template #acoes>.
2. Composable useAlternanciaTema
Extraia a lógica de alternarTema/ehEscuro do CabecalhoApp.vue para um composable src/composables/useAlternanciaTema.js, e use-o também em uma nova tela de configurações.
Dica
O composable recebe useTheme() internamente e retorna { ehEscuro, alternarTema }.
3. Guard de confirmação no formulário de novo evento
No AdminEventoFormView.vue, o onBeforeRouteLeave já existe, mas formularioAlterado nunca vira true ao digitar em campos que não passam por @update:model-value do form (ex.: se o navegador não disparar esse evento para todo campo). Ajuste para marcar formularioAlterado.value = true de forma confiável usando watch sobre os campos do formulário.
Dica
watch([titulo, descricao, categoria, local, vagas], () => { formularioAlterado.value = true }).
4. Query string de paginação
Adicione um v-pagination na AdminEventosView.vue (fora do v-data-table, como exercício) e sincronize a página atual com ?pagina=N na URL, seguindo o padrão da §4.
Dica
ref(Number(rota.query.pagina) || 1) mais um watch que chama router.push({ query: { ...rota.query, pagina } }).
5. v-menu de ações rápidas no EventoCard
Adicione um v-menu com um botão de três pontinhos no EventoCard, com opções "Compartilhar" e "Favoritar", que emitem eventos compartilhar e favoritar para o componente pai.
Dica
Use o slot #activator="{ props }" do v-menu, como no exemplo da §5.
🐛 Erros comuns e como resolver¶
| Sintoma | Causa | Solução |
|---|---|---|
v-model em componente customizado não atualiza nada |
Componente não implementa defineModel() nem o par modelValue/update:modelValue |
Adicione defineModel() (ou a forma clássica) no componente filho |
Prop chega como undefined mesmo passando valor no pai |
Nome da prop em kebab-case no template não bate com o nome em camelCase no defineProps |
Vue converte automaticamente total-eventos ↔ totalEventos — confira grafia exata dos dois lados |
formRef.value.validate() lança erro "Cannot read properties of null" |
O ref="formRef" não está associado a um <v-form> ainda montado (chamado antes do onMounted) |
Garanta que a chamada acontece após o componente montar, tipicamente dentro de um handler de clique |
No v-slot="{ isValid }" do v-form, isValid.value é undefined |
Código copiado do Vuetify 3 — variáveis de slot não são mais refs no Vuetify 4 | Use isValid diretamente, sem .value |
RouterView da rota aninhada nunca renderiza |
Falta um <RouterView /> dentro do componente de layout (AdminLayoutView.vue) |
Toda rota com children precisa de um RouterView próprio no componente pai da rota |
onBeforeRouteLeave não é chamado |
Guard declarado fora de um componente renderizado pela rota (ex.: em um componente filho que não é o componente-alvo da rota) | onBeforeRouteLeave só funciona dentro do componente que a rota renderiza diretamente |
| Slot com escopo não recebe os dados esperados | Faltou vincular os dados no <slot> do componente filho (:evento="evento") |
Toda variável que o slot precisa expor deve ser passada como atributo do <slot> |
🏠 Atividade assíncrona (1 h)¶
No seu projeto autoral:
- Extraia pelo menos um componente reutilizável de apresentação (equivalente ao
EventoCard), comdefinePropstipado e ao menos um evento emitido. - Crie um componente com slot nomeado (equivalente ao
CartaoBase) e use-o em pelo menos duas telas diferentes. - Extraia a lógica de carregamento/filtro de dados para um composable
use*. - Adicione uma área com rotas aninhadas (ex.: painel administrativo do seu domínio) com pelo menos duas rotas-filhas.
- Crie um formulário de cadastro/edição com
v-formerulespara pelo menos dois campos.
Critério de pronto: o formulário não deixa salvar com campos inválidos; a navegação entre rotas aninhadas funciona sem recarregar a página; pelo menos um componente usa slot nomeado com sucesso. Suba o commit no repositório.
✅ Checkpoint do projeto autoral¶
- [ ] Pelo menos 3 componentes de apresentação extraídos, com
definePropstipado. - [ ] Pelo menos um componente usando
v-modelcustomizado (defineModel()ou forma clássica). - [ ] Pelo menos um componente com slot nomeado ou com escopo.
- [ ] Um composable
use*extraindo lógica de dados/filtro. - [ ] Rotas aninhadas funcionando em pelo menos uma seção da aplicação.
- [ ] Formulário com
v-formerulesde validação. - [ ]
v-data-table(ou lista equivalente) listando os dados do domínio com busca.
📚 Para aprofundar¶
- Vue.js — Componentes: Props: https://vuejs.org/guide/components/props.html
- Vue.js —
defineModel(): https://vuejs.org/guide/components/v-model.html - Vue.js — Slots: https://vuejs.org/guide/components/slots.html
- Vue.js — Composables: https://vuejs.org/guide/reusability/composables.html
- Vue Router — Rotas aninhadas: https://router.vuejs.org/guide/essentials/nested-routes.html
- Vue Router — Guards de navegação: https://router.vuejs.org/guide/advanced/navigation-guards.html
- Vuetify —
v-forme validação: https://vuetifyjs.com/en/components/forms/ - Vuetify —
v-data-table: https://vuetifyjs.com/en/components/data-tables/basics/ - Referências básicas do plano de curso: capítulos sobre reuso de componentes e roteamento avançado.
Na Aula 06 o UniEventos passa a consumir dados de uma API de verdade com Axios, organizados em uma camada de serviços — e o estado de eventos e inscrições migra para Pinia, substituindo os refs locais que temos usado até aqui.
Aula 06 — Axios e Pinia
🎯 Objetivos de aprendizagem¶
Ao final desta aula você será capaz de:
- Explicar o protocolo HTTP na prática: métodos, os status codes que realmente importam no dia a dia e por que o CORS existe.
- Configurar uma instância dedicada do Axios (
axios.create) com interceptors de request e response, e tratar erros distinguindoerror.responsedeerror.request. - Cancelar requisições com
AbortControllere enviar arquivos comFormData. - Organizar chamadas HTTP em uma camada de serviços, mantendo os componentes livres de detalhes de rede.
- Subir uma API falsa com
json-serverpara desenvolvimento e testes. - Criar stores Pinia no estilo setup store, com
storeToRefs, ações assíncronas,$reset,$patch,$subscribee persistência emlocalStorage. - Conectar o UniEventos a uma API real via camada de serviços e stores, com feedback visual de carregamento, erro e sucesso.
📋 Pré-requisitos desta aula¶
- [ ] UniEventos da Aula 05 com componentes extraídos, composable
useEventos, rotas aninhadas e formulário validado funcionando. - [ ] Node.js 22.22.2 e npm 10.9.7 instalados (
node -v,npm -v). - [ ] Terminal disponível para rodar dois processos simultâneos (API falsa + app Vue).
Na Aula 05 você quebrou o UniEventos em componentes e extraiu a lógica de dados para o composable
useEventos. Esse composable ainda trabalha com um array estático importado desrc/data/eventos.js. Hoje esse array vira uma API de verdade, e o estado que hoje vive emrefs locais migra para stores Pinia — compartilhadas por toda a aplicação.
🗺️ Roteiro¶
| Bloco | Tempo | Atividade |
|---|---|---|
| 1 | 50 min | HTTP na prática, CORS, Axios: instância dedicada, interceptors, tratamento de erro |
| 2 | 50 min | Camada de serviços, json-server, Pinia: setup store, storeToRefs, ações assíncronas, persistência |
| 3 | 50 min | Mão na massa: UniEventos consumindo API real via serviços + stores |
1. HTTP na prática¶
Você já usou fetch na Aula 03. Antes de trocar por Axios, vale consolidar o que realmente importa saber sobre HTTP para trabalhar com APIs no dia a dia.
Métodos¶
| Método | Uso típico |
|---|---|
GET |
ler um recurso (lista de eventos, um evento específico) |
POST |
criar um recurso novo |
PUT |
substituir um recurso inteiro |
PATCH |
atualizar parcialmente um recurso |
DELETE |
remover um recurso |
Status codes que importam¶
Não é preciso decorar os ~60 códigos HTTP — só os que aparecem o tempo todo:
| Código | Significado | Quando aparece |
|---|---|---|
200 OK |
sucesso, resposta com corpo | GET, PUT, PATCH bem-sucedidos |
201 Created |
recurso criado | POST bem-sucedido |
204 No Content |
sucesso, sem corpo de resposta | DELETE bem-sucedido |
400 Bad Request |
requisição malformada | corpo JSON inválido, campo faltando |
401 Unauthorized |
não autenticado | token ausente ou inválido |
403 Forbidden |
autenticado, mas sem permissão | usuário comum tentando ação de admin |
404 Not Found |
recurso não existe | ID inexistente na URL |
409 Conflict |
conflito de estado | tentar criar um recurso duplicado |
422 Unprocessable Entity |
validação de negócio falhou | e-mail em formato inválido, vagas negativas |
500 Internal Server Error |
erro não tratado no servidor | bug no back-end |
📌 Na prova: a diferença entre
400e422é sutil, mas cai em prova:400é sobre a forma da requisição (JSON quebrado, tipo errado);422é sobre o conteúdo semanticamente inválido de uma requisição bem formada (ex.:vagas: -5).
Headers e JSON¶
Toda requisição e resposta HTTP carrega headers — metadados como Content-Type: application/json (informa que o corpo é JSON) e Authorization: Bearer <token> (credencial de autenticação). O corpo em si, na grande maioria das APIs modernas, é um texto no formato JSON — o mesmo JSON.stringify/JSON.parse que você já usa em JavaScript puro.
CORS explicado de verdade¶
CORS (Cross-Origin Resource Sharing) é uma política de segurança do navegador, não do servidor. Quando sua aplicação Vue, servida em http://localhost:5173, faz uma requisição para uma API em http://localhost:3000, o navegador considera isso uma requisição cross-origin (origens diferentes: porta diferente já conta como origem diferente, mesmo com o mesmo domínio localhost).
Por padrão, o navegador bloqueia a leitura da resposta de uma requisição cross-origin, a menos que o servidor responda explicitamente autorizando aquela origem, através do header Access-Control-Allow-Origin. Isso existe para impedir que um site malicioso, rodando no seu navegador enquanto você está autenticado em outro site (ex.: seu banco), faça requisições silenciosas para esse outro site usando suas credenciais de sessão sem seu conhecimento.
Para requisições "simples" (GET/POST com Content-Type comum), o navegador já bloqueia a leitura da resposta se o header de autorização não vier certo. Para requisições consideradas "não simples" — como PUT, DELETE, ou POST com Content-Type: application/json combinado com headers customizados — o navegador primeiro envia uma requisição OPTIONS chamada preflight, perguntando ao servidor "você aceita esse tipo de requisição desta origem, com estes headers?". Só se o servidor responder afirmativamente ao preflight é que o navegador envia a requisição real.
⚠️ Atenção: CORS é responsabilidade do servidor resolver (autorizando origens), não do front-end. Se você está desenvolvendo e vê um erro de CORS no console, a correção não é "tentar outra sintaxe no Axios" — é configurar o servidor para responder com os headers corretos. Vamos configurar isso na prática quando construirmos a API Express, na Unidade 3 (Aula 07 em diante). Por hoje, o
json-serverque vamos usar já vem com CORS liberado por padrão.
2. Axios: por que uma biblioteca além do fetch¶
fetch é nativo do navegador e funciona bem para casos simples — foi o suficiente até a Aula 03. Mas em uma aplicação real, algumas limitações do fetch pesam:
| Recurso | fetch |
Axios |
|---|---|---|
| Corpo da resposta já convertido em JSON | precisa de .json() manual |
response.data já vem pronto |
| Erros HTTP (4xx/5xx) | não rejeitam a Promise automaticamente | rejeitam a Promise automaticamente |
| Timeout de requisição | precisa implementar manualmente com AbortController |
prop timeout pronta |
| Interceptors (request/response) | não existe nativamente | suportado nativamente |
Instância com configuração padrão (baseURL, headers) |
precisa reimplementar um wrapper | axios.create({...}) pronto |
| Cancelamento | AbortController |
AbortController (compatível) |
O ponto mais importante da tabela é o segundo: com fetch, uma resposta 404 ou 500 não faz a Promise falhar — você precisa checar response.ok manualmente. Isso é uma fonte comum de bugs silenciosos. Com Axios, qualquer status fora da faixa 2xx já cai automaticamente no catch.
Instalação¶
npm install axios
Versão usada nesta disciplina: axios 1.19.0.
Instância dedicada¶
⚠️ Atenção: nunca use o
axiosimportado diretamente (import axios from 'axios') espalhado pelos componentes. Sempre crie uma instância dedicada, configurada uma única vez, e reutilize-a em toda a aplicação.
// src/services/http.js
import axios from 'axios'
const http = axios.create({
baseURL: 'http://localhost:3000',
timeout: 8000,
headers: {
'Content-Type': 'application/json',
},
})
export default http
Isso centraliza baseURL (endereço da API), timeout (tempo máximo de espera antes de desistir da requisição) e headers padrão em um único lugar — trocar de ambiente (desenvolvimento → produção) vira uma alteração em um arquivo só.
Interceptor de request — injetar token¶
Um interceptor é uma função que roda automaticamente antes de cada requisição sair (interceptor de request) ou antes de cada resposta chegar ao código que a chamou (interceptor de response):
// src/services/http.js (trecho — adicionar após criar a instância)
http.interceptors.request.use((config) => {
const token = localStorage.getItem('uniEventosToken')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
Com isso, nenhum componente ou serviço precisa se lembrar de anexar o token manualmente — toda requisição feita através de http já sai com o header Authorization quando há um token salvo. Vamos usar esse mecanismo de verdade na Unidade 3, quando implementarmos login com Firebase.
Interceptor de response — tratar 401 e normalizar erros¶
// src/services/http.js (trecho — adicionar após o interceptor de request)
http.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('uniEventosToken')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
Esse interceptor de response roda para toda resposta com erro, em qualquer lugar da aplicação: se o servidor responder 401 (token expirado ou inválido), o interceptor limpa o token salvo e redireciona para o login — sem que cada chamada de API precise repetir essa lógica.
Tratamento de erro: error.response vs. error.request¶
Ao capturar um erro do Axios, existem três cenários possíveis, e cada um exige um tratamento diferente:
try {
const resposta = await http.get('/eventos')
console.log(resposta.data)
} catch (erro) {
if (erro.response) {
// o servidor respondeu, mas com status de erro (4xx, 5xx)
console.error('Erro do servidor:', erro.response.status, erro.response.data)
} else if (erro.request) {
// a requisição foi enviada, mas nenhuma resposta chegou
// (servidor fora do ar, sem rede, CORS bloqueando)
console.error('Sem resposta do servidor:', erro.request)
} else {
// erro ao montar a própria requisição (configuração inválida, etc.)
console.error('Erro ao configurar a requisição:', erro.message)
}
}
Essa distinção importa na prática: um erro.response com 404 deve mostrar "evento não encontrado"; um erro.request (sem resposta nenhuma) deve mostrar "não foi possível conectar ao servidor — verifique sua internet".
Cancelamento com AbortController¶
Em telas com busca "ao digitar" (busca incremental), cada tecla pode disparar uma nova requisição antes da anterior terminar — sem cancelamento, respostas antigas podem chegar depois das novas e sobrescrever dados mais recentes na tela.
let controlador = null
async function buscar(termo) {
if (controlador) controlador.abort() // cancela a busca anterior, se existir
controlador = new AbortController()
try {
const resposta = await http.get('/eventos', {
params: { titulo_like: termo },
signal: controlador.signal,
})
return resposta.data
} catch (erro) {
if (axios.isCancel(erro) || erro.code === 'ERR_CANCELED') {
return [] // busca cancelada, não é um erro de verdade
}
throw erro
}
}
Upload com FormData¶
Quando o UniEventos precisar permitir upload de uma imagem de evento (em vez de só uma URL), o corpo da requisição deixa de ser JSON e passa a ser multipart/form-data, construído com FormData:
async function enviarImagem(arquivo) {
const dados = new FormData()
dados.append('imagem', arquivo)
const resposta = await http.post('/upload', dados, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return resposta.data.url
}
FormData é uma API nativa do navegador (não específica do Axios) para montar corpos de requisição no formato usado tradicionalmente por formulários HTML com arquivos.
3. Camada de serviços¶
Um erro comum é chamar http.get(...) diretamente dentro de um componente .vue. Isso mistura duas responsabilidades que deveriam ser independentes: como a tela se comporta e como os dados são buscados. Se a API mudar (endpoint renomeado, formato de resposta diferente), você teria que caçar cada componente que faz chamadas HTTP.
A solução é uma camada de serviços: um módulo por recurso, que expõe funções com nomes de negócio (listar, criar, remover) e esconde os detalhes de URL, método HTTP e formato de payload.
// src/services/eventosService.js
import http from './http'
export default {
async listar(filtros = {}) {
const resposta = await http.get('/eventos', { params: filtros })
return resposta.data
},
async buscarPorId(id) {
const resposta = await http.get(`/eventos/${id}`)
return resposta.data
},
async criar(evento) {
const resposta = await http.post('/eventos', evento)
return resposta.data
},
async atualizar(id, evento) {
const resposta = await http.put(`/eventos/${id}`, evento)
return resposta.data
},
async remover(id) {
await http.delete(`/eventos/${id}`)
},
}
Um componente (ou, como veremos, uma store) usa isso assim:
import eventosService from '../services/eventosService'
const eventos = await eventosService.listar({ categoria: 'palestra' })
Por que os componentes não devem chamar Axios diretamente:
- Testabilidade — testar um serviço isolado (mockando
http) é muito mais simples do que testar um componente inteiro só para validar uma chamada de API. - Reuso — a mesma função
eventosService.listar()é chamada pelaHomeView, pela busca administrativa e por um composable, sem repetir a URL em três lugares. - Um ponto único de mudança — se o endpoint
/eventosvirar/api/v1/eventos, você edita um arquivo, não uma dúzia de componentes. - Separação de camadas — é o mesmo princípio de "não misturar HTML com lógica de banco de dados" que você vai aplicar no back-end, na Unidade 3.
4. API falsa para a aula: json-server¶
Antes de existir um back-end real (isso vem na Unidade 3, com Express), usamos o json-server: uma ferramenta que transforma um arquivo JSON em uma API REST completa, com poucos minutos de configuração.
Criando o db.json do UniEventos¶
// db.json
{
"eventos": [
{ "id": 1, "titulo": "Semana Acadêmica de Computação", "descricao": "Palestras e minicursos sobre tendências em tecnologia.", "categoria": "palestra", "dataHora": "2026-09-29T19:00:00", "local": "Auditório Central", "vagas": 40, "imagemUrl": "https://picsum.photos/seed/evento1/600/300" },
{ "id": 2, "titulo": "Minicurso de Vue.js Avançado", "descricao": "Componentização, roteamento e gerenciamento de estado.", "categoria": "minicurso", "dataHora": "2026-09-15T18:30:00", "local": "Laboratório 3", "vagas": 25, "imagemUrl": "https://picsum.photos/seed/evento2/600/300" },
{ "id": 3, "titulo": "Workshop de Prototipação em Figma", "descricao": "Fundamentos de design de interfaces para desenvolvedores.", "categoria": "workshop", "dataHora": "2026-09-20T14:00:00", "local": "Sala 12", "vagas": 30, "imagemUrl": "https://picsum.photos/seed/evento3/600/300" },
{ "id": 4, "titulo": "Palestra: Carreira em Dados", "descricao": "Trilhas profissionais em ciência e engenharia de dados.", "categoria": "palestra", "dataHora": "2026-10-02T19:30:00", "local": "Auditório Central", "vagas": 50, "imagemUrl": "https://picsum.photos/seed/evento4/600/300" },
{ "id": 5, "titulo": "Minicurso de Banco de Dados NoSQL", "descricao": "Modelagem de dados com MongoDB na prática.", "categoria": "minicurso", "dataHora": "2026-09-22T18:30:00", "local": "Laboratório 2", "vagas": 20, "imagemUrl": "https://picsum.photos/seed/evento5/600/300" },
{ "id": 6, "titulo": "Workshop de Testes Automatizados", "descricao": "Testes unitários e de integração em aplicações web.", "categoria": "workshop", "dataHora": "2026-10-05T14:00:00", "local": "Sala 12", "vagas": 25, "imagemUrl": "https://picsum.photos/seed/evento6/600/300" },
{ "id": 7, "titulo": "Palestra: Ética em Inteligência Artificial", "descricao": "Discussão sobre vieses e responsabilidade em sistemas de IA.", "categoria": "palestra", "dataHora": "2026-10-10T19:00:00", "local": "Auditório Central", "vagas": 60, "imagemUrl": "https://picsum.photos/seed/evento7/600/300" },
{ "id": 8, "titulo": "Minicurso de Node.js e Express", "descricao": "Construindo APIs REST do zero.", "categoria": "minicurso", "dataHora": "2026-09-25T18:30:00", "local": "Laboratório 1", "vagas": 25, "imagemUrl": "https://picsum.photos/seed/evento8/600/300" }
],
"inscricoes": []
}
Rodando o servidor¶
npx json-server --watch db.json --port 3000
Isso sobe uma API completa em http://localhost:3000, com:
GET /eventos— lista todos os eventos.GET /eventos/3— retorna o evento comid: 3, ou404se não existir.GET /eventos?categoria=palestra— filtro por campo exato.GET /eventos?titulo_like=vue— busca parcial, sem diferenciar maiúsculas/minúsculas.POST /eventos— cria um evento novo (retorna201).PUT /eventos/3— substitui o evento3inteiro.PATCH /eventos/3— atualiza campos específicos do evento3.DELETE /eventos/3— remove o evento3(retorna200com corpo vazio no json-server).
O --watch faz o json-server recarregar automaticamente sempre que db.json é editado manualmente — útil para resetar o estado de teste durante a aula.
💡 Dica: rode o
json-servere onpm run devdo Vite em dois terminais separados. Nenhum dos dois substitui o outro — um serve a API, o outro serve a aplicação Vue.
🔎 Por baixo do capô: o
json-servernão é o que você vai construir de verdade. Ele existe para permitir treinar consumo de API antes de saber construir uma. Na Unidade 3 (Aulas 07–08), você vai construir a API real do UniEventos com Express, replicando esses mesmos endpoints — e aí vai entender por dentro o que o json-server faz por baixo dos panos.
5. Pinia: estado compartilhado de verdade¶
O problema do prop drilling e do estado espalhado¶
Na Aula 05, você usou provide/inject para dados amplamente compartilhados, e o composable useEventos para lógica reutilizável. Mas o composable tem uma limitação: cada componente que o chama recebe seu próprio estado isolado. Se a HomeView e o CabecalhoApp chamarem useEventos() separadamente, cada um tem sua própria cópia da lista de eventos — atualizar uma não atualiza a outra.
Para estado que precisa ser verdadeiramente compartilhado — a lista de eventos carregada uma vez e usada em várias telas, as inscrições do usuário, o carrinho de um e-commerce —, a resposta é uma store: um único objeto reativo, acessível de qualquer componente, sem precisar passar por props em cada nível da árvore.
Pinia: createPinia¶
O Pinia já vem instalado e registrado se você criou o projeto com a flag --pinia (como recomenda a §4 da especificação):
// src/main.js (trecho, já presente no scaffold)
import { createPinia } from 'pinia'
// ...
app.use(createPinia())
defineStore: dois estilos¶
Pinia suporta dois estilos de declaração de store. Esta disciplina usa o setup store — mas você precisa reconhecer os dois, porque o estilo options ainda aparece bastante em projetos e tutoriais existentes.
Options store (parecido com a Options API do Vue 2):
// exemplo — NÃO é o estilo usado nesta disciplina, mas você deve reconhecê-lo
import { defineStore } from 'pinia'
export const useContadorStore = defineStore('contador', {
state: () => ({ valor: 0 }),
getters: {
dobro: (state) => state.valor * 2,
},
actions: {
incrementar() {
this.valor++
},
},
})
Setup store (usa a Composition API — ref, computed, funções comuns):
// src/stores/contadorStore.js — estilo usado nesta disciplina
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useContadorStore = defineStore('contador', () => {
const valor = ref(0)
const dobro = computed(() => valor.value * 2)
function incrementar() {
valor.value++
}
return { valor, dobro, incrementar }
})
No setup store: ref vira state, computed vira getter, função comum vira action — e você retorna explicitamente tudo que deve ficar público. É o mesmo modelo mental que você já usa em <script setup> e em composables, o que reduz a curva de aprendizado: uma store é, na prática, um composable que vive fora de qualquer componente e é compartilhado por todos eles.
📌 Na prova: se te perguntarem a diferença entre uma store setup e um composable comum, a resposta central é: uma store é um singleton (uma instância única compartilhada por toda a aplicação, gerenciada pelo Pinia); um composable comum cria estado novo a cada chamada. Veja o box de padrões de projeto logo abaixo.
storeToRefs — por que desestruturar direto quebra a reatividade¶
import { useEventosStore } from '../stores/eventosStore'
const store = useEventosStore()
// ERRADO — quebra a reatividade
const { eventos, carregando } = store
Desestruturar propriedades reativas diretamente de store quebra a reatividade: eventos e carregando viram cópias estáticas do valor no momento da desestruturação, desconectadas da store. Se a store atualizar depois, essas variáveis locais não acompanham.
import { storeToRefs } from 'pinia'
import { useEventosStore } from '../stores/eventosStore'
const store = useEventosStore()
const { eventos, carregando } = storeToRefs(store) // CORRETO — mantém reatividade
// ações continuam sendo chamadas direto da store, sem storeToRefs
store.carregarEventos()
storeToRefs converte cada propriedade de state/getter em um ref reativo de verdade, ligado à store original. Ações (funções) não precisam desse tratamento — elas não são reativas, só são chamadas — então continuam sendo acessadas direto de store.nomeDaAcao().
🔎 Por baixo do capô: isso acontece pela mesma razão pela qual desestruturar um
reactive()comum quebra a reatividade (você viu isso na Aula 03, ao estudarreactivevs.ref): a store internamente é um objetoreactive, e desestruturar umreactiveextrai o valor primitivo naquele instante, perdendo o Proxy que rastreia mudanças.storeToRefscontorna isso criando umrefpara cada propriedade, que continua "ligado" ao Proxy original.
Store completa: eventosStore.js¶
// src/stores/eventosStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import eventosService from '../services/eventosService'
export const useEventosStore = defineStore('eventos', () => {
const eventos = ref([])
const carregando = ref(false)
const erro = ref(null)
const categoriaFiltro = ref('Todas')
const busca = ref('')
const eventosFiltrados = computed(() => {
return eventos.value.filter((evento) => {
const bateCategoria =
categoriaFiltro.value === 'Todas' ||
evento.categoria === categoriaFiltro.value.toLowerCase()
const bateBusca = evento.titulo
.toLowerCase()
.includes(busca.value.toLowerCase())
return bateCategoria && bateBusca
})
})
async function carregarEventos() {
carregando.value = true
erro.value = null
try {
eventos.value = await eventosService.listar()
} catch (e) {
erro.value = 'Não foi possível carregar os eventos. Tente novamente.'
} finally {
carregando.value = false
}
}
async function removerEvento(id) {
await eventosService.remover(id)
eventos.value = eventos.value.filter((e) => e.id !== id)
}
async function salvarEvento(dadosEvento) {
if (dadosEvento.id) {
const atualizado = await eventosService.atualizar(dadosEvento.id, dadosEvento)
const indice = eventos.value.findIndex((e) => e.id === dadosEvento.id)
if (indice !== -1) eventos.value[indice] = atualizado
} else {
const criado = await eventosService.criar(dadosEvento)
eventos.value.push(criado)
}
}
function $reset() {
eventos.value = []
carregando.value = false
erro.value = null
categoriaFiltro.value = 'Todas'
busca.value = ''
}
return {
eventos,
carregando,
erro,
categoriaFiltro,
busca,
eventosFiltrados,
carregarEventos,
removerEvento,
salvarEvento,
$reset,
}
})
Ações assíncronas¶
Repare que carregarEventos, removerEvento e salvarEvento são funções async comuns — Pinia não exige nenhuma sintaxe especial para ações assíncronas. O padrão carregando/erro como state da própria store (em vez de refs locais em cada componente) é o que permite que qualquer tela mostre o estado de carregamento correto, sem duplicar essa lógica.
$reset, $patch, $subscribe¶
const store = useEventosStore()
// $reset — no setup store, você define sua própria função $reset (como acima),
// pois o Pinia só gera $reset automaticamente para options stores
store.$reset()
// $patch — atualiza várias propriedades de uma vez, útil para mudanças em lote
store.$patch({ categoriaFiltro: 'Palestra', busca: '' })
// $patch também aceita uma função, útil quando a mudança depende do estado atual
store.$patch((state) => {
state.eventos.push({ id: 99, titulo: 'Evento de teste' })
})
// $subscribe — reage a qualquer mudança de state da store (ótimo para persistência/log)
store.$subscribe((mutation, state) => {
console.log('Store eventos mudou:', mutation.type, state)
})
⚠️ Atenção: em uma setup store,
$reset()não é gerado automaticamente pelo Pinia (isso só acontece no estilo options store) — por isso a store acima define sua própria função$resetmanualmente e a expõe noreturn. É um detalhe pequeno, mas comum de esquecer.
Composição de stores¶
Uma store pode usar outra dentro de si, exatamente como um composable usa outro:
// src/stores/inscricoesStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useEventosStore } from './eventosStore'
export const useInscricoesStore = defineStore('inscricoes', () => {
const eventosStore = useEventosStore()
const idsInscritos = ref(
JSON.parse(localStorage.getItem('uniEventosInscricoes') || '[]')
)
const eventosInscritos = computed(() =>
eventosStore.eventos.filter((evento) => idsInscritos.value.includes(evento.id))
)
function inscrever(idEvento) {
if (!idsInscritos.value.includes(idEvento)) {
idsInscritos.value.push(idEvento)
persistir()
}
}
function cancelarInscricao(idEvento) {
idsInscritos.value = idsInscritos.value.filter((id) => id !== idEvento)
persistir()
}
function estaInscrito(idEvento) {
return idsInscritos.value.includes(idEvento)
}
function persistir() {
localStorage.setItem('uniEventosInscricoes', JSON.stringify(idsInscritos.value))
}
return {
idsInscritos,
eventosInscritos,
inscrever,
cancelarInscricao,
estaInscrito,
}
})
useInscricoesStore depende de useEventosStore para calcular eventosInscritos — uma composição direta, sem nenhuma cerimônia especial: dentro do setup store, você simplesmente chama useEventosStore() como chamaria em qualquer componente.
Persistência em localStorage¶
A inscricoesStore acima já persiste manualmente, chamando persistir() a cada mudança. Uma alternativa mais genérica é usar $subscribe para persistir qualquer mudança de state automaticamente, sem espalhar chamadas de localStorage.setItem pelas ações:
// src/stores/inscricoesStore.js (trecho — alternativa com $subscribe)
export const useInscricoesStore = defineStore('inscricoes', () => {
const idsInscritos = ref(
JSON.parse(localStorage.getItem('uniEventosInscricoes') || '[]')
)
function inscrever(idEvento) {
if (!idsInscritos.value.includes(idEvento)) {
idsInscritos.value.push(idEvento)
}
}
function cancelarInscricao(idEvento) {
idsInscritos.value = idsInscritos.value.filter((id) => id !== idEvento)
}
return { idsInscritos, inscrever, cancelarInscricao }
})
// src/main.js (trecho — assinatura global, fora da store)
import { useInscricoesStore } from './stores/inscricoesStore'
const inscricoesStore = useInscricoesStore()
inscricoesStore.$subscribe((mutation, state) => {
localStorage.setItem('uniEventosInscricoes', JSON.stringify(state.idsInscritos))
})
Ambas as abordagens são válidas; a primeira (persistir dentro da própria ação) é mais explícita e fácil de acompanhar em uma disciplina introdutória — é a que vamos usar no Mão na massa.
Vue DevTools inspecionando a store¶
Instale a extensão Vue DevTools no navegador (ou use vite-plugin-vue-devtools, incluído por padrão em muitos scaffolds do create-vue). Na aba Pinia, você vê, em tempo real: todas as stores ativas, o state atual de cada uma, e um histórico de mutações — útil para depurar por que eventosFiltrados não está retornando o que você espera, sem precisar espalhar console.log pelo código.
🧩 Padrão de projeto em uso¶
🧩 Padrão de projeto em uso — Singleton e Decorator¶
Singleton (criacional): uma store Pinia é, por construção, uma instância única compartilhada. Não importa quantas vezes
useEventosStore()seja chamado, em quantos componentes diferentes — todos recebem a mesma instância de store, gerenciada internamente pelo Pinia (identificada pelo primeiro argumento dedefineStore,'eventos'). Isso é exatamente o padrão Singleton: garantir que existe no máximo uma instância de um objeto, e fornecer um ponto de acesso global a ela. É a diferença estrutural entre uma store e um composable comum — o composable cria estado novo a cada chamada; a store sempre devolve a mesma instância.Decorator (estrutural): os interceptors do Axios são um exemplo direto de Decorator. Cada interceptor "envolve" a requisição (ou resposta) original, adicionando comportamento sem alterar o código que originou a chamada — o interceptor de request adiciona o header
Authorization; o interceptor de response adiciona tratamento de401. O componente que chamahttp.get('/eventos')não sabe (nem precisa saber) que essas camadas extras existem — elas são "decoradas" por fora, de forma transparente.
💻 Mão na massa — UniEventos consumindo API real¶
Passo 1 — instalar Axios e criar db.json¶
npm install axios
Crie db.json na raiz do projeto (conteúdo completo na §4 acima), e rode em um terminal separado:
npx json-server --watch db.json --port 3000
Deixe esse terminal aberto durante toda a aula — é a "API" que o front vai consumir.
Passo 2 — criar a instância HTTP¶
// src/services/http.js
import axios from 'axios'
const http = axios.create({
baseURL: 'http://localhost:3000',
timeout: 8000,
headers: {
'Content-Type': 'application/json',
},
})
http.interceptors.request.use((config) => {
const token = localStorage.getItem('uniEventosToken')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
http.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('uniEventosToken')
}
return Promise.reject(error)
}
)
export default http
Passo 3 — criar eventosService.js¶
// src/services/eventosService.js
import http from './http'
export default {
async listar(filtros = {}) {
const resposta = await http.get('/eventos', { params: filtros })
return resposta.data
},
async buscarPorId(id) {
const resposta = await http.get(`/eventos/${id}`)
return resposta.data
},
async criar(evento) {
const resposta = await http.post('/eventos', evento)
return resposta.data
},
async atualizar(id, evento) {
const resposta = await http.put(`/eventos/${id}`, evento)
return resposta.data
},
async remover(id) {
await http.delete(`/eventos/${id}`)
},
}
Passo 4 — criar eventosStore.js¶
// src/stores/eventosStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import eventosService from '../services/eventosService'
export const useEventosStore = defineStore('eventos', () => {
const eventos = ref([])
const carregando = ref(false)
const erro = ref(null)
const categoriaFiltro = ref('Todas')
const busca = ref('')
const eventosFiltrados = computed(() => {
return eventos.value.filter((evento) => {
const bateCategoria =
categoriaFiltro.value === 'Todas' ||
evento.categoria === categoriaFiltro.value.toLowerCase()
const bateBusca = evento.titulo
.toLowerCase()
.includes(busca.value.toLowerCase())
return bateCategoria && bateBusca
})
})
async function carregarEventos() {
carregando.value = true
erro.value = null
try {
eventos.value = await eventosService.listar()
} catch (e) {
erro.value = 'Não foi possível carregar os eventos. Verifique se o json-server está rodando.'
} finally {
carregando.value = false
}
}
async function removerEvento(id) {
await eventosService.remover(id)
eventos.value = eventos.value.filter((e) => e.id !== id)
}
async function salvarEvento(dadosEvento) {
if (dadosEvento.id) {
const atualizado = await eventosService.atualizar(dadosEvento.id, dadosEvento)
const indice = eventos.value.findIndex((e) => e.id === dadosEvento.id)
if (indice !== -1) eventos.value[indice] = atualizado
} else {
const criado = await eventosService.criar(dadosEvento)
eventos.value.push(criado)
}
}
function $reset() {
eventos.value = []
carregando.value = false
erro.value = null
categoriaFiltro.value = 'Todas'
busca.value = ''
}
return {
eventos,
carregando,
erro,
categoriaFiltro,
busca,
eventosFiltrados,
carregarEventos,
removerEvento,
salvarEvento,
$reset,
}
})
Passo 5 — criar inscricoesStore.js¶
// src/stores/inscricoesStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useEventosStore } from './eventosStore'
const CHAVE_LOCALSTORAGE = 'uniEventosInscricoes'
export const useInscricoesStore = defineStore('inscricoes', () => {
const eventosStore = useEventosStore()
const idsInscritos = ref(
JSON.parse(localStorage.getItem(CHAVE_LOCALSTORAGE) || '[]')
)
const eventosInscritos = computed(() =>
eventosStore.eventos.filter((evento) => idsInscritos.value.includes(evento.id))
)
function persistir() {
localStorage.setItem(CHAVE_LOCALSTORAGE, JSON.stringify(idsInscritos.value))
}
function inscrever(idEvento) {
if (!idsInscritos.value.includes(idEvento)) {
idsInscritos.value.push(idEvento)
persistir()
}
}
function cancelarInscricao(idEvento) {
idsInscritos.value = idsInscritos.value.filter((id) => id !== idEvento)
persistir()
}
function estaInscrito(idEvento) {
return idsInscritos.value.includes(idEvento)
}
return {
idsInscritos,
eventosInscritos,
inscrever,
cancelarInscricao,
estaInscrito,
}
})
Passo 6 — atualizar HomeView.vue para usar a store¶
<!-- src/views/HomeView.vue -->
<script setup>
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useEventosStore } from '../stores/eventosStore'
import FiltroEventos from '../components/FiltroEventos.vue'
import EventoLista from '../components/EventoLista.vue'
const store = useEventosStore()
const { carregando, erro, categoriaFiltro, busca, eventosFiltrados } = storeToRefs(store)
onMounted(() => {
store.carregarEventos()
})
</script>
<template>
<v-container>
<h1 class="text-h4 mb-4">Eventos disponíveis</h1>
<FiltroEventos v-model:busca="busca" v-model:categoria="categoriaFiltro" />
<div v-if="carregando" class="d-flex justify-center pa-8">
<v-skeleton-loader type="card" v-for="n in 3" :key="n" class="mb-4" />
</div>
<v-alert v-else-if="erro" type="error" variant="tonal" title="Erro ao carregar eventos">
{{ erro }}
</v-alert>
<v-alert
v-else-if="eventosFiltrados.length === 0"
type="info"
variant="tonal"
title="Nenhum evento encontrado"
>
Tente ajustar os filtros de categoria ou o termo de busca.
</v-alert>
<EventoLista v-else :eventos="eventosFiltrados" />
</v-container>
</template>
Note os três estados de tela que você já pratica desde a Aula 03 (carregando / erro / vazio), agora alimentados pela store em vez de lógica local — e uma quarta condição implícita (dados carregados com sucesso), coberta pelo v-else final.
Passo 7 — feedback com v-snackbar na inscrição¶
<!-- src/views/EventoDetalheView.vue -->
<script setup>
import { computed, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useEventosStore } from '../stores/eventosStore'
import { useInscricoesStore } from '../stores/inscricoesStore'
const rota = useRoute()
const router = useRouter()
const eventosStore = useEventosStore()
const { eventos } = storeToRefs(eventosStore)
const inscricoesStore = useInscricoesStore()
const snackbarAberto = ref(false)
const snackbarMensagem = ref('')
const snackbarCor = ref('success')
onMounted(() => {
if (eventos.value.length === 0) {
eventosStore.carregarEventos()
}
})
const evento = computed(() =>
eventos.value.find((e) => e.id === Number(rota.params.id))
)
const jaInscrito = computed(() =>
evento.value ? inscricoesStore.estaInscrito(evento.value.id) : false
)
function formatarDataHora(dataIso) {
return new Date(dataIso).toLocaleString('pt-BR', {
dateStyle: 'long',
timeStyle: 'short',
})
}
function inscrever() {
inscricoesStore.inscrever(evento.value.id)
snackbarMensagem.value = 'Inscrição realizada com sucesso!'
snackbarCor.value = 'success'
snackbarAberto.value = true
}
function cancelarInscricao() {
inscricoesStore.cancelarInscricao(evento.value.id)
snackbarMensagem.value = 'Inscrição cancelada.'
snackbarCor.value = 'warning'
snackbarAberto.value = true
}
function voltar() {
router.push({ name: 'home' })
}
</script>
<template>
<v-container>
<v-btn variant="text" prepend-icon="mdi-arrow-left" class="mb-4" @click="voltar">
Voltar para eventos
</v-btn>
<v-alert v-if="!evento" type="error" variant="tonal" title="Evento não encontrado">
Não existe evento com este identificador. Confira o link acessado.
</v-alert>
<v-card v-else>
<v-img :src="evento.imagemUrl" height="280" cover />
<v-card-title class="text-h5">{{ evento.titulo }}</v-card-title>
<v-card-subtitle>
<v-icon icon="mdi-calendar" size="small" class="mr-1" />
{{ formatarDataHora(evento.dataHora) }}
</v-card-subtitle>
<v-card-text>
<p class="mb-4">{{ evento.descricao }}</p>
<div class="d-flex align-center mb-2">
<v-icon icon="mdi-map-marker" class="mr-2" />
<span>{{ evento.local }}</span>
</div>
<div class="d-flex align-center">
<v-icon icon="mdi-account-group" class="mr-2" />
<span>{{ evento.vagas }} vagas disponíveis</span>
</div>
</v-card-text>
<v-card-actions>
<v-chip color="secondary">{{ evento.categoria }}</v-chip>
<v-spacer />
<v-btn v-if="!jaInscrito" color="primary" variant="flat" @click="inscrever">
Inscrever-se
</v-btn>
<v-btn v-else color="error" variant="outlined" @click="cancelarInscricao">
Cancelar inscrição
</v-btn>
</v-card-actions>
</v-card>
<v-snackbar v-model="snackbarAberto" :color="snackbarCor" timeout="3000">
{{ snackbarMensagem }}
</v-snackbar>
</v-container>
</template>
Passo 8 — atualizar AdminEventosView.vue e o formulário para usar a store¶
<!-- src/views/admin/AdminEventosView.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useEventosStore } from '../../stores/eventosStore'
import DialogoConfirmacao from '../../components/DialogoConfirmacao.vue'
const store = useEventosStore()
const { eventos, carregando, erro } = storeToRefs(store)
const dialogoAberto = ref(false)
const eventoParaExcluir = ref(null)
const busca = ref('')
const snackbarAberto = ref(false)
const cabecalhos = [
{ title: 'Título', key: 'titulo' },
{ title: 'Categoria', key: 'categoria' },
{ title: 'Vagas', key: 'vagas' },
{ title: 'Ações', key: 'acoes', sortable: false },
]
onMounted(() => {
if (eventos.value.length === 0) store.carregarEventos()
})
function pedirExclusao(evento) {
eventoParaExcluir.value = evento
dialogoAberto.value = true
}
async function confirmarExclusao() {
await store.removerEvento(eventoParaExcluir.value.id)
eventoParaExcluir.value = null
snackbarAberto.value = true
}
</script>
<template>
<div>
<div class="d-flex justify-space-between align-center mb-4">
<v-text-field
v-model="busca"
label="Buscar"
prepend-inner-icon="mdi-magnify"
density="compact"
style="max-width: 300px"
/>
<v-btn color="primary" prepend-icon="mdi-plus" :to="{ name: 'admin-evento-novo' }">
Novo evento
</v-btn>
</div>
<v-alert v-if="erro" type="error" variant="tonal" class="mb-4">{{ erro }}</v-alert>
<v-data-table
:headers="cabecalhos"
:items="eventos"
:search="busca"
:loading="carregando"
items-per-page="5"
>
<template #item.acoes="{ item }">
<v-btn
icon="mdi-pencil"
size="small"
variant="text"
:to="{ name: 'admin-evento-editar', params: { id: item.id } }"
/>
<v-btn
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="pedirExclusao(item)"
/>
</template>
</v-data-table>
<DialogoConfirmacao
v-model="dialogoAberto"
titulo="Excluir evento"
:mensagem="`Excluir '${eventoParaExcluir?.titulo}'? Esta ação não pode ser desfeita.`"
@confirmar="confirmarExclusao"
/>
<v-snackbar v-model="snackbarAberto" color="success" timeout="3000">
Evento excluído com sucesso.
</v-snackbar>
</div>
</template>
<!-- src/views/admin/AdminEventoFormView.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useEventosStore } from '../../stores/eventosStore'
const rota = useRoute()
const router = useRouter()
const store = useEventosStore()
const { eventos } = storeToRefs(store)
const modoEdicao = computed(() => rota.name === 'admin-evento-editar')
const formRef = ref(null)
const formularioAlterado = ref(false)
const salvando = ref(false)
const erroSalvar = ref(null)
const titulo = ref('')
const descricao = ref('')
const categoria = ref('palestra')
const local = ref('')
const vagas = ref(null)
const categorias = ['palestra', 'minicurso', 'workshop']
const regrasTitulo = [
(v) => !!v || 'O título é obrigatório',
(v) => (v && v.length >= 5) || 'Mínimo de 5 caracteres',
]
const regrasLocal = [(v) => !!v || 'O local é obrigatório']
const regrasVagas = [
(v) => !!v || 'Informe o número de vagas',
(v) => v > 0 || 'Deve ser maior que zero',
]
onMounted(() => {
if (modoEdicao.value) {
const evento = eventos.value.find((e) => e.id === Number(rota.params.id))
if (evento) {
titulo.value = evento.titulo
descricao.value = evento.descricao
categoria.value = evento.categoria
local.value = evento.local
vagas.value = evento.vagas
}
}
})
onBeforeRouteLeave(() => {
if (formularioAlterado.value) {
const confirmar = window.confirm('Existem alterações não salvas. Sair mesmo assim?')
if (!confirmar) return false
}
})
async function salvar() {
const { valid } = await formRef.value.validate()
if (!valid) return
salvando.value = true
erroSalvar.value = null
try {
await store.salvarEvento({
id: modoEdicao.value ? Number(rota.params.id) : undefined,
titulo: titulo.value,
descricao: descricao.value,
categoria: categoria.value,
local: local.value,
vagas: vagas.value,
dataHora: new Date().toISOString(),
imagemUrl: `https://picsum.photos/seed/evento${Date.now()}/600/300`,
})
formularioAlterado.value = false
router.push({ name: 'admin-eventos' })
} catch (e) {
erroSalvar.value = 'Não foi possível salvar o evento. Tente novamente.'
} finally {
salvando.value = false
}
}
</script>
<template>
<v-card class="pa-4">
<v-card-title>{{ modoEdicao ? 'Editar evento' : 'Novo evento' }}</v-card-title>
<v-card-text>
<v-alert v-if="erroSalvar" type="error" variant="tonal" class="mb-4">{{ erroSalvar }}</v-alert>
<v-form ref="formRef" @submit.prevent="salvar" @update:model-value="formularioAlterado = true">
<v-text-field v-model="titulo" label="Título" :rules="regrasTitulo" class="mb-2" />
<v-textarea v-model="descricao" label="Descrição" rows="3" class="mb-2" />
<v-select v-model="categoria" :items="categorias" label="Categoria" class="mb-2" />
<v-text-field v-model="local" label="Local" :rules="regrasLocal" class="mb-2" />
<v-text-field v-model.number="vagas" label="Vagas" type="number" :rules="regrasVagas" class="mb-4" />
<v-btn type="submit" color="primary" variant="flat" :loading="salvando">Salvar</v-btn>
<v-btn variant="text" class="ml-2" :to="{ name: 'admin-eventos' }">Cancelar</v-btn>
</v-form>
</v-card-text>
</v-card>
</template>
Passo 9 — testar de ponta a ponta¶
Com o json-server rodando em um terminal e npm run dev em outro: a home carrega eventos da API (confira na aba Rede do navegador que a requisição GET http://localhost:3000/eventos acontece); inscrever-se em um evento persiste em localStorage (recarregue a página — a inscrição continua marcada); editar um evento na área administrativa reflete na home; derrubar o json-server (Ctrl+C) e recarregar a home deve mostrar o alerta de erro, não uma tela quebrada.
🧪 Laboratório¶
1. Cancelamento de requisição na busca
Aplique a técnica de AbortController da §2 no eventosService.listar, cancelando a busca anterior sempre que o usuário digitar um novo termo antes da resposta anterior chegar.
Dica
Guarde a instância de AbortController em uma variável de módulo dentro do próprio serviço, como no exemplo da §2.
2. Getter totalPorCategoria na store
Adicione um computed em eventosStore.js chamado totalPorCategoria, que retorna um objeto { palestra: n, minicurso: n, workshop: n } contando eventos de cada categoria. Exiba isso em três v-chip no AdminHomeView.vue.
Dica
eventos.value.reduce((acc, e) => { acc[e.categoria] = (acc[e.categoria] || 0) + 1; return acc }, {}).
3. $subscribe para log de auditoria
No main.js, use eventosStore.$subscribe para imprimir no console, a cada mudança, quantos eventos existem na store — útil para depurar sincronizações inesperadas.
Dica
store.$subscribe((mutation, state) => console.log('eventos:', state.eventos.length)), chamado após app.mount('#app').
4. Tratamento de erro de rede real
Derrube o json-server propositalmente e force um erro.request (não erro.response). Ajuste eventosStore.carregarEventos para mostrar uma mensagem diferente quando o erro for de conexão (sem resposta) versus quando for um erro HTTP com resposta.
Dica
Dentro do catch, verifique if (e.response) { ... } else if (e.request) { ... }, como na §2.
5. Persistência de tema com Pinia
Crie src/stores/preferenciasStore.js com uma setup store que guarda o tema atual ('light'/'dark'), persiste em localStorage e é usada pelo CabecalhoApp.vue no lugar da lógica local de useTheme() isolada.
Dica
A store guarda o nome do tema em um ref; um watch sobre esse ref chama tema.global.name.value = novoValor e localStorage.setItem.
🐛 Erros comuns e como resolver¶
| Sintoma | Causa | Solução |
|---|---|---|
| Erro de CORS no console, requisição bloqueada | Servidor não autoriza a origem do front-end | Confirme que o json-server está rodando (ele libera CORS por padrão); em uma API própria, configure o header Access-Control-Allow-Origin |
eventos.value nunca atualiza na tela, mesmo a store mudando |
Desestruturação direta da store (const { eventos } = store) em vez de storeToRefs |
Use storeToRefs(store) para state/getters; ações continuam vindo direto de store.acao() |
store.$reset is not a function |
Setup store não define $reset manualmente — Pinia só gera isso automaticamente em options stores |
Declare uma função $reset na própria store e inclua-a no return |
Cannot read properties of undefined (reading 'status') no catch de um erro Axios |
Tentando ler erro.response.status quando o erro é erro.request (sem resposta) |
Sempre cheque erro.response antes de acessar erro.response.status |
json-server responde, mas com 404 em toda requisição |
Nome da propriedade no db.json não bate com a rota chamada (ex.: db.json tem "evento" no singular, mas o serviço chama /eventos) |
O nome da chave raiz do db.json define o endpoint — confira a grafia exata |
| Inscrição some ao recarregar a página | localStorage.setItem não está sendo chamado após a mutação, ou a chave usada na leitura é diferente da usada na escrita |
Confirme que persistir() roda em toda ação que muda idsInscritos, e que a chave é idêntica nos dois lugares |
| Interceptor de request não injeta o token | Token não existe ainda em localStorage (usuário nunca logou) ou a chave usada é diferente da chave de login |
Confirme a chave (uniEventosToken) e teste manualmente com localStorage.setItem('uniEventosToken', 'teste') |
🏠 Atividade assíncrona (1 h)¶
No seu projeto autoral:
- Crie um
db.jsoncom os mesmos dados do seu domínio (mínimo 8 registros) e suba comjson-server. - Crie
src/services/http.jscom instância dedicada, interceptor de request e de response. - Crie um serviço (
*Service.js) com pelo menoslistar,buscarPorId,criar,remover. - Crie uma store Pinia (setup store) para o recurso principal do seu domínio, com
carregando,erroe ao menos uma ação assíncrona. - Conecte pelo menos uma tela à store usando
storeToRefs, com feedback dev-snackbarem pelo menos uma ação (criar, excluir ou favoritar).
Critério de pronto: a tela principal carrega dados reais do json-server (não mais do array estático); desligar o json-server e recarregar mostra uma mensagem de erro, não uma tela em branco ou quebrada. Suba o commit.
✅ Checkpoint do projeto autoral¶
- [ ]
db.jsoncriado com pelo menos 8 registros do domínio autoral. - [ ]
src/services/http.jscom instância Axios dedicada e ao menos um interceptor. - [ ] Camada de serviço (
*Service.js) usada por pelo menos uma store — nenhum componente chama Axios direto. - [ ] Pelo menos uma setup store Pinia com state, getter e ação assíncrona.
- [ ]
storeToRefsusado corretamente em pelo menos uma tela. - [ ] Estado de carregando/erro refletido visualmente na interface (skeleton ou spinner + alert).
- [ ] Alguma forma de persistência em
localStorage(favoritos, inscrições, tema — a seu critério).
📚 Para aprofundar¶
- Documentação oficial do Axios: https://axios-http.com/docs/intro
- MDN — CORS: https://developer.mozilla.org/pt-BR/docs/Web/HTTP/CORS
- MDN — Status HTTP: https://developer.mozilla.org/pt-BR/docs/Web/HTTP/Status
json-server(repositório oficial): https://github.com/typicode/json-server- Documentação oficial do Pinia: https://pinia.vuejs.org/
- Pinia — setup stores: https://pinia.vuejs.org/core-concepts/#setup-stores
- Pinia —
storeToRefs: https://pinia.vuejs.org/core-concepts/state.html#accessing-the-state - Referências básicas do plano de curso: capítulos sobre consumo de API e gerenciamento de estado.
Isso encerra a Unidade 2. A Avaliação 2 vence em 07/10/2026, com as instruções completas de entrega na Aula 08 — mas o escopo, resumido em 5 linhas: seu projeto autoral deve consumir dados de uma API (própria ou json-server) através de uma camada de serviços com Axios; ter estado gerenciado por pelo menos uma store Pinia com carregando/erro; refletir esses estados visualmente na interface; persistir algum dado em localStorage; e manter tudo isso rodando em cima da estrutura de rotas e componentes que você já construiu nas Aulas 04 e 05. Comece a organizar seu db.json e sua camada de serviços desde já — não deixe para a última semana.
Aula 07 — Introdução ao Firebase, Node.js e Express
Na Aula 06 você conectou o UniEventos a uma API falsa com json-server, encapsulou as chamadas numa instância dedicada do Axios e organizou o estado global com Pinia. O front-end ficou pronto para conversar com um back-end de verdade. A partir de hoje ele existe — e você é quem vai escrevê-lo.
🎯 Objetivos de aprendizagem¶
Ao final desta aula você será capaz de:
- explicar por que uma aplicação séria não pode viver só no navegador, e o que precisa acontecer do lado do servidor;
- descrever o que é o Node.js, como funciona seu event loop e por que ele é adequado para servidores web;
- diferenciar módulos CommonJS de ES Modules e configurar um projeto Node com
"type": "module"; - criar um projeto no Firebase, obter as credenciais do app web e ler/escrever documentos no Firestore com o SDK modular;
- explicar o que é um BaaS (Backend as a Service) e quando ele resolve — e quando não resolve — o problema de back-end;
- subir um servidor Express 5 mínimo, com CORS e rotas JSON, evitando as armadilhas de sintaxe do Express 4;
- apontar o front-end UniEventos para uma API Express real, no lugar do
json-server.
📋 Pré-requisitos desta aula¶
- [ ] Front-end
unieventos-webda Aula 06 rodando localmente comnpm run dev, consumindojson-servervia instância Axios dedicada. - [ ] Store Pinia de eventos funcionando (estado,
carregando,erro). - [ ] Node.js 22.x instalado (
node -v). Se você tem outra versão, usenvm install 22 && nvm use 22. - [ ] Conta Google para criar o projeto no console do Firebase.
- [ ] VS Code com a extensão REST Client ou Thunder Client instalada (vamos usar hoje).
- [ ] Terminal com
curldisponível (já vem no Linux/macOS; no Windows use ocurldo PowerShell ou WSL). - [ ] Editor com abas suficientes para acompanhar dois projetos abertos ao mesmo tempo (
unieventos-webe, a partir de hoje,unieventos-api). - [ ] Duas janelas de terminal livres — uma para cada projeto rodando simultaneamente.
⚠️ Atenção Muito tutorial de Express na internet — inclusive vídeos recentes — ainda usa a sintaxe do Express 4. A partir de hoje você trabalha com o Express 5.2.1, que já é o padrão de instalação (
npm install expresstraz a v5). Este material tem uma seção inteira (§5) só sobre isso. Leia com atenção antes de copiar código de fora.
Retomando rapidamente onde a Aula 06 parou: você tem hoje um front-end Vue com Vuetify, Router e Pinia, consumindo dados de um json-server através de uma instância Axios dedicada, com interceptors e uma camada src/services/. Essa arquitetura de consumo não muda — o que muda, a partir de agora, é o que está do outro lado da rede.
Duas frentes novas se abrem hoje, e vamos alternar entre elas: primeiro o Node.js e o Firebase (uma introdução rápida a um back-end pronto), depois o Express (o back-end que você mesmo escreve, e que vai crescer pelo resto do semestre).
🗺️ Roteiro¶
| Bloco | Tempo | Atividade |
|---|---|---|
| 1 | 50 min | Por que back-end existe; arquitetura cliente-servidor; Node.js e seu modelo de execução |
| 2 | 50 min | BaaS vs API própria; criação do projeto Firebase; primeira leitura/escrita no Firestore |
| 3 | 50 min | Express 5 na prática: servidor mínimo, rotas, CORS, armadilhas de sintaxe; conectar o front real |
1. Por que o front sozinho não basta¶
Até a Aula 06, o UniEventos rodava inteiro no navegador de quem acessa. O json-server simulava uma API, mas ele não impõe nenhuma regra: qualquer pessoa com o DevTools aberto pode alterar o corpo de uma requisição e gravar o que quiser. Isso é aceitável para prototipar, mas não para um sistema real. Três problemas aparecem assim que você tenta ir além do protótipo.
Segredos não podem ficar no navegador. Toda variável, toda constante, todo arquivo .js que você entrega ao navegador é público — qualquer pessoa pode abrir o DevTools, ver o código-fonte baixado e ler o que está ali. Uma chave de API paga, uma credencial de banco de dados ou uma regra de negócio sigilosa não podem estar no front-end. Elas precisam morar em um ambiente que o usuário não acessa diretamente: o servidor.
Regras de negócio precisam de um lugar confiável para rodar. Pense no UniEventos: um evento tem um número de vagas. Se a lógica "não deixar inscrever além do limite de vagas" estiver só no front-end (por exemplo, desabilitando um botão quando vagas === 0), basta alguém chamar a API diretamente — pelo curl, pelo Postman, por um script — ignorando a interface, para furar a regra. A regra de negócio real precisa ser verificada no servidor, porque é o único lugar que o usuário não controla.
Integridade e autorização dependem de um árbitro imparcial. "Este usuário pode editar este evento?" "Este evento realmente existe e tem vaga disponível neste exato momento?" Essas perguntas não podem ser respondidas com confiança por código que roda na máquina do próprio usuário — ele poderia simplesmente alterar a resposta. É preciso um terceiro, fora do alcance do cliente, que centralize a decisão. Esse terceiro é o back-end.
Um jeito direto de sentir isso na prática: abra o DevTools do navegador (F12) numa aplicação Vue rodando com npm run dev, vá na aba Sources e procure pelos arquivos .js da sua própria aplicação. Estão todos ali, legíveis, com nomes de variáveis e comentários incluídos (a não ser que você tenha ativado minificação/ofuscação — que dificulta a leitura, mas não impede). Qualquer verificação de senha, qualquer "if usuário é admin" escrito só em JavaScript de front-end, está exposto a quem quiser ler.
🔎 Por baixo do capô "Confiável" aqui não é sobre honestidade — é sobre controle de execução. O servidor é confiável não porque é "mais correto", mas porque só você (o dono da infraestrutura) pode alterar o código que roda nele. O código do navegador, qualquer usuário pode alterar antes de ele rodar (interceptando a requisição, editando o JS carregado, etc.).
Arquitetura cliente-servidor: o que trafega, onde cada coisa roda¶
O modelo cliente-servidor divide responsabilidades em duas metades que se comunicam por rede:
- Cliente — o front-end UniEventos rodando no navegador do usuário. Responsável por interface, navegação (Vue Router), estado local de tela (Pinia) e por pedir dados e ações ao servidor via HTTP (Axios).
- Servidor — um processo rodando em uma máquina que você controla (seu notebook agora, um provedor de nuvem depois). Responsável por validar entradas, aplicar regras de negócio, autenticar e autorizar, e ler/gravar dados persistentes.
O que trafega entre os dois é HTTP: requisições com método, URL, cabeçalhos e corpo (geralmente JSON), e respostas com status code, cabeçalhos e corpo. Você já usa isso desde a Aula 06 com o Axios — a diferença é que, a partir de hoje, do outro lado da requisição não tem mais o json-server genérico, tem um programa que você escreve, controla e pode fazer aplicar qualquer regra que quiser.
┌─────────────────────┐ HTTP (JSON) ┌──────────────────────┐
│ unieventos-web │ ────────────────────▶ │ unieventos-api │
│ (Vue + Vuetify) │ │ (Node + Express) │
│ roda no navegador │ ◀──────────────────── │ roda no servidor │
│ do usuário │ │ que você controla │
└─────────────────────┘ └──────────────────────┘
│
▼
banco de dados / Firestore
📌 Na prova Se a pergunta for "por que não validar tudo no front-end?", a resposta certa cita que o código do cliente é executado em uma máquina que o usuário controla, portanto não é confiável para decisões de segurança ou integridade — só o servidor pode ser esse árbitro.
Relembrando HTTP, porque hoje você escreve os dois lados¶
Desde a Aula 06 você usa o Axios para fazer requisições. Hoje você passa a escrever o código que recebe essas requisições, então vale relembrar o vocabulário do protocolo — ele é o mesmo dos dois lados.
Toda requisição HTTP tem um método, que expressa a intenção da ação:
| Método | Intenção |
|---|---|
GET |
ler um recurso, sem alterar nada |
POST |
criar um recurso novo |
PUT/PATCH |
atualizar um recurso existente (inteiro ou parcial) |
DELETE |
remover um recurso |
E toda resposta HTTP tem um status code, um número de três dígitos que resume o resultado sem precisar ler o corpo:
| Faixa | Significado |
|---|---|
2xx |
sucesso (200 OK, 201 Created, 204 No Content) |
4xx |
erro do cliente — pedido malformado, recurso inexistente (400, 404, 422) |
5xx |
erro do servidor — algo quebrou ao processar (500) |
Você já viu o Axios lançar exceção quando o status vem 4xx ou 5xx (Aula 06, nos interceptors). Hoje o ponto de vista muda: você é quem decide qual status devolver em cada rota. Vamos aprofundar status codes por operação na Aula 08 — por ora, guarde que res.status(código) é como o Express define esse número antes do corpo da resposta.
Por trás do que o Axios monta e o Express interpreta, uma requisição HTTP crua se parece com isto (é texto puro, trafegando pela rede):
GET /api/eventos/1 HTTP/1.1
Host: localhost:3000
Accept: application/json
E a resposta, também texto puro, com cabeçalhos seguidos de uma linha em branco e depois o corpo:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 132
{"id":1,"titulo":"Semana Acadêmica de Computação","categoria":"palestra","vagas":80}
O Axios, no front, monta essa requisição a partir do que você escreve em api.get(...). O Express, no servidor, faz o caminho inverso: recebe esse texto cru, faz o parse do método, da URL, dos cabeçalhos e do corpo, e entrega tudo isso organizado em req.method, req.path, req.headers e req.body para o seu código usar. É exatamente esse trabalho de parsing que express.json() completa para o corpo, quando o Content-Type é application/json.
2. Node.js: o runtime por trás do servidor¶
JavaScript nasceu para rodar dentro de navegadores. O Node.js é um runtime — um ambiente de execução — que tira o motor V8 (o mesmo que roda JS no Chrome) de dentro do navegador e o coloca para rodar direto no sistema operacional, com acesso a coisas que o navegador não dá: sistema de arquivos, rede em nível baixo, processos. É isso que permite escrever um servidor HTTP em JavaScript.
Event loop e I/O não bloqueante, sem mistério¶
A pergunta que costuma travar quem vem de outras linguagens: como um programa de uma única thread atende centenas de requisições ao mesmo tempo sem travar?
A resposta é que o Node.js é bom em uma coisa específica: esperar. A maior parte do trabalho de um servidor web não é "calcular muito" — é "esperar coisas lentas": esperar o banco de dados responder, esperar um arquivo ser lido do disco, esperar outra API responder. Essas operações são de I/O (entrada/saída) e, no Node, elas não bloqueiam a thread principal.
Pense assim: quando seu código pede "leia este arquivo" ou "consulte este banco de dados", o Node não fica parado esperando. Ele delega essa espera para o sistema operacional (ou para uma thread interna de apoio) e imediatamente volta a executar a próxima linha de código disponível — atendendo outra requisição, por exemplo. Quando a operação de I/O termina, o resultado entra em uma fila. O event loop é o mecanismo que fica continuamente perguntando "tem algo pronto na fila para eu processar agora?" e, quando tem, executa o callback (ou resolve a Promise, ou retoma o await) correspondente.
chegou requisição A (buscar eventos no Firestore)
│
▼
Node dispara a consulta e NÃO espera parado
│
▼
thread livre → atende requisição B (buscar 1 evento por id)
│
▼
Node dispara a consulta B e NÃO espera parado
│
▼
resultado de A fica pronto → event loop retoma o código de A
│
▼
resultado de B fica pronto → event loop retoma o código de B
Isso é diferente de um modelo bloqueante, onde a thread ficaria parada, sem fazer nada, do início ao fim de cada consulta — atendendo uma requisição de cada vez, em fila, mesmo que 99% do tempo seja espera. Um servidor Node consegue lidar com milhares de conexões simultâneas com uma única thread principal porque quase todo esse tempo é espera de I/O, não cálculo.
⚠️ Atenção Isso não significa que o Node é mágico para tudo. Se seu código fizer um cálculo pesado e síncrono (por exemplo, um laço
forgigantesco processando dados em memória), ele bloqueia a thread principal e trava todas as requisições até terminar. O modelo não bloqueante vale para I/O — rede, disco, banco de dados —, não para processamento pesado de CPU. Para isso existem worker threads, fora do escopo desta disciplina.
Na prática, isso aparece no seu código como async/await e Promise, que você já usa desde a Aula 01. await pool.query(...) (aula 09) ou await getDocs(...) (Firestore, ainda hoje) são exatamente isso: "dispare esta operação de I/O e me devolva o controle quando o resultado chegar, sem travar o resto do programa".
🔎 Por baixo do capô Internamente, o Node usa uma biblioteca em C chamada libuv para implementar o event loop e delegar operações de I/O ao sistema operacional (ou a uma pequena pool de threads auxiliares, para coisas como leitura de arquivo que o SO não oferece de forma assíncrona nativa). Sua thread JavaScript principal continua única — é a libuv que faz o trabalho de bastidores para nunca bloqueá-la. Você não precisa mexer nisso diretamente; só precisa saber que existe, para entender por que
async/await"simplesmente funciona" sem travar o servidor.
💡 Dica Uma analogia útil: pense num garçom (a thread do Node) atendendo várias mesas (requisições) num restaurante. Um garçom bloqueante ficaria parado do lado de uma mesa esperando a cozinha terminar um prato antes de atender a próxima mesa. Um garçom não bloqueante anota o pedido, leva para a cozinha, e imediatamente vai atender a próxima mesa — voltando a cada mesa só quando o prato dela está pronto. É o mesmo garçom (uma thread), mas ele nunca fica parado esperando.
Módulos: CommonJS vs ES Modules¶
Node.js existe desde 2009, muito antes de o JavaScript ter um sistema de módulos padronizado na própria linguagem. Por isso o Node criou o seu: CommonJS, baseado em require() e module.exports.
// estilo CommonJS (antigo, ainda muito comum em tutoriais e pacotes legados)
const express = require('express')
module.exports = { minhaFuncao }
Anos depois, o JavaScript ganhou um sistema de módulos oficial da linguagem: ES Modules (ESM), baseado em import/export — o mesmo que você já usa em todo componente Vue desde a Aula 01.
// estilo ES Modules (o que esta disciplina usa no back-end)
import express from 'express'
export function minhaFuncao() { /* ... */ }
Nesta disciplina, o back-end usa ESM. É consistente com o que você já escreve no front-end, é o padrão recomendado para projetos novos e evita misturar dois estilos de import/require no mesmo projeto. Para o Node tratar seus arquivos .js como ESM (e não CommonJS, que é o padrão histórico), é preciso declarar isso no package.json:
{
"name": "unieventos-api",
"type": "module"
}
Com "type": "module" presente, todo arquivo .js do projeto passa a ser interpretado como ESM. require deixa de funcionar; use sempre import.
⚠️ Atenção Se você copiar um trecho de tutorial que usa
require('express')e colar em um projeto com"type": "module"nopackage.json, o Node lançaReferenceError: require is not defined in ES module scope. A correção é reescrever paraimport express from 'express'. Isso vai acontecer — memorize a mensagem de erro.
Preparando o ambiente: npm init, scripts, node --watch¶
Todo projeto Node começa com um package.json, que descreve o projeto, suas dependências e seus scripts.
mkdir unieventos-api && cd unieventos-api
npm init -y
O npm init -y gera um package.json com valores padrão. Ajuste-o para o que a disciplina usa:
{
"name": "unieventos-api",
"version": "1.0.0",
"description": "API do projeto UniEventos — FACET-SNP-310",
"type": "module",
"main": "src/servidor.js",
"scripts": {
"dev": "node --watch --env-file=.env src/servidor.js",
"start": "node --env-file=.env src/servidor.js"
}
}
Dois pontos merecem atenção:
node --watch no lugar do nodemon. Historicamente, quem desenvolvia com Node instalava o pacote nodemon para reiniciar o servidor automaticamente a cada alteração de arquivo. Desde a versão 18.11, o próprio Node tem essa funcionalidade embutida: a flag --watch. Não é preciso instalar mais nada.
node --watch src/servidor.js
Variáveis de ambiente com process.env e --env-file. Toda configuração que muda entre ambientes (porta do servidor, credenciais de banco, chaves de API) deve vir de variáveis de ambiente, nunca de valores fixos no código. O Node expõe essas variáveis no objeto global process.env. Desde a versão 20.6 (estável desde a 22), o Node lê arquivos .env nativamente, sem precisar do pacote dotenv:
node --env-file=.env src/servidor.js
# .env (nunca commitar este arquivo)
PORTA=3000
// uso de process.env em qualquer arquivo do projeto
const porta = process.env.PORTA || 3000
💡 Dica Crie sempre um
.env.exampleversionado, com as chaves (sem os valores sigilosos), para quem clonar o repositório saber o que configurar. E adicione.envao.gitignoreimediatamente — antes do primeiro commit, não depois.
dependencies vs devDependencies, e o que é o package-lock.json¶
Quando você roda npm install express cors, dois efeitos acontecem: os pacotes são baixados para node_modules/, e o package.json ganha uma entrada em "dependencies". Pacotes que só existem para ajudar durante o desenvolvimento — nunca rodam em produção — vão em "devDependencies", instalados com a flag -D:
npm install express cors # vai para "dependencies" — necessário em produção
npm install -D algum-pacote-de-teste # vai para "devDependencies" — só em desenvolvimento
O package-lock.json, gerado automaticamente, trava a versão exata (inclusive das dependências transitivas — as dependências das suas dependências) que foi instalada. Ele deve ser commitado: garante que qualquer pessoa que clone o repositório e rode npm install receba exatamente as mesmas versões que você testou, evitando o clássico "na minha máquina funciona".
⚠️ Atenção
node_modules/nunca é commitado — é sempre reconstruído comnpm installa partir dopackage.jsone dopackage-lock.json. Ele já está no.gitignoredo projeto.
🧩 Padrão de projeto em uso¶
🧩 Padrão de projeto em uso — Chain of Responsibility¶
Um servidor Express processa toda requisição através de uma sequência de funções chamadas middlewares:
express.json(),cors(), sua rota, e (aula 08) validadores e tratadores de erro. Cada função na cadeia decide se trata a requisição, a repassa adiante comnext(), ou interrompe o fluxo respondendo diretamente. Isso é o padrão comportamental Chain of Responsibility: uma corrente de handlers, cada um com a chance de agir e passar adiante. Você já viu a ideia em ação nos interceptors do Axios (Aula 06) — lá era uma cadeia de duas etapas (requisição/resposta); aqui é uma cadeia configurável de N etapas. Vamos aprofundar isso na Aula 08, quando você escrever seus próprios middlewares.
3. BaaS vs API própria: o que é o Firebase¶
Nem todo back-end precisa ser escrito do zero. Um BaaS (Backend as a Service) é um serviço de terceiros que já entrega pedaços prontos de back-end — banco de dados, autenticação, upload de arquivos, hospedagem — através de um SDK que você chama direto do seu front-end, sem escrever seu próprio servidor para essas partes.
O Firebase, do Google, é o BaaS mais usado no mercado. Os serviços relevantes para esta disciplina:
| Serviço | Para que serve |
|---|---|
| Authentication | login/cadastro (e-mail+senha, Google, etc.) — usado na Aula 10 |
| Firestore | banco de dados NoSQL orientado a documentos, em tempo real |
| Storage | upload e hospedagem de arquivos (ex.: imagem do evento) |
| Hosting | hospedagem estática do front-end compilado |
| Cloud Functions | código de back-end sob demanda, sem gerenciar servidor |
Quando um BaaS resolve: protótipos, MVPs, projetos pequenos ou médios onde autenticação e um banco de dados de propósito geral já cobrem a necessidade. Você escreve praticamente zero código de servidor — o SDK do Firebase fala direto com a nuvem do Google a partir do seu Vue.
Quando um BaaS não resolve: quando a regra de negócio é complexa demais para expressar só em regras de segurança do Firestore; quando você precisa de consultas relacionais complexas (JOINs, agregações — ponto forte de um SGBD relacional, Aula 09); quando você precisa de controle total sobre a lógica do servidor; ou, como nesta disciplina, quando o objetivo é justamente aprender a construir um back-end. Por isso o UniEventos vai usar o Firestore hoje para uma leitura/escrita simples, mas a partir da Aula 08 a lógica de negócio migra para uma API Express própria, e na Aula 09 os dados migram para MySQL.
⚠️ Atenção Nunca use a API antiga do Firebase, com namespace (
firebase.initializeApp(...),firebase.firestore()). Ela ainda aparece em vídeos e artigos antigos. Esta disciplina usa exclusivamente a API modular, versão 12, comimportnomeado de funções:import { initializeApp } from 'firebase/app'.
Criando o projeto no console do Firebase¶
- Acesse console.firebase.google.com com sua conta Google.
- Clique em Adicionar projeto, dê o nome
unieventos(ouunieventos-seu-nome, já que o nome do projeto precisa ser único globalmente) e siga o assistente (pode desativar o Google Analytics, não é necessário para a disciplina). - Dentro do projeto, clique no ícone **
`** (Web) para registrar um app web. Dê o apelidounieventos-web`. - O console mostra um objeto
firebaseConfig— copie-o, ele contém as chaves de configuração do seu projeto (não são segredos no sentido de senha, mas identificam seu projeto):
// exemplo de firebaseConfig — o seu terá valores diferentes
const firebaseConfig = {
apiKey: 'AIzaSyExemploDeChaveNaoUseEsta',
authDomain: 'unieventos-xxxxx.firebaseapp.com',
projectId: 'unieventos-xxxxx',
storageBucket: 'unieventos-xxxxx.firebasestorage.app',
messagingSenderId: '123456789012',
appId: '1:123456789012:web:abcdef1234567890',
}
- No menu lateral, vá em Build → Firestore Database e clique em Criar banco de dados. Escolha a localização (qualquer região das Américas serve) e, quando perguntado sobre regras de segurança, escolha modo de teste.
⚠️ Atenção O modo de teste libera leitura e escrita para qualquer um por 30 dias, sem autenticação nenhuma. Isso é intencional para você aprender sem se preocupar com regras agora — mas nunca vá para produção assim. Na Aula 10, quando integrarmos o Firebase Auth, vamos escrever regras de segurança de verdade, amarradas ao usuário autenticado.
Instalando o SDK e inicializando o app¶
Isso aqui roda no front-end (unieventos-web), não na API que vamos criar depois — o SDK do Firebase fala direto com a nuvem do Google a partir do navegador.
npm install firebase@12
// src/firebase.js — em unieventos-web
import { initializeApp } from 'firebase/app'
import { getFirestore } from 'firebase/firestore'
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
}
// inicializa a conexão com o projeto Firebase
export const appFirebase = initializeApp(firebaseConfig)
// instância do Firestore usada em todo o projeto
export const db = getFirestore(appFirebase)
# .env (em unieventos-web, prefixo VITE_ obrigatório para o Vite expor a variável ao front)
VITE_FIREBASE_API_KEY=AIzaSyExemploDeChaveNaoUseEsta
VITE_FIREBASE_AUTH_DOMAIN=unieventos-xxxxx.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=unieventos-xxxxx
VITE_FIREBASE_STORAGE_BUCKET=unieventos-xxxxx.firebasestorage.app
VITE_FIREBASE_MESSAGING_SENDER_ID=123456789012
VITE_FIREBASE_APP_ID=1:123456789012:web:abcdef1234567890
💡 Dica No Vite, variáveis de ambiente expostas ao código do navegador precisam começar com
VITE_. É uma proteção: assim você não expõe acidentalmente uma variável sensível do servidor de build para o navegador.
Primeira leitura e escrita no Firestore¶
O Firestore organiza dados em coleções (como uma tabela, mas sem schema fixo) de documentos (como uma linha, mas um objeto JSON aninhável). Vamos escrever e ler eventos de teste.
// src/testeFirestore.js — script de exploração, não faz parte da app final
import {
collection,
addDoc,
getDocs,
doc,
updateDoc,
deleteDoc,
query,
where,
orderBy,
} from 'firebase/firestore'
import { db } from './firebase.js'
// referência para a coleção "eventos"
const colecaoEventos = collection(db, 'eventos')
async function criarEventoDeTeste() {
// addDoc gera um id automático e grava o documento
const referencia = await addDoc(colecaoEventos, {
titulo: 'Semana Acadêmica de Computação',
categoria: 'palestra',
vagas: 80,
dataHora: '2026-10-15T19:00:00',
})
console.log('documento criado com id:', referencia.id)
}
async function listarTodosOsEventos() {
const snapshot = await getDocs(colecaoEventos)
const eventos = snapshot.docs.map((d) => ({ id: d.id, ...d.data() }))
console.log('eventos encontrados:', eventos)
}
async function listarPalestrasOrdenadasPorData() {
// query + where + orderBy formam uma consulta filtrada e ordenada
const consulta = query(
colecaoEventos,
where('categoria', '==', 'palestra'),
orderBy('dataHora', 'asc'),
)
const snapshot = await getDocs(consulta)
return snapshot.docs.map((d) => ({ id: d.id, ...d.data() }))
}
async function atualizarVagas(idDoEvento, novasVagas) {
// doc() aponta para um documento específico dentro da coleção
const referenciaDoDocumento = doc(db, 'eventos', idDoEvento)
await updateDoc(referenciaDoDocumento, { vagas: novasVagas })
}
async function removerEvento(idDoEvento) {
const referenciaDoDocumento = doc(db, 'eventos', idDoEvento)
await deleteDoc(referenciaDoDocumento)
}
await criarEventoDeTeste()
await listarTodosOsEventos()
🔎 Por baixo do capô
getDocsdevolve umQuerySnapshot, não um array direto. Cada item é umQueryDocumentSnapshot, com.id(o id do documento) separado de.data()(o conteúdo). Por isso o padrão{ id: d.id, ...d.data() }aparece toda vez que você lê uma coleção — é assim que você recompõe um objeto "normal" com id incluso.
As regras de segurança por trás do modo de teste¶
Quando você escolheu "modo de teste" ao criar o banco, o Firebase gravou uma regra de segurança liberando tudo por 30 dias. Vale abrir Firestore Database → Regras no console e olhar o que foi gerado:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if request.time < timestamp.date(2026, 10, 12);
}
}
}
Note a condição: allow read, write só vale até uma data. Depois disso, toda leitura e escrita passa a ser negada por padrão — é assim que o Firestore evita bancos de teste esquecidos abertos para o mundo. Não precisa mexer nessas regras hoje; na Aula 10, quando o Firebase Authentication entrar, vamos trocar essa condição por algo como allow read: if true; allow write: if request.auth != null; — leitura pública, escrita só para quem está autenticado.
⚠️ Atenção Um erro comum é confundir "regra de segurança do Firestore" com "regra de negócio da aplicação". A regra de segurança só decide quem pode ler/escrever; ela não valida, por exemplo, se o número de vagas de um evento é positivo. Esse tipo de validação continua sendo responsabilidade do código — do front-end como primeira camada de UX, e do back-end como camada de verdade (Aula 08).
Esta exploração no Firestore serve para você conhecer o SDK modular — ele volta com força na Aula 10, quando o Firebase Authentication entrar em cena. A partir de agora, porém, o motor de dados principal do UniEventos passa a ser a API Express que vamos construir, primeiro em memória (hoje e na Aula 08) e depois em MySQL (Aula 09).
4. Express 5.2.1: o servidor mínimo¶
Express é um framework web minimalista para Node.js: ele não decide como você organiza pastas nem qual banco usar, só oferece o essencial para receber requisições HTTP, rotear por método e caminho, e responder. É a ferramenta padrão de mercado para APIs Node.
mkdir -p src
npm install express cors
// src/servidor.js
import express from 'express'
import cors from 'cors'
const app = express()
// middlewares de aplicação: rodam em toda requisição, nesta ordem
app.use(cors()) // libera requisições de outras origens (o front em outra porta)
app.use(express.json()) // faz o parse do corpo JSON e popula req.body
// primeira rota: responde a GET /
app.get('/', (req, res) => {
res.json({ mensagem: 'API do UniEventos no ar' })
})
const porta = process.env.PORTA || 3000
app.listen(porta, () => {
console.log(`unieventos-api rodando em http://localhost:${porta}`)
})
Suba o servidor:
node --watch src/servidor.js
Teste no navegador acessando http://localhost:3000/, ou no terminal:
curl http://localhost:3000/
# {"mensagem":"API do UniEventos no ar"}
express.json() e cors() são exemplos de middlewares de aplicação: funções que rodam para toda requisição, registradas com app.use(), antes de qualquer rota. express.json() lê o corpo da requisição, faz o parse como JSON, e disponibiliza o resultado em req.body. cors() adiciona os cabeçalhos que autorizam o navegador a aceitar a resposta quando a requisição vem de uma origem diferente (por exemplo, o front-end rodando em localhost:5173 chamando a API em localhost:3000) — sem isso, o navegador bloqueia a resposta por política de mesma origem.
Estrutura inicial de pastas¶
unieventos-api/
├─ src/
│ └─ servidor.js
├─ .env
├─ .env.example
├─ .gitignore
├─ package.json
└─ requests.http
Vamos crescer essa estrutura nas próximas duas aulas (routes/, middlewares/, repositories/, services/, controllers/). Por enquanto, um único arquivo é suficiente.
⚠️ Express 5 não é Express 4¶
Quando você instala Express hoje (npm install express), recebe a versão 5.2.1 — mas a maioria dos tutoriais, cursos gravados e respostas de fórum na internet ainda ensina Express 4, que tem sintaxe diferente em pontos que quebram silenciosamente ou lançam erro. A tabela a seguir foi testada no ambiente real desta disciplina — não é teoria, é o que de fato acontece rodando o código.
| Express 4 (não use) | Express 5.2.1 (use) |
|---|---|
erro em handler async precisa de .catch(next) manual |
erro em handler async é capturado automaticamente pelo Express |
req.query podia ser reatribuído |
req.query é somente leitura |
app.del('/rota', ...) |
app.delete('/rota', ...) — app.del foi removido |
res.redirect('/rota', 302) |
res.redirect(302, '/rota') — ordem invertida |
res.json(objeto, 201) |
res.status(201).json(objeto) — a assinatura de dois argumentos não existe mais |
req.param('id') |
req.params.id — req.param() foi removido |
res.sendfile() |
res.sendFile() |
app.get('/arquivos/*') |
app.get('/arquivos/*splat') — curinga nomeado, req.params.splat vira array |
app.get('/relatorio/:ano?') |
app.get('/relatorio{/:ano}') — segmento opcional é chave, não ? |
exigia body-parser instalado à parte |
express.json()/express.urlencoded() já são nativos |
req.body virava {} sem parser |
req.body é undefined se nada foi parseado |
Os pontos que mais pegam quem está aprendendo:
Erros assíncronos, agora automáticos. No Express 4, se um handler async lançasse uma exceção, ela não era capturada pelo tratador de erros — a requisição ficava pendurada ou o processo caía, a menos que você embrulhasse manualmente com .catch(next) ou usasse o pacote express-async-handler. É por isso que tanto código por aí ainda importa esse pacote. No Express 5, isso já funciona sem nada extra:
// Express 5: pode dar throw dentro de um handler async — cai direto no error handler
app.get('/api/eventos/:id', async (req, res) => {
const evento = await buscarEventoPorId(req.params.id)
if (!evento) {
throw new Error('Evento não encontrado') // capturado automaticamente
}
res.json(evento)
})
Vamos explorar isso a fundo na Aula 08, com uma classe de erro própria (ErroHttp) e um tratador central.
req.query é somente leitura. No Express 4 era comum, embora não recomendado, fazer req.query.pagina = Number(req.query.pagina) para normalizar um valor. No Express 5, isso lança erro em runtime — req.query não pode ser reatribuído. Se precisar de um valor tratado, crie uma variável nova:
// ERRADO no Express 5: lança TypeError
// req.query.pagina = Number(req.query.pagina)
// CORRETO: crie uma variável nova a partir do valor lido
const pagina = Number(req.query.pagina) || 1
Curingas e segmentos opcionais mudaram de sintaxe. O Express 5 trocou o motor de rotas para path-to-regexp v8, que não aceita mais * solto nem ? para tornar um segmento opcional. É preciso nomear o curinga e envolver o opcional em chaves:
// Express 4 (não use): curinga solto
// app.get('/arquivos/*', (req, res) => { ... })
// Express 5: curinga nomeado — req.params.splat vem como ARRAY de segmentos
app.get('/arquivos/*splat', (req, res) => {
console.log(req.params.splat) // ex.: ['pdf', 'edital-2026.pdf']
})
// Express 4 (não use): segmento opcional com "?"
// app.get('/relatorio/:ano?', (req, res) => { ... })
// Express 5: segmento opcional entre chaves
app.get('/relatorio{/:ano}', (req, res) => {
// GET /relatorio -> req.params.ano é undefined
// GET /relatorio/2026 -> req.params.ano é '2026'
const ano = req.params.ano || 'atual'
res.json({ relatorioDoAno: ano })
})
Não vamos precisar de curingas nem de segmentos opcionais na API do UniEventos por enquanto, mas é comum encontrar essa sintaxe em documentação de upload de arquivos ou rotas de relatório — reconhecer a diferença evita copiar sintaxe do Express 4 sem perceber.
app.del e res.sendfile, os "quase iguais" que quebram silenciosamente. Esses dois têm um detalhe traiçoeiro: o Express 5 não lança erro amigável — app.del simplesmente não existe mais como método (TypeError: app.del is not a function), e res.sendfile (tudo minúsculo) também não existe (res.sendfile is not a function). A diferença de capitalização em sendFile é sutil o bastante para passar despercebida numa leitura rápida.
// ERRADO — Express 4
// app.del('/api/eventos/:id', removerEvento)
// res.sendfile(caminhoDoArquivo)
// CORRETO — Express 5
app.delete('/api/eventos/:id', removerEvento)
res.sendFile(caminhoDoArquivo)
📌 Na prova Se aparecer um trecho de código com
app.del(...),res.json(obj, 201)oureq.param('id'), é Express 4 — identifique a sintaxe errada e corrija para a equivalente do Express 5.
💻 Mão na massa — criando a unieventos-api e conectando o front¶
Passo 1 — criar o repositório e o projeto Node¶
mkdir unieventos-api && cd unieventos-api
git init
npm init -y
npm install express cors
Edite o package.json gerado:
{
"name": "unieventos-api",
"version": "1.0.0",
"description": "API do projeto UniEventos — FACET-SNP-310",
"type": "module",
"main": "src/servidor.js",
"scripts": {
"dev": "node --watch --env-file=.env src/servidor.js",
"start": "node --env-file=.env src/servidor.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^5.2.1"
}
}
# .gitignore
node_modules/
.env
# .env.example
PORTA=3000
# .env
PORTA=3000
Passo 2 — dados de eventos em memória¶
// src/dados/eventos.js
// dados em memória — nesta aula ainda não temos banco de dados (chega na Aula 09)
export const eventos = [
{
id: 1,
titulo: 'Semana Acadêmica de Computação',
descricao: 'Palestras e minicursos sobre o mercado de tecnologia.',
categoria: 'palestra',
dataHora: '2026-10-15T19:00:00',
local: 'Auditório FACET',
vagas: 80,
imagemUrl: 'https://picsum.photos/seed/semana-computacao/400/240',
},
{
id: 2,
titulo: 'Minicurso de Vue 3',
descricao: 'Introdução prática ao framework Vue com Composition API.',
categoria: 'minicurso',
dataHora: '2026-10-20T14:00:00',
local: 'Laboratório 3',
vagas: 30,
imagemUrl: 'https://picsum.photos/seed/minicurso-vue/400/240',
},
{
id: 3,
titulo: 'Workshop de Firebase e Express',
descricao: 'Construindo uma API real do zero.',
categoria: 'workshop',
dataHora: '2026-10-28T19:30:00',
local: 'Laboratório 1',
vagas: 25,
imagemUrl: 'https://picsum.photos/seed/workshop-firebase/400/240',
},
]
Passo 3 — servidor com as duas primeiras rotas¶
// src/servidor.js
import express from 'express'
import cors from 'cors'
import { eventos } from './dados/eventos.js'
const app = express()
app.use(cors())
app.use(express.json())
// GET /api/eventos — lista todos os eventos
app.get('/api/eventos', (req, res) => {
res.json(eventos)
})
// GET /api/eventos/:id — busca um evento específico
app.get('/api/eventos/:id', (req, res) => {
// req.params.id sempre chega como string — convertemos para comparar com o id numérico
const id = Number(req.params.id)
const evento = eventos.find((e) => e.id === id)
if (!evento) {
return res.status(404).json({ erro: 'Evento não encontrado' })
}
res.json(evento)
})
const porta = process.env.PORTA || 3000
app.listen(porta, () => {
console.log(`unieventos-api rodando em http://localhost:${porta}`)
})
Suba com npm run dev e teste:
curl http://localhost:3000/api/eventos
curl http://localhost:3000/api/eventos/1
curl http://localhost:3000/api/eventos/999
# {"erro":"Evento não encontrado"}
Três formas de testar, e quando usar cada uma¶
Você vai testar a mesma API de três jeitos diferentes ao longo do curso. Cada um serve para um momento:
Navegador. Rápido para conferir uma rota GET simples visualmente — cole a URL na barra de endereços. Limitação: o navegador só faz GET ao digitar uma URL; não dá para testar POST, PUT, DELETE nem enviar cabeçalhos customizados dessa forma.
curl. Funciona para qualquer método, direto do terminal, sem depender do VS Code estar aberto. Ótimo para scripts, para depuração rápida e para copiar/colar em relatos de bug. A sintaxe fica mais verbosa conforme a requisição cresce:
curl -X POST http://localhost:3000/api/eventos \
-H "Content-Type: application/json" \
-d '{"titulo":"Palestra de teste","categoria":"palestra","vagas":10}'
REST Client / Thunder Client (VS Code). O melhor equilíbrio para desenvolvimento do dia a dia: a requisição fica escrita em um arquivo versionado (requests.http), legível, reaproveitável pelo time todo, e você clica para executar sem digitar nada no terminal. É o que vamos usar como principal ferramenta de teste manual a partir de agora — inclusive na Aula 08, onde o arquivo requests.http cresce para cobrir todo o CRUD.
💡 Dica Nenhuma das três ferramentas substitui testes automatizados (fora do escopo desta disciplina). Elas servem para verificação manual durante o desenvolvimento — e o arquivo
requests.httptem a vantagem extra de documentar a API para quem vai usá-la depois, inclusive você mesmo daqui a um mês.
Passo 4 — testando com REST Client (VS Code)¶
Crie um arquivo de requisições versionado, que serve de documentação viva da API:
### requests.http — abra este arquivo no VS Code com a extensão REST Client instalada
### clique em "Send Request" acima de cada bloco para testar
### listar todos os eventos
GET http://localhost:3000/api/eventos
### buscar um evento específico
GET http://localhost:3000/api/eventos/1
### buscar um evento que não existe (deve responder 404)
GET http://localhost:3000/api/eventos/999
💡 Dica Se você usa a extensão Thunder Client em vez de REST Client, a ideia é a mesma, mas a interface é uma aba própria no VS Code, com histórico de requisições e coleções salvas. Escolha a que preferir — o importante é testar toda rota antes de conectar o front.
Passo 5 — apontar o front-end da Aula 06 para a API real¶
No unieventos-web, você já tem uma instância dedicada do Axios (Aula 06). Troque só a baseURL:
// src/services/api.js — em unieventos-web
import axios from 'axios'
const api = axios.create({
// antes: baseURL: 'http://localhost:3001' (json-server)
baseURL: 'http://localhost:3000/api',
})
export default api
Nada mais no front-end precisa mudar — nem a store Pinia, nem os componentes. O service continua chamando api.get('/eventos') e api.get(/eventos/${id}); quem muda é só o destino das requisições. Esse desacoplamento é exatamente o motivo pelo qual a Aula 06 insistiu em centralizar o baseURL numa instância única, em vez de espalhar URLs pelo código.
# em dois terminais separados:
# terminal 1 — dentro de unieventos-api
npm run dev
# terminal 2 — dentro de unieventos-web
npm run dev
Abra o front no navegador. A lista de eventos deve carregar exatamente como antes — só que agora vem de um servidor Express que você escreveu, não de um json-server.
🧪 Laboratório¶
1. Rota de saudação personalizada. Crie GET /api/saudacao/:nome que responde { "mensagem": "Olá, <nome>!" }, capitalizando a primeira letra do nome recebido.
Dica
Use req.params.nome e uma função para capitalizar: nome.charAt(0).toUpperCase() + nome.slice(1).
2. Filtro por categoria via query string. Modifique GET /api/eventos para aceitar ?categoria=palestra e retornar só os eventos daquela categoria. Sem o parâmetro, retorna todos.
Dica
Leia req.query.categoria (lembre-se: é somente leitura, não reatribua). Se estiver presente, filtre o array com .filter() antes de responder.
3. Contagem total no cabeçalho. Adicione um cabeçalho de resposta X-Total-Count com a quantidade de eventos retornados em GET /api/eventos, usando res.set('X-Total-Count', String(eventos.length)).
Dica
res.set(nome, valor) precisa vir antes de res.json(...), porque depois que o corpo é enviado os cabeçalhos não podem mais ser alterados.
4. Teste de erro proposital. Escreva uma rota GET /api/quebra que dá throw new Error('falha proposital') dentro de um handler async. Suba o servidor, acesse a rota e observe no terminal o que acontece — sem nenhum tratamento de erro escrito por você ainda.
Dica
No Express 5 isso não derruba o servidor: a resposta padrão é um HTML de erro 500. Guarde essa observação — na Aula 08 você substitui isso por um tratador de erros customizado.
5. Front consumindo a nova rota de filtro. No unieventos-web, adicione um <v-select> de categoria na Home e faça a requisição incluir params: { categoria } quando um filtro estiver selecionado (lembre do terceiro parâmetro do axios.get, visto na Aula 06).
Dica
api.get('/eventos', { params: { categoria: valorSelecionado } }) — se valorSelecionado for undefined ou string vazia, o Axios omite o parâmetro da URL automaticamente.
6. Explorando o Firestore com uma segunda coleção. Crie, pelo console do Firebase ou por script, uma coleção organizadores com pelo menos 2 documentos (nome, email). Escreva uma função listarOrganizadores() que usa getDocs para trazer todos e imprime no console. Depois, escreva buscarOrganizadorPorEmail(email) usando query + where('email', '==', email).
Dica
where sempre entra como argumento de query(colecao, where(...), ...) — não é um método encadeado como em outras bibliotecas. Lembre de importar where de 'firebase/firestore'.
🐛 Erros comuns e como resolver¶
| Sintoma | Causa | Solução |
|---|---|---|
ReferenceError: require is not defined in ES module scope |
copiou código CommonJS num projeto com "type": "module" |
troque require/module.exports por import/export |
EADDRINUSE: address already in use :::3000 |
outro processo já ocupa a porta 3000 | finalize o processo anterior ou mude PORTA no .env |
| Front recebe erro de CORS no console do navegador | esqueceu app.use(cors()) ou ele vem depois das rotas |
registre app.use(cors()) antes de declarar as rotas |
req.body chega undefined |
express.json() não foi registrado, ou o Content-Type da requisição não é application/json |
adicione app.use(express.json()) antes das rotas; confira o cabeçalho enviado |
TypeError: Cannot set property query of #<IncomingMessage> |
tentou reatribuir req.query diretamente (hábito do Express 4) |
crie uma variável nova a partir do valor lido, não reatribua req.query |
| Página em branco ao testar rota no navegador, sem erro no terminal | esqueceu do return antes de um segundo res.status().json() na mesma função |
sempre return res... quando houver mais de uma resposta possível no handler |
Firestore: getDocs retorna vazio mesmo com dados no console |
está lendo de uma coleção com nome diferente do usado ao gravar (ex.: Eventos vs eventos) |
nomes de coleção são case-sensitive; confira a grafia exata |
FirebaseError: Missing or insufficient permissions |
as regras de segurança de teste do Firestore expiraram (30 dias) | reabra o modo de teste no console ou escreva regras explícitas (Aula 10) |
Cannot find module 'firebase/app' |
instalou uma versão muito antiga do pacote, ou digitou firebase-admin por engano no front |
confirme npm install firebase@12 no unieventos-web; firebase-admin é só para back-end (Aula 10) |
Rota GET /api/eventos/:id sempre cai no "não encontrado" |
comparou req.params.id (string) com id (number) sem converter |
use Number(req.params.id) antes de comparar com === |
npm run dev não reinicia ao salvar o arquivo |
versão do Node anterior à 18.11, sem suporte a --watch |
rode node -v e atualize para a 22.x indicada na disciplina |
🏠 Atividade assíncrona (1 h)¶
No seu projeto autoral, replique o que foi feito hoje:
- Crie o repositório
<seu-projeto>-api, compackage.jsonconfigurado em ESM, scriptsdev/start,.env/.env.example/.gitignore. - Monte um arquivo
src/dados/<entidade-principal>.jscom pelo menos 4 itens de exemplo do domínio do seu projeto autoral (ex.: se seu tema é "cardápio de restaurante", 4 pratos). - Escreva
src/servidor.jscom Express 5,cors(),express.json(), e as duas rotas equivalentes: listar tudo e buscar por id. - Teste as duas rotas com
curle com REST Client/Thunder Client — cole as evidências (prints ou saída do terminal) num arquivoEVIDENCIAS.mdno repositório. - Aponte o front-end do seu projeto autoral (já existente desde a Aula 06) para esta nova API, trocando só o
baseURL.
Critério de pronto: os dois repositórios (-web e -api) rodando simultaneamente em portas diferentes, com a listagem do seu projeto autoral carregando dados vindos da sua própria API Express — nada de json-server a partir de agora.
✅ Checkpoint do projeto autoral¶
- [ ] Repositório
<seu-projeto>-apicriado, com"type": "module"nopackage.json. - [ ] Servidor Express 5 rodando com
npm run dev(usandonode --watch). - [ ]
.enve.env.exampleconfigurados;.envno.gitignore(nunca commitado). - [ ]
cors()eexpress.json()registrados antes das rotas. - [ ]
GET /api/<entidade>eGET /api/<entidade>/:idrespondendo corretamente, inclusive o caso de id inexistente (404). - [ ] Front-end do projeto autoral consumindo essa API real via a instância Axios dedicada.
- [ ] Nenhuma sintaxe de Express 4 (
app.del,res.json(obj, 201),req.param()) presente no código.
📚 Para aprofundar¶
- Documentação oficial do Node.js — nodejs.org/docs (seções Modules: ECMAScript modules e Command-line API para
--watche--env-file). - Documentação oficial do Express — expressjs.com e o guia de migração Express 5 changes — expressjs.com/en/guide/migrating-5.html.
- Documentação oficial do Firebase — firebase.google.com/docs/web/setup e firebase.google.com/docs/firestore (API modular).
- Referência de
path-to-regexpv8, usado internamente pelo Express 5 para casar rotas — útil para entender a sintaxe de curingas e segmentos opcionais em profundidade. - Plano de curso FACET-SNP-310 — bibliografia básica, capítulos sobre arquitetura cliente-servidor e Node.js.
Na Aula 08 você transforma o servidor de hoje num CRUD completo, modulariza rotas com express.Router(), escreve middlewares próprios e recebe as instruções da Avaliação 2. Deixe a unieventos-api rodando — ela cresce a partir daqui, aula após aula, até virar a API do seu projeto autoral final.
Aula 08 — Definindo endpoints e middlewares
Na Aula 07 você criou a unieventos-api com Express 5, duas rotas GET em memória, CORS habilitado, e conectou o front-end real a ela. Hoje essa API vira um CRUD completo, ganha middlewares próprios e validação de entrada — e você recebe as instruções da Avaliação 2, com entrega até hoje às 23h59.
🎯 Objetivos de aprendizagem¶
Ao final desta aula você será capaz de:
- projetar endpoints REST coerentes: recursos no plural, verbos HTTP corretos, status codes apropriados por operação;
- implementar um CRUD completo (
GET,POST,PUT,PATCH,DELETE) com Express 5, modularizado emexpress.Router(); - escrever middlewares próprios — logger, medidor de tempo, validador, tratador de 404 e tratador de erros centralizado — entendendo a ordem de execução da cadeia;
- explicar a diferença entre middleware de aplicação, de rota e de erro, e por que este último precisa vir por último;
- validar corpos de requisição com
zod, devolvendo422com mensagens em português; - entender por que
throwdentro de um handlerasyncdo Express 5 cai automaticamente no tratador de erros; - organizar testes manuais num arquivo
requests.httpcobrindo todos os endpoints.
📋 Pré-requisitos desta aula¶
- [ ]
unieventos-apida Aula 07 rodando, comGET /api/eventoseGET /api/eventos/:idfuncionando em memória. - [ ] Front-end
unieventos-webapontando para essa API viabaseURLdo Axios. - [ ] Entendimento de
async/awaite por que erros em handlersasyncdo Express 5 são capturados automaticamente (Aula 07). - [ ] Projeto autoral com API própria (
<seu-projeto>-api) criada na atividade assíncrona da Aula 07.
⚠️ Atenção Esta é a aula da Avaliação 2. Leia a seção "📝 Avaliação 2 — instruções de entrega" logo no início do período de aula, para planejar seu tempo — o prazo de entrega é hoje, 07/10/2026, às 23h59.
🗺️ Roteiro¶
| Bloco | Tempo | Atividade |
|---|---|---|
| 1 | 50 min | REST na prática: recursos, verbos, status codes, formato de resposta, paginação e filtros |
| 2 | 50 min | CRUD completo com express.Router(); middlewares próprios e de terceiros; tratador de erros central |
| 3 | 50 min | Validação com zod; requests.http completo; instruções da Avaliação 2 |
1. REST na prática¶
Você já usa APIs "estilo REST" desde a Aula 06, mas hoje é você quem projeta os endpoints. REST não é um protocolo com regras fixadas em pedra — é um conjunto de convenções que, seguidas com consistência, tornam uma API previsível para quem consome.
O nome vem de Representational State Transfer — a ideia central é que cada recurso do seu domínio (um evento, um usuário, uma inscrição) tem uma representação (o JSON que a API devolve) e um endereço próprio (a URL). O cliente manipula o estado do sistema transferindo representações desse recurso para lá e para cá, usando os verbos HTTP para expressar a intenção. Você não precisa decorar a definição formal — o que importa na prática são as convenções que seguem daqui.
Por que seguir convenção importa: quando toda API do mercado usa GET para ler e POST para criar, qualquer desenvolvedor que chega no seu projeto já sabe, sem ler documentação nenhuma, que POST /api/v1/eventos cria um evento. Quebrar essa expectativa (por exemplo, usando GET /api/deletarEvento?id=3 para apagar algo) obriga quem consome sua API a ler cada linha de código para entender o que uma rota faz — e, pior, faz com que caches e proxies HTTP, que assumem que GET nunca tem efeito colateral, possam repetir a chamada e apagar coisas sem querer.
Recursos: substantivos no plural, sempre¶
Um endpoint representa um recurso — uma entidade do seu domínio — nunca uma ação. O verbo da ação já está no método HTTP, não precisa (e não deve) repetir no caminho.
✅ GET /api/eventos (correto: recurso no plural, sem verbo)
❌ GET /api/buscarEventos (errado: verbo no caminho)
❌ GET /api/evento (errado: singular)
✅ POST /api/eventos (criar um evento)
❌ POST /api/criarEvento (errado: verbo redundante)
✅ DELETE /api/eventos/3 (remover o evento de id 3)
❌ GET /api/deletarEvento?id=3 (errado: usa GET para uma ação destrutiva)
Sub-recursos seguem o mesmo padrão, aninhando o caminho:
GET /api/eventos/3/inscricoes (inscrições do evento 3)
POST /api/eventos/3/inscricoes (inscrever alguém no evento 3)
Verbos HTTP e o que cada um significa neste domínio¶
| Verbo | Uso no UniEventos | Idempotente? |
|---|---|---|
GET |
ler evento(s), sem efeito colateral | sim |
POST |
criar um evento novo | não |
PUT |
substituir um evento inteiro | sim |
PATCH |
atualizar campos específicos de um evento | não, em geral |
DELETE |
remover um evento | sim |
Idempotência significa: repetir a mesma requisição várias vezes produz o mesmo resultado final que executá-la uma vez. GET /api/eventos/3 sempre devolve o mesmo evento (até que ele mude por outro motivo) — chamar dez vezes não altera nada. DELETE /api/eventos/3 é idempotente porque, depois da primeira chamada, o evento já não existe; chamar de novo continua resultando em "evento 3 não existe" (ainda que a segunda chamada responda 404 em vez de 204 — o estado final do sistema é o mesmo). Já POST /api/eventos não é idempotente: cada chamada cria um evento novo, mesmo enviando o corpo idêntico.
📌 Na prova Se a pergunta pedir para classificar um verbo HTTP como idempotente ou não, lembre:
GET,PUT,DELETEsão idempotentes;POSTnão é.PATCHdepende de como é implementado, mas normalmente também não é.
Status codes por operação¶
| Operação | Status de sucesso | Quando falha |
|---|---|---|
GET (lista ou item existente) |
200 OK |
404 se o item não existe |
POST (criação) |
201 Created + cabeçalho Location |
422 se a validação falha |
PUT/PATCH (atualização) |
200 OK com o recurso atualizado |
404 se não existe, 422 se inválido |
DELETE (remoção) |
204 No Content, sem corpo |
404 se já não existe |
O cabeçalho Location em uma criação bem-sucedida informa ao cliente onde o novo recurso pode ser lido depois — é uma convenção REST, não uma obrigação técnica, mas boas APIs seguem.
// dentro do handler de criação, depois de gerar o novo evento com id 7
res.status(201).location(`/api/eventos/${novoEvento.id}`).json(novoEvento)
⚠️ Atenção Lembre da armadilha do Express 5 vista na Aula 07:
res.json(objeto, 201)não existe. A sintaxe correta é sempreres.status(201).json(objeto), com o status vindo antes, encadeado.
Versionamento¶
Prefixar rotas com /api/v1 sinaliza desde o início que a API pode evoluir sem quebrar clientes existentes — quando uma mudança incompatível for necessária, ela nasce em /api/v2, e /v1 continua funcionando para quem ainda depende dele.
app.use('/api/v1/eventos', eventosRoutes)
Esta disciplina usa /api sem versão explícita nos exemplos anteriores para simplificar, mas a partir de hoje adotamos /api/v1 na unieventos-api — é o padrão que se espera num projeto profissional, e é exigido na Avaliação 2.
Formato de resposta consistente¶
Uma API previsível responde sempre no mesmo formato — envelope de sucesso e envelope de erro —, para que o front-end trate qualquer resposta da mesma forma, sem checar caso a caso.
{
"dados": { "id": 1, "titulo": "Semana Acadêmica de Computação" },
"meta": { "pagina": 1, "limite": 10, "total": 42 }
}
{
"erro": {
"mensagem": "Evento não encontrado",
"codigo": "EVENTO_NAO_ENCONTRADO"
}
}
dados carrega o conteúdo (objeto único ou array); meta carrega metadados de paginação quando aplicável; erro só aparece em respostas de falha, nunca junto com dados. Vamos implementar exatamente esse envelope no CRUD desta aula.
O ganho prático aparece no front-end: um interceptor de resposta do Axios (Aula 06) pode, por exemplo, sempre extrair response.data.dados automaticamente, ou sempre reconhecer response.data.erro para disparar uma notificação padronizada — porque a forma nunca muda, só o conteúdo. Sem esse envelope, cada endpoint devolveria uma "forma" diferente (às vezes um array solto, às vezes um objeto solto, às vezes um objeto com results), obrigando o front a tratar cada chamada como um caso especial.
Paginação, filtros e ordenação por query string¶
GET /api/v1/eventos?pagina=2&limite=10
GET /api/v1/eventos?categoria=palestra
GET /api/v1/eventos?ordenarPor=dataHora&direcao=asc
Paginação evita devolver milhares de registros de uma vez — o cliente pede uma "página" por vez. Filtros restringem o conjunto por algum critério. Ordenação decide a sequência dos resultados. As três são independentes e combináveis na mesma URL. Vamos implementar isso no CRUD abaixo.
2. CRUD completo em memória¶
Vamos reescrever a unieventos-api da Aula 07, agora com o CRUD inteiro e o formato de resposta padronizado.
Dados em memória, com função de próximo id¶
// src/dados/eventos.js
export const eventos = [
{
id: 1,
titulo: 'Semana Acadêmica de Computação',
descricao: 'Palestras e minicursos sobre o mercado de tecnologia.',
categoria: 'palestra',
dataHora: '2026-10-15T19:00:00',
local: 'Auditório FACET',
vagas: 80,
imagemUrl: 'https://picsum.photos/seed/semana-computacao/400/240',
},
{
id: 2,
titulo: 'Minicurso de Vue 3',
descricao: 'Introdução prática ao framework Vue com Composition API.',
categoria: 'minicurso',
dataHora: '2026-10-20T14:00:00',
local: 'Laboratório 3',
vagas: 30,
imagemUrl: 'https://picsum.photos/seed/minicurso-vue/400/240',
},
{
id: 3,
titulo: 'Workshop de Firebase e Express',
descricao: 'Construindo uma API real do zero.',
categoria: 'workshop',
dataHora: '2026-10-28T19:30:00',
local: 'Laboratório 1',
vagas: 25,
imagemUrl: 'https://picsum.photos/seed/workshop-firebase/400/240',
},
]
// gera o próximo id disponível — em memória; na Aula 09 o próprio banco faz isso
export function proximoId() {
const maiorId = eventos.reduce((max, e) => Math.max(max, e.id), 0)
return maiorId + 1
}
Classe de erro HTTP customizada¶
Antes de escrever as rotas, criamos uma classe de erro que carrega o status HTTP junto da mensagem — assim qualquer parte do código pode throw um erro que já sabe se traduzir em resposta.
// src/erros/ErroHttp.js
export class ErroHttp extends Error {
constructor(status, mensagem, codigo = 'ERRO') {
super(mensagem)
this.name = 'ErroHttp'
this.status = status
this.codigo = codigo
}
}
// atalhos comuns, para não repetir "new ErroHttp(404, ...)" em todo lugar
export function erroNaoEncontrado(mensagem = 'Recurso não encontrado') {
return new ErroHttp(404, mensagem, 'NAO_ENCONTRADO')
}
export function erroValidacao(mensagem = 'Dados inválidos') {
return new ErroHttp(422, mensagem, 'VALIDACAO')
}
Rotas modularizadas com express.Router()¶
Até a Aula 07, as rotas viviam direto em src/servidor.js, registradas com app.get(...). Isso funciona para duas rotas; não escala para uma API com vários recursos, cada um com seu CRUD completo. express.Router() cria um "mini aplicativo Express" — um objeto que aceita .get(), .post(), .put(), .patch(), .delete() exatamente como app, mas que fica isolado num arquivo próprio, sem saber em qual prefixo vai ser montado.
Repare que dentro do arquivo de rotas os caminhos são relativos: router.get('/') e router.get('/:id'), sem repetir /api/v1/eventos. É só na hora de montar, em servidor.js, que o prefixo é definido:
app.use('/api/v1/eventos', eventosRoutes)
Isso significa que, se amanhã você decidir que a API deve responder em /api/v2/eventos também, basta montar o mesmo eventosRoutes num segundo prefixo — nenhuma rota interna precisa mudar.
// src/routes/eventos.routes.js
import { Router } from 'express'
import { eventos, proximoId } from '../dados/eventos.js'
import { erroNaoEncontrado, erroValidacao } from '../erros/ErroHttp.js'
const router = Router()
// função auxiliar: encontra o índice do evento pelo id, ou -1
function indiceDoEvento(id) {
return eventos.findIndex((e) => e.id === id)
}
// GET /api/v1/eventos — lista com filtro, ordenação e paginação
router.get('/', (req, res) => {
let resultado = [...eventos]
// filtro por categoria
if (req.query.categoria) {
resultado = resultado.filter((e) => e.categoria === req.query.categoria)
}
// ordenação
const ordenarPor = req.query.ordenarPor || 'id'
const direcao = req.query.direcao === 'desc' ? -1 : 1
resultado.sort((a, b) => {
if (a[ordenarPor] < b[ordenarPor]) return -1 * direcao
if (a[ordenarPor] > b[ordenarPor]) return 1 * direcao
return 0
})
// paginação
const pagina = Number(req.query.pagina) || 1
const limite = Number(req.query.limite) || 10
const inicio = (pagina - 1) * limite
const pagina_de_resultados = resultado.slice(inicio, inicio + limite)
res.json({
dados: pagina_de_resultados,
meta: { pagina, limite, total: resultado.length },
})
})
// GET /api/v1/eventos/:id — busca um evento específico
router.get('/:id', (req, res) => {
const id = Number(req.params.id)
const evento = eventos.find((e) => e.id === id)
if (!evento) {
throw erroNaoEncontrado('Evento não encontrado')
}
res.json({ dados: evento })
})
// POST /api/v1/eventos — cria um evento novo
router.post('/', (req, res) => {
const corpo = req.body
if (!corpo || !corpo.titulo || !corpo.categoria) {
throw erroValidacao('Campos "titulo" e "categoria" são obrigatórios')
}
const novoEvento = {
id: proximoId(),
titulo: corpo.titulo,
descricao: corpo.descricao || '',
categoria: corpo.categoria,
dataHora: corpo.dataHora || null,
local: corpo.local || '',
vagas: Number(corpo.vagas) || 0,
imagemUrl: corpo.imagemUrl || '',
}
eventos.push(novoEvento)
res
.status(201)
.location(`/api/v1/eventos/${novoEvento.id}`)
.json({ dados: novoEvento })
})
// PUT /api/v1/eventos/:id — substitui o evento inteiro
router.put('/:id', (req, res) => {
const id = Number(req.params.id)
const indice = indiceDoEvento(id)
if (indice === -1) {
throw erroNaoEncontrado('Evento não encontrado')
}
const corpo = req.body
if (!corpo || !corpo.titulo || !corpo.categoria) {
throw erroValidacao('Campos "titulo" e "categoria" são obrigatórios')
}
eventos[indice] = {
id,
titulo: corpo.titulo,
descricao: corpo.descricao || '',
categoria: corpo.categoria,
dataHora: corpo.dataHora || null,
local: corpo.local || '',
vagas: Number(corpo.vagas) || 0,
imagemUrl: corpo.imagemUrl || '',
}
res.json({ dados: eventos[indice] })
})
// PATCH /api/v1/eventos/:id — atualiza campos específicos
router.patch('/:id', (req, res) => {
const id = Number(req.params.id)
const indice = indiceDoEvento(id)
if (indice === -1) {
throw erroNaoEncontrado('Evento não encontrado')
}
// mescla só os campos enviados — PATCH é parcial, diferente de PUT
eventos[indice] = { ...eventos[indice], ...req.body }
res.json({ dados: eventos[indice] })
})
// DELETE /api/v1/eventos/:id — remove o evento
router.delete('/:id', (req, res) => {
const id = Number(req.params.id)
const indice = indiceDoEvento(id)
if (indice === -1) {
throw erroNaoEncontrado('Evento não encontrado')
}
eventos.splice(indice, 1)
res.status(204).send()
})
export default router
⚠️ Atenção Note
router.delete(...), nãorouter.del(...).app.del/router.delforam removidos no Express 5 (Aula 07, §5).
Montando o Router no servidor¶
// src/servidor.js
import express from 'express'
import cors from 'cors'
import eventosRoutes from './routes/eventos.routes.js'
import { middlewareNaoEncontrado, tratadorDeErros } from './middlewares/erros.js'
import { logger } from './middlewares/logger.js'
import { medidorDeTempo } from './middlewares/medidorDeTempo.js'
const app = express()
app.use(cors())
app.use(express.json())
app.use(logger)
app.use(medidorDeTempo)
// monta o router em /api/v1/eventos — dentro do router, as rotas usam caminhos relativos
app.use('/api/v1/eventos', eventosRoutes)
// a partir daqui, nenhuma rota casou: 404
app.use(middlewareNaoEncontrado)
// tratador de erros SEMPRE por último
app.use(tratadorDeErros)
const porta = process.env.PORTA || 3000
app.listen(porta, () => {
console.log(`unieventos-api rodando em http://localhost:${porta}`)
})
Modularizar com express.Router() separa a definição das rotas de eventos do arquivo principal do servidor. Isso escala: cada recurso (eventos, e futuramente inscricoes, usuarios) ganha seu próprio arquivo de rotas, e servidor.js só monta cada um em seu prefixo.
🧩 Padrão de projeto em uso¶
🧩 Padrão de projeto em uso — Chain of Responsibility e Strategy¶
A cadeia
cors → express.json → logger → medidorDeTempo → eventosRoutes → middlewareNaoEncontrado → tratadorDeErrosé o Chain of Responsibility completo: cada middleware decide se processa a requisição e a passa adiante comnext(), ou se responde e encerra a cadeia ali. A ordem importa — é o próprio desenho do padrão: cada elo só recebe a requisição se o anterior decidiu repassá-la.Já os validadores de corpo que vamos construir com
zodilustram o Strategy (comportamental): a funçãovalidar(schema)é genérica — ela não sabe nada sobre "evento" —, e recebe de fora, como parâmetro, a estratégia de validação específica (o schema Zod do evento, do usuário, do que for). Trocar a validação de uma rota é só trocar o schema passado, sem tocar no middlewarevalidar. Isso é Strategy: o algoritmo (validação) é injetado, intercambiável, sem alterar quem o usa.
3. Middlewares a fundo¶
Um middleware é uma função com a assinatura (req, res, next) — ou (err, req, res, next) no caso especial de tratador de erros, com quatro argumentos. Ele roda entre a chegada da requisição e a resposta final, podendo:
- ler ou modificar
req/res; - encerrar o ciclo respondendo diretamente (
res.send(),res.json(), etc.); - passar a bola adiante chamando
next(); - passar um erro adiante chamando
next(erro)(embora no Express 5, como vimos, umthrowdentro de um handlerasyncjá faz isso sozinho).
Pense em cada middleware como uma estação de inspeção numa linha de produção. A requisição entra por um lado, passa estação por estação, e cada uma pode carimbá-la (adicionar algo a req), rejeitá-la ali mesmo (responder e nunca chamar next()) ou deixá-la seguir para a próxima estação. Uma rota (router.get, router.post, etc.) é só a última estação da linha — a que finalmente produz uma resposta para o cliente, na maioria das requisições.
Ordem de execução¶
Middlewares rodam na ordem em que são registrados com app.use() ou dentro de uma rota. Se um middleware não chamar next() nem responder, a requisição fica pendurada para sempre — esse é o erro mais comum ao escrever middleware pela primeira vez.
app.use(cors()) // 1º: libera CORS
app.use(express.json()) // 2º: faz o parse do corpo
app.use(logger) // 3º: registra a requisição no console
app.use(medidorDeTempo) // 4º: começa a medir o tempo de resposta
app.use('/api/v1/eventos', eventosRoutes) // 5º: tenta casar com alguma rota de evento
app.use(middlewareNaoEncontrado) // 6º: só roda se nada casou acima
app.use(tratadorDeErros) // 7º: só roda se algo lançou erro em qualquer ponto anterior
Middleware de aplicação × de rota × de erro¶
Middleware de aplicação roda para toda requisição, registrado direto em app.use(fn), sem caminho — é o caso de cors(), express.json(), logger.
Middleware de rota roda só para requisições que casam com um caminho e método específicos, registrado como argumento extra antes do handler final:
// middleware de rota: só roda para POST /api/v1/eventos
router.post('/', validar(schemaEvento), (req, res) => {
// aqui req.body já passou pela validação
})
Middleware de erro tem quatro parâmetros — (err, req, res, next) — e o Express o reconhece pela aridade da função (contagem de parâmetros), não por onde está registrado. Ele só é chamado quando algum middleware ou handler anterior invoca next(erro) ou lança uma exceção (capturada automaticamente em handlers async, como vimos).
Por que o tratador de erros vem por último¶
O Express testa os middlewares registrados na ordem em que aparecem. Um middleware de erro só é alcançado quando a cadeia "pula" para ele — o que acontece quando algo dá errado em qualquer ponto anterior. Se você registrar o tratador de erros antes de uma rota, ele nunca vai capturar os erros dela, porque a execução normal (sem erro) nem chega a considerá-lo — e mesmo em caso de erro, o Express busca o próximo middleware de erro à frente na cadeia, nunca voltando para trás. Por isso a regra é fixa: middlewares normais primeiro, depois o 404 (que captura tudo que não casou com nenhuma rota), depois o tratador de erros por último de todos.
requisição
│
▼
cors() ──────────────► ok, next()
│
▼
express.json() ──────► ok, next()
│
▼
eventosRoutes ───────► lançou erro (throw)
│ │
│ Express pula direto para o
│ próximo middleware DE ERRO
│ │
▼ ▼
middlewareNaoEncontrado tratadorDeErros
(não roda: já tinha (roda: recebe o erro,
uma rota que casou) responde ao cliente)
Se middlewareNaoEncontrado estivesse depois de tratadorDeErros, ele nunca seria alcançado no caminho de erro — e se estivesse antes das rotas, capturaria toda requisição como "não encontrada", mesmo as que tinham rota válida. A ordem — rotas, depois 404, depois tratador de erros — não é estilo, é a única ordem que faz os três cumprirem seu papel corretamente.
Escrevendo os middlewares do zero¶
// src/middlewares/logger.js
// registra método, caminho e horário de cada requisição recebida
export function logger(req, res, next) {
const agora = new Date().toISOString()
console.log(`[${agora}] ${req.method} ${req.originalUrl}`)
next()
}
// src/middlewares/medidorDeTempo.js
// mede quanto tempo o servidor levou para responder, em milissegundos
export function medidorDeTempo(req, res, next) {
const inicio = process.hrtime.bigint()
// 'finish' dispara quando a resposta terminou de ser enviada
res.on('finish', () => {
const fim = process.hrtime.bigint()
const duracaoMs = Number(fim - inicio) / 1_000_000
console.log(` ↳ ${res.statusCode} em ${duracaoMs.toFixed(1)}ms`)
})
next()
}
// src/middlewares/erros.js
import { ErroHttp } from '../erros/ErroHttp.js'
// roda quando nenhuma rota casou com a requisição — precisa vir depois de todas as rotas
export function middlewareNaoEncontrado(req, res, next) {
next(new ErroHttp(404, `Rota ${req.method} ${req.originalUrl} não existe`, 'ROTA_NAO_ENCONTRADA'))
}
// tratador de erros central — repare nos QUATRO parâmetros, é assim que o Express o reconhece
export function tratadorDeErros(err, req, res, next) {
// erros conhecidos (ErroHttp) já sabem seu status; erros inesperados viram 500
const status = err instanceof ErroHttp ? err.status : 500
const codigo = err instanceof ErroHttp ? err.codigo : 'ERRO_INTERNO'
const mensagem = err instanceof ErroHttp ? err.message : 'Erro interno do servidor'
if (status === 500) {
// erro inesperado: registre o stack completo no servidor, mas não exponha ao cliente
console.error(err)
}
res.status(status).json({ erro: { mensagem, codigo } })
}
// src/middlewares/validador.js
export function validar(schema) {
// retorna um middleware de rota configurado para o schema recebido — isto é Strategy
return (req, res, next) => {
const resultado = schema.safeParse(req.body)
if (!resultado.success) {
const mensagens = resultado.error.issues.map((problema) => problema.message)
return res.status(422).json({
erro: { mensagem: 'Dados inválidos', codigo: 'VALIDACAO', detalhes: mensagens },
})
}
// substitui req.body pelos dados já validados e tipados pelo Zod
req.body = resultado.data
next()
}
}
Middlewares de terceiros¶
npm install morgan helmet express-rate-limit compression
// src/servidor.js (trecho adicional)
import morgan from 'morgan'
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import compression from 'compression'
app.use(helmet()) // cabeçalhos de segurança padrão (evita alguns ataques comuns)
app.use(compression()) // comprime respostas grandes (gzip) — mais rápido para o cliente
app.use(morgan('dev')) // log de requisições formatado — mais completo que nosso logger
const limitador = rateLimit({
windowMs: 15 * 60 * 1000, // janela de 15 minutos
max: 100, // no máximo 100 requisições por IP nessa janela
message: { erro: { mensagem: 'Muitas requisições, tente novamente mais tarde', codigo: 'RATE_LIMIT' } },
})
app.use('/api/', limitador) // aplica o limite só nas rotas de API
| Pacote | Para que serve |
|---|---|
cors |
libera requisições de outras origens (front em outra porta/domínio) |
morgan |
log de requisições HTTP formatado (substitui nosso logger em produção) |
helmet |
adiciona cabeçalhos HTTP de segurança (proteção básica contra alguns ataques) |
express-rate-limit |
limita quantas requisições um IP pode fazer numa janela de tempo |
compression |
comprime o corpo das respostas (gzip), reduzindo tráfego |
💡 Dica
morgan('dev')e nossologger/medidorDeTempofazem trabalho parecido. Escrever o seu próprio primeiro é pedagógico — mostra o que acontece por baixo —, mas em projetos reais é comum usar sómorgan, já testado e configurável.
4. Async no Express 5, revisitado¶
Na Aula 07 você viu que throw dentro de um handler async cai automaticamente no tratador de erros — e testou isso no laboratório com a rota /api/quebra. Agora, com um tratador de erros de verdade escrito, o comportamento fica completo:
// Express 5: qualquer throw, síncrono ou dentro de um await, é capturado
router.get('/:id', async (req, res) => {
const evento = await buscarEventoPorIdNoBanco(req.params.id) // função hipotética assíncrona
if (!evento) {
throw erroNaoEncontrado('Evento não encontrado')
}
res.json({ dados: evento })
})
Se buscarEventoPorIdNoBanco rejeitasse a Promise (por exemplo, uma falha de conexão), o Express 5 também capturaria automaticamente e encaminharia para tratadorDeErros. Nenhum try/catch manual é necessário para isso — o framework embrulha cada handler async internamente.
Por que tanto código por aí usa express-async-handler então? Porque esse pacote foi criado para o Express 4, que não tinha essa captura automática — era preciso embrulhar manualmente cada handler assíncrono:
// Express 4 (não use): precisava embrulhar manualmente
// const asyncHandler = require('express-async-handler')
// router.get('/:id', asyncHandler(async (req, res) => { ... }))
No Express 5, esse pacote é desnecessário. Se você encontrar em um projeto ou tutorial, é sinal de código escrito para Express 4 (ou copiado de um).
📌 Na prova Se perguntarem por que
express-async-handlernão é mais necessário no Express 5, a resposta é: o próprio framework agora captura automaticamente qualquer exceção lançada (ou Promise rejeitada) dentro de um handlerasync, encaminhando para o middleware de erro — antes isso exigia embrulhar manualmente.
💻 Mão na massa — validação com Zod e testes organizados¶
Passo 1 — instalar e escrever o schema¶
npm install zod
// src/schemas/evento.schema.js
import { z } from 'zod'
export const schemaEvento = z.object({
titulo: z.string().min(3, 'O título precisa ter ao menos 3 caracteres'),
descricao: z.string().optional(),
categoria: z.enum(['palestra', 'minicurso', 'workshop'], {
message: 'Categoria deve ser palestra, minicurso ou workshop',
}),
dataHora: z.string().min(1, 'Informe a data e hora do evento'),
local: z.string().min(1, 'Informe o local do evento'),
vagas: z.number({ message: 'Vagas deve ser um número' }).int().positive('Vagas deve ser maior que zero'),
imagemUrl: z.string().url('URL de imagem inválida').optional().or(z.literal('')),
})
// schema para PATCH: os mesmos campos, mas todos opcionais
export const schemaEventoParcial = schemaEvento.partial()
Passo 2 — aplicar o middleware validar nas rotas¶
// src/routes/eventos.routes.js (trechos alterados)
import { Router } from 'express'
import { eventos, proximoId } from '../dados/eventos.js'
import { erroNaoEncontrado } from '../erros/ErroHttp.js'
import { validar } from '../middlewares/validador.js'
import { schemaEvento, schemaEventoParcial } from '../schemas/evento.schema.js'
const router = Router()
// ...rotas GET permanecem como antes...
router.post('/', validar(schemaEvento), (req, res) => {
// req.body já chega validado e com os tipos corretos (vagas já é number, por exemplo)
const novoEvento = { id: proximoId(), ...req.body }
eventos.push(novoEvento)
res.status(201).location(`/api/v1/eventos/${novoEvento.id}`).json({ dados: novoEvento })
})
router.put('/:id', validar(schemaEvento), (req, res) => {
const id = Number(req.params.id)
const indice = eventos.findIndex((e) => e.id === id)
if (indice === -1) throw erroNaoEncontrado('Evento não encontrado')
eventos[indice] = { id, ...req.body }
res.json({ dados: eventos[indice] })
})
router.patch('/:id', validar(schemaEventoParcial), (req, res) => {
const id = Number(req.params.id)
const indice = eventos.findIndex((e) => e.id === id)
if (indice === -1) throw erroNaoEncontrado('Evento não encontrado')
eventos[indice] = { ...eventos[indice], ...req.body }
res.json({ dados: eventos[indice] })
})
export default router
Com validar(schemaEvento) na frente do handler, o corpo malformado nunca chega a ser processado pela lógica de negócio — a validação já respondeu 422 e encerrou a cadeia antes disso.
Passo 3 — requests.http completo¶
### requests.http — todos os endpoints da unieventos-api
@baseUrl = http://localhost:3000/api/v1
### listar eventos (com paginação, filtro e ordenação)
GET {{baseUrl}}/eventos?pagina=1&limite=10&categoria=palestra&ordenarPor=dataHora&direcao=asc
### buscar evento por id
GET {{baseUrl}}/eventos/1
### buscar evento inexistente (espera 404)
GET {{baseUrl}}/eventos/999
### criar evento válido (espera 201 + Location)
POST {{baseUrl}}/eventos
Content-Type: application/json
{
"titulo": "Palestra de Segurança da Informação",
"categoria": "palestra",
"dataHora": "2026-11-05T19:00:00",
"local": "Auditório FACET",
"vagas": 60
}
### criar evento inválido (espera 422)
POST {{baseUrl}}/eventos
Content-Type: application/json
{
"titulo": "AB",
"categoria": "show"
}
### substituir evento inteiro (espera 200)
PUT {{baseUrl}}/eventos/1
Content-Type: application/json
{
"titulo": "Semana Acadêmica de Computação — atualizada",
"categoria": "palestra",
"dataHora": "2026-10-16T19:00:00",
"local": "Auditório FACET",
"vagas": 100
}
### atualizar parcialmente (espera 200)
PATCH {{baseUrl}}/eventos/2
Content-Type: application/json
{
"vagas": 25
}
### remover evento (espera 204)
DELETE {{baseUrl}}/eventos/3
### remover evento já removido (espera 404)
DELETE {{baseUrl}}/eventos/3
### rota inexistente (espera 404 do middlewareNaoEncontrado)
GET {{baseUrl}}/qualquer-coisa
💡 Dica A variável
@baseUrlno topo do arquivo evita repetirhttp://localhost:3000/api/v1em toda linha — troque só ali quando mudar de ambiente (local, homologação, produção).
🧪 Laboratório¶
1. Endpoint de contagem por categoria. Crie GET /api/v1/eventos/estatisticas/por-categoria que devolve { "dados": { "palestra": 2, "minicurso": 1, "workshop": 1 } }, contando quantos eventos existem em cada categoria.
Dica
Cuidado com a ordem: registre essa rota antes de router.get('/:id', ...), senão o Express interpreta estatisticas como um valor de :id.
2. Middleware de log condicional. Modifique o logger para só imprimir requisições cujo método seja POST, PUT, PATCH ou DELETE (as que alteram dados) — omita GET.
Dica
Um if (req.method !== 'GET') { ... } dentro do middleware, antes de chamar next().
3. Erro de validação com múltiplos campos. Envie, pelo requests.http, um POST /api/v1/eventos com titulo vazio e categoria inválida ao mesmo tempo. Confirme que a resposta 422 lista as duas mensagens de erro no array detalhes.
Dica
O Zod, por padrão, coleta todos os problemas antes de falhar — não para no primeiro. resultado.error.issues é um array com um item por campo problemático.
4. Rate limit em ação. Reduza temporariamente o max do express-rate-limit para 5 e a windowMs para 60000 (1 minuto). Dispare mais de 5 requisições seguidas com curl num loop e observe a resposta 429 Too Many Requests. Depois volte os valores originais.
Dica
for i in 1 2 3 4 5 6 7; do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/v1/eventos; done
5. PATCH que tenta mudar o id. Envie PATCH /api/v1/eventos/1 com corpo { "id": 999 }. Verifique o que acontece com o registro em memória. Corrija o handler para ignorar qualquer id enviado no corpo (o id da URL é sempre a fonte da verdade).
Dica
Depois do merge ({ ...eventos[indice], ...req.body }), force eventos[indice].id = id (o id da URL, já convertido para número) por cima, sobrescrevendo qualquer valor vindo do corpo.
6. Middleware de erro específico para JSON malformado. Envie, via curl, um POST /api/v1/eventos com corpo JSON propositalmente quebrado (ex.: {"titulo": "teste",} com vírgula sobrando). Observe qual status volta. express.json() lança um erro de parsing antes mesmo de sua rota rodar — confirme que esse erro também é capturado pelo seu tratadorDeErros, e ajuste a mensagem para ficar amigável ("corpo da requisição não é um JSON válido") quando o erro vier do parser.
Dica
O erro lançado pelo express.json() tem err.type === 'entity.parse.failed'. No tratadorDeErros, adicione uma verificação extra antes da checagem de ErroHttp: if (err.type === 'entity.parse.failed') { return res.status(400).json({ erro: { mensagem: 'JSON inválido no corpo da requisição', codigo: 'JSON_INVALIDO' } }) }.
🐛 Erros comuns e como resolver¶
| Sintoma | Causa | Solução |
|---|---|---|
GET /eventos/estatisticas cai no handler de /:id |
rota com parâmetro dinâmico registrada antes da rota fixa | registre rotas fixas (/estatisticas/...) antes de rotas com :id |
| Middleware "trava" a requisição, sem resposta nem erro | esqueceu de chamar next() dentro do middleware |
toda função de middleware precisa terminar chamando next() ou respondendo diretamente |
| Tratador de erros nunca é chamado | ele foi registrado antes das rotas, ou tem menos de 4 parâmetros | mova app.use(tratadorDeErros) para o fim; confira a assinatura (err, req, res, next) |
422 não aparece, servidor retorna 500 |
schema Zod não corresponde ao formato de req.body, gerando erro diferente do esperado |
confira o schema campo a campo; teste isoladamente com schema.safeParse(objetoDeTeste) |
POST retorna 201 mas sem Location no cabeçalho |
esqueceu .location(...) antes de .json(...) |
encadeie res.status(201).location(url).json(dados) |
express-rate-limit bloqueia até requisições legítimas em sala |
max configurado baixo demais para uma turma toda testando do mesmo IP/rede |
aumente max durante a aula, ou aplique o limitador só em rotas de escrita |
zod: erro Cannot read properties of undefined (reading 'issues') |
schema.safeParse não foi usado (usou schema.parse, que lança exceção em vez de devolver objeto) |
use sempre safeParse no middleware validar, para tratar o erro manualmente |
DELETE retorna 204 mas o corpo aparece vazio "errado" no REST Client |
comportamento esperado — 204 No Content nunca deve ter corpo |
confirme com res.status(204).send() sem argumento; não chame .json() depois de 204 |
| Duas rotas parecem casar com a mesma URL, só a primeira responde | ordem de registro determina qual middleware/rota atende primeiro | reordene: rotas mais específicas antes das mais genéricas |
Front-end para de funcionar depois de adicionar helmet() |
helmet por padrão bloqueia carregamento de alguns recursos cross-origin |
ajuste as políticas de helmet conforme a necessidade, ou mantenha o padrão em desenvolvimento e ajuste caso a caso |
📝 Avaliação 2 — instruções de entrega¶
Escopo. Uma aplicação Vue 3 completa, consumindo uma API (a sua, em memória ou já com Firestore — MySQL só é exigido a partir da Aula 09), sobre o projeto autoral de cada estudante (não o UniEventos, que é o exemplo do professor).
Requisitos obrigatórios:
- Vuetify para toda a interface (nenhum CSS puro estrutural fora do Vuetify, exceto ajustes pontuais).
- Vue Router, com no mínimo 4 rotas (ex.: Home, Detalhe, Formulário de criação/edição, uma quarta rota própria do domínio — listagem filtrada, painel, etc.).
- No mínimo 6 componentes próprios (
.vueautorais, além dos componentes do Vuetify) — componentes de card, formulário, lista, filtro, layout, etc. - Uma store Pinia com estado assíncrono: ações que chamam a API, estados de
carregandoeerro, getters quando fizer sentido. - Axios com instância dedicada (
axios.create) e ao menos um interceptor. - Consumo de API com tratamento visível de carregando / erro / vazio (três estados, não só o caminho feliz) em pelo menos uma tela de listagem.
- Formulário com validação (Vuetify
rulesou biblioteca de validação) para criar ou editar um registro do domínio. - Layout responsivo — funcional em tela de celular e de desktop, usando o sistema de grid do Vuetify.
Rubrica:
| Critério | Peso |
|---|---|
| Vue Router — 4+ rotas, navegação coerente, guards se aplicável | 1,5 |
| Componentização — 6+ componentes próprios, props/emits corretos | 2,0 |
| Pinia — store com estado assíncrono, carregando/erro tratados | 2,0 |
| Axios — instância dedicada, interceptor, integração com a store | 1,5 |
| Vuetify — uso consistente, responsividade | 1,5 |
| Formulário com validação funcionando | 1,0 |
| Organização do código e commits (histórico git coerente) | 0,5 |
Total: 10,0 pontos.
Formato de entrega. Link do repositório Git (GitHub, GitLab ou similar), público ou com acesso liberado para o professor, enviado via SIGAA, no campo de entrega da Avaliação 2. O README.md do repositório deve conter: nome do projeto autoral, instruções de instalação (npm install, npm run dev) e uma breve descrição do domínio escolhido.
Prazo. Até 07/10/2026, 23h59, horário de Brasília. O SIGAA registra o horário da submissão — entregas após o prazo entram na política de atraso abaixo.
Política de atraso. Cada 24h de atraso desconta 1,0 ponto da nota final da avaliação, até o limite de 5 dias corridos; após esse prazo, a atividade recebe nota zero, salvo justificativa formal (atestado médico ou similar) protocolada junto à coordenação.
Política de plágio e uso de IA. É permitido usar ferramentas de IA como apoio (explicar um erro, sugerir uma correção pontual, revisar um trecho) — é o mesmo tipo de apoio que se espera de qualquer ferramenta de desenvolvimento moderna. Não é permitido entregar um projeto majoritariamente gerado por IA sem compreensão do próprio código: na correção, o professor pode fazer perguntas orais sobre qualquer trecho entregue, e a incapacidade de explicar decisões básicas do próprio código (por que essa rota, por que essa store, o que faz esse computed) resulta em revisão da nota. Cópia entre colegas — código idêntico ou com alterações cosméticas — resulta em nota zero para todos os envolvidos, sem exceção.
🏠 Atividade assíncrona (1 h)¶
Além de finalizar e entregar a Avaliação 2, use esta hora para:
- Adicionar ao seu
requests.httpautoral os casos de erro esperados (404,422) — não só o caminho feliz. - Rodar o laboratório de rate limit (exercício 4) no seu próprio projeto, confirmando que o
429aparece. - Revisar seu tratador de erros: force um erro inesperado (ex.: acesse uma propriedade de
undefinedde propósito dentro de uma rota) e confirme que a resposta chega como500com o envelope{ "erro": { ... } }, sem vazar o stack trace para o cliente.
Critério de pronto: sua API autoral tem CRUD completo, middlewares próprios funcionando na ordem correta, validação com Zod retornando 422 com mensagens claras, e a Avaliação 2 já submetida no SIGAA.
✅ Checkpoint do projeto autoral¶
- [ ] CRUD completo (
GETlista,GETpor id,POST,PUT,PATCH,DELETE) funcionando na sua API autoral. - [ ] Rotas modularizadas com
express.Router(), montadas com prefixo/api/v1/<recurso>. - [ ] Middlewares próprios (
logger,medidorDeTempo,middlewareNaoEncontrado,tratadorDeErros) escritos e na ordem correta. - [ ] Validação de entrada com
zod, retornando422com mensagens em português. - [ ]
requests.httpcobrindo todos os endpoints, inclusive casos de erro. - [ ] Front-end autoral consumindo o CRUD completo, com tratamento de carregando/erro/vazio.
- [ ] Avaliação 2 entregue via SIGAA.
📚 Para aprofundar¶
- Guia oficial de roteamento do Express — expressjs.com/en/guide/routing.html.
- Guia oficial de middleware — expressjs.com/en/guide/using-middleware.html e error handling — expressjs.com/en/guide/error-handling.html.
- Documentação do Zod — zod.dev.
- Documentação do
helmet— helmetjs.github.io — o que cada cabeçalho de segurança faz. - Documentação do
express-rate-limit— express-rate-limit.mintlify.app — estratégias de limitação além do padrão por IP. - RFC 9110 (semântica HTTP) — referência formal de métodos e status codes, para quem quiser a fonte primária.
- Plano de curso FACET-SNP-310 — bibliografia básica, capítulos sobre APIs REST e middleware.
Na Aula 09 os dados em memória desta API saem de cena: você migra tudo para MySQL, com pool de conexões, consultas parametrizadas e camada de repositório — mantendo os mesmos contratos de endpoint, para o front-end não perceber a diferença.
Aula 09 — Integrando com SGBD MySQL
Na Aula 08 você entregou a Avaliação 2 com um CRUD completo, middlewares próprios e validação com Zod — tudo isso guardando dados num array em memória. Hoje esse array desaparece. Toda a unieventos-api passa a persistir em um banco de dados relacional de verdade: MySQL.
Vale reforçar o que muda e o que não muda hoje. O que muda: de onde os dados vêm e para onde vão — de um array na RAM para tabelas em disco, com todas as garantias que isso traz. O que não muda: o formato de cada requisição, o formato de cada resposta, os status codes, as rotas, os middlewares de validação e de erro. Esse é o teste que valida se você fez a migração corretamente — se o requests.http da Aula 08 continuar passando sem editar uma linha sequer, a API está correta.
🎯 Objetivos de aprendizagem¶
Ao final desta aula você será capaz de:
- explicar por que dados em memória não servem para uma aplicação real, e o que persistência significa;
- modelar as tabelas do UniEventos em MySQL, com chaves primárias, estrangeiras, tipos, restrições e índices;
- instalar e configurar um servidor MySQL (nativo ou via Docker) e conectar-se a ele com uma ferramenta gráfica;
- criar um pool de conexões com
mysql2/promise, entendendo por que pool e não conexão única; - escrever consultas parametrizadas, evitando SQL injection por concatenação de string;
- ler resultados de
INSERT,UPDATE,DELETEeSELECTcorretamente com a API baseada em Promise; - usar transações para operações que precisam ser atômicas (inscrever em evento e decrementar vagas);
- organizar o back-end em camadas — repositório, serviço, controlador — migrando o CRUD da Aula 08 sem quebrar contrato nenhum com o front-end.
📋 Pré-requisitos desta aula¶
- [ ]
unieventos-apida Aula 08, com CRUD completo em memória, middlewares e validação Zod funcionando. - [ ]
requests.httpcobrindo todos os endpoints (Aula 08). - [ ] Avaliação 2 entregue.
- [ ] Modelagem relacional revisada: entidade, atributo, chave primária e estrangeira (conteúdo de disciplinas anteriores de banco de dados — hoje é aplicação, não introdução).
- [ ] Máquina com privilégios de administrador para instalar o MySQL (ou Docker instalado, como alternativa).
- [ ] Ao menos uma ferramenta gráfica de banco escolhida (MySQL Workbench, DBeaver ou a extensão do VS Code) para inspecionar tabelas visualmente durante a aula.
⚠️ Atenção Nunca commite senha de banco de dados no repositório. Toda credencial desta aula vive em
.env, fora do controle de versão. Se você acidentalmente commitar uma senha, troque-a imediatamente — trocar a senha é mais rápido e mais seguro do que tentar "remover" o commit do histórico.
🗺️ Roteiro¶
| Bloco | Tempo | Atividade |
|---|---|---|
| 1 | 50 min | Modelagem relacional do UniEventos; instalação do MySQL; schema.sql completo |
| 2 | 50 min | mysql2/promise: pool, consultas parametrizadas, SQL injection, transações |
| 3 | 50 min | Camadas repositório/serviço/controlador; migração dos endpoints da Aula 08 para MySQL |
1. Por que dados em memória não servem¶
O array eventos das Aulas 07 e 08 vive na memória RAM do processo Node. Isso tem três problemas fatais para uso real:
Não sobrevive a um reinício. Toda vez que você reinicia o servidor (ou ele cai por qualquer motivo), o array volta ao estado inicial do código-fonte — qualquer evento criado, editado ou removido durante a execução se perde.
Não escala para múltiplas instâncias. Se você um dia rodar duas cópias da API (para atender mais tráfego), cada cópia tem seu próprio array, na sua própria memória — uma não sabe o que a outra gravou. Os dados ficam inconsistentes entre instâncias.
Não sobrevive a um deploy. Publicar uma nova versão do código normalmente significa derrubar o processo antigo e subir um novo — e o processo novo começa com o array do código, não com o estado anterior.
Persistência é a propriedade de dados sobreviverem além do tempo de vida do processo que os manipula. Um banco de dados é software especializado exatamente nisso: gravar em disco (ou em memória de forma replicada e durável) de um jeito que sobrevive a reinícios, crashes e múltiplas instâncias acessando ao mesmo tempo — com garantias de consistência que um array simples não oferece.
Por que relacional, e por que MySQL¶
O UniEventos tem entidades com relações claras entre si: um evento tem várias inscrições; uma inscrição pertence a um evento e a um usuário. Esse tipo de relação — um-para-muitos, muitos-para-muitos — é exatamente o que um banco de dados relacional (SGBD — Sistema Gerenciador de Banco de Dados) modela bem, com chaves estrangeiras garantindo a integridade dessas relações no próprio banco, não só no código da aplicação.
MySQL é um dos SGBDs relacionais mais usados no mercado, de código aberto, com décadas de maturidade. A disciplina usa a versão 8, com o driver mysql2 (Node) na versão 3.23, sempre pelo submódulo mysql2/promise — a variante que devolve Promises em vez de exigir callbacks, compatível com async/await, no mesmo estilo que você já usa desde a Aula 01.
🔎 Por baixo do capô Você já viu o Firestore (Aula 07) como alternativa de persistência. A diferença central: o Firestore é um banco NoSQL orientado a documentos — cada documento é um JSON flexível, sem schema fixo entre documentos da mesma coleção, e relações entre coleções são geridas manualmente pela aplicação. Um SGBD relacional como o MySQL exige schema definido antes de inserir dados (as tabelas do script abaixo), mas em troca oferece integridade referencial garantida pelo próprio banco (
FOREIGN KEY), consultas relacionais poderosas (JOIN) e transações ACID robustas. Nenhum dos dois é "melhor" em absoluto — a escolha depende do formato dos dados e das garantias que a aplicação precisa. O UniEventos usa MySQL a partir de hoje porque suas entidades são fortemente relacionadas (evento ↔ inscrição ↔ usuário), o caso de uso clássico para modelagem relacional.
2. Modelagem relacional aplicada ao UniEventos¶
Relembrando o modelo de dados do projeto (Aula 07, §3), as três entidades centrais do UniEventos:
eventos— os eventos acadêmicos: palestras, minicursos, workshops.usuarios— quem se cadastra e se inscreve (autenticados via Firebase Auth a partir da Aula 10; aqui já preparamos a tabela).inscricoes— a relação muitos-para-muitos entreusuarioseeventos: um usuário pode se inscrever em vários eventos, um evento tem vários inscritos.
┌───────────────┐ ┌──────────────────┐ ┌───────────────┐
│ eventos │ │ inscricoes │ │ usuarios │
├───────────────┤ ├──────────────────┤ ├───────────────┤
│ id (PK) │◄─────────│ evento_id (FK) │ │ id (PK) │
│ titulo │ 1:N │ usuario_id (FK) │─────────►│ firebase_uid │
│ descricao │ │ id (PK) │ N:1 │ nome │
│ categoria │ │ criado_em │ │ email │
│ data_hora │ └──────────────────┘ │ criado_em │
│ local │ └───────────────┘
│ vagas │
│ imagem_url │
│ criado_em │
└───────────────┘
Tipos de dados, restrições e índices¶
| Coluna | Tipo | Por quê |
|---|---|---|
id |
INT AUTO_INCREMENT |
número inteiro que o próprio banco incrementa a cada inserção |
titulo, nome, email |
VARCHAR(n) |
texto de tamanho limitado e conhecido |
descricao |
TEXT |
texto longo, sem limite prático relevante |
data_hora, criado_em |
DATETIME |
data e hora, sem fuso embutido (cuidado explicado adiante) |
vagas |
INT |
número inteiro não negativo |
NOT NULL |
restrição | impede gravar um registro sem aquele campo |
UNIQUE |
restrição | impede duplicar um valor (ex.: dois usuários com o mesmo e-mail) |
| índice em chave estrangeira | otimização | acelera buscas e junções (JOIN) que filtram por aquela coluna |
⚠️ Atenção
DATETIMEno MySQL grava data e hora sem informação de fuso — é literalmente "19:00 no dia 15", sem dizer em qual fuso horário. Se a aplicação gravar horários locais (fuso de Sinop, UTC−4) e outra parte do sistema assumir UTC (o padrão do JavaScript comnew Date().toISOString()), o horário exibido para o usuário fica deslocado. A prática mais segura: padronize um único fuso para toda a aplicação — o mais comum é gravar tudo em UTC no banco e converter para o fuso do usuário só na apresentação (no front-end). Esta disciplina, por simplicidade didática, grava os horários já no fuso local do evento; em um sistema com usuários em fusos diferentes, prefira UTC no banco.
Script sql/schema.sql completo¶
-- sql/schema.sql
-- Script de criação do banco de dados do UniEventos.
-- Execute com: mysql -u root -p < sql/schema.sql
CREATE DATABASE IF NOT EXISTS unieventos
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE unieventos;
-- tabela de usuários — uid do Firebase Auth vem na Aula 10, já deixamos o campo pronto
CREATE TABLE usuarios (
id INT AUTO_INCREMENT PRIMARY KEY,
firebase_uid VARCHAR(128) UNIQUE,
nome VARCHAR(120) NOT NULL,
email VARCHAR(160) NOT NULL UNIQUE,
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- tabela de eventos
CREATE TABLE eventos (
id INT AUTO_INCREMENT PRIMARY KEY,
titulo VARCHAR(160) NOT NULL,
descricao TEXT,
categoria ENUM('palestra', 'minicurso', 'workshop') NOT NULL,
data_hora DATETIME NOT NULL,
local VARCHAR(160) NOT NULL,
vagas INT NOT NULL DEFAULT 0,
imagem_url VARCHAR(400),
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_eventos_categoria (categoria),
INDEX idx_eventos_data_hora (data_hora)
);
-- tabela de inscrições — relação N:N entre usuarios e eventos
CREATE TABLE inscricoes (
id INT AUTO_INCREMENT PRIMARY KEY,
evento_id INT NOT NULL,
usuario_id INT NOT NULL,
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_inscricoes_evento
FOREIGN KEY (evento_id) REFERENCES eventos(id)
ON DELETE CASCADE,
CONSTRAINT fk_inscricoes_usuario
FOREIGN KEY (usuario_id) REFERENCES usuarios(id)
ON DELETE CASCADE,
-- um mesmo usuário não pode se inscrever duas vezes no mesmo evento
UNIQUE KEY uk_inscricao_unica (evento_id, usuario_id)
);
-- dados de exemplo
INSERT INTO usuarios (firebase_uid, nome, email) VALUES
('uid-exemplo-001', 'Ana Souza', 'ana.souza@exemplo.com'),
('uid-exemplo-002', 'Bruno Lima', 'bruno.lima@exemplo.com');
INSERT INTO eventos (titulo, descricao, categoria, data_hora, local, vagas, imagem_url) VALUES
('Semana Acadêmica de Computação', 'Palestras e minicursos sobre o mercado de tecnologia.', 'palestra', '2026-10-15 19:00:00', 'Auditório FACET', 80, 'https://picsum.photos/seed/semana-computacao/400/240'),
('Minicurso de Vue 3', 'Introdução prática ao framework Vue com Composition API.', 'minicurso', '2026-10-20 14:00:00', 'Laboratório 3', 30, 'https://picsum.photos/seed/minicurso-vue/400/240'),
('Workshop de Firebase e Express', 'Construindo uma API real do zero.', 'workshop', '2026-10-28 19:30:00', 'Laboratório 1', 25, 'https://picsum.photos/seed/workshop-firebase/400/240');
INSERT INTO inscricoes (evento_id, usuario_id) VALUES
(1, 1),
(1, 2),
(2, 1);
ON DELETE CASCADE garante que, se um evento for removido, todas as inscrições associadas a ele são removidas automaticamente pelo próprio banco — sem precisar de código na aplicação para limpar registros órfãos. A restrição UNIQUE KEY uk_inscricao_unica (evento_id, usuario_id) impede, no nível do banco, que o mesmo usuário se inscreva duas vezes no mesmo evento — mesmo que a aplicação, por algum bug, tentasse permitir.
💡 Dica
utf8mb4(em vez do antigoutf8do MySQL, que na verdade só suporta um subconjunto do Unicode) é o padrão recomendado hoje — suporta acentos, emojis e qualquer caractere completo do Unicode sem surpresas.
3. Instalando o MySQL¶
Três caminhos chegam ao mesmo lugar: um servidor MySQL 8 escutando em localhost:3306. Escolha o que for mais conveniente para o seu sistema operacional e siga — não é preciso instalar mais de um.
Linux (Debian/Ubuntu)¶
sudo apt update
sudo apt install mysql-server
sudo systemctl start mysql
sudo mysql_secure_installation # define senha do root e remove configurações inseguras padrão
Windows¶
Baixe o MySQL Installer em dev.mysql.com/downloads/installer, escolha "Server only" (ou "Full" se quiser o Workbench junto), e siga o assistente — ele já pede para definir a senha do usuário root durante a instalação.
Alternativa: Docker (qualquer sistema operacional)¶
Se você já tem Docker instalado, essa é a forma mais rápida de ter um MySQL isolado, sem instalar nada permanentemente no sistema:
docker run --name mysql-fds \
-e MYSQL_ROOT_PASSWORD=senhaDeDesenvolvimento123 \
-e MYSQL_DATABASE=unieventos \
-p 3306:3306 \
-d mysql:8
Isso sobe um contêiner MySQL 8, já criando o banco unieventos, expondo a porta padrão 3306 na sua máquina. Para parar e voltar a usar depois:
docker stop mysql-fds # para o contêiner
docker start mysql-fds # volta a rodar, com os dados preservados
⚠️ Atenção A senha do exemplo (
senhaDeDesenvolvimento123) é só para desenvolvimento local. Nunca reutilize senhas de exemplo de material didático em nada que vá para produção.
💡 Dica Se você usa Docker no dia a dia, considere adicionar um
docker-compose.ymlao repositóriounieventos-api, versionando a configuração do banco de desenvolvimento junto do código — assim qualquer colega que clonar o projeto sobe o mesmo ambiente com um únicodocker compose up -d, sem precisar copiar o comandodocker runmanualmente.
Ferramentas para explorar o banco visualmente¶
| Ferramenta | Característica |
|---|---|
| MySQL Workbench | oficial da Oracle/MySQL, completa, modelagem visual de schema |
| DBeaver | multiplataforma, suporta vários SGBDs além de MySQL, gratuita |
| extensão MySQL do VS Code | fica dentro do próprio editor, boa para consultas rápidas sem trocar de janela |
linha de comando (mysql) |
sempre disponível, sem instalação extra, ótima para scripts e automação |
| phpMyAdmin | interface web, comum em hospedagens compartilhadas; menos usada em desenvolvimento local |
Escolha uma, conecte em localhost:3306 com o usuário root e a senha definida, e rode o sql/schema.sql — ou pela ferramenta gráfica, ou direto no terminal:
mysql -u root -p < sql/schema.sql
Depois de rodar o script, use a ferramenta escolhida para navegar visualmente pelas tabelas criadas, conferir os INSERTs de exemplo e, se quiser, gerar um diagrama entidade-relacionamento a partir do schema existente — a maioria dessas ferramentas faz engenharia reversa do banco para um diagrama automaticamente, útil para conferir se as relações ficaram como o desenhado na §2.
🧩 Padrão de projeto em uso¶
🧩 Padrão de projeto em uso — Factory / Object Pool e Repository¶
mysql2.createPool(...)é uma aplicação combinada de dois padrões criacionais. Factory Method: você não instancia uma conexão diretamente comnew Conexao()— chama uma função de fábrica (createPool) que encapsula a lógica de criação e devolve o objeto pronto para uso, escondendo os detalhes de configuração interna. Object Pool: em vez de criar uma conexão nova para cada requisição (caro: negociar protocolo, autenticar, alocar recursos no servidor de banco), o pool mantém um conjunto de conexões já abertas, prontas, emprestando uma a cada consulta e devolvendo-a ao pool quando termina — reduzindo drasticamente o custo de abrir/fechar conexão repetidamente.A camada de Repository, que construímos a seguir, é um padrão estrutural de organização: isola todo o SQL da aplicação dentro de funções com nomes de domínio (
buscarEventoPorId,inserirEvento), para que o resto do código nunca precise saber que existe SQL por trás — só chama métodos. Trocar de MySQL para outro banco (Aula 12, com Supabase) significa reescrever o repositório, sem tocar em serviço, controlador ou rotas.
4. mysql2/promise na prática¶
npm install mysql2
Pool de conexões: por que, e não conexão única¶
Uma conexão única a um banco de dados atende uma consulta por vez — se sua API recebe cinco requisições simultâneas, e cada uma precisa consultar o banco, quatro delas ficam esperando a primeira terminar. Um pool de conexões mantém várias conexões abertas simultaneamente, e o driver empresta uma livre para cada consulta, devolvendo ao pool quando ela termina.
// src/bancoDeDados.js
import mysql from 'mysql2/promise'
export const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true, // se todas as conexões estiverem ocupadas, espera na fila em vez de falhar
connectionLimit: 10, // no máximo 10 conexões simultâneas no pool
namedPlaceholders: true, // permite usar :nome em vez de só "?" nas queries
})
waitForConnections: true significa que, se as 10 conexões do connectionLimit estiverem todas ocupadas no momento de uma nova consulta, o driver enfileira a requisição e espera uma liberar, em vez de lançar erro imediatamente. namedPlaceholders: true habilita a sintaxe :nomeDoParametro nas consultas, além da tradicional ? posicional — útil quando a query tem muitos parâmetros e a ordem fica difícil de acompanhar.
pool.query vs pool.execute¶
// pool.query: envia a consulta e os valores juntos, o driver monta e executa
const [linhas] = await pool.query('SELECT * FROM eventos WHERE categoria = ?', ['palestra'])
// pool.execute: usa prepared statements no protocolo do MySQL — o SQL é compilado
// uma vez pelo servidor e reutilizado, mais eficiente para consultas repetidas
const [linhas2] = await pool.execute('SELECT * FROM eventos WHERE categoria = ?', ['palestra'])
Para a maioria dos casos, o comportamento observável é o mesmo — a diferença é performance em consultas repetidas com muita frequência (prepared statements do execute compensam o custo extra de preparo quando a mesma consulta roda muitas vezes). Esta disciplina usa pool.execute como padrão no repositório, por ser a prática mais recomendada em produção.
Consultas parametrizadas — e o ataque que elas evitam¶
Nunca, em hipótese alguma, concatene valores vindos do usuário diretamente numa string SQL:
// NUNCA FAÇA ISSO — vulnerável a SQL injection
const categoria = req.query.categoria
const sql = `SELECT * FROM eventos WHERE categoria = '${categoria}'`
const [linhas] = await pool.query(sql)
Se alguém enviar categoria como ' OR 1=1 --, a string final montada fica:
SELECT * FROM eventos WHERE categoria = '' OR 1=1 --'
OR 1=1 é sempre verdadeiro, e -- comenta o resto da linha — a consulta passa a devolver todos os eventos da tabela, ignorando completamente o filtro pretendido. Em consultas de autenticação, o mesmo tipo de ataque pode permitir login sem senha correta; em DELETE/UPDATE malformados dessa forma, pode apagar ou alterar a tabela inteira.
A correção é sempre usar placeholders (? ou :nome), nunca concatenação:
// CORRETO — consulta parametrizada
const categoria = req.query.categoria
const [linhas] = await pool.execute('SELECT * FROM eventos WHERE categoria = ?', [categoria])
Com placeholder, o driver envia a consulta e os valores separadamente para o servidor MySQL — o valor nunca é interpretado como parte da sintaxe SQL, não importa o que ele contenha. ' OR 1=1 -- viraria, nesse caso, literalmente o texto que está sendo procurado na coluna categoria, e não devolveria nada (porque nenhuma categoria se chama isso).
⚠️ Atenção SQL injection é uma das vulnerabilidades mais antigas e mais exploradas da web, e ainda aparece em sistemas reais porque alguém, em algum momento, concatenou uma string "só dessa vez". A regra não tem exceção: todo valor vindo de fora (query string, corpo da requisição, cabeçalho) entra numa query como parâmetro, nunca como texto concatenado.
Lendo o resultado de cada tipo de consulta¶
// SELECT: o resultado é um array de linhas (mesmo com 0 ou 1 resultado)
const [linhas] = await pool.execute('SELECT * FROM eventos WHERE id = ?', [1])
const evento = linhas[0] // undefined se não encontrou
// INSERT: o resultado é um objeto com metadados da inserção
const [resultado] = await pool.execute(
'INSERT INTO eventos (titulo, categoria, data_hora, local, vagas) VALUES (?, ?, ?, ?, ?)',
['Palestra de teste', 'palestra', '2026-11-05 19:00:00', 'Auditório FACET', 60],
)
console.log(resultado.insertId) // id gerado pelo AUTO_INCREMENT
console.log(resultado.affectedRows) // quantas linhas foram afetadas (1, aqui)
// UPDATE / DELETE: também devolvem affectedRows
const [resultadoUpdate] = await pool.execute('UPDATE eventos SET vagas = ? WHERE id = ?', [50, 1])
console.log(resultadoUpdate.affectedRows) // 0 se o id não existia, 1 se atualizou
pool.query/pool.execute sempre devolvem um array de dois elementos — por isso o padrão const [linhas] = await ... (desestruturação, já vista desde a Aula 01). O primeiro elemento é o resultado propriamente dito; o segundo (normalmente descartado com const [linhas], ignorando a segunda posição) traz metadados de campos, que raramente usamos diretamente.
Consultas relacionais com JOIN¶
A vantagem de ter um banco relacional aparece quando você precisa combinar dados de mais de uma tabela numa única consulta — algo que, com dados em memória (Aulas 07–08), exigia laços manuais em JavaScript para "juntar" arrays.
// buscar as inscrições de um evento, já trazendo o nome e e-mail de cada inscrito,
// numa única ida ao banco — sem precisar de uma segunda consulta por usuário
const [inscricoesDoEvento] = await pool.execute(
`SELECT i.id, i.criado_em, u.nome, u.email
FROM inscricoes i
INNER JOIN usuarios u ON u.id = i.usuario_id
WHERE i.evento_id = ?
ORDER BY i.criado_em ASC`,
[eventoId],
)
INNER JOIN combina linhas de inscricoes com as linhas correspondentes de usuarios, casando pela condição u.id = i.usuario_id — exatamente a relação de chave estrangeira definida no schema.sql. O resultado já vem com os dados prontos para a resposta da API, sem processamento adicional em JavaScript.
Transações: inscrever em evento e decrementar vagas¶
Considere a operação "inscrever um usuário num evento": ela precisa (1) verificar se há vaga, (2) inserir a inscrição, e (3) decrementar o contador de vagas. Se o passo 2 tiver sucesso mas o passo 3 falhar (por exemplo, o servidor cair no meio), o banco fica em um estado inconsistente — uma inscrição existe, mas a vaga não foi descontada. Uma transação garante que um grupo de operações aconteça tudo ou nada.
// src/repositories/inscricoesRepository.js
import { pool } from '../bancoDeDados.js'
import { ErroHttp } from '../erros/ErroHttp.js'
export async function inscreverUsuarioNoEvento(eventoId, usuarioId) {
// pool.getConnection() empresta UMA conexão específica do pool, exclusiva para esta transação
const conexao = await pool.getConnection()
try {
await conexao.beginTransaction()
// trava a linha do evento para leitura, evitando que duas inscrições simultâneas
// leiam "vagas: 1" ao mesmo tempo e ambas decidam que podem inscrever
const [eventos] = await conexao.execute(
'SELECT vagas FROM eventos WHERE id = ? FOR UPDATE',
[eventoId],
)
if (eventos.length === 0) {
throw new ErroHttp(404, 'Evento não encontrado')
}
if (eventos[0].vagas <= 0) {
throw new ErroHttp(422, 'Não há vagas disponíveis para este evento')
}
await conexao.execute(
'INSERT INTO inscricoes (evento_id, usuario_id) VALUES (?, ?)',
[eventoId, usuarioId],
)
await conexao.execute(
'UPDATE eventos SET vagas = vagas - 1 WHERE id = ?',
[eventoId],
)
// só grava tudo em definitivo se as três operações acima passaram sem erro
await conexao.commit()
} catch (erro) {
// desfaz TUDO que essa transação tentou fazer — o banco volta ao estado anterior
await conexao.rollback()
throw erro
} finally {
// devolve a conexão ao pool, sempre — sucesso ou falha
conexao.release()
}
}
FOR UPDATE no SELECT trava a linha lida até o fim da transação, impedindo que outra transação concorrente leia o mesmo valor de vagas antes do commit — evitando o cenário de duas inscrições simultâneas "roubarem" a última vaga ao mesmo tempo.
⚠️ Atenção
conexao.release()nofinallyé obrigatório. Se você esquecer de liberar uma conexão emprestada do pool, ela fica presa — e depois deconnectionLimitconexões presas, o pool se esgota e toda nova consulta trava esperando uma conexão livre que nunca aparece. Isso é a causa mais comum de uma API que "funciona bem no início e trava depois de um tempo".
O caminho de sucesso e o caminho de falha, lado a lado:
beginTransaction()
│
▼
SELECT ... FOR UPDATE (lê e trava a linha do evento)
│
▼
vagas > 0? ──── não ────► throw erroValidacao(422)
│ sim │
▼ ▼
INSERT em inscricoes catch: rollback()
│ (nada gravado)
▼ │
UPDATE vagas = vagas - 1 │
│ │
▼ │
commit() │
(tudo gravado) │
│ │
└──────────┬───────────────┘
▼
finally: release()
(conexão sempre volta ao pool)
Note que release() roda em ambos os caminhos — é justamente o papel do finally: executar independentemente de a try ter chegado ao commit() ou de o catch ter chegado ao rollback().
5. Configuração por ambiente¶
# .env (nunca commitar)
PORTA=3000
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=senhaDeDesenvolvimento123
DB_NAME=unieventos
# .env.example (versionado, sem valores sigilosos)
PORTA=3000
DB_HOST=localhost
DB_USER=
DB_PASSWORD=
DB_NAME=unieventos
node --watch --env-file=.env src/servidor.js
Cada ambiente (sua máquina, a de um colega, um servidor de produção futuro) tem seu próprio .env, com valores possivelmente diferentes — mas o mesmo código-fonte funciona em todos, porque nada de configuração está fixado (hardcoded) no JavaScript.
💻 Mão na massa — camadas repositório, serviço e controlador¶
A partir de agora a unieventos-api ganha três camadas com responsabilidades separadas:
requisição HTTP
│
▼
controller — lê req, chama o service, monta a resposta HTTP (não sabe SQL)
│
▼
service — regra de negócio (ex.: "vagas não pode ficar negativo")
│
▼
repository — só SQL: monta e executa queries, devolve dados "crus"
│
▼
MySQL
O controller não sabe que existe SQL — ele lida só com req/res e delega tudo ao service. O service não sabe que existe req/res — ele recebe parâmetros simples e devolve dados ou lança erros de negócio. O repository não sabe nada sobre HTTP — só executa SQL e devolve linhas. Essa separação permite testar a regra de negócio sem precisar simular uma requisição HTTP, e trocar o banco de dados (Aula 12, Supabase) sem tocar em controller nem service.
Passo 1 — repositório de eventos¶
// src/repositories/eventosRepository.js
import { pool } from '../bancoDeDados.js'
export async function listarEventos({ categoria, ordenarPor, direcao, limite, offset }) {
const colunasPermitidas = ['id', 'titulo', 'data_hora', 'vagas']
const coluna = colunasPermitidas.includes(ordenarPor) ? ordenarPor : 'id'
const sentidoOrdenacao = direcao === 'desc' ? 'DESC' : 'ASC'
// nomes de coluna/direção não podem ser parametrizados com "?" (só valores podem);
// por isso validamos contra uma lista fixa (colunasPermitidas) antes de montar a string
let sql = 'SELECT * FROM eventos'
const parametros = []
if (categoria) {
sql += ' WHERE categoria = ?'
parametros.push(categoria)
}
sql += ` ORDER BY ${coluna} ${sentidoOrdenacao} LIMIT ? OFFSET ?`
parametros.push(limite, offset)
const [linhas] = await pool.execute(sql, parametros)
return linhas
}
export async function contarEventos(categoria) {
let sql = 'SELECT COUNT(*) AS total FROM eventos'
const parametros = []
if (categoria) {
sql += ' WHERE categoria = ?'
parametros.push(categoria)
}
const [linhas] = await pool.execute(sql, parametros)
return linhas[0].total
}
export async function buscarEventoPorId(id) {
const [linhas] = await pool.execute('SELECT * FROM eventos WHERE id = ?', [id])
return linhas[0] || null
}
export async function inserirEvento(evento) {
const [resultado] = await pool.execute(
`INSERT INTO eventos (titulo, descricao, categoria, data_hora, local, vagas, imagem_url)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
evento.titulo,
evento.descricao || null,
evento.categoria,
evento.dataHora,
evento.local,
evento.vagas,
evento.imagemUrl || null,
],
)
return buscarEventoPorId(resultado.insertId)
}
export async function substituirEvento(id, evento) {
const [resultado] = await pool.execute(
`UPDATE eventos
SET titulo = ?, descricao = ?, categoria = ?, data_hora = ?, local = ?, vagas = ?, imagem_url = ?
WHERE id = ?`,
[
evento.titulo,
evento.descricao || null,
evento.categoria,
evento.dataHora,
evento.local,
evento.vagas,
evento.imagemUrl || null,
id,
],
)
if (resultado.affectedRows === 0) return null
return buscarEventoPorId(id)
}
export async function atualizarEventoParcial(id, campos) {
const colunasPermitidas = {
titulo: 'titulo',
descricao: 'descricao',
categoria: 'categoria',
dataHora: 'data_hora',
local: 'local',
vagas: 'vagas',
imagemUrl: 'imagem_url',
}
const atribuicoes = []
const parametros = []
for (const [chave, valor] of Object.entries(campos)) {
if (colunasPermitidas[chave]) {
atribuicoes.push(`${colunasPermitidas[chave]} = ?`)
parametros.push(valor)
}
}
if (atribuicoes.length === 0) return buscarEventoPorId(id)
parametros.push(id)
const [resultado] = await pool.execute(
`UPDATE eventos SET ${atribuicoes.join(', ')} WHERE id = ?`,
parametros,
)
if (resultado.affectedRows === 0) return null
return buscarEventoPorId(id)
}
export async function removerEvento(id) {
const [resultado] = await pool.execute('DELETE FROM eventos WHERE id = ?', [id])
return resultado.affectedRows > 0
}
⚠️ Atenção Note que nomes de coluna e direção de ordenação (
ORDER BY coluna ASC/DESC) não podem vir de placeholder?— o protocolo de prepared statements do MySQL só parametriza valores, não identificadores de coluna nem palavras-chave SQL. É por isso quelistarEventosvalidaordenarPorcontra uma lista fixa (colunasPermitidas) antes de montar a string com esse nome — validar contra uma lista fechada de valores aceitos é seguro; aceitar qualquer string do usuário nessa posição reabriria a porta para injection.
Passo 2 — serviço de eventos¶
// src/services/eventosService.js
import * as eventosRepository from '../repositories/eventosRepository.js'
import { erroNaoEncontrado, erroValidacao } from '../erros/ErroHttp.js'
export async function obterListaDeEventos({ categoria, ordenarPor, direcao, pagina, limite }) {
const paginaSegura = Math.max(1, Number(pagina) || 1)
const limiteSeguro = Math.min(100, Math.max(1, Number(limite) || 10))
const offset = (paginaSegura - 1) * limiteSeguro
const [eventos, total] = await Promise.all([
eventosRepository.listarEventos({ categoria, ordenarPor, direcao, limite: limiteSeguro, offset }),
eventosRepository.contarEventos(categoria),
])
return {
eventos,
meta: { pagina: paginaSegura, limite: limiteSeguro, total },
}
}
export async function obterEventoPorId(id) {
const evento = await eventosRepository.buscarEventoPorId(id)
if (!evento) {
throw erroNaoEncontrado('Evento não encontrado')
}
return evento
}
export async function criarEvento(dadosEvento) {
if (dadosEvento.vagas < 0) {
throw erroValidacao('Vagas não pode ser negativo')
}
return eventosRepository.inserirEvento(dadosEvento)
}
export async function atualizarEventoCompleto(id, dadosEvento) {
const eventoAtualizado = await eventosRepository.substituirEvento(id, dadosEvento)
if (!eventoAtualizado) {
throw erroNaoEncontrado('Evento não encontrado')
}
return eventoAtualizado
}
export async function atualizarEventoParcial(id, campos) {
const eventoAtualizado = await eventosRepository.atualizarEventoParcial(id, campos)
if (!eventoAtualizado) {
throw erroNaoEncontrado('Evento não encontrado')
}
return eventoAtualizado
}
export async function excluirEvento(id) {
const removeu = await eventosRepository.removerEvento(id)
if (!removeu) {
throw erroNaoEncontrado('Evento não encontrado')
}
}
O service centraliza regras que o repository não deveria conhecer (como "vagas não pode ser negativo") e traduz "não encontrado no banco" (null) em um erro de domínio (erroNaoEncontrado) — o controller nunca precisa checar if (!evento) porque o service já garante isso via exceção.
Passo 3 — controlador de eventos¶
// src/controllers/eventosController.js
import * as eventosService from '../services/eventosService.js'
export async function listar(req, res) {
const { categoria, ordenarPor, direcao, pagina, limite } = req.query
const { eventos, meta } = await eventosService.obterListaDeEventos({
categoria,
ordenarPor,
direcao,
pagina,
limite,
})
res.json({ dados: eventos, meta })
}
export async function buscarPorId(req, res) {
const evento = await eventosService.obterEventoPorId(Number(req.params.id))
res.json({ dados: evento })
}
export async function criar(req, res) {
const novoEvento = await eventosService.criarEvento(req.body)
res.status(201).location(`/api/v1/eventos/${novoEvento.id}`).json({ dados: novoEvento })
}
export async function substituir(req, res) {
const eventoAtualizado = await eventosService.atualizarEventoCompleto(Number(req.params.id), req.body)
res.json({ dados: eventoAtualizado })
}
export async function atualizarParcial(req, res) {
const eventoAtualizado = await eventosService.atualizarEventoParcial(Number(req.params.id), req.body)
res.json({ dados: eventoAtualizado })
}
export async function excluir(req, res) {
await eventosService.excluirEvento(Number(req.params.id))
res.status(204).send()
}
Repare que nenhum controller trata erro manualmente — todo throw (vindo do service, vindo do repository, vindo de qualquer lugar da cadeia de await) é capturado automaticamente pelo Express 5 e cai no tratadorDeErros da Aula 08, sem nenhuma mudança nele.
Passo 4 — rotas usando o controller¶
// src/routes/eventos.routes.js
import { Router } from 'express'
import * as eventosController from '../controllers/eventosController.js'
import { validar } from '../middlewares/validador.js'
import { schemaEvento, schemaEventoParcial } from '../schemas/evento.schema.js'
const router = Router()
router.get('/', eventosController.listar)
router.get('/:id', eventosController.buscarPorId)
router.post('/', validar(schemaEvento), eventosController.criar)
router.put('/:id', validar(schemaEvento), eventosController.substituir)
router.patch('/:id', validar(schemaEventoParcial), eventosController.atualizarParcial)
router.delete('/:id', eventosController.excluir)
export default router
Compare com o eventos.routes.js da Aula 08: a assinatura de cada rota é idêntica (mesmo método, mesmo caminho, mesmo middleware de validação). Só o corpo mudou de "manipula um array" para "chama um controller que fala com MySQL por baixo". Esse é o ponto central da aula: o contrato HTTP não mudou, então o front-end não precisa de nenhuma alteração.
Passo 5 — atualizando o servidor.js¶
// src/servidor.js
import express from 'express'
import cors from 'cors'
import helmet from 'helmet'
import compression from 'compression'
import morgan from 'morgan'
import rateLimit from 'express-rate-limit'
import eventosRoutes from './routes/eventos.routes.js'
import { middlewareNaoEncontrado, tratadorDeErros } from './middlewares/erros.js'
import { logger } from './middlewares/logger.js'
import { medidorDeTempo } from './middlewares/medidorDeTempo.js'
import './bancoDeDados.js' // garante que o pool é criado na subida do servidor
const app = express()
app.use(cors())
app.use(helmet())
app.use(compression())
app.use(express.json())
app.use(morgan('dev'))
app.use(logger)
app.use(medidorDeTempo)
const limitador = rateLimit({ windowMs: 15 * 60 * 1000, max: 100 })
app.use('/api/', limitador)
app.use('/api/v1/eventos', eventosRoutes)
app.use(middlewareNaoEncontrado)
app.use(tratadorDeErros)
const porta = process.env.PORTA || 3000
app.listen(porta, () => {
console.log(`unieventos-api rodando em http://localhost:${porta}`)
})
Teste com o mesmo requests.http da Aula 08 — nenhuma linha dele precisa mudar. Se algum teste que passava antes agora falha, o problema está na camada MySQL nova, não no contrato da API.
🧪 Laboratório¶
1. Repositório de usuários. Escreva src/repositories/usuariosRepository.js com listarUsuarios(), buscarUsuarioPorId(id) e inserirUsuario({ nome, email }). Use consultas parametrizadas em todas.
Dica
Siga exatamente o padrão de eventosRepository.js: pool.execute(sql, parametros), desestruturando [linhas] do retorno.
2. Endpoint de inscrição. Crie POST /api/v1/eventos/:id/inscricoes que recebe { "usuarioId": N } no corpo e chama inscreverUsuarioNoEvento (já escrita nesta aula). Teste o caso de sucesso e o caso de vagas esgotadas (zere as vagas de um evento no banco antes de testar).
Dica
O erro de vagas esgotadas já vem como ErroHttp(422, ...) de dentro da transação — seu controller só precisa dar await e deixar o Express capturar automaticamente.
3. Ataque de SQL injection controlado. Na sua máquina de desenvolvimento, temporariamente reescreva buscarEventoPorId para concatenar a string (sem placeholder), e tente buscar com um id malicioso do tipo 1 OR 1=1. Observe o resultado. Depois reverta para a versão parametrizada e repita o teste, confirmando que o ataque não funciona mais.
Dica
Como id nessa rota já passa por Number(req.params.id) no controller, o ataque de string não chega inteiro ao repository nesse caso específico — para realmente ver o ataque funcionar, teste diretamente no eventosRepository, chamando a função com uma string maliciosa manualmente, sem o Number() do meio do caminho. Isso mostra por que duas camadas de proteção (validação de tipo + parametrização) são melhores que uma só.
4. Índice e EXPLAIN. Rode EXPLAIN SELECT * FROM eventos WHERE categoria = 'palestra' no MySQL Workbench ou DBeaver, antes e depois de remover o índice idx_eventos_categoria (DROP INDEX idx_eventos_categoria ON eventos). Compare o campo rows do resultado (recrie o índice depois do teste).
Dica
Com o índice, o MySQL deve mostrar type: ref e um número baixo em rows. Sem o índice, type: ALL (varredura completa da tabela) e rows igual ao total de linhas da tabela.
5. Transação com falha proposital. No meio de inscreverUsuarioNoEvento, adicione temporariamente um throw new Error('falha proposital') logo depois do INSERT na tabela inscricoes, antes do UPDATE de vagas. Rode a função, confirme que a inscrição não aparece na tabela (porque o rollback desfez tudo), e remova o throw de teste depois.
Dica
Consulte a tabela inscricoes direto pelo Workbench/DBeaver antes e depois de rodar o teste, para confirmar visualmente que nada foi persistido.
6. Endpoint de listagem com JOIN. Crie GET /api/v1/eventos/:id/inscricoes que devolve a lista de inscritos de um evento, usando a consulta JOIN desta aula, no formato de envelope { "dados": [...] }. Trate o caso de evento inexistente com 404.
Dica
Siga a mesma separação em camadas: uma função no repository (listarInscricoesDoEvento), verificação de existência do evento no service (reaproveite obterEventoPorId), e um controller enxuto.
🐛 Erros comuns e como resolver¶
| Sintoma | Causa | Solução |
|---|---|---|
ER_ACCESS_DENIED_ERROR |
usuário ou senha incorretos no .env |
confira DB_USER/DB_PASSWORD; teste login manual com mysql -u usuario -p |
ECONNREFUSED |
MySQL não está rodando, ou porta/host errados | confirme systemctl status mysql (Linux) ou o contêiner Docker rodando (docker ps) |
ER_NO_SUCH_TABLE |
schema.sql não foi executado, ou executado no banco errado |
rode mysql -u root -p < sql/schema.sql; confira USE unieventos; no início do script |
ER_BAD_DB_ERROR |
DB_NAME no .env não corresponde ao banco criado |
confira o nome exato do banco criado pelo CREATE DATABASE |
| Datas retornam com horário deslocado | fuso horário do servidor MySQL diferente do esperado pela aplicação | padronize o fuso do servidor e/ou converta explicitamente no código, sem assumir local implícito |
ER_DUP_ENTRY |
tentou inserir um valor que viola UNIQUE (e-mail repetido, inscrição duplicada) |
trate esse erro específico no service, devolvendo 409 Conflict com mensagem clara |
| Pool trava depois de um tempo de uso | conexão emprestada com getConnection() nunca foi liberada com .release() |
sempre libere no finally, mesmo em caminhos de erro |
Too many connections no lado do servidor MySQL |
connectionLimit do pool maior que o limite configurado no servidor MySQL |
ajuste connectionLimit para um valor compatível com a capacidade do servidor |
req.body chega vazio no POST de inscrição |
testou direto no banco sem passar pela API, ou esqueceu Content-Type: application/json no requests.http |
confirme o cabeçalho e o corpo no arquivo .http |
resultado.insertId vem 0 ou undefined |
a tabela não tem coluna AUTO_INCREMENT, ou a query não era um INSERT |
confira o CREATE TABLE; insertId só é preenchido em INSERT sobre coluna AUTO_INCREMENT |
| Erro de sintaxe SQL só em produção, funcionava local | diferença de versão do MySQL entre ambientes, ou script schema.sql não aplicado no novo ambiente |
garanta que schema.sql seja executado em todo ambiente novo antes de subir a API |
PROTOCOL_CONNECTION_LOST durante uso prolongado |
conexão do pool expirou por inatividade (timeout do servidor MySQL) | normal em pools ociosos; o mysql2 reabre conexões automaticamente na próxima consulta — se persistir, revise connectionLimit e tempo de vida da conexão |
🏠 Atividade assíncrona (1 h)¶
No seu projeto autoral:
- Modele as tabelas do seu domínio em
sql/schema.sql, com ao menos duas tabelas relacionadas por chave estrangeira (equivalente aeventos/inscricoes, adaptado ao seu tema). - Crie o banco (nativo ou Docker) e execute o script.
- Migre seu repositório, serviço e controlador da Aula 08 (em memória) para MySQL, seguindo exatamente a separação em camadas desta aula.
- Rode novamente o seu
requests.httpda Aula 08 sem alterar nenhuma linha — confirme que todos os testes continuam passando, agora contra o MySQL. - Implemente pelo menos uma operação transacional própria do seu domínio (qualquer ação que precise de "tudo ou nada" entre duas tabelas).
Critério de pronto: sua API autoral persiste em MySQL, os testes do requests.http da Aula 08 passam sem modificação, e o front-end autoral continua funcionando sem alterações — prova de que a migração foi transparente para quem consome a API.
✅ Checkpoint do projeto autoral¶
- [ ]
sql/schema.sqlversionado, comCREATE DATABASE,CREATE TABLE, chaves eINSERTs de exemplo. - [ ] MySQL rodando localmente (nativo ou Docker), banco criado a partir do script.
- [ ]
.env/.env.examplecom as quatro variáveis de conexão (DB_HOST,DB_USER,DB_PASSWORD,DB_NAME),.envnunca commitado. - [ ] Pool de conexões criado com
createPool, usado em toda consulta — nenhuma conexão avulsa. - [ ] Todas as consultas parametrizadas — nenhuma concatenação de valor de usuário em string SQL.
- [ ] Camadas repository/service/controller separadas, cada uma com responsabilidade única.
- [ ] Ao menos uma operação usando transação (
beginTransaction/commit/rollback). - [ ]
requests.httpda Aula 08 passando sem alterações contra a API já em MySQL.
📚 Para aprofundar¶
- Documentação oficial do MySQL 8 — dev.mysql.com/doc/refman/8.0 (seções de tipos de dados,
FOREIGN KEY, transações). - Documentação do driver
mysql2— github.com/sidorares/node-mysql2 (README cobre pool, prepared statements e Promise API). - OWASP — SQL Injection Prevention Cheat Sheet — referência de mercado sobre o assunto.
- Documentação do Docker Hub para a imagem oficial
mysql— hub.docker.com/_/mysql. - MySQL 8 Reference Manual — capítulo The InnoDB Storage Engine — para entender transações,
FOR UPDATEe isolamento em profundidade. - Documentação do MySQL Workbench — dev.mysql.com/doc/workbench/en — modelagem visual (ER Diagram) a partir de um schema existente.
- Plano de curso FACET-SNP-310 — bibliografia básica, capítulos sobre bancos de dados relacionais e persistência.
Na Aula 10, a API que você acabou de migrar para MySQL ganha autenticação de verdade: o Firebase Authentication entra em cena para identificar quem faz cada requisição, e você vai proteger rotas — como criar, editar e remover eventos — para que só usuários autenticados (e autorizados) possam executá-las.
Aula 10 — Requisições autenticadas com Firebase
Na Aula 09 o UniEventos passou a persistir eventos no MySQL, com a API unieventos-api seguindo a arquitetura controller → service → repository. Qualquer pessoa com acesso à API conseguia criar, editar ou excluir um evento — não havia noção de "quem" fazia a requisição. Hoje isso muda: vamos exigir identidade.
🎯 Objetivos de aprendizagem¶
Ao final desta aula você será capaz de:
- Explicar a diferença entre autenticação e autorização, e por que senha em texto puro no banco é um erro grave.
- Descrever a estrutura de um JWT (header, payload, assinatura) e explicar por que ele é assinado, não criptografado.
- Implementar cadastro, login, logout e login com Google usando o SDK modular do Firebase Auth.
- Construir uma store Pinia de autenticação que resolve corretamente o problema do F5 (recarregar a página autenticado).
- Proteger rotas do Vue Router com
beforeEachaguardando a inicialização da autenticação. - Enviar o token do usuário em cada requisição Axios via interceptor e validá-lo no back-end com
firebase-admin. - Diferenciar dois níveis de proteção (rota no front = UX; middleware no back = segurança) e implementar autorização por papel com custom claims.
📋 Pré-requisitos desta aula¶
Checklist antes de começar:
- [ ]
unieventos-webrodando com Vue Router e Pinia configurados (Aulas 04–06). - [ ]
unieventos-apirodando com Express 5, endpoints de eventos e persistência MySQL (Aulas 07–09). - [ ] Projeto Firebase criado (Aula 07) — anote o
projectId. - [ ] Node.js 22.22.2 e npm 10.9.7 instalados (
node -v,npm -v). - [ ] Acesso ao console do Firebase com o projeto do UniEventos.
🗺️ Roteiro¶
| Bloco | Tempo | Atividade |
|---|---|---|
| 1 | 50 min | Autenticação × autorização; anatomia de um JWT; habilitar provedores no console; Firebase Auth no front (cadastro, login, logout) |
| 2 | 50 min | Store de autenticação em Pinia; guard de rota; interceptor Axios enviando o token |
| 3 | 50 min | Verificação do token no back-end com firebase-admin; middleware autenticar/autorizar; custom claims; testes com e sem token |
1. Autenticação não é autorização¶
São duas perguntas diferentes, e misturar as duas é a origem de muito bug de segurança:
- Autenticação responde "quem é você?". O sistema confirma sua identidade — normalmente com e-mail e senha, ou delegando a um provedor como Google.
- Autorização responde "o que você pode fazer?". Depois de saber quem você é, o sistema decide se você pode ler, criar, editar ou excluir um recurso.
Um usuário autenticado pode não estar autorizado a excluir um evento — só o administrador está. Um visitante não autenticado pode estar autorizado a ler a lista de eventos, que é pública. As duas coisas são independentes e o back-end precisa checar as duas, sempre, endpoint por endpoint.
Por que não guardar senha no seu próprio banco (se puder evitar)¶
Até aqui o UniEventos não tinha usuários — só eventos. Se fôssemos implementar login "na mão", a tentação seria criar uma tabela usuarios com uma coluna senha. Isso é perigoso por dois motivos:
- Nunca se guarda a senha em texto puro. Se o banco vazar, todas as senhas vazam — e como a maioria das pessoas reutiliza senha entre sites, o estrago vai muito além do seu sistema.
- Hash não é criptografia. Criptografia é reversível (existe uma chave para desfazer). Hash é uma função de mão única: você transforma a senha em uma sequência de caracteres da qual, na prática, não dá para voltar. No login, você faz o hash da senha digitada e compara com o hash guardado — nunca descriptografa nada. Bibliotecas como
bcryptfazem isso com "salt" (um valor aleatório por usuário) para que duas pessoas com a mesma senha não gerem o mesmo hash, e com um custo computacional propositalmente alto, para dificultar ataques de força bruta.
Fazer isso corretamente — hash com salt, custo ajustável, fluxo de "esqueci minha senha", verificação de e-mail, proteção contra força bruta, login social — é trabalho considerável e cheio de detalhes fáceis de errar. Por isso, na disciplina (e em grande parte dos projetos reais de pequeno e médio porte) delegamos a identidade a um provedor especializado: o Firebase Authentication. Ele guarda a senha (com hash correto, num banco que não é o seu), emite um token assinado provando quem é o usuário, e você só precisa validar esse token.
⚠️ Atenção Delegar autenticação não elimina a responsabilidade de proteger seus endpoints. O Firebase resolve "provar quem é o usuário". Decidir "o que esse usuário pode fazer no meu sistema" continua sendo trabalho do seu back-end.
2. Anatomia de um JWT¶
O Firebase (e a grande maioria dos sistemas de autenticação modernos) usa JSON Web Token (JWT) como formato do token de identidade. Um JWT é uma string com três partes separadas por ponto:
eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyJ9.eyJuYW1lIjoiTWFyaWEgU2lsdmEiLCJlbWFpbCI6Im1hcmlhQGV4ZW1wbG8uY29tIiwiYWRtaW4iOnRydWUsImlhdCI6MTc2MTQ4MDAwMCwiZXhwIjoxNzYxNDgzNjAwfQ.QqE8f3s1Zx7pR2nL9mK4vT6wY0aB1cD8eF3gH5iJ7kL
header . payload . assinatura
Cada parte é um objeto codificado em Base64URL. Decodificando as duas primeiras (a assinatura não se decodifica — ela não é Base64 de um JSON, é um bloco de bytes criptográfico):
Header — diz qual algoritmo assinou o token:
{
"alg": "RS256",
"kid": "abc123"
}
Payload — as "claims" (afirmações) sobre o usuário. É aqui que vive a informação:
{
"name": "Maria Silva",
"email": "maria@exemplo.com",
"admin": true,
"iat": 1761480000,
"exp": 1761483600
}
iat (issued at) e exp (expiration) são timestamps Unix. admin é um exemplo de custom claim — vamos usar exatamente isso na seção 6 para autorização.
🔎 Por baixo do capô Um JWT é assinado, não é criptografado. Qualquer pessoa pode pegar esse token e decodificar o header e o payload num site como jwt.io ou com
atob()no console do navegador — não há segredo nenhum escondido ali, e por isso nunca coloque dados sensíveis no payload (senha, número de cartão, CPF). A assinatura (terceira parte) é o que garante que ninguém alterou o conteúdo sem ter a chave privada do emissor. Se você mudar um único caractere do payload — por exemplo, trocar"admin": falsepara"admin": true— a assinatura deixa de bater, e quem valida o token (no nosso caso, ofirebase-adminno back-end) rejeita o token inteiro.
Access token × refresh token¶
O Firebase trabalha com dois tokens:
- ID token (access token): de curta duração (1 hora), é o que você envia em cada requisição para provar identidade. É o JWT que acabamos de decodificar.
- Refresh token: de longa duração, fica guardado pelo SDK do Firebase e é usado automaticamente, nos bastidores, para pedir um novo ID token quando o atual expira — sem exigir que o usuário faça login de novo.
Essa separação existe porque um token de vida curta limita o estrago se ele vazar (ex.: em um log, em uma extensão maliciosa do navegador), enquanto o refresh token, mais sensível, fica protegido e raramente trafega.
📌 Na prova JWT tem três partes (header.payload.assinatura), é codificado em Base64URL (não criptografado) e assinado (não pode ser alterado sem invalidar a assinatura). ID token expira em 1h; o SDK renova sozinho usando o refresh token.
3. Habilitando autenticação no console do Firebase¶
No console do Firebase, projeto do UniEventos:
- Menu lateral → Build → Authentication → Get started.
- Aba Sign-in method → Add new provider.
- Habilite Email/Password (o toggle simples, sem "passwordless").
- Habilite também Google — escolha um e-mail de suporte do projeto e salve.
Isso é configuração de infraestrutura, feita uma vez. O código vem agora.
4. Firebase Auth no front — SDK modular¶
O pacote já está instalado desde a Aula 07 (firebase@12.17.1). Se o seu projeto ainda não tem, instale:
npm install firebase@12.17.1
// src/services/firebase.js
import { initializeApp } from 'firebase/app'
import { getAuth } from 'firebase/auth'
import { getFirestore } from 'firebase/firestore'
// Cole aqui o objeto de configuração exibido em
// Configurações do projeto → Geral → Seus apps → Config SDK.
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
}
const app = initializeApp(firebaseConfig)
export const auth = getAuth(app)
export const db = getFirestore(app)
# .env (na raiz de unieventos-web, sem aspas, sem espaço ao redor do =)
VITE_FIREBASE_API_KEY=AIzaSy...
VITE_FIREBASE_AUTH_DOMAIN=unieventos-xxxxx.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=unieventos-xxxxx
VITE_FIREBASE_STORAGE_BUCKET=unieventos-xxxxx.appspot.com
VITE_FIREBASE_MESSAGING_SENDER_ID=123456789
VITE_FIREBASE_APP_ID=1:123456789:web:abcdef
⚠️ Atenção
firebase.auth()com namespace não existe mais. A única forma correta no SDK 12 é a API modular: importar funções soltas (getAuth,signInWithEmailAndPassword,onAuthStateChangedetc.) de'firebase/auth'e passar a instânciaauthcomo primeiro argumento. Se você encontrar tutorial usandofirebase.auth().signInWithEmailAndPassword(...), está desatualizado — não copie.
Um serviço dedicado para as operações de autenticação, separado do firebase.js de inicialização:
// src/services/authService.js
import {
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
signOut,
onAuthStateChanged,
updateProfile,
sendPasswordResetEmail,
GoogleAuthProvider,
signInWithPopup,
} from 'firebase/auth'
import { auth } from './firebase'
// Traduz os códigos de erro mais comuns do Firebase Auth para mensagens
// em português — o usuário final não precisa saber o que é "auth/weak-password".
const MENSAGENS_ERRO = {
'auth/invalid-credential': 'E-mail ou senha incorretos.',
'auth/invalid-email': 'E-mail em formato inválido.',
'auth/email-already-in-use': 'Este e-mail já está cadastrado.',
'auth/weak-password': 'A senha precisa ter pelo menos 6 caracteres.',
'auth/network-request-failed': 'Falha de conexão. Verifique sua internet.',
'auth/too-many-requests': 'Muitas tentativas seguidas. Aguarde um instante.',
'auth/popup-closed-by-user': 'Janela de login fechada antes de concluir.',
}
function traduzirErro(erro) {
const mensagem = MENSAGENS_ERRO[erro.code]
return mensagem ?? 'Não foi possível concluir a operação. Tente novamente.'
}
export async function cadastrar(nome, email, senha) {
try {
const credencial = await createUserWithEmailAndPassword(auth, email, senha)
// O Firebase não pede nome no cadastro por e-mail/senha — setamos depois.
await updateProfile(credencial.user, { displayName: nome })
return credencial.user
} catch (erro) {
throw new Error(traduzirErro(erro))
}
}
export async function entrar(email, senha) {
try {
const credencial = await signInWithEmailAndPassword(auth, email, senha)
return credencial.user
} catch (erro) {
throw new Error(traduzirErro(erro))
}
}
export async function entrarComGoogle() {
try {
const provedor = new GoogleAuthProvider()
const credencial = await signInWithPopup(auth, provedor)
return credencial.user
} catch (erro) {
throw new Error(traduzirErro(erro))
}
}
export async function sair() {
await signOut(auth)
}
export async function solicitarRedefinicaoSenha(email) {
try {
await sendPasswordResetEmail(auth, email)
} catch (erro) {
throw new Error(traduzirErro(erro))
}
}
// Registra um observador do estado de login. Retorna a função de
// cancelamento — quem chamar deve guardá-la e invocar ao desmontar.
export function observarAutenticacao(callback) {
return onAuthStateChanged(auth, callback)
}
💡 Dica
onAuthStateChangeddispara sempre que o estado de login muda — login, logout, e também na primeira carga da página, depois que o SDK verifica o refresh token salvo no navegador. É esse terceiro caso que vamos explorar na store, a seguir.
5. Store de autenticação: resolvendo o problema do F5¶
Se você guardar o usuário logado só em uma variável reativa comum, ao apertar F5 ela reseta para null — mesmo que o usuário continue logado no Firebase. O SDK vai confirmar isso, mas de forma assíncrona, alguns milissegundos depois do primeiro render. Se o seu guard de rota checar usuario antes desse retorno, ele vai redirecionar um usuário legitimamente logado para a tela de login. É um bug clássico.
A solução: a store expõe uma Promise de inicialização, e o guard de rota aguarda essa Promise antes de decidir.
// src/stores/authStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { observarAutenticacao } from '@/services/authService'
export const useAuthStore = defineStore('auth', () => {
const usuario = ref(null)
const carregando = ref(false)
const inicializado = ref(false)
// Promise única, compartilhada por todos que chamarem inicializar().
// Evita registrar o observador do Firebase mais de uma vez.
let promessaInicializacao = null
function inicializar() {
if (promessaInicializacao) return promessaInicializacao
promessaInicializacao = new Promise((resolve) => {
observarAutenticacao((usuarioFirebase) => {
usuario.value = usuarioFirebase
if (!inicializado.value) {
inicializado.value = true
resolve() // só resolve no PRIMEIRO disparo do observador
}
})
})
return promessaInicializacao
}
const estaLogado = computed(() => usuario.value !== null)
// Custom claim "admin" só aparece depois de setCustomUserClaims (seção 7)
// e de o usuário obter um novo ID token — ver observação na seção 7.
const ehAdmin = computed(() => usuario.value?.customClaims?.admin === true)
return { usuario, carregando, inicializado, inicializar, estaLogado, ehAdmin }
})
🔎 Por baixo do capô
onAuthStateChangeddispara de novo toda vez que o token é renovado, mas resolvemos a Promise só na primeira vez (if (!inicializado.value)). Depois disso, os componentes que precisam de reatividade (menu, header) simplesmente leemusuarioeestaLogado, que são refs/computed normais e continuam atualizando sozinhos.
ehAdmin como escrito acima lê customClaims diretamente do objeto User do Firebase, que não expõe essa propriedade por padrão — claims custom exigem decodificar o ID token (getIdTokenResult). Ajustamos isso corretamente na seção 7, depois de explicar custom claims no back-end; por ora, mantenha o getter, ele será completado adiante.
6. Protegendo rotas no Vue Router¶
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue'),
},
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
},
{
path: '/cadastro',
name: 'cadastro',
component: () => import('@/views/CadastroView.vue'),
},
{
path: '/minhas-inscricoes',
name: 'minhas-inscricoes',
component: () => import('@/views/MinhasInscricoesView.vue'),
meta: { requerAuth: true },
},
{
path: '/admin/eventos',
name: 'admin-eventos',
component: () => import('@/views/admin/EventosAdminView.vue'),
meta: { requerAuth: true, requerAdmin: true },
},
],
})
router.beforeEach(async (to) => {
const authStore = useAuthStore()
// Aguarda o primeiro retorno do onAuthStateChanged antes de decidir
// qualquer coisa — sem isso, um F5 numa rota protegida redireciona
// para /login mesmo com o usuário já autenticado.
await authStore.inicializar()
if (to.meta.requerAuth && !authStore.estaLogado) {
return {
name: 'login',
query: { redirect: to.fullPath },
}
}
if (to.meta.requerAdmin && !authStore.ehAdmin) {
return { name: 'home' }
}
return true
})
export default router
Depois do login, a tela de login redireciona de volta para onde o usuário queria ir:
<!-- src/views/LoginView.vue (trecho de script) -->
<script setup>
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
async function aoLogarComSucesso() {
const destino = route.query.redirect || '/'
router.push(destino)
}
</script>
Escondendo itens de menu conforme o estado de login:
<!-- src/components/BarraNavegacao.vue -->
<script setup>
import { useAuthStore } from '@/stores/authStore'
import { sair } from '@/services/authService'
import { useRouter } from 'vue-router'
const authStore = useAuthStore()
const router = useRouter()
async function aoClicarSair() {
await sair()
router.push('/login')
}
</script>
<template>
<v-app-bar>
<v-app-bar-title>UniEventos</v-app-bar-title>
<template v-if="!authStore.estaLogado">
<v-btn to="/login">Entrar</v-btn>
<v-btn to="/cadastro">Cadastrar</v-btn>
</template>
<template v-else>
<v-btn v-if="authStore.ehAdmin" to="/admin/eventos">Administração</v-btn>
<v-btn to="/minhas-inscricoes">Minhas inscrições</v-btn>
<v-menu>
<template #activator="{ props }">
<v-avatar v-bind="props" class="mr-2" style="cursor: pointer">
<v-img
v-if="authStore.usuario?.photoURL"
:src="authStore.usuario.photoURL"
:alt="authStore.usuario.displayName ?? 'Avatar do usuário'"
/>
<span v-else>{{ authStore.usuario?.email?.[0]?.toUpperCase() }}</span>
</v-avatar>
</template>
<v-list>
<v-list-item :title="authStore.usuario?.displayName ?? authStore.usuario?.email" />
<v-list-item title="Sair" @click="aoClicarSair" />
</v-list>
</v-menu>
</template>
</v-app-bar>
</template>
⚠️ Atenção Um
beforeEachno Router impede que a interface mostre a tela protegida — mas qualquer pessoa pode desligar o JavaScript, chamar a API diretamente comcurlou editar o guard no DevTools. Guard de rota é UX, não segurança. A única barreira real está no back-end, validando o token em cada requisição — é o que vem na seção 7.
🧩 Padrão de projeto em uso: Proxy de proteção + Guard¶
O Proxy de proteção (variação estrutural do padrão Proxy) intercepta o acesso a um objeto real e decide se o acesso é permitido antes de repassar a chamada. É exatamente o papel do middleware autenticar que construímos na seção 7: ele fica na frente do controller real, verifica credenciais, e só deixa a chamada prosseguir se o token for válido — o controller nunca sabe que existe um "porteiro" antes dele.
O Guard (aqui usado no sentido do Vue Router — um "guarda de rota" comportamental, correlato ao Proxy de proteção do lado do front) cumpre o mesmo papel do lado da navegação: intercepta a transição de rota e decide, antes de renderizar, se ela deve prosseguir, ser bloqueada ou redirecionada. Repare que os dois padrões resolvem o mesmo problema — controlar acesso — em duas camadas diferentes da aplicação, e nenhum substitui o outro.
7. Enviando o token em cada requisição¶
O usuário logado no Firebase tem um método getIdToken() que devolve o JWT atual (renovando-o automaticamente se estiver perto de expirar). Plugamos isso no interceptor de requisição do Axios, criado na Aula 06:
// src/services/api.js
import axios from 'axios'
import { auth } from './firebase'
import router from '@/router'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:3000/api',
})
api.interceptors.request.use(async (config) => {
const usuarioAtual = auth.currentUser
if (usuarioAtual) {
// getIdToken() usa o cache do SDK; só bate na rede do Firebase
// quando o token está perto de expirar (renovação automática).
const token = await usuarioAtual.getIdToken()
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(resposta) => resposta,
(erro) => {
if (erro.response?.status === 401) {
// Token ausente, inválido ou expirado sem chance de renovação
// automática (ex.: usuário revogado no console). Mandamos para
// o login preservando a rota atual.
router.push({ name: 'login', query: { redirect: router.currentRoute.value.fullPath } })
}
return Promise.reject(erro)
},
)
export default api
💡 Dica Não é preciso gerenciar expiração de token manualmente. O SDK do Firebase renova o ID token sozinho (usando o refresh token) sempre que
getIdToken()é chamado e o token atual está a menos de 5 minutos de expirar. O interceptor de requisição, ao chamargetIdToken()antes de cada chamada, já se beneficia disso de graça.
8. Verificando o token no back-end com firebase-admin¶
Do lado do cliente, qualquer um pode afirmar ser quem quiser — inclusive forjar um cabeçalho Authorization. A prova de identidade real só existe quando o back-end valida a assinatura do token contra as chaves públicas do Firebase. É isso que o pacote firebase-admin faz.
8.1 Gerando a chave de conta de serviço¶
No console do Firebase: Configurações do projeto → Contas de serviço → Gerar nova chave privada. Isso baixa um .json com credenciais completas de administrador do projeto — trate como uma senha.
# unieventos-api/.gitignore
node_modules/
.env
serviceAccountKey.json
⚠️ Atenção
serviceAccountKey.jsonnunca vai para o Git. Se você já commitou por engano, o arquivo precisa ser considerado comprometido: revogue a chave no console (Contas de serviço → gerenciar chaves) e gere outra. Em produção (Render, Railway etc.) prefira uma variável de ambiente com o JSON inteiro codificado em base64, decodificada na inicialização — assim nenhum arquivo sensível precisa existir no disco do servidor.
# instalação, versão travada conforme especificação da disciplina
npm install firebase-admin@14.2.0
// unieventos-api/src/config/firebaseAdmin.js
import { initializeApp, cert, getApps } from 'firebase-admin/app'
import { getAuth } from 'firebase-admin/auth'
import fs from 'node:fs'
function carregarCredencial() {
// Em produção: variável de ambiente com o JSON em base64.
if (process.env.FIREBASE_SERVICE_ACCOUNT_BASE64) {
const json = Buffer.from(process.env.FIREBASE_SERVICE_ACCOUNT_BASE64, 'base64').toString('utf-8')
return JSON.parse(json)
}
// Em desenvolvimento: arquivo local, fora do Git.
const conteudo = fs.readFileSync(new URL('../../serviceAccountKey.json', import.meta.url), 'utf-8')
return JSON.parse(conteudo)
}
// getApps() evita inicializar duas vezes se este módulo for importado
// em mais de um lugar (ex.: em testes).
if (getApps().length === 0) {
initializeApp({ credential: cert(carregarCredencial()) })
}
export const authAdmin = getAuth()
8.2 Middleware autenticar¶
// unieventos-api/src/middlewares/autenticar.js
import { authAdmin } from '../config/firebaseAdmin.js'
export async function autenticar(req, res, next) {
const cabecalho = req.headers.authorization
if (!cabecalho?.startsWith('Bearer ')) {
return res.status(401).json({ erro: 'Token de autenticação ausente.' })
}
const token = cabecalho.replace('Bearer ', '')
try {
const tokenDecodificado = await authAdmin.verifyIdToken(token)
// Popula req.usuario para os middlewares e controllers seguintes
// usarem — igual fizemos com req.body validado na Aula 08.
req.usuario = {
uid: tokenDecodificado.uid,
email: tokenDecodificado.email,
admin: tokenDecodificado.admin === true, // custom claim, seção 8.4
}
next()
} catch (erro) {
// Cobre token expirado, assinatura inválida, token forjado etc.
return res.status(401).json({ erro: 'Token inválido ou expirado.' })
}
}
Express 5 captura erros de handlers async automaticamente (Aula 08), mas aqui usamos try/catch de propósito: um token inválido não é um erro inesperado do servidor (500), é uma resposta de negócio esperada (401). Deixar o errorHandler central tratar isso como 500 estaria errado.
8.3 Middleware autorizar¶
// unieventos-api/src/middlewares/autorizar.js
export function autorizar(papeis = []) {
return (req, res, next) => {
if (!req.usuario) {
// autenticar() deve sempre rodar antes de autorizar() na cadeia
return res.status(401).json({ erro: 'Token de autenticação ausente.' })
}
const temPermissao = papeis.includes('admin') ? req.usuario.admin : true
if (!temPermissao) {
return res.status(403).json({ erro: 'Você não tem permissão para esta ação.' })
}
next()
}
}
🔎 Por baixo do capô 401 (Unauthorized) significa "eu não sei quem você é" — token ausente ou inválido. 403 (Forbidden) significa "eu sei quem você é, mas você não pode fazer isso" — token válido, mas sem a permissão necessária. Misturar os dois confunde quem está depurando o front.
8.4 Custom claims: marcando um usuário como admin¶
Custom claims são pares chave-valor extras que o Firebase embute no payload do JWT, definidos pelo back-end (nunca pelo próprio usuário). Um script único, rodado manualmente, promove um usuário a administrador:
// unieventos-api/scripts/promoverAdmin.js
// Uso: node scripts/promoverAdmin.js email@exemplo.com
import '../src/config/firebaseAdmin.js'
import { getAuth } from 'firebase-admin/auth'
const email = process.argv[2]
if (!email) {
console.error('Uso: node scripts/promoverAdmin.js <email>')
process.exit(1)
}
const auth = getAuth()
const usuario = await auth.getUserByEmail(email)
await auth.setCustomUserClaims(usuario.uid, { admin: true })
console.log(`${email} agora é administrador.`)
node scripts/promoverAdmin.js professor@unemat.br
⚠️ Atenção Custom claims só aparecem em um novo ID token. Se o usuário já estava logado quando você rodou o script, ele precisa deslogar e logar de novo (ou o front precisa forçar
getIdToken(true), comtruepedindo renovação forçada) para o token trazeradmin: true. É um erro comum: "rodei o script e continua sem permissão" — o token antigo, em cache no navegador, simplesmente ainda não tem a claim.
Com isso, completamos o ehAdmin da store (seção 5), que ficou pendente. A forma correta de ler a claim no front é via getIdTokenResult(), não pela propriedade customClaims (que não existe no objeto User):
// src/stores/authStore.js — ajuste da action inicializar()
function inicializar() {
if (promessaInicializacao) return promessaInicializacao
promessaInicializacao = new Promise((resolve) => {
observarAutenticacao(async (usuarioFirebase) => {
if (usuarioFirebase) {
const resultado = await usuarioFirebase.getIdTokenResult()
usuario.value = usuarioFirebase
ehAdminClaim.value = resultado.claims.admin === true
} else {
usuario.value = null
ehAdminClaim.value = false
}
if (!inicializado.value) {
inicializado.value = true
resolve()
}
})
})
return promessaInicializacao
}
// e trocar o computed ehAdmin por uma ref simples atualizada acima
const ehAdminClaim = ref(false)
const ehAdmin = computed(() => ehAdminClaim.value)
8.5 Aplicando nos endpoints de eventos¶
// unieventos-api/src/routes/eventosRoutes.js
import { Router } from 'express'
import { autenticar } from '../middlewares/autenticar.js'
import { autorizar } from '../middlewares/autorizar.js'
import * as eventosController from '../controllers/eventosController.js'
const router = Router()
// Leitura pública — qualquer visitante, sem token, vê os eventos
router.get('/', eventosController.listar)
router.get('/:id', eventosController.buscarPorId)
// Escrita exige apenas estar autenticado
router.post('/', autenticar, eventosController.criar)
router.put('/:id', autenticar, eventosController.atualizar)
// Exclusão exige estar autenticado E ser admin
router.delete('/:id', autenticar, autorizar(['admin']), eventosController.remover)
export default router
💻 Mão na massa — telas de autenticação completas¶
Passo 1 — Tela de cadastro¶
<!-- src/views/CadastroView.vue -->
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { cadastrar } from '@/services/authService'
const router = useRouter()
const nome = ref('')
const email = ref('')
const senha = ref('')
const confirmarSenha = ref('')
const erro = ref('')
const carregando = ref(false)
const regraObrigatorio = (v) => !!v || 'Campo obrigatório'
const regraEmail = (v) => /.+@.+\..+/.test(v) || 'E-mail inválido'
const regraSenhaMinima = (v) => v.length >= 6 || 'Mínimo de 6 caracteres'
const regraSenhasIguais = (v) => v === senha.value || 'As senhas não coincidem'
async function aoSubmeter() {
erro.value = ''
carregando.value = true
try {
await cadastrar(nome.value, email.value, senha.value)
router.push('/')
} catch (e) {
erro.value = e.message
} finally {
carregando.value = false
}
}
</script>
<template>
<v-container class="d-flex align-center justify-center" style="min-height: 80vh">
<v-card max-width="420" width="100%" class="pa-4">
<v-card-title>Criar conta</v-card-title>
<v-form @submit.prevent="aoSubmeter">
<v-card-text>
<v-alert v-if="erro" type="error" class="mb-4" density="compact">
{{ erro }}
</v-alert>
<v-text-field
v-model="nome"
label="Nome completo"
:rules="[regraObrigatorio]"
/>
<v-text-field
v-model="email"
label="E-mail"
type="email"
:rules="[regraObrigatorio, regraEmail]"
/>
<v-text-field
v-model="senha"
label="Senha"
type="password"
:rules="[regraObrigatorio, regraSenhaMinima]"
/>
<v-text-field
v-model="confirmarSenha"
label="Confirmar senha"
type="password"
:rules="[regraObrigatorio, regraSenhasIguais]"
/>
</v-card-text>
<v-card-actions>
<v-btn type="submit" color="primary" block :loading="carregando">
Cadastrar
</v-btn>
</v-card-actions>
</v-form>
<v-card-text class="text-center">
Já tem conta? <router-link to="/login">Entrar</router-link>
</v-card-text>
</v-card>
</v-container>
</template>
Passo 2 — Tela de login, com Google e redirecionamento¶
<!-- src/views/LoginView.vue -->
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { entrar, entrarComGoogle, solicitarRedefinicaoSenha } from '@/services/authService'
const route = useRoute()
const router = useRouter()
const email = ref('')
const senha = ref('')
const erro = ref('')
const mensagemSucesso = ref('')
const carregando = ref(false)
function irParaDestino() {
const destino = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
router.push(destino)
}
async function aoSubmeter() {
erro.value = ''
carregando.value = true
try {
await entrar(email.value, senha.value)
irParaDestino()
} catch (e) {
erro.value = e.message
} finally {
carregando.value = false
}
}
async function aoClicarGoogle() {
erro.value = ''
carregando.value = true
try {
await entrarComGoogle()
irParaDestino()
} catch (e) {
erro.value = e.message
} finally {
carregando.value = false
}
}
async function aoEsquecerSenha() {
erro.value = ''
mensagemSucesso.value = ''
if (!email.value) {
erro.value = 'Informe o e-mail para receber o link de redefinição.'
return
}
try {
await solicitarRedefinicaoSenha(email.value)
mensagemSucesso.value = 'Enviamos um link de redefinição para o seu e-mail.'
} catch (e) {
erro.value = e.message
}
}
</script>
<template>
<v-container class="d-flex align-center justify-center" style="min-height: 80vh">
<v-card max-width="420" width="100%" class="pa-4">
<v-card-title>Entrar</v-card-title>
<v-form @submit.prevent="aoSubmeter">
<v-card-text>
<v-alert v-if="erro" type="error" class="mb-4" density="compact">
{{ erro }}
</v-alert>
<v-alert v-if="mensagemSucesso" type="success" class="mb-4" density="compact">
{{ mensagemSucesso }}
</v-alert>
<v-text-field v-model="email" label="E-mail" type="email" />
<v-text-field v-model="senha" label="Senha" type="password" />
<v-btn variant="text" size="small" @click="aoEsquecerSenha">
Esqueci minha senha
</v-btn>
</v-card-text>
<v-card-actions class="flex-column">
<v-btn type="submit" color="primary" block :loading="carregando">
Entrar
</v-btn>
<v-btn
variant="outlined"
block
class="mt-2"
prepend-icon="mdi-google"
:loading="carregando"
@click="aoClicarGoogle"
>
Entrar com Google
</v-btn>
</v-card-actions>
</v-form>
<v-card-text class="text-center">
Não tem conta? <router-link to="/cadastro">Cadastrar</router-link>
</v-card-text>
</v-card>
</v-container>
</template>
Passo 3 — Inicializando a store no main.js¶
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { useAuthStore } from '@/stores/authStore'
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
import { createVuetify } from 'vuetify'
const vuetify = createVuetify({ theme: { defaultTheme: 'light' } })
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(vuetify)
// Dispara a inicialização o quanto antes; o router aguarda a mesma
// Promise no beforeEach, então não há corrida entre os dois.
useAuthStore().inicializar()
app.mount('#app')
Passo 4 — Área administrativa protegida¶
<!-- src/views/admin/EventosAdminView.vue -->
<script setup>
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/authStore'
const authStore = useAuthStore()
onMounted(() => {
// Se chegou até aqui, o guard de rota já garantiu requerAuth + requerAdmin.
console.log('Admin logado:', authStore.usuario.email)
})
</script>
<template>
<v-container>
<h1 class="text-h4 mb-4">Administração de eventos</h1>
<p>Bem-vindo(a), {{ authStore.usuario?.displayName ?? authStore.usuario?.email }}.</p>
<!-- CRUD completo de eventos vem na Aula 11 -->
</v-container>
</template>
Passo 5 — Testando a API manualmente¶
Sem token — deve retornar 401:
curl -i http://localhost:3000/api/eventos -X POST \
-H "Content-Type: application/json" \
-d '{"titulo":"Semana da Computação"}'
Com token inválido (qualquer string) — também 401:
curl -i http://localhost:3000/api/eventos -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer token-forjado-qualquer" \
-d '{"titulo":"Semana da Computação"}'
Com token válido — copie o token real do DevTools (aba Network, requisição feita pelo front logado, cabeçalho Authorization) e cole aqui:
curl -i http://localhost:3000/api/eventos -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer COLE_O_TOKEN_AQUI" \
-d '{"titulo":"Semana da Computação","categoria":"palestra","data_hora":"2026-12-01T19:00:00","local":"Auditório","vagas":80}'
Tentando excluir sem ser admin (usuário autenticado comum) — deve retornar 403:
curl -i http://localhost:3000/api/eventos/1 -X DELETE \
-H "Authorization: Bearer TOKEN_DE_USUARIO_COMUM"
🧪 Laboratório¶
1. Cadastro e login funcionando. Crie uma conta pelo formulário de cadastro do seu projeto autoral, faça logout e faça login de novo.
Dica
Abra o DevTools → Application → verifique se há chaves salvas pelo Firebase no IndexedDB/LocalStorage após o login.2. Login com Google. Habilite o provedor Google no console e teste entrarComGoogle().
Dica
Se o popup fechar sozinho sem erro visível, confira o console — geralmente é domínio não autorizado em Authentication → Settings → Authorized domains.3. Rota protegida. Crie uma rota meta: { requerAuth: true } no seu projeto e confirme que, deslogado, você é redirecionado para /login?redirect=... e volta para a rota certa após logar.
Dica
Teste apertando F5 na rota protegida já logado — não pode redirecionar para login.4. Middleware autenticar na API. Proteja um endpoint de escrita do seu projeto autoral e teste os três cenários de curl da seção anterior.
Dica
Um token expira em 1h — se testar depois de muito tempo, gere outro logando de novo no front.5. Custom claim de admin. Rode o script promoverAdmin.js com seu próprio e-mail, deslogue e logue de novo, e confirme que authStore.ehAdmin fica true e que o menu de administração aparece.
Dica
Se continuar `false`, o token em cache é o antigo — force `getIdTokenResult(true)` ou deslogue mesmo.🐛 Erros comuns e como resolver¶
| Sintoma | Causa | Solução |
|---|---|---|
| 401 mesmo logado no front | Interceptor não aguardou getIdToken() (esqueceu await) |
Confirme que a função do interceptor é async e usa await usuarioAtual.getIdToken() |
| Guard redireciona para login mesmo autenticado, só no F5 | beforeEach não aguardou inicializar() |
Adicione await authStore.inicializar() como primeira linha do guard |
| "auth/network-request-failed" | Sem internet, ou domínio bloqueado por extensão/firewall | Verificar conexão; testar em aba anônima sem extensões |
| Token válido no Postman mas 401 na API | Relógio do servidor fora de sincronia (token "ainda não válido" ou "expirado" por diferença de horário) | Sincronizar o relógio do servidor (NTP); em nuvem isso raramente acontece, mas em VM local pode |
CORS bloqueia a requisição com Authorization |
cors() no Express sem liberar o header customizado |
Configurar cors({ origin: 'http://localhost:5173', allowedHeaders: ['Content-Type', 'Authorization'] }) |
admin sempre false mesmo após setCustomUserClaims |
Token antigo em cache, claim não propagada | Deslogar e logar de novo, ou getIdTokenResult(true) para forçar renovação |
req.usuario é undefined no controller |
autorizar usado sem autenticar antes na cadeia de middlewares |
Sempre montar a rota como autenticar, autorizar([...]), nessa ordem |
🏠 Atividade assíncrona (1 h)¶
No seu projeto autoral: implemente cadastro, login, logout e proteção de pelo menos uma rota do front (requerAuth: true) e um endpoint de escrita da API (autenticar). Grave um GIF ou vídeo curto (menos de 1 minuto) mostrando: (1) tentativa de acessar a rota protegida deslogado sendo redirecionada, (2) login, (3) acesso liberado, (4) curl sem token retornando 401. Suba o material (código + evidência) no repositório do projeto e envie o link pelo SIGAA.
Critério de pronto: os quatro passos do vídeo aparecem, e o commit com a implementação está no repositório.
✅ Checkpoint do projeto autoral¶
- [ ]
src/services/firebase.jsinicializado com variáveis de ambiente (nada de chave hardcoded no código). - [ ] Cadastro, login, logout e login com Google funcionando na interface.
- [ ]
stores/authStore.jscominicializar(),estaLogadoeehAdminimplementados corretamente. - [ ] Pelo menos uma rota protegida com
meta: { requerAuth: true }funcionando após F5. - [ ] Interceptor Axios enviando
Authorization: Bearer <token>em toda requisição autenticada. - [ ] API com
firebase-adminconfigurado eserviceAccountKey.jsonfora do Git. - [ ] Pelo menos um endpoint de escrita protegido por
autenticar, e um porautorizar(['admin']). - [ ] Testes manuais com
curl(sem token, token inválido, token válido) documentados.
📚 Para aprofundar¶
- Firebase Auth — Web (modular)
- Firebase Auth — gerenciar usuários com o Admin SDK
- Custom claims — documentação oficial
- jwt.io — debugger de JWT
- RFC 7519 — JSON Web Token
- Plano de curso, Unidade 3: Firebase, autenticação e banco de dados.
Na Aula 11 fechamos o ciclo: CRUD completo de eventos, ponta a ponta, autenticado — Vue consumindo a API Express, que persiste no MySQL, tudo validado com o token do Firebase que construímos hoje.
Aula 11 — Integrando front-end com back-end: CRUD
Na Aula 10 o UniEventos passou a exigir token do Firebase para escrever dados, e a API passou a validar esse token com firebase-admin. Todas as peças já existem separadas: Vue no front, Express no back, MySQL persistindo, Firebase autenticando. Hoje é a aula de fechar o ciclo — o CRUD completo de eventos, ponta a ponta, com as duas pontas conversando por um contrato bem definido.
🎯 Objetivos de aprendizagem¶
Ao final desta aula você será capaz de:
- Documentar um contrato de API em tabela, cobrindo os 5 endpoints de um recurso CRUD.
- Completar a camada controller → service → repository com regras de negócio, paginação e busca.
- Construir uma camada
services/no front alinhada exatamente ao contrato da API. - Implementar uma store Pinia de domínio com estados de lista, item atual, carregamento e erro.
- Diferenciar atualização otimista de pessimista e justificar a escolha da pessimista para o projeto.
- Construir telas de listagem, formulário (criar/editar na mesma tela) e confirmação de exclusão em Vuetify.
- Depurar uma integração front-back usando a aba Network do DevTools e reproduzir requisições em
curl.
📋 Pré-requisitos desta aula¶
Checklist antes de começar:
- [ ]
unieventos-apicom autenticação Firebase funcionando (Aula 10) e CRUD básico de eventos no MySQL (Aula 09). - [ ]
unieventos-webcom Pinia, Vue Router, Vuetify eauthStorefuncionando (Aulas 05–10). - [ ] MySQL rodando localmente com a tabela
eventoscriada. - [ ]
unieventos-apieunieventos-webrodando em portas diferentes (ex.:3000e5173) — vamos revisitar CORS.
🗺️ Roteiro¶
| Bloco | Tempo | Atividade |
|---|---|---|
| 1 | 50 min | Contrato de API; completar back-end (validação, regras de negócio, paginação, busca) |
| 2 | 50 min | eventosService.js, eventosStore.js, atualização otimista × pessimista, telas de listagem e formulário |
| 3 | 50 min | Upload de imagem; depuração ponta a ponta (Network, curl, CORS); laboratório |
1. Contrato de API: o acordo entre as duas pontas¶
Antes de escrever uma linha de código de integração, front e back precisam concordar sobre um contrato: para cada endpoint, qual método HTTP, qual caminho, o que vai no corpo da requisição, o que volta na resposta, quais status e se exige autenticação. É esse contrato — não o código de um lado ou de outro — que permite que duas pessoas (ou você, em momentos diferentes) trabalhem em front e back sem precisar ler o código um do outro toda hora.
Contrato do recurso evento:
| Método | Caminho | Autenticação |
|---|---|---|
| GET | /api/eventos |
Pública |
| GET | /api/eventos/:id |
Pública |
| POST | /api/eventos |
Autenticado |
| PUT | /api/eventos/:id |
Autenticado |
| DELETE | /api/eventos/:id |
Admin |
Detalhando corpo e resposta de cada um:
GET /api/eventos — lista paginada, com filtros por query string.
Query string: ?pagina=1&limite=10&busca=semana&categoria=palestra
{
"dados": [
{
"id": 1,
"titulo": "Semana da Computação",
"descricao": "Palestras e minicursos de tecnologia",
"categoria": "palestra",
"data_hora": "2026-12-01T19:00:00.000Z",
"local": "Auditório Central",
"vagas": 80,
"vagas_disponiveis": 62,
"imagem_url": "https://.../semana-computacao.jpg"
}
],
"paginacao": { "pagina": 1, "limite": 10, "total": 34, "totalPaginas": 4 }
}
Status: 200 OK.
GET /api/eventos/:id — um evento. Status 200 OK ou 404 Not Found com { "erro": "Evento não encontrado." }.
POST /api/eventos — corpo:
{
"titulo": "Semana da Computação",
"descricao": "Palestras e minicursos de tecnologia",
"categoria": "palestra",
"data_hora": "2026-12-01T19:00:00",
"local": "Auditório Central",
"vagas": 80,
"imagem_url": "https://.../semana-computacao.jpg"
}
Resposta: o evento criado, com id, status 201 Created. Erros de validação: 400 Bad Request com { "erro": "...", "detalhes": [...] }. Sem token: 401.
PUT /api/eventos/:id — mesmo corpo do POST (campos parciais também aceitos). Resposta: o evento atualizado, 200 OK. Sem token: 401. Não é dono nem admin: decisão de negócio do projeto (aqui, qualquer autenticado pode editar — ver seção 3). Evento inexistente: 404.
DELETE /api/eventos/:id — sem corpo. Resposta: 204 No Content. Sem token: 401. Sem ser admin: 403. Evento com inscritos: 409 Conflict com { "erro": "Não é possível excluir evento com inscritos." }.
💡 Dica Escreva esse contrato antes de codificar, mesmo sozinho. Ele vira a fonte da verdade quando front e back divergem — e em equipes reais costuma virar um arquivo OpenAPI/Swagger, que veremos na Aula 14. Por ora, uma tabela em Markdown já resolve.
2. Back-end: completando controller → service → repository¶
Revisamos a estrutura da Aula 09 e adicionamos: validação com zod, regras de negócio, paginação e busca.
2.1 Repository — só acesso a dados, sem regra de negócio¶
// unieventos-api/src/repositories/eventosRepository.js
import { pool } from '../config/database.js'
export async function listar({ pagina, limite, busca, categoria }) {
const offset = (pagina - 1) * limite
const condicoes = []
const parametros = []
if (busca) {
condicoes.push('(titulo LIKE ? OR descricao LIKE ?)')
parametros.push(`%${busca}%`, `%${busca}%`)
}
if (categoria) {
condicoes.push('categoria = ?')
parametros.push(categoria)
}
const clausulaWhere = condicoes.length > 0 ? `WHERE ${condicoes.join(' AND ')}` : ''
const [linhas] = await pool.query(
`SELECT e.*, (e.vagas - COALESCE(COUNT(i.id), 0)) AS vagas_disponiveis
FROM eventos e
LEFT JOIN inscricoes i ON i.evento_id = e.id
${clausulaWhere}
GROUP BY e.id
ORDER BY e.data_hora ASC
LIMIT ? OFFSET ?`,
[...parametros, limite, offset],
)
const [[{ total }]] = await pool.query(
`SELECT COUNT(*) AS total FROM eventos e ${clausulaWhere}`,
parametros,
)
return { linhas, total }
}
export async function buscarPorId(id) {
const [linhas] = await pool.query(
`SELECT e.*, (e.vagas - COALESCE(COUNT(i.id), 0)) AS vagas_disponiveis
FROM eventos e
LEFT JOIN inscricoes i ON i.evento_id = e.id
WHERE e.id = ?
GROUP BY e.id`,
[id],
)
return linhas[0] ?? null
}
export async function contarInscritos(id, conexao = pool) {
const [[{ total }]] = await conexao.query(
'SELECT COUNT(*) AS total FROM inscricoes WHERE evento_id = ?',
[id],
)
return total
}
export async function criar(evento) {
const [resultado] = await pool.query(
`INSERT INTO eventos (titulo, descricao, categoria, data_hora, local, vagas, imagem_url)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
evento.titulo,
evento.descricao,
evento.categoria,
evento.data_hora,
evento.local,
evento.vagas,
evento.imagem_url ?? null,
],
)
return buscarPorId(resultado.insertId)
}
export async function atualizar(id, evento) {
await pool.query(
`UPDATE eventos
SET titulo = ?, descricao = ?, categoria = ?, data_hora = ?, local = ?, vagas = ?, imagem_url = ?
WHERE id = ?`,
[
evento.titulo,
evento.descricao,
evento.categoria,
evento.data_hora,
evento.local,
evento.vagas,
evento.imagem_url ?? null,
id,
],
)
return buscarPorId(id)
}
export async function remover(id) {
await pool.query('DELETE FROM eventos WHERE id = ?', [id])
}
export async function decrementarVagaEmTransacao(id) {
// Transação: ler vagas disponíveis e inserir a inscrição são duas
// operações que precisam ser atômicas — senão dois usuários podem
// "ganhar" a última vaga ao mesmo tempo (condição de corrida).
const conexao = await pool.getConnection()
try {
await conexao.beginTransaction()
const [linhas] = await conexao.query(
'SELECT vagas, (SELECT COUNT(*) FROM inscricoes WHERE evento_id = ?) AS inscritos FROM eventos WHERE id = ? FOR UPDATE',
[id, id],
)
const evento = linhas[0]
if (!evento) {
await conexao.rollback()
throw new Error('EVENTO_NAO_ENCONTRADO')
}
if (evento.inscritos >= evento.vagas) {
await conexao.rollback()
throw new Error('SEM_VAGAS')
}
await conexao.commit()
return true
} catch (erro) {
await conexao.rollback()
throw erro
} finally {
conexao.release()
}
}
🔎 Por baixo do capô
FOR UPDATEtrava a linha lida até o fim da transação, impedindo que outra requisição simultânea leia o mesmo número de vagas antes do commit. Sem isso, duas requisições concorrentes poderiam ambas ler "1 vaga disponível" e ambas inserirem a inscrição, estourando a capacidade do evento.
2.2 Service — regras de negócio e validação¶
// unieventos-api/src/services/eventosService.js
import { z } from 'zod'
import * as eventosRepository from '../repositories/eventosRepository.js'
export const esquemaEvento = z.object({
titulo: z.string().trim().min(3, 'Título precisa ter ao menos 3 caracteres'),
descricao: z.string().trim().min(10, 'Descrição precisa ter ao menos 10 caracteres'),
categoria: z.enum(['palestra', 'minicurso', 'workshop'], {
message: 'Categoria precisa ser palestra, minicurso ou workshop',
}),
data_hora: z
.string()
.datetime({ offset: true, message: 'Data e hora em formato ISO inválido' })
.or(z.string().min(1)) // aceita também "2026-12-01T19:00:00" sem offset
.refine((valor) => !Number.isNaN(Date.parse(valor)), 'Data e hora inválidas')
.refine((valor) => new Date(valor).getTime() > Date.now(), 'A data do evento não pode estar no passado'),
local: z.string().trim().min(3, 'Local precisa ter ao menos 3 caracteres'),
vagas: z.number().int().positive('Vagas precisa ser um número positivo'),
imagem_url: z.url('URL de imagem inválida').optional().or(z.literal('')),
})
export const esquemaEventoParcial = esquemaEvento.partial()
export async function listar({ pagina = 1, limite = 10, busca, categoria }) {
const paginaSegura = Math.max(1, Number(pagina))
const limiteSeguro = Math.min(50, Math.max(1, Number(limite)))
const { linhas, total } = await eventosRepository.listar({
pagina: paginaSegura,
limite: limiteSeguro,
busca,
categoria,
})
return {
dados: linhas,
paginacao: {
pagina: paginaSegura,
limite: limiteSeguro,
total,
totalPaginas: Math.ceil(total / limiteSeguro),
},
}
}
export async function buscarPorId(id) {
const evento = await eventosRepository.buscarPorId(id)
if (!evento) {
const erro = new Error('Evento não encontrado.')
erro.status = 404
throw erro
}
return evento
}
export async function criar(dadosBrutos) {
const dados = esquemaEvento.parse(dadosBrutos)
return eventosRepository.criar(dados)
}
export async function atualizar(id, dadosBrutos) {
await buscarPorId(id) // garante 404 antes de tentar validar/atualizar
const dados = esquemaEvento.parse(dadosBrutos)
return eventosRepository.atualizar(id, dados)
}
export async function remover(id) {
await buscarPorId(id)
const inscritos = await eventosRepository.contarInscritos(id)
if (inscritos > 0) {
const erro = new Error('Não é possível excluir evento com inscritos.')
erro.status = 409
throw erro
}
await eventosRepository.remover(id)
}
2.3 Controller — só orquestra requisição/resposta¶
// unieventos-api/src/controllers/eventosController.js
import * as eventosService from '../services/eventosService.js'
export async function listar(req, res) {
const { pagina, limite, busca, categoria } = req.query
const resultado = await eventosService.listar({ pagina, limite, busca, categoria })
res.json(resultado)
}
export async function buscarPorId(req, res) {
const evento = await eventosService.buscarPorId(req.params.id)
res.json(evento)
}
export async function criar(req, res) {
const evento = await eventosService.criar(req.body)
res.status(201).json(evento)
}
export async function atualizar(req, res) {
const evento = await eventosService.atualizar(req.params.id, req.body)
res.json(evento)
}
export async function remover(req, res) {
await eventosService.remover(req.params.id)
res.status(204).send()
}
Sem try/catch nos controllers: Express 5 encaminha automaticamente qualquer rejeição de handler async para o middleware de erro central, criado na Aula 08. Só precisamos garantir que esse middleware trate ZodError (400), erros com .status customizado (404, 409) e, por padrão, 500:
// unieventos-api/src/middlewares/tratadorErros.js
import { ZodError } from 'zod'
export function tratadorErros(erro, req, res, next) {
if (erro instanceof ZodError) {
return res.status(400).json({
erro: 'Dados inválidos.',
detalhes: erro.issues.map((i) => ({ campo: i.path.join('.'), mensagem: i.message })),
})
}
if (erro.status) {
return res.status(erro.status).json({ erro: erro.message })
}
console.error(erro)
res.status(500).json({ erro: 'Erro interno do servidor.' })
}
2.4 Rotas com validação por middleware Zod¶
Reaproveitando o padrão de validação da Aula 08, mas agora com o esquema parcial para PUT:
// unieventos-api/src/middlewares/validar.js
export function validar(esquema) {
return (req, res, next) => {
req.body = esquema.parse(req.body) // lança ZodError, capturado pelo tratadorErros
next()
}
}
// unieventos-api/src/routes/eventosRoutes.js
import { Router } from 'express'
import { autenticar } from '../middlewares/autenticar.js'
import { autorizar } from '../middlewares/autorizar.js'
import { validar } from '../middlewares/validar.js'
import { esquemaEvento, esquemaEventoParcial } from '../services/eventosService.js'
import * as eventosController from '../controllers/eventosController.js'
const router = Router()
router.get('/', eventosController.listar)
router.get('/:id', eventosController.buscarPorId)
router.post('/', autenticar, validar(esquemaEvento), eventosController.criar)
router.put('/:id', autenticar, validar(esquemaEventoParcial), eventosController.atualizar)
router.delete('/:id', autenticar, autorizar(['admin']), eventosController.remover)
export default router
3. Front-end: services/ alinhado ao contrato¶
// src/services/eventosService.js
import api from './api'
export function listarEventos({ pagina = 1, limite = 10, busca = '', categoria = '' } = {}) {
return api
.get('/eventos', { params: { pagina, limite, busca, categoria } })
.then((resposta) => resposta.data)
}
export function buscarEvento(id) {
return api.get(`/eventos/${id}`).then((resposta) => resposta.data)
}
export function criarEvento(evento) {
return api.post('/eventos', evento).then((resposta) => resposta.data)
}
export function atualizarEvento(id, evento) {
return api.put(`/eventos/${id}`, evento).then((resposta) => resposta.data)
}
export function removerEvento(id) {
return api.delete(`/eventos/${id}`)
}
💡 Dica Repare que cada função do service tem exatamente uma responsabilidade e um nome que espelha o contrato da seção 1. Ninguém que ler esse arquivo precisa saber que por trás existe Axios, interceptors ou token — e é exatamente esse esconderijo que a store vai explorar.
🧩 Padrão de projeto em uso: Facade¶
A camada services/ do front é um Facade (padrão estrutural): oferece uma interface simples (listarEventos(), criarEvento()) escondendo a complexidade de configurar o Axios, montar query string, tratar cabeçalhos de autenticação e formatar a resposta. A store, os componentes e as views nunca chamam api.get(...) diretamente — eles conversam só com o Facade. Se amanhã trocarmos Axios por fetch nativo, ou a URL base da API mudar de estrutura, só o services/ muda; store e telas continuam iguais. Voltaremos a esse mesmo princípio na Aula 12, quando o Adapter permitir trocar Express+MySQL por Supabase sem tocar no front.
4. Store Pinia: estado da lista, item atual e paginação¶
// src/stores/eventosStore.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as eventosService from '@/services/eventosService'
export const useEventosStore = defineStore('eventos', () => {
const lista = ref([])
const itemAtual = ref(null)
const carregando = ref(false)
const erro = ref(null)
const paginacao = ref({ pagina: 1, limite: 10, total: 0, totalPaginas: 0 })
async function carregar({ pagina = 1, limite = 10 } = {}) {
carregando.value = true
erro.value = null
try {
const resultado = await eventosService.listarEventos({ pagina, limite })
lista.value = resultado.dados
paginacao.value = resultado.paginacao
} catch (e) {
erro.value = e.response?.data?.erro ?? 'Não foi possível carregar os eventos.'
} finally {
carregando.value = false
}
}
async function buscar({ termo = '', categoria = '', pagina = 1 } = {}) {
carregando.value = true
erro.value = null
try {
const resultado = await eventosService.listarEventos({
pagina,
limite: paginacao.value.limite,
busca: termo,
categoria,
})
lista.value = resultado.dados
paginacao.value = resultado.paginacao
} catch (e) {
erro.value = e.response?.data?.erro ?? 'Não foi possível buscar os eventos.'
} finally {
carregando.value = false
}
}
async function carregarUm(id) {
carregando.value = true
erro.value = null
try {
itemAtual.value = await eventosService.buscarEvento(id)
} catch (e) {
erro.value = e.response?.data?.erro ?? 'Evento não encontrado.'
} finally {
carregando.value = false
}
}
// Atualização PESSIMISTA: só mexemos no estado local depois que o
// servidor confirmar. Mais lento na percepção do usuário, mas nunca
// mostra dado que pode não ter sido salvo de fato — ver seção 5.
async function criar(evento) {
carregando.value = true
erro.value = null
try {
const novoEvento = await eventosService.criarEvento(evento)
lista.value = [novoEvento, ...lista.value]
return novoEvento
} catch (e) {
erro.value = e.response?.data?.erro ?? 'Não foi possível criar o evento.'
throw e
} finally {
carregando.value = false
}
}
async function atualizar(id, evento) {
carregando.value = true
erro.value = null
try {
const eventoAtualizado = await eventosService.atualizarEvento(id, evento)
const indice = lista.value.findIndex((e) => e.id === Number(id))
if (indice !== -1) lista.value[indice] = eventoAtualizado
return eventoAtualizado
} catch (e) {
erro.value = e.response?.data?.erro ?? 'Não foi possível atualizar o evento.'
throw e
} finally {
carregando.value = false
}
}
async function remover(id) {
carregando.value = true
erro.value = null
try {
await eventosService.removerEvento(id)
lista.value = lista.value.filter((e) => e.id !== Number(id))
} catch (e) {
erro.value = e.response?.data?.erro ?? 'Não foi possível excluir o evento.'
throw e
} finally {
carregando.value = false
}
}
return { lista, itemAtual, carregando, erro, paginacao, carregar, buscar, carregarUm, criar, atualizar, remover }
})
5. Atualização otimista × pessimista¶
Duas estratégias para refletir uma mudança na interface depois de uma ação do usuário (criar, editar, excluir):
- Otimista: a interface muda imediatamente, antes da resposta do servidor chegar — assumindo que vai dar certo. Se der errado, é preciso desfazer a mudança local e mostrar um erro. Percepção de velocidade excelente; complexidade de "desfazer" real.
- Pessimista: a interface só muda depois que o servidor confirmar o sucesso. Mais lenta na percepção (o usuário espera o
carregando), mas nunca mente sobre o estado — o que a tela mostra é sempre o que o servidor de fato tem salvo.
A store acima implementa a pessimista de propósito: cada ação (criar, atualizar, remover) só atualiza lista.value depois do await na chamada de serviço resolver com sucesso. Para um CRUD acadêmico, essa é a escolha mais segura — evita o cenário em que o aluno vê "Evento criado!" na tela, mas na verdade a validação do back-end rejeitou e nada foi salvo.
📌 Na prova Otimista = muda a tela antes de saber o resultado (rápido, mas exige lógica de desfazer). Pessimista = muda a tela só após confirmação do servidor (mais lento, mais seguro). Nesta disciplina, sempre pessimista.
💻 Mão na massa — telas de CRUD completas¶
Passo 1 — Listagem com v-data-table, busca e paginação no servidor¶
<!-- src/views/EventosListaView.vue -->
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useEventosStore } from '@/stores/eventosStore'
import { useAuthStore } from '@/stores/authStore'
import DialogoConfirmacao from '@/components/DialogoConfirmacao.vue'
const router = useRouter()
const eventosStore = useEventosStore()
const authStore = useAuthStore()
const termoBusca = ref('')
const opcoesTabela = ref({ page: 1, itemsPerPage: 10 })
const dialogoExclusaoAberto = ref(false)
const eventoParaExcluir = ref(null)
const snackbar = ref({ aberto: false, texto: '', cor: 'success' })
const cabecalhos = [
{ title: 'Título', key: 'titulo' },
{ title: 'Categoria', key: 'categoria' },
{ title: 'Data', key: 'data_hora' },
{ title: 'Vagas', key: 'vagas_disponiveis' },
{ title: 'Ações', key: 'acoes', sortable: false },
]
async function carregarPagina() {
await eventosStore.buscar({
termo: termoBusca.value,
pagina: opcoesTabela.value.page,
})
}
onMounted(carregarPagina)
watch(() => opcoesTabela.value.page, carregarPagina)
let temporizadorBusca = null
watch(termoBusca, () => {
clearTimeout(temporizadorBusca)
// debounce simples: espera 400ms sem digitar antes de buscar de novo
temporizadorBusca = setTimeout(() => {
opcoesTabela.value.page = 1
carregarPagina()
}, 400)
})
function formatarData(isoString) {
return new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(isoString))
}
function abrirNovo() {
router.push({ name: 'evento-form' })
}
function abrirEdicao(evento) {
router.push({ name: 'evento-form', params: { id: evento.id } })
}
function pedirConfirmacaoExclusao(evento) {
eventoParaExcluir.value = evento
dialogoExclusaoAberto.value = true
}
async function confirmarExclusao() {
try {
await eventosStore.remover(eventoParaExcluir.value.id)
snackbar.value = { aberto: true, texto: 'Evento excluído com sucesso.', cor: 'success' }
} catch (e) {
snackbar.value = { aberto: true, texto: eventosStore.erro ?? 'Erro ao excluir.', cor: 'error' }
} finally {
dialogoExclusaoAberto.value = false
}
}
</script>
<template>
<v-container>
<div class="d-flex align-center justify-space-between mb-4">
<h1 class="text-h4">Eventos</h1>
<v-btn v-if="authStore.estaLogado" color="primary" prepend-icon="mdi-plus" @click="abrirNovo">
Novo evento
</v-btn>
</div>
<v-text-field
v-model="termoBusca"
label="Buscar por título ou descrição"
prepend-inner-icon="mdi-magnify"
clearable
class="mb-4"
/>
<v-progress-linear v-if="eventosStore.carregando" indeterminate color="primary" class="mb-2" />
<v-alert v-if="eventosStore.erro" type="error" class="mb-4">
{{ eventosStore.erro }}
</v-alert>
<v-data-table-server
v-model:page="opcoesTabela.page"
v-model:items-per-page="opcoesTabela.itemsPerPage"
:headers="cabecalhos"
:items="eventosStore.lista"
:items-length="eventosStore.paginacao.total"
:loading="eventosStore.carregando"
item-value="id"
>
<template #item.data_hora="{ item }">
{{ formatarData(item.data_hora) }}
</template>
<template #item.acoes="{ item }">
<v-btn
v-if="authStore.estaLogado"
icon="mdi-pencil"
size="small"
variant="text"
@click="abrirEdicao(item)"
/>
<v-btn
v-if="authStore.ehAdmin"
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="pedirConfirmacaoExclusao(item)"
/>
</template>
<template #no-data>
<p class="pa-4">Nenhum evento encontrado.</p>
</template>
</v-data-table-server>
<DialogoConfirmacao
v-model="dialogoExclusaoAberto"
titulo="Excluir evento"
:mensagem="`Tem certeza que deseja excluir '${eventoParaExcluir?.titulo}'? Esta ação não pode ser desfeita.`"
@confirmar="confirmarExclusao"
/>
<v-snackbar v-model="snackbar.aberto" :color="snackbar.cor" timeout="4000">
{{ snackbar.texto }}
</v-snackbar>
</v-container>
</template>
Passo 2 — Componente de confirmação reutilizável¶
<!-- src/components/DialogoConfirmacao.vue -->
<script setup>
defineProps({
modelValue: { type: Boolean, required: true },
titulo: { type: String, default: 'Confirmar ação' },
mensagem: { type: String, required: true },
})
const emit = defineEmits(['update:modelValue', 'confirmar'])
function cancelar() {
emit('update:modelValue', false)
}
function confirmar() {
emit('confirmar')
}
</script>
<template>
<v-dialog :model-value="modelValue" max-width="420" @update:model-value="$emit('update:modelValue', $event)">
<v-card>
<v-card-title>{{ titulo }}</v-card-title>
<v-card-text>{{ mensagem }}</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="cancelar">Cancelar</v-btn>
<v-btn color="error" variant="flat" @click="confirmar">Excluir</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
Passo 3 — Formulário único para criar e editar¶
A mesma tela serve para os dois casos: a rota /eventos/novo não tem :id, e /eventos/:id/editar tem. O componente decide o modo olhando route.params.id.
// src/router/index.js — trecho das rotas de evento (adicionar ao array de routes)
{
path: '/eventos/novo',
name: 'evento-form',
component: () => import('@/views/EventoFormView.vue'),
meta: { requerAuth: true },
},
{
path: '/eventos/:id/editar',
name: 'evento-form-editar',
component: () => import('@/views/EventoFormView.vue'),
meta: { requerAuth: true },
props: true,
},
<!-- src/views/EventoFormView.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useEventosStore } from '@/stores/eventosStore'
const props = defineProps({
id: { type: [String, Number], default: null },
})
const route = useRoute()
const router = useRouter()
const eventosStore = useEventosStore()
const idEvento = computed(() => props.id ?? route.params.id ?? null)
const ehEdicao = computed(() => idEvento.value !== null)
const form = ref({
titulo: '',
descricao: '',
categoria: 'palestra',
data_hora: '',
local: '',
vagas: 1,
imagem_url: '',
})
const categorias = [
{ title: 'Palestra', value: 'palestra' },
{ title: 'Minicurso', value: 'minicurso' },
{ title: 'Workshop', value: 'workshop' },
]
const salvando = ref(false)
const erroSubmissao = ref('')
const regraObrigatorio = (v) => !!v || 'Campo obrigatório'
const regraVagasPositiva = (v) => Number(v) > 0 || 'Vagas precisa ser maior que zero'
onMounted(async () => {
if (ehEdicao.value) {
await eventosStore.carregarUm(idEvento.value)
if (eventosStore.itemAtual) {
const evento = eventosStore.itemAtual
form.value = {
titulo: evento.titulo,
descricao: evento.descricao,
categoria: evento.categoria,
data_hora: evento.data_hora?.slice(0, 16), // ISO -> formato do input datetime-local
local: evento.local,
vagas: evento.vagas,
imagem_url: evento.imagem_url ?? '',
}
}
}
})
async function aoSubmeter() {
erroSubmissao.value = ''
salvando.value = true
try {
const payload = { ...form.value, vagas: Number(form.value.vagas) }
if (ehEdicao.value) {
await eventosStore.atualizar(idEvento.value, payload)
} else {
await eventosStore.criar(payload)
}
router.push({ name: 'eventos-lista' })
} catch (e) {
erroSubmissao.value = eventosStore.erro ?? 'Não foi possível salvar o evento.'
} finally {
salvando.value = false
}
}
</script>
<template>
<v-container>
<h1 class="text-h4 mb-4">{{ ehEdicao ? 'Editar evento' : 'Novo evento' }}</h1>
<v-skeleton-loader v-if="eventosStore.carregando && ehEdicao" type="article" />
<v-form v-else @submit.prevent="aoSubmeter">
<v-alert v-if="erroSubmissao" type="error" class="mb-4" density="compact">
{{ erroSubmissao }}
</v-alert>
<v-text-field v-model="form.titulo" label="Título" :rules="[regraObrigatorio]" />
<v-textarea v-model="form.descricao" label="Descrição" :rules="[regraObrigatorio]" />
<v-select v-model="form.categoria" :items="categorias" label="Categoria" />
<v-text-field v-model="form.data_hora" type="datetime-local" label="Data e hora" :rules="[regraObrigatorio]" />
<v-text-field v-model="form.local" label="Local" :rules="[regraObrigatorio]" />
<v-text-field v-model.number="form.vagas" type="number" label="Vagas" :rules="[regraObrigatorio, regraVagasPositiva]" />
<v-text-field v-model="form.imagem_url" label="URL da imagem (opcional)" />
<v-btn type="submit" color="primary" :loading="salvando">
{{ ehEdicao ? 'Salvar alterações' : 'Criar evento' }}
</v-btn>
<v-btn variant="text" class="ml-2" :to="{ name: 'eventos-lista' }">Cancelar</v-btn>
</v-form>
</v-container>
</template>
⚠️ Atenção
data_hora?.slice(0, 16)funciona porque o back-end devolve um ISO 8601 completo (2026-12-01T19:00:00.000Z) e o inputdatetime-localesperaAAAA-MM-DDTHH:mm. É um detalhe de formato pequeno, mas quebra silenciosamente se esquecido — o campo simplesmente aparece vazio.
6. Upload de imagem do evento com Firebase Storage¶
Escolhemos Firebase Storage (o front já tem o SDK do Firebase configurado desde a Aula 07/10) para o upload da imagem do evento.
// src/services/storageService.js
import { getStorage, ref as storageRef, uploadBytes, getDownloadURL } from 'firebase/storage'
import { auth } from './firebase'
const storage = getStorage()
export async function enviarImagemEvento(arquivo) {
if (!auth.currentUser) {
throw new Error('É preciso estar autenticado para enviar imagens.')
}
const nomeUnico = `${Date.now()}-${arquivo.name}`
const caminho = `eventos/${nomeUnico}`
const referencia = storageRef(storage, caminho)
await uploadBytes(referencia, arquivo)
return getDownloadURL(referencia)
}
<!-- trecho a adicionar em EventoFormView.vue: campo de upload -->
<script setup>
// ...imports existentes
import { enviarImagemEvento } from '@/services/storageService'
const enviandoImagem = ref(false)
async function aoSelecionarImagem(arquivos) {
const arquivo = arquivos?.[0]
if (!arquivo) return
enviandoImagem.value = true
try {
form.value.imagem_url = await enviarImagemEvento(arquivo)
} catch (e) {
erroSubmissao.value = 'Falha ao enviar imagem: ' + e.message
} finally {
enviandoImagem.value = false
}
}
</script>
<template>
<!-- dentro do v-form, antes do botão de submit -->
<v-file-input
label="Imagem do evento"
accept="image/*"
prepend-icon="mdi-camera"
:loading="enviandoImagem"
@update:model-value="aoSelecionarImagem"
/>
<v-img v-if="form.imagem_url" :src="form.imagem_url" max-height="200" class="mb-4" cover />
</template>
💡 Dica A alternativa é usar
multerno Express, recebendo o arquivo direto no back-end (multipart/form-data) e salvando em disco ou repassando para um storage. É uma escolha igualmente válida — inclusive mais simples de proteger, já que o upload passa pelos seus próprios middlewares de autenticação. A vantagem do Firebase Storage é tirar carga de rede do seu servidor: o arquivo vai direto do navegador para o Firebase, e sua API só recebe a URL final, pequena, no corpo doPOST/PUT.
7. Depuração ponta a ponta¶
Quando o front manda uma requisição e algo dá errado, o fluxo de depuração é sempre o mesmo:
- Aba Network do DevTools. Filtre por Fetch/XHR, clique na requisição. Aba Headers mostra método, URL, status. Aba Payload (ou Request) mostra o corpo enviado. Aba Response mostra o corpo devolvido pelo servidor — é aqui que aparece a mensagem de erro do
tratadorErros. - Reproduza em
curl. Copie a requisição do Network (botão direito → Copy → Copy as cURL) ou monte à mão:
curl -i http://localhost:3000/api/eventos \
-X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"titulo": "Minicurso de Docker",
"descricao": "Introdução prática a containers",
"categoria": "minicurso",
"data_hora": "2026-12-10T14:00:00",
"local": "Laboratório 3",
"vagas": 30
}'
Isso isola o problema: se o curl reproduz o erro, o problema é no back-end (ou nos dados enviados). Se o curl funciona mas o front falha, o problema é no front (token não enviado, payload montado errado, CORS).
- Leia os logs do servidor. O terminal onde
unieventos-apiestá rodando mostra qualquerconsole.errordotratadorErrose, se usarmorganou similar, cada requisição recebida — confirme que ela chegou, com o método e caminho certos.
CORS: erros mais comuns e configuração correta¶
CORS (Cross-Origin Resource Sharing) é uma proteção do navegador, não do servidor — ele bloqueia a resposta de chegar ao JavaScript da página quando origem (protocolo + domínio + porta) da página é diferente da origem da API, a menos que o servidor autorize explicitamente via cabeçalhos.
Sintomas típicos no console do navegador:
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present→ o servidor não está usandocors(), ou está usando comoriginque não bate com a URL do front.Request header field authorization is not allowed by Access-Control-Allow-Headers→ o servidor não liberou explicitamente o cabeçalhoAuthorization.- Requisição aparece como
OPTIONSseguida de falha → é o preflight automático do navegador para métodos comoPUT/DELETEou cabeçalhos customizados; se o servidor não responde200/204a esseOPTIONS, o navegador cancela a requisição real.
Configuração correta para o UniEventos:
// unieventos-api/src/servidor.js (trecho)
import express from 'express'
import cors from 'cors'
const app = express()
app.use(
cors({
origin: process.env.FRONT_URL ?? 'http://localhost:5173',
credentials: true,
allowedHeaders: ['Content-Type', 'Authorization'],
}),
)
app.use(express.json())
⚠️ Atenção
origin: '*'(liberar qualquer origem) parece resolver tudo rápido, mas não funciona junto comcredentials: true— o navegador rejeita essa combinação por especificação. Como o UniEventos usaAuthorization(não cookies),credentials: truenem é estritamente necessário aqui, mas vale registrar: se um dia usar cookies de sessão,originprecisa ser um domínio explícito, nunca*.
🧪 Laboratório¶
1. Contrato documentado. Escreva a tabela de contrato (seção 1) para uma entidade do seu projeto autoral.
Dica
Cinco linhas — uma por endpoint — método, caminho, autenticação. Corpo e resposta podem ir em blocos JSON abaixo da tabela.2. Back-end completo. Implemente controller → service → repository da sua entidade principal com validação zod, paginação e ao menos uma regra de negócio (ex.: não aceitar valor negativo, não excluir se houver dependência).
Dica
Reaproveite a estrutura de `eventosService.js` — troque só os campos do `z.object`.3. Store pessimista. Implemente a store Pinia da entidade com lista, carregando, erro e as ações CRUD, todas aguardando confirmação do servidor antes de mudar o estado local.
Dica
Todo `try` termina em `finally { carregando.value = false }` — não esqueça, senão a tela trava em loading para sempre em caso de erro.4. Telas de listagem e formulário. Construa ListaView com v-data-table-server (busca + paginação) e FormView servindo criar e editar pela mesma rota parametrizada.
Dica
Confira o formato do campo de data — é a causa mais comum de formulário de edição aparecer "vazio" mesmo com dado no banco.5. Depuração guiada. Provoque de propósito um erro 400 (mande um campo inválido) e um erro de CORS (mude temporariamente o origin do cors() para uma URL errada). Documente, com print da aba Network, o que cada um parece no navegador.
Dica
Depois do teste de CORS, não esqueça de voltar o `origin` correto — é fácil esquecer e passar a aula seguinte "quebrada".🐛 Erros comuns e como resolver¶
| Sintoma | Causa | Solução |
|---|---|---|
| Formulário de edição abre vazio | Formato de data_hora incompatível com datetime-local |
Usar .slice(0, 16) no ISO recebido antes de atribuir ao v-model |
| Lista não atualiza após criar/editar | Store otimista incompleta, ou índice errado ao substituir item | Conferir findIndex comparando tipos (Number(id) × item.id) |
| Erro 400 sem detalhe visível na tela | Front não está lendo e.response.data.erro |
Padronizar leitura do erro em todo catch da store |
CORS bloqueia só PUT/DELETE, GET funciona |
Preflight OPTIONS não tratado — normalmente falta de cors() global antes das rotas |
Garantir app.use(cors(...)) antes de app.use('/api/eventos', ...) |
| Upload de imagem falha com "permission denied" | Regra do Firebase Storage exige autenticação e o usuário não está logado | Checar auth.currentUser antes de chamar uploadBytes |
curl funciona mas o front não |
Token não está sendo enviado pelo interceptor, ou front aponta para porta errada | Conferir VITE_API_URL e o cabeçalho Authorization na aba Network |
| Paginação trava na página 2+ | v-data-table-server não está usando items-length vindo do back, ou store não repassa paginacao.total |
Confirmar que paginacao.total reflete o total real, não o tamanho da página atual |
🏠 Atividade assíncrona (1 h)¶
No seu projeto autoral: implemente o CRUD completo (os 5 endpoints do contrato) de uma segunda entidade, diferente da que você já trabalhou no laboratório de hoje. Ela deve ter, no mínimo, uma regra de negócio própria (ex.: não permitir dois registros com o mesmo nome, não excluir se estiver em uso por outra entidade). Documente o contrato dela em uma tabela, igual à da seção 1, e inclua no README do repositório.
Critério de pronto: os 5 endpoints respondem corretamente (teste com curl), a store e as telas de listagem/formulário funcionam no front, e o contrato está documentado no README.
✅ Checkpoint do projeto autoral¶
- [ ] Contrato de API documentado em tabela para pelo menos duas entidades.
- [ ] Back-end com
controller → service → repository, validação zod e ao menos uma regra de negócio por entidade. - [ ] Paginação e busca por query string funcionando no endpoint de listagem.
- [ ]
services/do front alinhado ao contrato, sem chamadas diretas aapi.get/post/...fora dessa camada. - [ ] Store Pinia com
lista,itemAtual,carregando,erro,paginacaoe atualização pessimista. - [ ] Telas de listagem (busca + paginação), formulário único (criar/editar) e diálogo de confirmação de exclusão.
- [ ] Upload de imagem funcionando (Firebase Storage ou
multer) em pelo menos uma entidade. - [ ] CORS configurado corretamente entre front e back, sem
origin: '*'combinado comcredentials: true.
📚 Para aprofundar¶
- MDN — CORS
- Vuetify —
v-data-table-server - Firebase Storage — Web (modular)
- zod — documentação oficial
- MySQL — transações com
mysql2/promise - Plano de curso, Unidade 3: integração front-end/back-end.
Na Aula 12 trocamos de fornecedor: o mesmo CRUD de eventos, agora falando com Supabase — Postgres gerenciado, autenticação própria e Row Level Security no lugar da validação manual de dono/admin que fizemos hoje na API Express.
Aula 12 — CRUD com banco em nuvem (Supabase)
Na Aula 11 fechamos o CRUD de eventos ponta a ponta: Vue chamando services/, Express validando e persistindo no MySQL, Firebase autenticando. Hoje mudamos de fornecedor: o mesmo recurso evento, agora falando direto com o Supabase — sem API própria no meio. É a mesma pergunta de arquitetura de sempre ("onde mora a lógica?"), respondida de um jeito diferente.
🎯 Objetivos de aprendizagem¶
Ao final desta aula você será capaz de:
- Comparar API própria (Express+MySQL), Firebase e Supabase, e justificar quando cada um é a escolha certa.
- Criar um projeto Supabase, entender a diferença entre chave
anoneservice_role, e nunca expor a segunda no front. - Criar tabelas com SQL diretamente no SQL Editor, com
uuidcomo chave primária etimestamptzpara datas. - Explicar o que é Row Level Security, por que o Supabase exige, e reconhecer a armadilha do
data: []silencioso. - Escrever policies de leitura pública, inserção autenticada e edição/exclusão restrita ao dono.
- Usar
@supabase/supabase-jsparaselect,insert,update,delete, joins e paginação, sempre tratando{ data, error }. - Implementar login com Supabase Auth e conectar
auth.uid()às policies. - Assinar mudanças em tempo real com Realtime e implementar o padrão Adapter trocando o back-end via variável de ambiente.
📋 Pré-requisitos desta aula¶
Checklist antes de começar:
- [ ]
unieventos-webfuncionando com o CRUD da Aula 11 (Express+MySQL+Firebase). - [ ] Conta no supabase.com (login com GitHub é o mais rápido).
- [ ] Node.js 22.22.2 e npm 10.9.7 instalados.
- [ ] Confortável com SQL básico (
SELECT,INSERT,CREATE TABLE) — revisado na Aula 09 no contexto do MySQL.
🗺️ Roteiro¶
| Bloco | Tempo | Atividade |
|---|---|---|
| 1 | 50 min | Comparação Express×Firebase×Supabase; criar projeto; chaves; criar tabelas por SQL; Row Level Security e policies |
| 2 | 50 min | @supabase/supabase-js: CRUD completo, joins, paginação, Supabase Auth |
| 3 | 50 min | Storage, Realtime, padrão Adapter, laboratório comparativo |
1. Três formas de resolver o mesmo problema¶
O UniEventos já tem back-end funcionando: Express + MySQL, com autenticação Firebase por cima. Por que aprender mais uma abordagem?
Porque na vida profissional você vai escolher — e a escolha tem trade-offs reais, não é só gosto. Comparação honesta:
| Critério | API própria (Express+MySQL) | Firebase | Supabase |
|---|---|---|---|
| O que resolve | Controle total sobre lógica e dados | Auth + Firestore/Storage prontos, sem servidor próprio | Postgres gerenciado + Auth + Storage, sem servidor próprio |
| Banco de dados | Você escolhe e administra (MySQL aqui) | Firestore (NoSQL, documentos) | Postgres (SQL relacional, o mesmo paradigma do MySQL) |
| Onde mora a regra de negócio | No seu back-end, você escreve tudo | Cloud Functions (custo extra) ou no front (arriscado) | SQL/policies no banco, ou funções Postgres, ou API própria por cima |
| Curva de aprendizado | Alta (você monta tudo) | Média (SDK, mas modelo de dados diferente) | Baixa se já sabe SQL |
| Vendor lock-in | Nenhum — seu código, seu servidor | Alto — Firestore não é portável | Médio — é Postgres puro por baixo, mais fácil de migrar |
| Quando escolher | Regra de negócio complexa, controle fino, já tem back-end | Protótipo rápido, app mobile-first, tempo real nativo | Precisa de SQL relacional gerenciado, quer Postgres sem administrar servidor |
💡 Dica Não existe "o melhor" fora de contexto. O UniEventos usa MySQL porque a disciplina precisa ensinar SQL relacional e arquitetura em camadas. Se o requisito fosse "app mobile com sincronização offline automática", Firebase seria mais natural. Se o requisito fosse "preciso de Postgres gerenciado sem administrar servidor, com auth pronta", Supabase entra bem. Custo de saída (trocar de fornecedor depois) também pesa: Postgres é um padrão aberto, então uma base Supabase se exporta e migra com muito menos atrito que uma base Firestore.
Sobre custo, e por que isso importa desde já¶
Os três caminhos têm modelos de cobrança bem diferentes, e vale entender isso antes de escolher, não depois que a fatura chegar:
- API própria (Express+MySQL): você paga o servidor (VM, container, PaaS) e o banco, direto, independente de quantas requisições ou quanto tráfego passa. Previsível, mas você também é responsável por escalar, fazer backup e manter tudo no ar.
- Firebase: camada gratuita generosa para protótipos, mas cobra por leituras/escritas no Firestore e por armazenamento e tráfego de saída (egress) — em produtos com alto volume de leitura (uma lista que recarrega toda hora, por exemplo), o custo pode crescer rápido e de forma menos previsível.
- Supabase: também tem camada gratuita (com o projeto "pausando" após um tempo sem uso no plano free), e cobra por armazenamento de banco, egress e por hora de computação do banco nos planos pagos. Como é Postgres puro por baixo, migrar para um Postgres autogerenciado depois (se o custo justificar) é factível sem reescrever o modelo de dados.
"Custo de saída" (egress) é o valor cobrado por dados que saem do provedor em direção ao seu usuário — toda resposta de select, toda imagem baixada do Storage, conta. É um item fácil de esquecer ao estimar custo de um app com uso intenso de leitura, como uma lista de eventos que recarrega a cada navegação.
📌 Na prova Os três modelos resolvem "onde guardar e servir dados", mas com contratos de responsabilidade diferentes: API própria = você administra tudo, custo previsível, controle total. Firebase = NoSQL gerenciado, ótimo para tempo real e mobile, lock-in alto. Supabase = Postgres gerenciado, SQL relacional, lock-in menor por ser padrão aberto.
2. Criando o projeto no Supabase¶
- Em supabase.com, New project. Escolha organização, nome (
unieventos), senha do banco (guarde — é a senha do Postgres, usada em conexões diretas) e região (mais próxima do Brasil, ex. São Paulo/sa-east-1se disponível). - Aguarde o provisionamento (1–2 minutos).
- No painel do projeto, vá em Project Settings → API. Anote:
- Project URL — algo como
https://xxxxxxxxxxxx.supabase.co. - anon / public key — chave longa, começando comeyJ...(é um JWT também). Pode ir no front. - service_role key — outra chave longa. Nunca vai para o front.
⚠️ Atenção A chave
anoné pública por design — ela vai no bundle JavaScript do seu front, qualquer pessoa que abrir o DevTools consegue vê-la. Isso é esperado e seguro desde que o Row Level Security esteja configurado corretamente (seção 4): a chaveanonsó consegue fazer o que as policies permitirem. Já aservice_roleignora RLS completamente — com ela, qualquer requisição lê e escreve qualquer linha de qualquer tabela, sem checagem nenhuma. Se ela vazar no front, é o mesmo que vazar acesso total ao banco. Useservice_rolesó em ambiente de servidor (scripts administrativos, back-end próprio), nunca em código que roda no navegador.
SQL Editor e Table Editor¶
No menu lateral: SQL Editor (para rodar comandos SQL diretamente, o que faremos agora) e Table Editor (interface visual tipo planilha, útil para inspecionar dados rapidamente — mas hoje vamos criar tudo por SQL, para reforçar o que você já sabe da Aula 09).
3. Criando as tabelas¶
No SQL Editor, uma nova query:
-- Tabela de eventos. uuid como PK (padrão do Supabase/Postgres,
-- gerado automaticamente, sem depender de auto-incremento sequencial).
create table eventos (
id uuid primary key default gen_random_uuid(),
titulo text not null,
descricao text not null,
categoria text not null check (categoria in ('palestra', 'minicurso', 'workshop')),
data_hora timestamptz not null,
local text not null,
vagas integer not null check (vagas > 0),
imagem_url text,
usuario_id uuid not null references auth.users(id),
criado_em timestamptz not null default now()
);
-- Tabela de inscrições, referenciando eventos e o usuário autenticado.
create table inscricoes (
id uuid primary key default gen_random_uuid(),
evento_id uuid not null references eventos(id) on delete cascade,
usuario_id uuid not null references auth.users(id),
criado_em timestamptz not null default now(),
unique (evento_id, usuario_id) -- um usuário não se inscreve duas vezes no mesmo evento
);
🔎 Por baixo do capô
timestamptz(timestamp with time zone) guarda o instante em UTC internamente e converte na leitura/escrita conforme o fuso da sessão — é o tipo certo para datas que cruzam fusos horários, diferente de umtimestampsem fuso, que é ambíguo.auth.usersé uma tabela que o próprio Supabase Auth já cria e mantém — é para lá quesignUp/signInWithPasswordgravam.references auth.users(id)garante, no nível do banco, que todo evento pertence a um usuário real.
Rode o SQL (botão Run ou Ctrl+Enter). Confirme no Table Editor que as duas tabelas apareceram.
4. Row Level Security: a armadilha nº1¶
Por padrão, o Supabase cria toda tabela sem RLS habilitado — o que na prática significa "qualquer um com a chave anon lê e escreve tudo", porque o Postgres do Supabase é acessado via API REST autogerada por cima do banco. Isso é perigoso, então o primeiro passo depois de criar uma tabela de verdade é:
alter table eventos enable row level security;
alter table inscricoes enable row level security;
Rode isso agora e tente buscar eventos do front (ou do próprio SQL Editor simulando a role anon) — o retorno vai ser uma lista vazia, sem nenhum erro:
{ "data": [], "error": null }
⚠️ Atenção — a armadilha nº1 do Supabase Uma tabela com RLS habilitado e sem nenhuma policy não gera erro de permissão — ela simplesmente se comporta como se estivesse vazia para quem não é dono/
service_role. É a causa mais comum de "meu código está certo mas não retorna nada" com Supabase. Sempre que você habilitar RLS numa tabela nova, o próximo passo, sem exceção, é escrever as policies dela.
O que é RLS e por que existe¶
Row Level Security é um recurso nativo do Postgres: em vez de controlar acesso só por tabela (você pode ou não fazer SELECT em eventos), ele controla acesso linha por linha, com uma condição SQL avaliada para cada linha. O Supabase se apoia nisso porque expõe o banco diretamente via API para o front — sem RLS, qualquer chave anon vazada (e ela É pública) daria acesso irrestrito. RLS é o que torna seguro o front conversar direto com o banco.
Policies: leitura pública, inserção autenticada, edição/exclusão só do dono¶
-- LEITURA: qualquer pessoa (mesmo não autenticada) pode ver eventos.
create policy "eventos_leitura_publica"
on eventos for select
using (true);
-- INSERÇÃO: só usuários autenticados podem criar evento, e o evento
-- criado precisa pertencer a quem está criando (não dá para criar
-- em nome de outro usuário).
create policy "eventos_insercao_autenticada"
on eventos for insert
to authenticated
with check (auth.uid() = usuario_id);
-- EDIÇÃO: só o dono do evento pode editar.
create policy "eventos_edicao_dono"
on eventos for update
to authenticated
using (auth.uid() = usuario_id)
with check (auth.uid() = usuario_id);
-- EXCLUSÃO: só o dono pode excluir.
create policy "eventos_exclusao_dono"
on eventos for delete
to authenticated
using (auth.uid() = usuario_id);
-- Inscrições: leitura pública (para mostrar vagas ocupadas),
-- inserção só autenticado e só em nome de si mesmo,
-- exclusão só de si mesmo (cancelar a própria inscrição).
create policy "inscricoes_leitura_publica"
on inscricoes for select
using (true);
create policy "inscricoes_insercao_propria"
on inscricoes for insert
to authenticated
with check (auth.uid() = usuario_id);
create policy "inscricoes_exclusao_propria"
on inscricoes for delete
to authenticated
using (auth.uid() = usuario_id);
USING × WITH CHECK¶
As duas cláusulas parecem sinônimos, mas checam momentos diferentes:
USINGfiltra quais linhas existentes a operação pode enxergar/afetar. Vale paraSELECT,UPDATEeDELETE— é a condição "essa linha, que já está no banco, pode ser vista/alterada/apagada por você?".WITH CHECKvalida os dados da linha depois da operação (ou os dados que vão ser inseridos). Vale paraINSERTeUPDATE— é a condição "o resultado desta escrita é permitido?".
Em um UPDATE, as duas coexistem e respondem perguntas diferentes: USING decide se você pode tocar naquela linha específica (ex.: só se usuario_id já era seu); WITH CHECK decide se o novo valor que você está tentando gravar é aceitável (ex.: impedir que você mude usuario_id da linha para outra pessoa, "roubando" o evento).
📌 Na prova
USING= filtro sobre a linha que já existe (quem pode ver/mexer).WITH CHECK= validação sobre o dado que está sendo escrito (o resultado é permitido?).INSERTsó temWITH CHECK(não existe linha "antes").SELECT/DELETEsó têmUSING.UPDATEtem os dois.
🧩 Padrão de projeto em uso: Adapter¶
O padrão Adapter (estrutural) permite que duas interfaces incompatíveis trabalhem juntas, criando uma camada intermediária que traduz uma para a outra. É exatamente o que vamos construir na seção 8: duas implementações de eventosRepo — uma fala com a API Express (Aula 11), outra fala direto com o Supabase — mas as duas expõem a mesma interface (listar(), buscarPorId(), criar(), atualizar(), remover()). O resto do front (store, telas) não sabe, e não precisa saber, qual das duas está em uso. Trocar de fornecedor de dados vira uma linha de variável de ambiente, não uma reescrita de tela.
5. @supabase/supabase-js: cliente e operações básicas¶
npm install @supabase/supabase-js@2.112.3
// src/services/supabase.js
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
# .env
VITE_SUPABASE_URL=https://xxxxxxxxxxxx.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs...
select, filtros, ordenação e paginação¶
// consultas de exemplo — cole no console do navegador ou num componente de teste
// Selecionar colunas específicas
const { data, error } = await supabase
.from('eventos')
.select('id, titulo, categoria, data_hora, vagas')
// Filtros: eq, neq, gt, lt, like, ilike, in
const { data: palestras } = await supabase
.from('eventos')
.select('*')
.eq('categoria', 'palestra')
const { data: buscaPorTitulo } = await supabase
.from('eventos')
.select('*')
.ilike('titulo', '%semana%') // ilike = LIKE case-insensitive
const { data: futuros } = await supabase
.from('eventos')
.select('*')
.gt('data_hora', new Date().toISOString())
const { data: algumasCategorias } = await supabase
.from('eventos')
.select('*')
.in('categoria', ['palestra', 'workshop'])
// Ordenação
const { data: ordenados } = await supabase
.from('eventos')
.select('*')
.order('data_hora', { ascending: true })
// Paginação: range(inicio, fim), ambos inclusive, base 0
const pagina = 1
const porPagina = 10
const inicio = (pagina - 1) * porPagina
const fim = inicio + porPagina - 1
const { data: pagina1, count } = await supabase
.from('eventos')
.select('*', { count: 'exact' }) // pede o total de linhas junto
.order('data_hora', { ascending: true })
.range(inicio, fim)
// Buscar um único registro (lança erro se vier mais de uma linha
// ou se nenhuma linha for encontrada)
const { data: evento, error: erroUnico } = await supabase
.from('eventos')
.select('*')
.eq('id', 'algum-uuid-aqui')
.single()
⚠️ Atenção
single()estoura em erro se a consulta não retornar exatamente uma linha — nem zero, nem duas ou mais. Se oidpode não existir (ex.: usuário editou a URL na mão), trate oerrorem vez de assumir quedatasempre vem preenchido. Para o caso "pode não existir, e tudo bem", use.maybeSingle()no lugar de.single()— ele devolvedata: nullsem erro quando não encontra.
{ data, error }: por que try/catch sozinho não basta¶
O supabase-js não lança exceção para a maioria dos erros de banco (violação de policy, coluna inexistente, check constraint falhando). Em vez disso, ele sempre resolve a Promise com sucesso e devolve um objeto { data, error } — se error não for null, a operação falhou, mas nenhuma exceção foi lançada e um try/catch ao redor não pega nada:
// ERRADO — o try/catch aqui nunca vê o erro de RLS/validação
try {
const { data } = await supabase.from('eventos').insert({ titulo: 'X' })
console.log('Criado:', data) // data pode ser null e o código nem percebe
} catch (e) {
console.error('Nunca chega aqui para erros de policy/validação')
}
// CORRETO — sempre desestruture e cheque error explicitamente
const { data, error } = await supabase.from('eventos').insert({ titulo: 'X' })
if (error) {
console.error('Falha ao criar evento:', error.message)
// trate aqui: mostrar mensagem, não seguir o fluxo, etc.
} else {
console.log('Criado:', data)
}
🔎 Por baixo do capô Isso é uma escolha de design da biblioteca: erros de banco de dados (RLS negou, constraint violada, coluna não existe) são tratados como resultado esperado da operação, não como falha excepcional do programa — parecido com como uma função de parsing pode devolver
nullem vez de lançar.try/catchcontinua útil para erros de rede (sem internet, timeout), mas a lógica de negócio do Supabase sempre passa peloerrordo objeto retornado. Esqueça isso e você vai debugar "por que meu insert não fez nada" sem nunca ver a mensagem real.
insert, update, delete¶
// INSERT — .select() no final devolve a linha criada (senão, data vem null)
const { data: novoEvento, error: erroInsert } = await supabase
.from('eventos')
.insert({
titulo: 'Minicurso de Docker',
descricao: 'Introdução prática a containers',
categoria: 'minicurso',
data_hora: '2026-12-10T14:00:00-04:00',
local: 'Laboratório 3',
vagas: 30,
usuario_id: (await supabase.auth.getUser()).data.user.id,
})
.select()
.single()
// UPDATE — sempre com .eq() para não atualizar a tabela inteira
const { data: eventoAtualizado, error: erroUpdate } = await supabase
.from('eventos')
.update({ vagas: 40 })
.eq('id', novoEvento.id)
.select()
.single()
// DELETE
const { error: erroDelete } = await supabase
.from('eventos')
.delete()
.eq('id', novoEvento.id)
⚠️ Atenção Um
update()oudelete()sem.eq(...)(ou outro filtro) tenta afetar a tabela inteira. O RLS te protege de estragos globais (a policyusuario_id = auth.uid()limita às suas próprias linhas), mas mesmo dentro das suas linhas isso é raramente o que você quer. Sempre filtre pelo identificador específico.
Joins por relacionamento¶
O Supabase entende as foreign keys que você declarou e permite buscar dados relacionados dentro do mesmo select, sem escrever JOIN manualmente:
// Buscar eventos já trazendo as inscrições relacionadas
const { data: eventosComInscritos, error } = await supabase
.from('eventos')
.select('*, inscricoes(*)')
// eventosComInscritos[0].inscricoes é um array com as inscrições daquele evento
// Contagem de relacionados sem trazer todas as linhas
const { data: eventosComContagem } = await supabase
.from('eventos')
.select('*, inscricoes(count)')
6. Supabase Auth¶
// src/services/supabaseAuthService.js
import { supabase } from './supabase'
export async function cadastrar(email, senha) {
const { data, error } = await supabase.auth.signUp({ email, password: senha })
if (error) throw new Error(error.message)
return data.user
}
export async function entrar(email, senha) {
const { data, error } = await supabase.auth.signInWithPassword({ email, password: senha })
if (error) throw new Error(error.message)
return data.user
}
export async function sair() {
const { error } = await supabase.auth.signOut()
if (error) throw new Error(error.message)
}
export async function obterSessaoAtual() {
const { data } = await supabase.auth.getSession()
return data.session
}
// Observa login/logout/renovação de token, igual ao onAuthStateChanged
// do Firebase que vimos na Aula 10.
export function observarAutenticacao(callback) {
const { data: assinatura } = supabase.auth.onAuthStateChange((_evento, sessao) => {
callback(sessao)
})
return assinatura.subscription.unsubscribe // função de cancelamento
}
A ligação entre Auth e RLS é direta: quando o front faz uma chamada autenticada, o supabase-js anexa automaticamente o token de sessão, e as policies usam auth.uid() para saber quem está pedindo. É o mesmo princípio do middleware autenticar da Aula 10 (ler o token, extrair a identidade) — só que aqui a checagem acontece dentro do banco, não numa camada de middleware que você escreve.
A store de autenticação segue exatamente a mesma forma da Aula 10 — Pinia, estado usuario/carregando/inicializado, Promise resolvida no primeiro evento do observador, guard de rota aguardando essa Promise. Só troca o serviço por baixo: observarAutenticacao do Supabase no lugar de onAuthStateChanged do Firebase.
// src/stores/authStoreSupabase.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { obterSessaoAtual, observarAutenticacao } from '@/services/supabaseAuthService'
export const useAuthStore = defineStore('auth', () => {
const usuario = ref(null)
const carregando = ref(false)
const inicializado = ref(false)
let promessaInicializacao = null
function inicializar() {
if (promessaInicializacao) return promessaInicializacao
promessaInicializacao = new Promise((resolve) => {
// Primeiro, lê a sessão já persistida (ex.: recarregou a página).
obterSessaoAtual().then((sessao) => {
usuario.value = sessao?.user ?? null
})
// Depois, mantém o estado sincronizado com login/logout/renovação.
observarAutenticacao((sessao) => {
usuario.value = sessao?.user ?? null
if (!inicializado.value) {
inicializado.value = true
resolve()
}
})
})
return promessaInicializacao
}
const estaLogado = computed(() => usuario.value !== null)
return { usuario, carregando, inicializado, inicializar, estaLogado }
})
// src/router/index.js — guard idêntico em espírito ao da Aula 10,
// trocando authStore de Firebase pela variante Supabase.
router.beforeEach(async (to) => {
const authStore = useAuthStore()
await authStore.inicializar()
if (to.meta.requerAuth && !authStore.estaLogado) {
return { name: 'login', query: { redirect: to.fullPath } }
}
return true
})
💡 Dica Repare que a forma do problema — "aguardar a primeira resolução do observador antes de deixar o guard decidir" — é idêntica entre Firebase e Supabase, mesmo os dois SDKs sendo de fornecedores diferentes. É um sinal de que o problema (evitar redirecionamento indevido no F5) é estrutural do padrão "autenticação assíncrona no cliente", não uma peculiaridade de um SDK específico.
7. Storage e Realtime¶
Storage: bucket público, upload, URL pública¶
No painel: Storage → New bucket, nome eventos-imagens, marque Public bucket.
// src/services/supabaseStorageService.js
import { supabase } from './supabase'
export async function enviarImagemEvento(arquivo) {
const nomeUnico = `${Date.now()}-${arquivo.name}`
const { error } = await supabase.storage
.from('eventos-imagens')
.upload(nomeUnico, arquivo)
if (error) throw new Error('Falha ao enviar imagem: ' + error.message)
const { data } = supabase.storage
.from('eventos-imagens')
.getPublicUrl(nomeUnico)
return data.publicUrl
}
Realtime: a lista se atualizando sozinha¶
// trecho de EventosListaView.vue (variante Supabase)
import { onMounted, onUnmounted } from 'vue'
import { supabase } from '@/services/supabase'
let canal = null
onMounted(() => {
canal = supabase
.channel('eventos-mudancas')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'eventos' },
(payload) => {
console.log('Mudança recebida:', payload.eventType, payload.new ?? payload.old)
eventosStore.carregar() // recarrega a lista quando algo muda
},
)
.subscribe()
})
onUnmounted(() => {
if (canal) supabase.removeChannel(canal)
})
Abra o UniEventos em duas abas lado a lado. Crie um evento em uma; a lista da outra atualiza sozinha, sem F5. É o momento em que a turma costuma reagir — vale demonstrar ao vivo antes de explicar o código.
🔎 Por baixo do capô Realtime do Supabase se apoia na replicação lógica do Postgres (
logical replication): o banco publica um fluxo de mudanças (postgres_changes), e osupabase-jsmantém um WebSocket assinando esse fluxo filtrado pela tabela/evento que você configurou. Não é polling — é o próprio banco avisando o cliente quando algo muda.
💻 Mão na massa — CRUD direto com Supabase e, depois, o Adapter¶
Passo 1 — Testando a conexão no console do navegador¶
Antes de montar telas, confirme que o cliente conecta e que as policies estão certas. Com unieventos-web rodando (npm run dev), abra o DevTools no navegador, importe o cliente e rode uma consulta:
// cole no console do navegador, na página do seu app rodando com Vite
const { supabase } = await import('/src/services/supabase.js')
const { data, error } = await supabase.from('eventos').select('*')
console.log({ data, error })
Se data vier [] e error vier null, e você já cadastrou alguma linha pelo Table Editor, é a armadilha da seção 4: falta a policy de leitura. Se error trouxer uma mensagem sobre coluna ou relação inexistente, revise o SQL de criação da tabela.
Passo 2 — Tela de listagem consumindo o Supabase diretamente¶
Antes de introduzir o Adapter, vale montar a versão mais direta — a store chamando o supabase-js sem nenhuma camada de repositório no meio. É o ponto de partida mais simples, e o que a maioria dos tutoriais mostra.
// src/stores/eventosStoreSupabase.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { supabase } from '@/services/supabase'
export const useEventosStore = defineStore('eventos', () => {
const lista = ref([])
const itemAtual = ref(null)
const carregando = ref(false)
const erro = ref(null)
const paginacao = ref({ pagina: 1, limite: 10, total: 0, totalPaginas: 0 })
async function carregar({ pagina = 1, limite = 10 } = {}) {
carregando.value = true
erro.value = null
const inicio = (pagina - 1) * limite
const fim = inicio + limite - 1
const { data, error, count } = await supabase
.from('eventos')
.select('*', { count: 'exact' })
.order('data_hora', { ascending: true })
.range(inicio, fim)
if (error) {
erro.value = error.message
} else {
lista.value = data
paginacao.value = { pagina, limite, total: count, totalPaginas: Math.ceil(count / limite) }
}
carregando.value = false
}
async function carregarUm(id) {
carregando.value = true
erro.value = null
const { data, error } = await supabase.from('eventos').select('*').eq('id', id).maybeSingle()
if (error) {
erro.value = error.message
} else if (!data) {
erro.value = 'Evento não encontrado.'
} else {
itemAtual.value = data
}
carregando.value = false
}
async function criar(evento) {
carregando.value = true
erro.value = null
const { data: sessao } = await supabase.auth.getUser()
const { data, error } = await supabase
.from('eventos')
.insert({ ...evento, usuario_id: sessao.user.id })
.select()
.single()
carregando.value = false
if (error) {
erro.value = error.message
throw new Error(error.message)
}
lista.value = [data, ...lista.value]
return data
}
async function atualizar(id, evento) {
carregando.value = true
erro.value = null
const { data, error } = await supabase.from('eventos').update(evento).eq('id', id).select().single()
carregando.value = false
if (error) {
erro.value = error.message
throw new Error(error.message)
}
const indice = lista.value.findIndex((e) => e.id === id)
if (indice !== -1) lista.value[indice] = data
return data
}
async function remover(id) {
carregando.value = true
erro.value = null
const { error } = await supabase.from('eventos').delete().eq('id', id)
carregando.value = false
if (error) {
erro.value = error.message
throw new Error(error.message)
}
lista.value = lista.value.filter((e) => e.id !== id)
}
return { lista, itemAtual, carregando, erro, paginacao, carregar, carregarUm, criar, atualizar, remover }
})
<!-- src/views/EventosListaSupabaseView.vue -->
<script setup>
import { onMounted } from 'vue'
import { useEventosStore } from '@/stores/eventosStoreSupabase'
import { useAuthStore } from '@/stores/authStoreSupabase'
const eventosStore = useEventosStore()
const authStore = useAuthStore()
onMounted(() => eventosStore.carregar())
function formatarData(isoString) {
return new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(isoString))
}
</script>
<template>
<v-container>
<h1 class="text-h4 mb-4">Eventos (Supabase)</h1>
<v-progress-linear v-if="eventosStore.carregando" indeterminate color="primary" class="mb-2" />
<v-alert v-if="eventosStore.erro" type="error" class="mb-4">{{ eventosStore.erro }}</v-alert>
<v-row>
<v-col v-for="evento in eventosStore.lista" :key="evento.id" cols="12" sm="6" md="4">
<v-card>
<v-img v-if="evento.imagem_url" :src="evento.imagem_url" height="140" cover />
<v-card-title>{{ evento.titulo }}</v-card-title>
<v-card-subtitle>{{ formatarData(evento.data_hora) }} · {{ evento.local }}</v-card-subtitle>
<v-card-text>{{ evento.descricao }}</v-card-text>
<v-card-actions v-if="authStore.usuario?.id === evento.usuario_id">
<v-btn variant="text" :to="`/eventos-supabase/${evento.id}/editar`">Editar</v-btn>
<v-btn variant="text" color="error" @click="eventosStore.remover(evento.id)">Excluir</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<p v-if="!eventosStore.carregando && eventosStore.lista.length === 0">Nenhum evento cadastrado ainda.</p>
</v-container>
</template>
⚠️ Atenção
authStore.usuario?.id === evento.usuario_idno template controla só a exibição do botão — é UX, igual ao guard de rota da Aula 10. A garantia de verdade é a policyeventos_edicao_dono(seção 4): mesmo que alguém forje uma requisição deupdatepara um evento alheio direto contra a API do Supabase, o banco recusa porqueauth.uid()não bate comusuario_id.
Do CRUD direto ao Adapter¶
Com o CRUD direto funcionando, damos o passo seguinte: extrair uma interface comum que permita alternar entre a API Express (Aula 11) e o Supabase sem tocar em store nem em tela.
Passo 3 — Interface comum e implementação para a API Express¶
// src/repositories/eventosRepoExpress.js
import api from '@/services/api'
export const eventosRepoExpress = {
async listar({ pagina = 1, limite = 10 } = {}) {
const resposta = await api.get('/eventos', { params: { pagina, limite } })
return resposta.data // { dados, paginacao }
},
async buscarPorId(id) {
const resposta = await api.get(`/eventos/${id}`)
return resposta.data
},
async criar(evento) {
const resposta = await api.post('/eventos', evento)
return resposta.data
},
async atualizar(id, evento) {
const resposta = await api.put(`/eventos/${id}`, evento)
return resposta.data
},
async remover(id) {
await api.delete(`/eventos/${id}`)
},
}
Passo 4 — Mesma interface, implementação Supabase¶
// src/repositories/eventosRepoSupabase.js
import { supabase } from '@/services/supabase'
export const eventosRepoSupabase = {
async listar({ pagina = 1, limite = 10 } = {}) {
const inicio = (pagina - 1) * limite
const fim = inicio + limite - 1
const { data, error, count } = await supabase
.from('eventos')
.select('*', { count: 'exact' })
.order('data_hora', { ascending: true })
.range(inicio, fim)
if (error) throw new Error(error.message)
// Formato devolvido igual ao da API Express — é isso que faz o
// Adapter funcionar: a FORMA da resposta precisa ser a mesma.
return {
dados: data,
paginacao: { pagina, limite, total: count, totalPaginas: Math.ceil(count / limite) },
}
},
async buscarPorId(id) {
const { data, error } = await supabase.from('eventos').select('*').eq('id', id).single()
if (error) throw new Error(error.message)
return data
},
async criar(evento) {
const { data: sessao } = await supabase.auth.getUser()
const { data, error } = await supabase
.from('eventos')
.insert({ ...evento, usuario_id: sessao.user.id })
.select()
.single()
if (error) throw new Error(error.message)
return data
},
async atualizar(id, evento) {
const { data, error } = await supabase
.from('eventos')
.update(evento)
.eq('id', id)
.select()
.single()
if (error) throw new Error(error.message)
return data
},
async remover(id) {
const { error } = await supabase.from('eventos').delete().eq('id', id)
if (error) throw new Error(error.message)
},
}
Passo 5 — Trocando a implementação por variável de ambiente¶
// src/repositories/eventosRepo.js
import { eventosRepoExpress } from './eventosRepoExpress'
import { eventosRepoSupabase } from './eventosRepoSupabase'
// VITE_BACKEND=express ou VITE_BACKEND=supabase no .env
const backendEscolhido = import.meta.env.VITE_BACKEND ?? 'express'
export const eventosRepo = backendEscolhido === 'supabase' ? eventosRepoSupabase : eventosRepoExpress
// src/services/eventosService.js — reescrito para usar o Adapter
import { eventosRepo } from '@/repositories/eventosRepo'
export function listarEventos(params) {
return eventosRepo.listar(params)
}
export function buscarEvento(id) {
return eventosRepo.buscarPorId(id)
}
export function criarEvento(evento) {
return eventosRepo.criar(evento)
}
export function atualizarEvento(id, evento) {
return eventosRepo.atualizar(id, evento)
}
export function removerEvento(id) {
return eventosRepo.remover(id)
}
# .env — uma linha decide qual back-end o front usa
VITE_BACKEND=supabase
Nenhuma linha da store (eventosStore.js) ou das telas (EventosListaView.vue, EventoFormView.vue) precisa mudar. Isso é o Adapter cumprindo sua função: a store continua chamando eventosService.listarEventos(...), que continua chamando eventosRepo.listar(...) — só a implementação por trás mudou, escolhida por uma variável de ambiente.
📌 Na prova Facade (Aula 11) simplifica uma interface complexa. Adapter (esta aula) traduz uma interface para outra, permitindo trocar a implementação sem o cliente perceber. A camada
services/do UniEventos usa os dois: é Facade em relação às telas (esconde detalhes de HTTP/Supabase) e se apoia num Adapter (eventosRepo) para trocar de fornecedor por baixo.
🧪 Laboratório¶
1. Projeto e tabelas. Crie seu projeto no Supabase e as tabelas da sua entidade principal (autoral), com uuid como PK, timestamptz para datas e RLS habilitado desde o início.
Dica
Habilite RLS na mesma migração/script SQL em que cria a tabela — não deixe para depois, é fácil esquecer.2. Policies completas. Escreva as quatro policies (leitura pública, inserção autenticada, edição e exclusão só do dono) para sua tabela principal.
Dica
Teste cada uma isoladamente: logado como usuário A, tente editar uma linha do usuário B — deve falhar silenciosamente (nenhuma linha afetada), não com erro.3. CRUD com supabase-js. Implemente select, insert, update, delete da sua entidade, sempre desestruturando { data, error } e tratando o erro.
Dica
Se `data` vier vazio sem erro nenhum, sua primeira suspeita deve ser RLS sem policy — releia a seção 4 antes de procurar bug no seu código.4. Realtime funcionando. Assine mudanças na sua tabela principal e demonstre, em duas abas, uma lista atualizando sozinha.
Dica
Confirme que o Realtime está habilitado para a tabela em Database → Replication no painel do Supabase — em alguns planos/tabelas ele vem desligado por padrão.5. Adapter comparativo. Implemente as duas versões do repositório (Repo...Express e Repo...Supabase) para sua entidade principal, com a mesma interface, e alterne entre elas por variável de ambiente.
Dica
O ponto de verificação: você deve conseguir trocar `VITE_BACKEND` no `.env`, reiniciar o `npm run dev`, e a tela continuar funcionando sem tocar em nenhuma linha de `store` ou `view`.🐛 Erros comuns e como resolver¶
| Sintoma | Causa | Solução |
|---|---|---|
data: [] sem erro nenhum |
RLS habilitado, sem policy correspondente à operação | Escrever a policy que falta; conferir se cobre select/insert/update/delete conforme necessário |
| Erro "JWT expired" ou 401 genérico | Chave anon errada, ou copiada de outro projeto |
Reconferir VITE_SUPABASE_URL e VITE_SUPABASE_ANON_KEY em Project Settings → API |
insert/update falha sem mensagem clara na tela |
Esqueceu de checar error do retorno (usou só try/catch) |
Sempre desestruturar { data, error } e tratar error explicitamente |
Policy de update "não funciona" mesmo parecendo certa |
Faltou with check, então a policy só filtra a linha original mas aceita qualquer novo valor (ou vice-versa) |
Escrever using e with check juntos em policies de update |
| Erro de sintaxe SQL mencionando palavra reservada | Coluna nomeada order, user, group etc. sem aspas |
Evitar nomes reservados; se inevitável, usar aspas duplas ("order") em todo lugar |
.single() lança erro "multiple (or no) rows returned" |
Consulta não bateu em exatamente uma linha | Usar .maybeSingle() se zero linhas é um caso válido; revisar o filtro se esperava uma única linha |
| Realtime não dispara nada | Tabela sem replicação habilitada, ou canal não te inscreveu no evento certo | Checar Database → Replication; conferir schema: 'public', table: 'nome_certo' no .on(...) |
🏠 Atividade assíncrona (1 h)¶
Recrie o CRUD da sua entidade principal usando Supabase (se ainda não completou no laboratório) e escreva uma análise comparativa de 1 página entre a abordagem Express+MySQL (Aula 11) e a abordagem Supabase (hoje), cobrindo: quantidade de código escrito em cada uma, onde ficou a validação e a regra de negócio em cada caso, o que foi mais rápido de implementar, o que você confiaria menos sem testes automatizados, e qual você escolheria para o seu projeto autoral final — com justificativa. Este texto é conteúdo de estudo para o exame final.
Critério de pronto: CRUD Supabase funcionando (RLS + policies + operações básicas) e o texto comparativo entregue, com pelo menos os cinco pontos acima abordados.
✅ Checkpoint do projeto autoral¶
- [ ] Projeto Supabase criado, com
VITE_SUPABASE_URLeVITE_SUPABASE_ANON_KEYno.env(nunca aservice_role). - [ ] Tabelas da entidade principal criadas por SQL, com
uuidcomo PK etimestamptzonde há data/hora. - [ ] RLS habilitado em toda tabela nova, com as quatro policies (leitura pública, inserção autenticada, edição e exclusão do dono) escritas e testadas.
- [ ] CRUD completo com
supabase-js, sempre tratando{ data, error }. - [ ] Login/logout via Supabase Auth conectado às policies por
auth.uid(). - [ ] Realtime funcionando em pelo menos uma tela.
- [ ]
eventosRepo(ou equivalente autoral) implementado nas duas versões (API própria e Supabase), com troca por variável de ambiente. - [ ] Análise comparativa de 1 página escrita e guardada no repositório.
📚 Para aprofundar¶
- Supabase — Row Level Security
- Supabase —
supabase-jsreference - Supabase — Auth
- Supabase — Realtime
- PostgreSQL —
timestamptze tipos de data - Plano de curso, Unidade 3: banco de dados em nuvem e padrões estruturais.
A Aula 13 muda de foco: em vez de mais um fornecedor, vamos refatorar e consolidar o back-end do UniEventos — revisando a arquitetura em camadas, aplicando injeção de dependência e organizando tudo o que construímos nas Aulas 07 a 12 num back-end coeso e defensável.
Aula 13 — Desenvolvimento do back-end em camadas
🎯 Objetivos de aprendizagem¶
Ao final desta aula você será capaz de:
- Organizar uma API Express em camadas (
routes → controllers → services → repositories → db), aplicando a regra de dependência entre elas. - Aplicar injeção de dependência sem framework, passando repositórios para services por parâmetro/factory, e explicar por que isso torna o código testável.
- Centralizar e validar a configuração da aplicação com
zodemsrc/config/index.js, falhando rápido quando falta uma variável de ambiente. - Construir uma hierarquia de erros de domínio e um tratador de erros central que mapeia cada erro para o status HTTP correto, com logs estruturados via
pino. - Aplicar proteções básicas de segurança (
helmet,express-rate-limit, CORS restritivo, limite de payload) e relacioná-las ao OWASP Top 10. - Escrever testes automatizados de integração (rota, com
supertest) e unitários (service, com repositório falso) usandovitest. - Criar e executar migrations de banco de dados com scripts numerados e uma tabela de controle, sem depender de
schema.sqlmanual.
📋 Pré-requisitos desta aula¶
- API
unieventos-apifuncionando com Express 5, persistência em MySQL (Aula 09) e autenticação Firebase (Aula 10), com CRUD completo (Aula 11). - Estrutura mínima de pastas
src/routes,src/controllersjá existente (mesmo que ainda sem separação completa em services/repositories). - Node.js 22 LTS e MySQL rodando localmente (ou acessível via
DATABASE_URL).
Checklist antes de começar:
- [ ]
npm run devsobe a API sem erro. - [ ] Existe pelo menos um endpoint de eventos funcionando (
GET /api/eventos). - [ ] Você sabe onde estão as credenciais do banco no seu
.env.
🗺️ Roteiro¶
| Bloco | Tempo | Atividade |
|---|---|---|
| 1 | 50 min | Arquitetura em camadas, injeção de dependência, configuração centralizada com zod |
| 2 | 50 min | Hierarquia de erros, segurança prática (helmet, rate limit, CORS), OWASP Top 10 |
| 3 | 50 min | Testes com vitest + supertest, migrations com scripts numerados |
Retomando a Aula 12¶
Na Aula 12 trocamos o MySQL por Supabase sem alterar uma linha do front-end, porque a camada services/ já escondia a origem dos dados atrás de uma interface única — o padrão Adapter em ação. Isso só foi possível porque o back-end já tinha, mesmo que informalmente, uma separação entre "o que a rota expõe" e "de onde o dado vem". Hoje formalizamos essa separação: paramos de escrever back-end que "funciona" e passamos a escrever back-end que se sustenta — testável, seguro, com erros previsíveis e configuração validada.
1. Arquitetura em camadas revisitada¶
Até aqui, o unieventos-api cresceu organicamente: rota chama controller, controller consulta o banco direto, às vezes até valida direto na rota. Isso funciona para um protótipo, mas não escala — cada mudança no banco obriga a mexer em código que deveria só cuidar de HTTP, e não dá para testar regra de negócio sem subir um banco de verdade.
A solução é formalizar cinco responsabilidades separadas:
routes/ → só sabe de HTTP: métodos, caminhos, parâmetros, chama o controller
controllers/ → traduz requisição/resposta HTTP para chamadas de service
services/ → regra de negócio pura, não sabe de Express nem de SQL
repositories/ → sabe conversar com a fonte de dados (MySQL, Supabase, memória...)
db/ → conexão de baixo nível (pool do mysql2, cliente do Supabase)
Mais três pastas de apoio, que qualquer camada pode usar:
middlewares/ → funções que interceptam a requisição (auth, validação, log, segurança)
validators/ → esquemas zod que descrevem o formato esperado de cada entrada
utils/ → funções puras reaproveitáveis (logger, formatação, helpers)
config/ → leitura e validação centralizada de variáveis de ambiente
1.1 O fluxo de uma requisição, camada por camada¶
Cliente HTTP (front-end / Postman)
│
│ POST /api/eventos { titulo, categoria, vagas, ... }
▼
┌─────────────────────┐
│ middlewares globais │ helmet, cors, express.json, rate-limit, log
└─────────┬────────────┘
▼
┌─────────────────────┐
│ routes/eventos.js │ define o path e delega ao controller
└─────────┬────────────┘
▼
┌─────────────────────┐
│ middlewares de rota │ autenticação (verificarToken), validação (validar(schema))
└─────────┬────────────┘
▼
┌──────────────────────────┐
│ controllers/eventosController │ lê req.body/req.params, chama o service,
│ │ monta a resposta HTTP (status + JSON)
└─────────┬──────────────────────┘
▼
┌──────────────────────────┐
│ services/eventosService │ regra de negócio: "vagas não pode ser negativo",
│ │ "só o dono pode editar", orquestra repositórios
└─────────┬──────────────────────┘
▼
┌──────────────────────────┐
│ repositories/eventosRepository │ monta e executa a query, mapeia linha → objeto
└─────────┬──────────────────────┘
▼
┌──────────────────────────┐
│ db/pool.js │ conexão física com o MySQL (ou outro SGBD)
└────────────────────────────┘
A resposta sobe pelo mesmo caminho, em ordem inversa: o repository devolve dados brutos, o service aplica regra de negócio e devolve um objeto de domínio, o controller decide o status HTTP e serializa em JSON, o Express entrega ao cliente.
1.2 A regra de dependência¶
⚠️ Atenção Camada de fora pode conhecer e importar a de dentro. Camada de dentro nunca pode importar a de fora.
Na prática:
routespode importarcontrollers.controllersnão pode importarroutes.controllerspode importarservices.servicesnão pode importarcontrollersnemreq/resdo Express.servicespode importarrepositories.repositoriesnão pode importarservices.repositoriespode importardb.dbnão sabe querepositoriesexiste.
O motivo: quanto mais "para dentro", mais a camada deveria ser reutilizável e testável sem HTTP nem banco real. Um service que importa req/res do Express está, na prática, acoplado ao protocolo HTTP — e não dá mais para chamá-lo a partir de um job agendado, de um teste unitário puro, ou de uma futura API GraphQL sem reescrever regra de negócio.
🔎 Por baixo do capô Essa regra é uma versão simplificada da Dependency Inversion Principle (o "D" do SOLID) e da Arquitetura Limpa (Clean Architecture, Robert C. Martin): as regras de negócio no centro, os detalhes de infraestrutura (HTTP, banco, filesystem) na borda, sempre apontando para dentro.
2. Injeção de dependência sem framework¶
O problema mais comum em back-ends que "crescem sem arquitetura" é o service importando o repository diretamente no topo do arquivo. Funciona, mas prende o service a uma implementação específica — impossível testar sem banco de verdade, impossível trocar de fonte de dados sem editar o service.
Antes — import direto, acoplado:
// src/services/eventosService.ANTES.js
// PROBLEMA: este arquivo só funciona se existir um MySQL de verdade rodando.
// Não dá para testar a regra "vagas não pode ser negativo" sem banco.
import { pool } from '../db/pool.js'
export async function listarEventos() {
const [linhas] = await pool.query('SELECT * FROM eventos ORDER BY data_hora')
return linhas
}
export async function criarEvento(dados) {
if (dados.vagas < 0) {
throw new Error('vagas não pode ser negativo')
}
const [resultado] = await pool.query(
'INSERT INTO eventos (titulo, categoria, vagas) VALUES (?, ?, ?)',
[dados.titulo, dados.categoria, dados.vagas],
)
return { id: resultado.insertId, ...dados }
}
Depois — o repositório é injetado (passado por parâmetro):
// src/services/eventosService.js
// O service NÃO SABE se o repositório fala com MySQL, Supabase ou memória.
// Ele só conhece a INTERFACE: listar(), buscarPorId(), criar(), atualizar(), remover().
import { ErroDeValidacao, ErroNaoEncontrado } from '../errors/index.js'
export function criarServicoDeEventos({ eventosRepository }) {
return {
async listarEventos(filtros) {
return eventosRepository.listar(filtros)
},
async buscarEventoPorId(id) {
const evento = await eventosRepository.buscarPorId(id)
if (!evento) {
throw new ErroNaoEncontrado(`Evento ${id} não encontrado`)
}
return evento
},
async criarEvento(dados) {
if (dados.vagas < 0) {
throw new ErroDeValidacao('vagas não pode ser negativo')
}
return eventosRepository.criar(dados)
},
async atualizarEvento(id, dados) {
await this.buscarEventoPorId(id) // reaproveita a validação de existência
return eventosRepository.atualizar(id, dados)
},
async removerEvento(id) {
await this.buscarEventoPorId(id)
return eventosRepository.remover(id)
},
}
}
A função criarServicoDeEventos é uma factory: recebe as dependências como argumento (aqui, um objeto com eventosRepository) e devolve o objeto pronto para uso. Quem monta a aplicação decide qual repositório injetar — em produção, o do MySQL; em teste, um repositório falso em memória, sem precisar de banco nenhum.
// src/app.js (montagem — quem decide as dependências concretas)
import { criarRepositorioDeEventosMySQL } from './repositories/eventosRepository.mysql.js'
import { criarServicoDeEventos } from './services/eventosService.js'
const eventosRepository = criarRepositorioDeEventosMySQL()
const eventosService = criarServicoDeEventos({ eventosRepository })
// eventosService agora pode ser passado ao controller, sem que o service
// jamais tenha importado o pool do MySQL diretamente.
💡 Dica Injeção de dependência não exige framework nenhum em JavaScript — não precisamos de
@Injectable()nem de container de DI. Uma função que recebe parâmetros já é injeção de dependência. O nome bonito não deve intimidar: é passar objetos como argumento, em vez de importar dentro do arquivo.
3. Configuração centralizada com zod¶
Espalhar process.env.ALGUMA_COISA pelo código inteiro é frágil: se a variável não existir, o erro só aparece no meio de uma requisição, em produção, na pior hora. A solução é validar todo o ambiente uma única vez, na inicialização, e falhar rápido se algo estiver faltando.
// src/config/index.js
import { z } from 'zod'
import 'dotenv/config'
const esquemaDeAmbiente = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
DB_HOST: z.string().min(1, 'DB_HOST é obrigatória'),
DB_PORT: z.coerce.number().int().positive().default(3306),
DB_USER: z.string().min(1, 'DB_USER é obrigatória'),
DB_PASSWORD: z.string().min(1, 'DB_PASSWORD é obrigatória'),
DB_NAME: z.string().min(1, 'DB_NAME é obrigatória'),
FIREBASE_PROJECT_ID: z.string().min(1, 'FIREBASE_PROJECT_ID é obrigatória'),
CORS_ORIGEM_PERMITIDA: z.string().min(1, 'CORS_ORIGEM_PERMITIDA é obrigatória'),
})
// safeParse NÃO lança exceção — devolve um objeto { success, data } ou { success, error }.
// Isso permite montar uma mensagem de erro clara antes de encerrar o processo.
const resultado = esquemaDeAmbiente.safeParse(process.env)
if (!resultado.success) {
console.error('❌ Configuração de ambiente inválida:')
for (const problema of resultado.error.issues) {
console.error(` - ${problema.path.join('.')}: ${problema.message}`)
}
// Falha rápido: melhor a aplicação nem subir do que subir quebrada.
process.exit(1)
}
export const config = Object.freeze(resultado.data)
# .env.example — copie para .env e preencha com valores reais
NODE_ENV=development
PORT=3000
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=troque-esta-senha
DB_NAME=uni_eventos
FIREBASE_PROJECT_ID=uni-eventos-12345
CORS_ORIGEM_PERMITIDA=http://localhost:5173
A partir de agora, nenhum outro arquivo lê process.env diretamente — todos importam config de src/config/index.js:
// src/db/pool.js — uso de config em vez de process.env espalhado
import { config } from '../config/index.js'
export const configuracaoDoPool = {
host: config.DB_HOST,
port: config.DB_PORT,
user: config.DB_USER,
password: config.DB_PASSWORD,
database: config.DB_NAME,
}
⚠️ Atenção Se você esquecer
DB_PASSWORDno.env, o processo não sobe — imprime exatamente qual variável falta e sai comprocess.exit(1). Isso é intencional: é infinitamente melhor descobrir isso nonpm run devdo que às 23h58 tentando fazer o deploy funcionar para a Avaliação 3.
4. Tratamento de erros maduro¶
4.1 Hierarquia de erros de domínio¶
// src/errors/index.js
// Erros de DOMÍNIO carregam significado de negócio, não de protocolo HTTP.
// Quem decide o status HTTP é o tratador central (Seção 4.2), não o service.
export class ErroDeAplicacao extends Error {
constructor(mensagem, status = 500) {
super(mensagem)
this.name = this.constructor.name
this.status = status
// Marca erros esperados/tratáveis, para diferenciar de bugs inesperados no log.
this.operacional = true
}
}
export class ErroDeValidacao extends ErroDeAplicacao {
constructor(mensagem, detalhes = []) {
super(mensagem, 400)
this.detalhes = detalhes
}
}
export class ErroNaoEncontrado extends ErroDeAplicacao {
constructor(mensagem = 'Recurso não encontrado') {
super(mensagem, 404)
}
}
export class ErroDeAutorizacao extends ErroDeAplicacao {
constructor(mensagem = 'Você não tem permissão para executar esta ação') {
super(mensagem, 403)
}
}
export class ErroDeConflito extends ErroDeAplicacao {
constructor(mensagem = 'Conflito com o estado atual do recurso') {
super(mensagem, 409)
}
}
Usar essa hierarquia no service fica direto:
// trecho de src/services/inscricoesService.js
import { ErroDeConflito, ErroDeAutorizacao } from '../errors/index.js'
async function inscrever({ eventoId, usuarioUid }) {
const jaInscrito = await inscricoesRepository.existeInscricao(eventoId, usuarioUid)
if (jaInscrito) {
throw new ErroDeConflito('Você já está inscrito neste evento')
}
// ...
}
async function cancelarInscricao({ inscricaoId, usuarioUidSolicitante }) {
const inscricao = await inscricoesRepository.buscarPorId(inscricaoId)
if (inscricao.usuario_uid !== usuarioUidSolicitante) {
throw new ErroDeAutorizacao('Só é possível cancelar a própria inscrição')
}
// ...
}
4.2 Logs estruturados com pino¶
npm install pino pino-http
// src/utils/logger.js
import pino from 'pino'
import { config } from '../config/index.js'
// Em desenvolvimento, log legível por humano (pino-pretty precisa ser instalado à parte
// como devDependency: npm install -D pino-pretty).
// Em produção, log em JSON puro — mais rápido e pronto para ferramentas de observabilidade.
export const logger = pino({
level: config.NODE_ENV === 'production' ? 'info' : 'debug',
transport:
config.NODE_ENV === 'production'
? undefined
: { target: 'pino-pretty', options: { colorize: true } },
})
4.3 O tratador de erros central¶
// src/middlewares/tratadorDeErros.js
import { logger } from '../utils/logger.js'
import { config } from '../config/index.js'
// Middleware de erro do Express: identificado pela ASSINATURA DE 4 PARÂMETROS.
// Em Express 5, erros lançados dentro de handlers async chegam aqui automaticamente,
// sem precisar de try/catch manual nem de .catch(next) em cada rota.
export function tratadorDeErros(erro, req, res, next) {
const status = erro.status ?? 500
const ehErroOperacional = erro.operacional === true
// Erros operacionais (esperados: validação, não encontrado...) viram log de aviso.
// Erros não-operacionais (bug inesperado) viram log de erro, com stack completo.
if (ehErroOperacional) {
logger.warn({ status, mensagem: erro.message, path: req.path }, 'erro operacional')
} else {
logger.error({ status, err: erro, path: req.path }, 'erro inesperado')
}
const corpoDaResposta = {
mensagem: ehErroOperacional ? erro.message : 'Erro interno do servidor',
}
if (erro.detalhes) {
corpoDaResposta.detalhes = erro.detalhes
}
// NUNCA vazar stack trace em produção — é informação valiosa para um atacante
// (caminhos de arquivo, versão de bibliotecas, estrutura interna).
if (config.NODE_ENV !== 'production') {
corpoDaResposta.stack = erro.stack
}
res.status(status).json(corpoDaResposta)
}
// src/server.js — captura de falhas que escapam do Express
import { app } from './app.js'
import { config } from './config/index.js'
import { logger } from './utils/logger.js'
const servidor = app.listen(config.PORT, () => {
logger.info(`API rodando na porta ${config.PORT} (${config.NODE_ENV})`)
})
// Promises rejeitadas sem .catch em NENHUM lugar do código (fora do ciclo de
// requisição do Express) caem aqui. Sem isso, o processo Node continua rodando
// em estado inconsistente, silenciosamente.
process.on('unhandledRejection', (motivo) => {
logger.error({ err: motivo }, 'unhandledRejection não tratada — encerrando processo')
servidor.close(() => process.exit(1))
})
process.on('uncaughtException', (erro) => {
logger.error({ err: erro }, 'uncaughtException — encerrando processo')
process.exit(1)
})
📌 Na prova
unhandledRejectioncaptura Promises rejeitadas que ninguém tratou;uncaughtExceptioncaptura exceções síncronas que escaparam de qualquertry/catch. Nenhum dos dois substitui tratamento de erro local — são uma rede de segurança final, não a primeira linha de defesa.
5. Segurança prática¶
Regra de ouro: nunca confie em nada que vem do cliente — nem no Content-Type declarado, nem no tamanho do payload, nem nos campos do corpo, nem no token de autenticação sem verificá-lo. Tudo que chega de fora é hostil até prova em contrário.
npm install helmet express-rate-limit cors
// src/middlewares/seguranca.js
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import cors from 'cors'
import { config } from '../config/index.js'
// helmet(): define um conjunto de cabeçalhos HTTP de segurança com um só import
// (X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security etc.).
export const cabecalhosDeSeguranca = helmet()
// Limita quantas requisições um mesmo IP pode fazer em uma janela de tempo —
// mitiga força bruta em login e ataques de negação de serviço simples.
export const limitadorDeTaxa = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutos
limit: 100, // 100 requisições por IP nessa janela
standardHeaders: true,
legacyHeaders: false,
message: { mensagem: 'Muitas requisições. Tente novamente mais tarde.' },
})
// CORS restritivo: só o domínio do front tem permissão — nunca use origin: '*'
// em uma API que aceita cookies ou token de autenticação.
export const corsConfigurado = cors({
origin: config.CORS_ORIGEM_PERMITIDA,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
})
// trecho de src/app.js — ordem importa: segurança primeiro, depois parsing, depois rotas
import express from 'express'
import { cabecalhosDeSeguranca, limitadorDeTaxa, corsConfigurado } from './middlewares/seguranca.js'
export function criarApp({ eventosRepository } = {}) {
const app = express()
app.use(cabecalhosDeSeguranca)
app.use(corsConfigurado)
app.use(limitadorDeTaxa)
// Limite de tamanho do corpo: evita que alguém envie um payload de 500 MB
// para derrubar o processo por consumo de memória.
app.use(express.json({ limit: '10kb' }))
app.use(express.urlencoded({ extended: true, limit: '10kb' }))
// ... rotas registradas depois daqui (Seção "Mão na massa")
return app
}
⚠️ Atenção
express.json({ limit: '10kb' })rejeita automaticamente corpos maiores com413 Payload Too Large. Ajuste o limite ao seu domínio — 10kb é generoso para um formulário de evento, mas seria pouco se você aceitasse upload de imagem em base64 no corpo (nesse caso, prefira Storage, como no Supabase da Aula 12).
5.1 Checklist OWASP Top 10 aplicado a esta disciplina¶
| Categoria OWASP | O que fazemos no UniEventos |
|---|---|
| A01 — Quebra de controle de acesso | Middleware autenticacao.js (Aula 10) + verificação de dono do recurso nos services (ex.: ErroDeAutorizacao ao cancelar inscrição alheia) |
| A02 — Falhas criptográficas | Senha nunca é gerenciada por nós — delegada ao Firebase Auth; .env fora do controle de versão; HTTPS obrigatório em produção (Aula 15) |
| A03 — Injeção | Queries sempre parametrizadas com ? no mysql2 (nunca concatenação de string); validação de entrada com zod antes de tocar no banco |
| A04 — Design inseguro | Arquitetura em camadas desta aula; regra de negócio centralizada no service, não espalhada em cada rota |
| A05 — Configuração incorreta | helmet, CORS restritivo, .env validado por zod, stack trace escondida em produção |
| A07 — Falhas de identificação | Token do Firebase verificado no back a cada requisição (Aula 10), nunca confiar em usuario_uid vindo do corpo da requisição |
| A09 — Falhas de log e monitoramento | Logs estruturados com pino, diferenciando erro operacional de erro inesperado |
🔎 Por baixo do capô Note que "sanitizar entrada" aqui não significa escapar HTML manualmente — significa validar contra um schema (zod) antes de qualquer processamento, e nunca montar SQL por concatenação. Essas duas práticas já eliminam a maior parte da superfície de ataque de injeção em uma API JSON.
🧩 Padrão de projeto em uso¶
🧩 Padrões de projeto em uso — Builder, Dependency Injection, Singleton, Facade, Repository, Strategy¶
Esta aula é a mais densa em padrões GoF do semestre, porque a arquitetura em camadas é literalmente a aplicação simultânea de vários deles.
Dependency Injection — Seção 3:
criarServicoDeEventos({ eventosRepository })recebe a dependência em vez de importá-la. O service não conhece a implementação concreta, só a interface (listar,buscarPorId,criar...).Singleton — o pool de conexões do MySQL (criado na Aula 09 com
mysql2.createPool) é instanciado uma única vez por processo e reutilizado por todos os repositórios: ```js // src/db/pool.js import mysql from 'mysql2/promise' import { configuracaoDoPool } from './configuracaoDoPool.js'let instanciaDoPool // módulo ES: só existe uma vez por processo Node — Singleton natural
export function obterPool() { if (!instanciaDoPool) { instanciaDoPool = mysql.createPool(configuracaoDoPool) } return instanciaDoPool }
`` Qualquer repositório que chameobterPool()` recebe a mesma instância — é assim que o Singleton evita esgotar conexões do banco.Facade —
services/eventosService.jsé uma fachada simples sobre o repositório: o controller não precisa saber que, por trás decriarEvento, existem validação de negócio e uma chamada ao banco. Ele só vê uma operação de alto nível.Repository —
repositories/eventosRepository.mysql.jsencapsula toda a SQL; o resto da aplicação nunca escreveSELECT/INSERTfora dessa camada.Strategy — a escolha de qual repositório usar em tempo de execução (ver
src/repositories/index.jsna seção "Mão na massa") é o padrão Strategy: a mesma interface (listar,criar...), implementações intercambiáveis por ambiente (MySQL em produção, memória em teste).Builder — a montagem de uma query de listagem com filtros opcionais (categoria, texto, paginação) usa um builder que acumula condições passo a passo antes de gerar o SQL final — ver
QueryBuilderna Seção 7.2 abaixo.
6. Testes automatizados¶
6.1 A pirâmide de testes¶
▲
╱ ╲ poucos, lentos, caros de manter
╱ E2E╲ (Cypress/Playwright rodando a UI inteira)
╱───────╲
╱ API/ ╲ médios: sobem a aplicação, testam rotas HTTP reais
╱ integração ╲ (supertest — Seção 6.3)
╱───────────────╲
╱ unitários ╲ muitos, rápidos, baratos — testam uma função/service
╱ (service, utils) ╲ isolado, sem rede nem banco (Seção 6.4)
▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
Testar o service unitariamente é mais barato que testar pela UI por três motivos: (1) roda em milissegundos, sem subir navegador nem servidor; (2) não depende de rede nem de banco de dados real, então não quebra por instabilidade externa; (3) aponta exatamente qual regra de negócio falhou, sem precisar navegar até a tela que dispara aquele fluxo.
6.2 Instalando e configurando¶
npm install -D vitest supertest
// package.json — trecho de "scripts"
{
"scripts": {
"dev": "node --watch src/server.js",
"start": "node src/server.js",
"test": "vitest run",
"test:watch": "vitest"
}
}
// vitest.config.js
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
},
})
6.3 Teste de integração (rota, com supertest)¶
// test/eventos.rota.test.js
import { describe, it, expect } from 'vitest'
import request from 'supertest'
import { criarApp } from '../src/app.js'
import { criarRepositorioDeEventosEmMemoria } from '../src/repositories/eventosRepository.memoria.js'
// Sobe a aplicação Express de verdade, mas com o repositório de MEMÓRIA —
// nenhum MySQL precisa estar rodando para este teste passar.
function montarAppDeTeste() {
const eventosRepository = criarRepositorioDeEventosEmMemoria([
{ id: 1, titulo: 'Semana da Computação', categoria: 'palestra', vagas: 40 },
])
return criarApp({ eventosRepository })
}
describe('rotas de /api/eventos', () => {
it('GET /api/eventos retorna 200 e um array', async () => {
const app = montarAppDeTeste()
const resposta = await request(app).get('/api/eventos')
expect(resposta.status).toBe(200)
expect(Array.isArray(resposta.body)).toBe(true)
expect(resposta.body).toHaveLength(1)
})
it('GET /api/eventos/:id inexistente retorna 404 com mensagem', async () => {
const app = montarAppDeTeste()
const resposta = await request(app).get('/api/eventos/999')
expect(resposta.status).toBe(404)
expect(resposta.body.mensagem).toMatch(/não encontrado/i)
})
it('POST /api/eventos sem título retorna 400 (validação zod)', async () => {
const app = montarAppDeTeste()
const resposta = await request(app)
.post('/api/eventos')
.send({ categoria: 'palestra', vagas: 10 })
expect(resposta.status).toBe(400)
expect(resposta.body.detalhes).toBeDefined()
})
it('POST /api/eventos válido retorna 201 e o evento criado', async () => {
const app = montarAppDeTeste()
const resposta = await request(app)
.post('/api/eventos')
.send({ titulo: 'Hackathon FACET', categoria: 'workshop', vagas: 60 })
expect(resposta.status).toBe(201)
expect(resposta.body.titulo).toBe('Hackathon FACET')
expect(resposta.body.id).toBeDefined()
})
})
6.4 Teste unitário (service, com repositório falso)¶
// test/eventos.service.test.js
import { describe, it, expect } from 'vitest'
import { criarServicoDeEventos } from '../src/services/eventosService.js'
// Repositório FALSO (test double): implementa a mesma interface do repositório
// real, mas guarda tudo em um array na memória do próprio teste — zero I/O.
function criarRepositorioFalso(eventosIniciais = []) {
const eventos = [...eventosIniciais]
return {
async listar() {
return eventos
},
async buscarPorId(id) {
return eventos.find((evento) => evento.id === id) ?? null
},
async criar(dados) {
const novoEvento = { id: eventos.length + 1, ...dados }
eventos.push(novoEvento)
return novoEvento
},
}
}
describe('eventosService (unitário)', () => {
it('listarEventos delega ao repositório e devolve o array', async () => {
const service = criarServicoDeEventos({
eventosRepository: criarRepositorioFalso([{ id: 1, titulo: 'Evento A' }]),
})
const eventos = await service.listarEventos()
expect(eventos).toHaveLength(1)
expect(eventos[0].titulo).toBe('Evento A')
})
it('buscarEventoPorId lança ErroNaoEncontrado quando o id não existe', async () => {
const service = criarServicoDeEventos({ eventosRepository: criarRepositorioFalso([]) })
await expect(service.buscarEventoPorId(42)).rejects.toThrow('não encontrado')
})
it('criarEvento lança ErroDeValidacao quando vagas é negativo', async () => {
const service = criarServicoDeEventos({ eventosRepository: criarRepositorioFalso([]) })
await expect(
service.criarEvento({ titulo: 'Evento inválido', categoria: 'palestra', vagas: -5 }),
).rejects.toThrow('vagas não pode ser negativo')
})
})
Rodando os testes:
npm test
RUN v2.1.9 unieventos-api
✓ test/eventos.service.test.js (3 tests) 4ms
✓ test/eventos.rota.test.js (4 tests) 29ms
Test Files 2 passed (2)
Tests 7 passed (7)
Start at 20:14:02
Duration 612ms
💡 Dica Sete testes cobrindo as regras mais importantes (listar, 404, validação, criação) já dão confiança real para refatorar sem medo. A meta não é "100% de cobertura" — é cobrir os caminhos de negócio que importam.
7. Migrations de banco¶
7.1 Por que schema.sql manual não escala¶
Até a Aula 09, o banco foi criado rodando um schema.sql inteiro na mão. Isso funciona sozinho, mas quebra em equipe: cada pessoa pode ter uma versão diferente do schema local, não há histórico do que mudou e quando, e aplicar uma mudança em produção vira "abrir o MySQL Workbench e rezar". Migration resolve isso: cada mudança de schema vira um arquivo numerado, versionado no Git, aplicado em ordem, uma única vez, com registro em uma tabela de controle.
7.2 Implementação simples: scripts numerados + tabela de controle¶
-- migrations/0001_criar_tabela_eventos.sql
CREATE TABLE IF NOT EXISTS eventos (
id INT AUTO_INCREMENT PRIMARY KEY,
titulo VARCHAR(150) NOT NULL,
descricao TEXT,
categoria ENUM('palestra', 'minicurso', 'workshop') NOT NULL,
data_hora DATETIME NOT NULL,
local VARCHAR(150) NOT NULL,
vagas INT NOT NULL DEFAULT 0,
imagem_url VARCHAR(255),
criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- migrations/0002_criar_tabela_inscricoes.sql
CREATE TABLE IF NOT EXISTS inscricoes (
id INT AUTO_INCREMENT PRIMARY KEY,
evento_id INT NOT NULL,
usuario_uid VARCHAR(128) NOT NULL,
criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (evento_id) REFERENCES eventos(id) ON DELETE CASCADE,
UNIQUE KEY uk_evento_usuario (evento_id, usuario_uid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- migrations/0003_adicionar_indice_categoria.sql
ALTER TABLE eventos ADD INDEX idx_categoria (categoria);
// scripts/migrar.js
// Executor de migrations minimalista: lê migrations/*.sql em ordem numérica,
// aplica só as que ainda não constam na tabela de controle.
import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
import mysql from 'mysql2/promise'
import { config } from '../src/config/index.js'
const PASTA_DE_MIGRATIONS = new URL('../migrations', import.meta.url)
async function garantirTabelaDeControle(conexao) {
await conexao.query(`
CREATE TABLE IF NOT EXISTS migrations_executadas (
nome_arquivo VARCHAR(255) PRIMARY KEY,
executado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB
`)
}
async function listarMigrationsJaExecutadas(conexao) {
const [linhas] = await conexao.query('SELECT nome_arquivo FROM migrations_executadas')
return new Set(linhas.map((linha) => linha.nome_arquivo))
}
async function executarMigracoes() {
const conexao = await mysql.createConnection({
host: config.DB_HOST,
port: config.DB_PORT,
user: config.DB_USER,
password: config.DB_PASSWORD,
database: config.DB_NAME,
multipleStatements: true,
})
try {
await garantirTabelaDeControle(conexao)
const jaExecutadas = await listarMigrationsJaExecutadas(conexao)
const arquivos = (await readdir(PASTA_DE_MIGRATIONS))
.filter((arquivo) => arquivo.endsWith('.sql'))
.sort() // nomes numerados (0001_..., 0002_...) garantem ordem correta
let quantidadeAplicada = 0
for (const arquivo of arquivos) {
if (jaExecutadas.has(arquivo)) {
console.log(`↷ ${arquivo} já aplicada, pulando`)
continue
}
const caminhoCompleto = path.join(new URL(PASTA_DE_MIGRATIONS).pathname, arquivo)
const sql = await readFile(caminhoCompleto, 'utf-8')
console.log(`▶ aplicando ${arquivo}...`)
await conexao.query(sql)
await conexao.query('INSERT INTO migrations_executadas (nome_arquivo) VALUES (?)', [arquivo])
quantidadeAplicada += 1
console.log(`✔ ${arquivo} aplicada`)
}
console.log(`\nConcluído: ${quantidadeAplicada} migration(s) nova(s) aplicada(s).`)
} finally {
await conexao.end()
}
}
executarMigracoes().catch((erro) => {
console.error('❌ falha ao rodar migrations:', erro.message)
process.exit(1)
})
// package.json — trecho de "scripts"
{
"scripts": {
"migrar": "node scripts/migrar.js"
}
}
npm run migrar
# ▶ aplicando 0001_criar_tabela_eventos.sql...
# ✔ 0001_criar_tabela_eventos.sql aplicada
# ▶ aplicando 0002_criar_tabela_inscricoes.sql...
# ✔ 0002_criar_tabela_inscricoes.sql aplicada
# ▶ aplicando 0003_adicionar_indice_categoria.sql...
# ✔ 0003_adicionar_indice_categoria.sql aplicada
#
# Concluído: 3 migration(s) nova(s) aplicada(s).
npm run migrar
# ↷ 0001_criar_tabela_eventos.sql já aplicada, pulando
# ↷ 0002_criar_tabela_inscricoes.sql já aplicada, pulando
# ↷ 0003_adicionar_indice_categoria.sql já aplicada, pulando
#
# Concluído: 0 migration(s) nova(s) aplicada(s).
🔎 Por baixo do capô Ferramentas prontas como
node-pg-migrate(Postgres) ouumzug(multi-banco) fazem exatamente isso — tabela de controle + arquivos ordenados — só que com mais recursos (rollback automático, geração de esqueleto de arquivo, migrations em JS além de SQL). Entender o mecanismo manual antes de usar a ferramenta pronta evita tratá-la como caixa-preta.
💻 Mão na massa — refatorando o unieventos-api para arquitetura em camadas¶
Passo 1 — instale as dependências novas:
cd unieventos-api
npm install zod pino pino-http helmet express-rate-limit dotenv
npm install -D vitest supertest pino-pretty
Passo 2 — crie a estrutura de pastas:
mkdir -p src/config src/db src/errors src/middlewares src/repositories src/services src/controllers src/routes src/validators src/utils migrations scripts test
Passo 3 — configuração (já mostrada na Seção 3):
Copie o conteúdo de src/config/index.js e .env.example da Seção 3 para o projeto.
Passo 4 — o pool de conexão (Singleton):
// src/db/pool.js
import mysql from 'mysql2/promise'
import { config } from '../config/index.js'
let instanciaDoPool
export function obterPool() {
if (!instanciaDoPool) {
instanciaDoPool = mysql.createPool({
host: config.DB_HOST,
port: config.DB_PORT,
user: config.DB_USER,
password: config.DB_PASSWORD,
database: config.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
})
}
return instanciaDoPool
}
Passo 5 — validador de entrada com zod:
// src/validators/eventoSchema.js
import { z } from 'zod'
export const eventoSchema = z.object({
titulo: z.string().min(3, 'titulo precisa ter ao menos 3 caracteres').max(150),
descricao: z.string().max(2000).optional(),
categoria: z.enum(['palestra', 'minicurso', 'workshop']),
dataHora: z.string().datetime({ message: 'dataHora precisa ser um ISO 8601 válido' }),
local: z.string().min(3).max(150),
vagas: z.coerce.number().int().nonnegative('vagas não pode ser negativo'),
imagemUrl: z.string().url().optional(),
})
export const eventoAtualizacaoSchema = eventoSchema.partial()
// src/middlewares/validar.js
import { ErroDeValidacao } from '../errors/index.js'
// Middleware genérico: recebe um schema zod e devolve um middleware Express
// que valida req.body antes de deixar a requisição seguir para o controller.
export function validar(schema) {
return (req, res, next) => {
const resultado = schema.safeParse(req.body)
if (!resultado.success) {
const detalhes = resultado.error.issues.map((problema) => ({
campo: problema.path.join('.'),
mensagem: problema.message,
}))
throw new ErroDeValidacao('Dados inválidos', detalhes)
}
req.body = resultado.data // body validado e com coerções aplicadas (ex.: vagas vira number)
next()
}
}
Passo 6 — os erros de domínio:
Use o conteúdo de src/errors/index.js da Seção 4.1.
Passo 7 — o repositório MySQL (com Builder de query):
// src/repositories/queryBuilderDeListagem.js
// Builder: monta incrementalmente a query SQL de listagem, adicionando cláusulas
// WHERE só para os filtros que realmente vieram preenchidos.
export class QueryBuilderDeListagem {
constructor(tabela) {
this.tabela = tabela
this.condicoes = []
this.parametros = []
this.limiteValor = 20
this.deslocamentoValor = 0
}
comCategoria(categoria) {
if (categoria) {
this.condicoes.push('categoria = ?')
this.parametros.push(categoria)
}
return this // encadeamento fluente — marca registrada do Builder
}
comBuscaDeTexto(termo) {
if (termo) {
this.condicoes.push('titulo LIKE ?')
this.parametros.push(`%${termo}%`)
}
return this
}
comPaginacao(pagina = 1, porPagina = 20) {
this.limiteValor = porPagina
this.deslocamentoValor = (pagina - 1) * porPagina
return this
}
construir() {
const clausulaWhere = this.condicoes.length > 0 ? `WHERE ${this.condicoes.join(' AND ')}` : ''
const sql = `
SELECT * FROM ${this.tabela}
${clausulaWhere}
ORDER BY data_hora ASC
LIMIT ? OFFSET ?
`
return {
sql,
parametros: [...this.parametros, this.limiteValor, this.deslocamentoValor],
}
}
}
// src/repositories/eventosRepository.mysql.js
import { obterPool } from '../db/pool.js'
import { QueryBuilderDeListagem } from './queryBuilderDeListagem.js'
function linhaParaEvento(linha) {
return {
id: linha.id,
titulo: linha.titulo,
descricao: linha.descricao,
categoria: linha.categoria,
dataHora: linha.data_hora,
local: linha.local,
vagas: linha.vagas,
imagemUrl: linha.imagem_url,
}
}
export function criarRepositorioDeEventosMySQL() {
const pool = obterPool()
return {
async listar({ categoria, busca, pagina, porPagina } = {}) {
const { sql, parametros } = new QueryBuilderDeListagem('eventos')
.comCategoria(categoria)
.comBuscaDeTexto(busca)
.comPaginacao(pagina, porPagina)
.construir()
const [linhas] = await pool.query(sql, parametros)
return linhas.map(linhaParaEvento)
},
async buscarPorId(id) {
const [linhas] = await pool.query('SELECT * FROM eventos WHERE id = ?', [id])
return linhas[0] ? linhaParaEvento(linhas[0]) : null
},
async criar(dados) {
const [resultado] = await pool.query(
`INSERT INTO eventos (titulo, descricao, categoria, data_hora, local, vagas, imagem_url)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[dados.titulo, dados.descricao ?? null, dados.categoria, dados.dataHora, dados.local, dados.vagas, dados.imagemUrl ?? null],
)
return { id: resultado.insertId, ...dados }
},
async atualizar(id, dados) {
await pool.query(
`UPDATE eventos SET titulo = ?, descricao = ?, categoria = ?, data_hora = ?, local = ?, vagas = ?, imagem_url = ?
WHERE id = ?`,
[dados.titulo, dados.descricao ?? null, dados.categoria, dados.dataHora, dados.local, dados.vagas, dados.imagemUrl ?? null, id],
)
return this.buscarPorId(id)
},
async remover(id) {
await pool.query('DELETE FROM eventos WHERE id = ?', [id])
},
}
}
Passo 8 — o repositório em memória (para testes):
// src/repositories/eventosRepository.memoria.js
// Implementa a MESMA interface do repositório MySQL, sem tocar em banco algum.
// Usado nos testes (Seção 6.3) e como referência didática de Strategy.
export function criarRepositorioDeEventosEmMemoria(eventosIniciais = []) {
let eventos = [...eventosIniciais]
let proximoId = eventos.length + 1
return {
async listar({ categoria } = {}) {
if (!categoria) return eventos
return eventos.filter((evento) => evento.categoria === categoria)
},
async buscarPorId(id) {
return eventos.find((evento) => evento.id === Number(id)) ?? null
},
async criar(dados) {
const novoEvento = { id: proximoId++, ...dados }
eventos.push(novoEvento)
return novoEvento
},
async atualizar(id, dados) {
eventos = eventos.map((evento) => (evento.id === Number(id) ? { ...evento, ...dados } : evento))
return this.buscarPorId(id)
},
async remover(id) {
eventos = eventos.filter((evento) => evento.id !== Number(id))
},
}
}
Passo 9 — Strategy: escolha do repositório por ambiente:
// src/repositories/index.js
// Strategy: a interface é sempre a mesma (listar/buscarPorId/criar/atualizar/remover);
// a implementação escolhida depende do ambiente de execução.
import { config } from '../config/index.js'
import { criarRepositorioDeEventosMySQL } from './eventosRepository.mysql.js'
import { criarRepositorioDeEventosEmMemoria } from './eventosRepository.memoria.js'
export function obterRepositorioDeEventos() {
if (config.NODE_ENV === 'test') {
return criarRepositorioDeEventosEmMemoria()
}
return criarRepositorioDeEventosMySQL()
}
Passo 10 — o service (mostrado completo na Seção 3):
Use src/services/eventosService.js da Seção 3, já com a hierarquia de erros da Seção 4.1.
Passo 11 — o controller:
// src/controllers/eventosController.js
export function criarControllerDeEventos({ eventosService }) {
return {
async listar(req, res) {
const { categoria, busca, pagina, porPagina } = req.query
const eventos = await eventosService.listarEventos({ categoria, busca, pagina, porPagina })
res.status(200).json(eventos)
},
async buscarPorId(req, res) {
const evento = await eventosService.buscarEventoPorId(Number(req.params.id))
res.status(200).json(evento)
},
async criar(req, res) {
const evento = await eventosService.criarEvento(req.body)
res.status(201).json(evento)
},
async atualizar(req, res) {
const evento = await eventosService.atualizarEvento(Number(req.params.id), req.body)
res.status(200).json(evento)
},
async remover(req, res) {
await eventosService.removerEvento(Number(req.params.id))
res.status(204).end()
},
}
}
⚠️ Atenção Repare que nenhum método do controller usa
try/catch. Em Express 5, um erro lançado dentro de um handlerasyncé capturado automaticamente e encaminhado ao middleware de erro — não precisamos mais de.catch(next)como no Express 4.
Passo 12 — as rotas:
// src/routes/eventos.routes.js
import { Router } from 'express'
import { validar } from '../middlewares/validar.js'
import { eventoSchema, eventoAtualizacaoSchema } from '../validators/eventoSchema.js'
import { verificarToken } from '../middlewares/autenticacao.js'
export function criarRotasDeEventos({ eventosController }) {
const router = Router()
router.get('/', eventosController.listar)
router.get('/:id', eventosController.buscarPorId)
// Rotas de escrita exigem autenticação (middleware da Aula 10) e corpo validado.
router.post('/', verificarToken, validar(eventoSchema), eventosController.criar)
router.put('/:id', verificarToken, validar(eventoAtualizacaoSchema), eventosController.atualizar)
router.delete('/:id', verificarToken, eventosController.remover)
return router
}
Passo 13 — montando a aplicação:
// src/app.js
import express from 'express'
import { cabecalhosDeSeguranca, limitadorDeTaxa, corsConfigurado } from './middlewares/seguranca.js'
import { tratadorDeErros } from './middlewares/tratadorDeErros.js'
import { criarRotasDeEventos } from './routes/eventos.routes.js'
import { criarControllerDeEventos } from './controllers/eventosController.js'
import { criarServicoDeEventos } from './services/eventosService.js'
import { obterRepositorioDeEventos } from './repositories/index.js'
export function criarApp({ eventosRepository = obterRepositorioDeEventos() } = {}) {
const app = express()
app.use(cabecalhosDeSeguranca)
app.use(corsConfigurado)
app.use(limitadorDeTaxa)
app.use(express.json({ limit: '10kb' }))
const eventosService = criarServicoDeEventos({ eventosRepository })
const eventosController = criarControllerDeEventos({ eventosService })
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' })
})
app.use('/api/eventos', criarRotasDeEventos({ eventosController }))
// O tratador de erros é SEMPRE o último app.use — Express identifica middlewares
// de erro pela assinatura de 4 parâmetros, não pela posição, mas a convenção
// de deixá-lo por último evita que ele "capture" middlewares registrados depois.
app.use(tratadorDeErros)
return app
}
Passo 14 — o server.js (mostrado completo na Seção 4.3). Rode e confira:
npm run migrar
npm run dev
curl http://localhost:3000/health
# {"status":"ok"}
Passo 15 — rode os testes:
npm test
Confira que os 7 testes das Seções 6.3 e 6.4 passam.
🧪 Laboratório¶
1. Refatore seu projeto autoral para a arquitetura em camadas — crie as pastas config/, db/, errors/, middlewares/, repositories/, services/, controllers/, routes/, mova o código existente para os lugares certos.
Resultado esperado: npm run dev continua funcionando, e nenhuma rota importa o pool do banco diretamente.
Dica
Comece de dentro para fora: primeiro extraia o repositório (funções que tocam o banco), depois o service (regra de negócio), depois o controller (o que sobrar do handler antigo).
2. Centralize a configuração com zod — crie src/config/index.js validando pelo menos 4 variáveis do seu .env.
Resultado esperado: remover uma variável obrigatória do .env faz o processo falhar ao iniciar, com mensagem clara.
Dica
Use safeParse, não parse — assim você controla a mensagem de erro antes de chamar process.exit(1).
3. Implemente a hierarquia de erros e o tratador central no seu projeto, substituindo throw new Error(...) genérico por ErroDeValidacao, ErroNaoEncontrado etc.
Resultado esperado: uma requisição a um recurso inexistente devolve 404 com { mensagem: "..." }, sem stack trace em produção.
Dica
Simule produção localmente com NODE_ENV=production npm start e confira que a resposta de erro não tem o campo stack.
4. Escreva 3 testes automatizados — pelo menos um de rota (supertest) e um de service (unitário, repositório falso).
Resultado esperado: npm test mostra os 3 testes passando.
Dica
Copie a estrutura dos testes das Seções 6.3/6.4 e troque eventos pela entidade do seu domínio.
5. Adicione helmet, express-rate-limit e CORS restritivo ao seu app.js.
Resultado esperado: uma requisição de origem diferente da configurada em CORS_ORIGEM_PERMITIDA é bloqueada pelo navegador (verifique no console do DevTools).
Dica
Teste abrindo o front em uma porta e fazendo uma requisição para a API configurada com outra origem em CORS_ORIGEM_PERMITIDA — o erro de CORS aparece no console do navegador, não no Postman (Postman ignora CORS).
🐛 Erros comuns e como resolver¶
| Sintoma | Causa | Solução |
|---|---|---|
| Processo não inicia, imprime lista de variáveis faltando | .env incompleto em relação ao schema de config/index.js |
Copie .env.example para .env e preencha todos os campos |
TypeError: eventosRepository.listar is not a function |
Repositório injetado não implementa a interface esperada pelo service | Confira se toda implementação de repositório (MySQL, memória) expõe os mesmos métodos |
| Teste de rota falha com erro de conexão ao MySQL | Testes estão usando o repositório real em vez do de memória | Injete eventosRepository: criarRepositorioDeEventosEmMemoria() explicitamente no criarApp dos testes |
| Erro 500 aparece no navegador com stack trace completo | NODE_ENV não está definido como production no deploy |
Configure NODE_ENV=production nas variáveis de ambiente do serviço de deploy |
npm run migrar reaplica uma migration já aplicada |
Tabela migrations_executadas não foi criada ou o nome do arquivo mudou |
Confira SELECT * FROM migrations_executadas e não renomeie arquivos de migration já aplicados |
| CORS bloqueando o front mesmo em desenvolvimento | CORS_ORIGEM_PERMITIDA no .env não bate com a porta real do Vite (5173 por padrão) |
Ajuste a variável para a URL exata mostrada pelo npm run dev do front |
🏠 Atividade assíncrona (1 h)¶
- No projeto autoral, garanta que os 5 endpoints principais (listar, buscar por id, criar, atualizar, remover) passam pela arquitetura em camadas completa.
- Escreva testes cobrindo pelo menos 40% dos métodos do service principal (liste no README quais foram testados e por quê).
- Aplique o checklist de segurança da Seção 5:
helmet, rate limit, CORS restritivo, limite de payload — cole no README um trecho de log ou print mostrando oX-RateLimit-Limitno cabeçalho de resposta. - Rode
npm teste cole a saída completa no README, em uma seção "Testes".
Critério de pronto: npm test passa localmente, README atualizado com a seção de testes e o checklist de segurança marcado.
✅ Checkpoint do projeto autoral¶
Ao final desta aula, seu repositório <tema>-api deve ter:
- [ ] Estrutura
routes/ → controllers/ → services/ → repositories/ → db/completa, commiddlewares/,validators/,utils/,config/. - [ ]
src/config/index.jsvalidando o.envcom zod e falhando rápido se algo faltar. - [ ] Hierarquia de erros de domínio e tratador central mapeando para status HTTP.
- [ ]
helmet,express-rate-limite CORS restritivo configurados. - [ ] Pelo menos 3 testes automatizados passando (
npm test). - [ ] Pasta
migrations/com scripts numerados e scriptnpm run migrarfuncional.
📚 Para aprofundar¶
- Documentação oficial do Express 5 — tratamento de erros.
- Zod — documentação oficial
- Pino — documentação oficial
- Vitest — documentação oficial
- Supertest — repositório no GitHub
- OWASP Top 10 (2021, referência atual)
- helmet.js — documentação
- Martin, Robert C. — Clean Architecture (capítulos sobre a regra de dependência), referência complementar da bibliografia do plano de curso.
Próxima aula (14, 09/12/2026): documentamos a API inteira com OpenAPI 3 e Swagger UI — cada endpoint que construímos até aqui ganha um contrato formal, testável direto do navegador. Traga o unieventos-api (ou seu projeto autoral) já na arquitetura em camadas desta aula.
Aula 14 — Documentação com Swagger
🎯 Objetivos de aprendizagem¶
Ao final desta aula você será capaz de:
- Explicar por que documentar uma API é parte do trabalho de engenharia, não um extra opcional.
- Diferenciar OpenAPI (a especificação) de Swagger (o conjunto de ferramentas que a implementa).
- Ler e escrever a anatomia de um documento OpenAPI 3.0:
info,servers,paths,components.schemas,components.securitySchemes. - Gerar a especificação a partir de anotações
@openapicomswagger-jsdoc, usando corretamente a chavedefinition. - Servir e customizar o Swagger UI com
swagger-ui-express, incluindo o endpoint com o JSON cru. - Documentar todos os endpoints do UniEventos com schemas reutilizáveis e segurança via
bearerAuth. - Testar endpoints protegidos direto pelo Swagger UI usando o botão "Authorize".
- Produzir um README de qualidade, um
CONTRIBUTING.mdmínimo e registrar decisões de arquitetura em formato ADR.
📋 Pré-requisitos desta aula¶
unieventos-api(ou projeto autoral) já refatorado para arquitetura em camadas (Aula 13), com rotas de eventos, inscrições e autenticação funcionando.- Node.js 22 LTS e a API rodando localmente com
npm run dev.
Checklist antes de começar:
- [ ]
GET /healthresponde200na sua API. - [ ] Você consegue autenticar via Firebase e obter um token de ID (Aula 10) para testar rotas protegidas.
- [ ] Ferramenta para chamadas HTTP manuais disponível (Insomnia, Postman ou
curl).
🗺️ Roteiro¶
| Bloco | Tempo | Atividade |
|---|---|---|
| 1 | 50 min | Por que documentar, OpenAPI vs. Swagger, anatomia do documento |
| 2 | 50 min | swagger-jsdoc + swagger-ui-express: configuração e primeiros endpoints anotados |
| 3 | 50 min | Documentando toda a API do UniEventos, segurança com bearerAuth, README/ADR |
Retomando a Aula 13¶
Na Aula 13 transformamos o unieventos-api em uma arquitetura em camadas testável e segura. O código ficou sólido por dentro — mas de fora, para quem nunca viu o projeto (um colega de equipe, um avaliador, você mesmo em três meses), ele ainda é uma caixa-preta: só descobre o que a API faz lendo o código-fonte inteiro. Hoje resolvemos isso com um contrato formal e navegável: OpenAPI + Swagger UI.
1. Por que documentar uma API¶
Um endpoint sem documentação obriga quem for consumi-lo a ler o código-fonte do back-end inteiro — ou pior, a adivinhar por tentativa e erro. Em qualquer cenário além do "eu programando sozinho e lembrando de tudo", isso custa tempo real:
- Contrato entre times. O time de front-end pode começar a construir a tela de "Minhas inscrições" antes do endpoint estar pronto, desde que o contrato (formato de entrada/saída) esteja documentado e estável. Documentação é o que permite front e back trabalharem em paralelo.
- Onboarding. Um novo integrante do time entende a API lendo uma página, não vasculhando 40 arquivos de rota.
- Geração de clientes. A partir de um documento OpenAPI, ferramentas geram automaticamente SDKs tipados em várias linguagens — você escreve o contrato uma vez, o cliente sai de graça.
- Contrato como teste. Ferramentas de teste de contrato conferem se a resposta real da API bate com o que foi documentado — a documentação vira uma fonte de verdade verificável, não um texto que fica defasado.
⚠️ Atenção Documentação que não é gerada a partir do código (ou vinculada a ele por anotação) apodrece rápido: alguém muda um campo na rota e esquece de atualizar o Word/Notion separado. É exatamente esse problema que o
swagger-jsdocresolve — a documentação vive ao lado do código, no mesmo arquivo, na mesma revisão de código.
1.1 OpenAPI vs. Swagger — não são sinônimos¶
- OpenAPI é a especificação: um formato (YAML ou JSON) que descreve endpoints, parâmetros, corpos de requisição, respostas e esquemas de segurança de uma API REST, de forma independente de linguagem. A versão usada nesta disciplina é a OpenAPI 3.0.
- Swagger é o conjunto de ferramentas (hoje mantido pela SmartBear) construído em torno da especificação OpenAPI — o nome "Swagger" é anterior ao nome "OpenAPI" (a especificação se chamava Swagger Specification até a versão 2.0; a partir da 3.0 passou a se chamar OpenAPI, mas o ecossistema de ferramentas manteve o nome Swagger).
Duas ferramentas do ecossistema Swagger que usaremos hoje:
| Ferramenta | Papel |
|---|---|
swagger-jsdoc |
Lê anotações @openapi em comentários JSDoc no seu código e gera o documento OpenAPI (JSON) |
swagger-ui-express |
Recebe esse documento OpenAPI e renderiza uma interface HTML interativa (o "Swagger UI") |
O swagger-ui é a interface visual que você provavelmente já viu em várias APIs públicas — aquela página com os endpoints agrupados por tag, cada um expansível, com botão "Try it out" para testar direto do navegador.
2. Anatomia de um documento OpenAPI 3.0¶
Um documento OpenAPI é um único objeto JSON (ou YAML) com estas chaves de topo:
openapi: 3.0.0 # versão da especificação usada
info: # metadados da API
title: UniEventos API
version: 1.0.0
description: API de eventos acadêmicos do UniEventos
servers: # onde a API está hospedada (pode ter vários)
- url: http://localhost:3000
description: Ambiente local
tags: # agrupamento visual dos endpoints no Swagger UI
- name: Eventos
- name: Inscrições
- name: Autenticação
paths: # cada endpoint documentado
/api/eventos:
get: { ... }
post: { ... }
components: # peças reutilizáveis entre paths
schemas: { ... } # formatos de objeto (Evento, EventoInput, Erro...)
securitySchemes: { ... } # como a API autentica (ex.: bearerAuth)
Explicando cada bloco com o UniEventos:
openapi— string fixa3.0.0, indica a versão da especificação. Não confunda com a versão da sua API (isso éinfo.version).info— título, versão e descrição da API. É o que aparece no topo do Swagger UI.servers— lista de URLs onde a API responde de verdade. Em desenvolvimento,http://localhost:3000; em produção, a URL pública (Aula 15). O Swagger UI usa isso para montar a URL completa quando você clica em "Try it out".tags— só organiza visualmente os endpoints em grupos colapsáveis (Eventos, Inscrições, Autenticação).paths— o coração do documento: cada rota (/api/eventos,/api/eventos/{id}...) e, dentro dela, cada método HTTP (get,post,put,delete), comparameters,requestBodyeresponses.components.schemas— formatos de objeto reutilizáveis (o formato de umEvento, de umEventoInput, de umErropadrão), referenciados de dentro depathscom$refem vez de repetidos em cada endpoint.components.securitySchemes— descreve como a API autentica (aqui, Bearer Token JWT do Firebase), sem misturar isso com a lógica de cada endpoint individual.parameters— parâmetros de path ({id}), query (?categoria=palestra) ou header, com tipo e descrição.requestBody— o formato esperado do corpo da requisição (POST/PUT), normalmente referenciando um schema via$ref.responses— para cada status HTTP possível (200,400,404...), o formato do corpo de resposta.$ref— mecanismo de referência: em vez de repetir a definição deEventoem 5 endpoints diferentes, cada um aponta para#/components/schemas/Evento. Mude uma vez, atualiza em todo lugar.
3. Duas abordagens para gerar o documento¶
3.1 Abordagem (a): anotações @openapi com swagger-jsdoc — a que vamos implementar¶
A ideia: você escreve um comentário JSDoc especial, com bloco YAML dentro, logo acima da definição da rota no próprio arquivo de rotas. O swagger-jsdoc varre os arquivos configurados, extrai esses comentários e monta o documento OpenAPI completo em tempo de execução.
npm install swagger-jsdoc swagger-ui-express
// src/docs/swaggerSpec.js
import swaggerJsdoc from 'swagger-jsdoc'
// ATENÇÃO: a chave é "definition", NÃO "swaggerDefinition" — swagger-jsdoc 6.x
// renomeou essa chave em relação a versões anteriores. Usar o nome errado faz
// a spec sair vazia, sem erro nenhum no console.
const opcoes = {
definition: {
openapi: '3.0.0',
info: {
title: 'UniEventos API',
version: '1.0.0',
description:
'API REST da plataforma UniEventos — divulgação e inscrição em eventos acadêmicos. ' +
'Desenvolvida na disciplina FACET-SNP-310 (UNEMAT/Sinop).',
contact: {
name: 'Prof. Ivan Luiz Pedroso Pires',
email: 'ivanpires@gmail.com',
},
license: {
name: 'MIT',
},
},
servers: [
{ url: 'http://localhost:3000', description: 'Ambiente local' },
{ url: 'https://unieventos-api.onrender.com', description: 'Produção' },
],
tags: [
{ name: 'Eventos', description: 'Cadastro e consulta de eventos acadêmicos' },
{ name: 'Inscrições', description: 'Inscrição de usuários autenticados em eventos' },
{ name: 'Autenticação', description: 'Fluxo de login com Firebase Auth' },
],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Token de ID do Firebase Auth, obtido após o login no front-end.',
},
},
},
},
// Arquivos onde o swagger-jsdoc procura comentários @openapi.
apis: ['./src/routes/*.js', './src/docs/schemas/*.js'],
}
export const swaggerSpec = swaggerJsdoc(opcoes)
⚠️ Atenção Repare na chave
definitiondentro deopcoes. Em versões antigas doswagger-jsdoc(2.x/3.x) essa chave se chamavaswaggerDefinition. Nesta disciplina usamosswagger-jsdoc@6.3.0, que exigedefinition. Se você copiar um tutorial antigo da internet comswaggerDefinition, a spec gerada fica compaths: {}vazio e nenhum erro é lançado — o bug é silencioso.
3.2 Abordagem (b): openapi.yaml escrito à mão¶
A alternativa é escrever o documento OpenAPI inteiro em um arquivo .yaml, sem anotação nenhuma no código, e servir esse arquivo estático:
# openapi.yaml (resumo — não é o que vamos usar hoje, é só para você conhecer a alternativa)
openapi: 3.0.0
info:
title: UniEventos API
version: 1.0.0
paths:
/api/eventos:
get:
tags: [Eventos]
summary: Lista eventos
responses:
'200':
description: Lista de eventos
// server.js — servindo o YAML escrito à mão, em vez de gerado por anotação
import { readFileSync } from 'node:fs'
import yaml from 'yaml' // npm install yaml
import swaggerUi from 'swagger-ui-express'
const documentoOpenApi = yaml.parse(readFileSync('./openapi.yaml', 'utf-8'))
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(documentoOpenApi))
Vantagem: controle total do texto, sem depender de comentário no meio do código. Desvantagem: fica fácil o YAML "descolar" do código real, porque nada obriga a atualizá-lo junto com a rota. Por isso, nesta disciplina, a abordagem oficial é a (a) — anotações junto ao código, sempre atualizadas na mesma revisão.
4. Servindo com swagger-ui-express¶
// src/app.js — trecho adicionado à montagem da aplicação (depois das rotas de negócio)
import swaggerUi from 'swagger-ui-express'
import { swaggerSpec } from './docs/swaggerSpec.js'
// Opções de customização visual do Swagger UI.
const opcoesDoSwaggerUi = {
customSiteTitle: 'UniEventos API — Documentação',
customCss: '.swagger-ui .topbar { display: none }', // esconde a barra verde padrão
}
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, opcoesDoSwaggerUi))
// Expõe o JSON cru da spec — útil para importar em Insomnia/Postman
// ou para ferramentas de geração de cliente consumirem diretamente.
app.get('/api-docs.json', (req, res) => {
res.status(200).json(swaggerSpec)
})
npm run dev
# abra no navegador:
# http://localhost:3000/api-docs → interface interativa
# http://localhost:3000/api-docs.json → JSON cru da especificação
💡 Dica
swaggerUi.serveé um array de middlewares (serve os arquivos estáticos da interface: CSS, JS, HTML);swaggerUi.setup(spec, opcoes)é o middleware que injeta sua spec nessa interface. Os dois sempre andam juntos, nessa ordem, no mesmoapp.use.
🧩 Padrão de projeto em uso¶
🧩 Padrão de projeto em uso — Decorator (documentação como anotação)¶
O padrão Decorator adiciona comportamento ou informação a um objeto sem alterar sua estrutura original. As anotações
@openapifazem exatamente isso, só que no nível de documentação de código-fonte em vez de tempo de execução: o comentário JSDoc "decora" a rota com metadados (parâmetros, respostas, segurança) sem alterar uma linha da lógica real dorouter.get(...). Remova o comentário e a rota continua funcionando idêntica — a documentação é uma camada adicionada por cima, não uma dependência funcional.
js /** * @openapi * /api/eventos: * get: * summary: Lista eventos ← "decoração": metadado * tags: [Eventos] ← "decoração": metadado */ router.get('/', eventosController.listar) // ← comportamento real, intocadoÉ a mesma lógica dos decorators de linguagens como TypeScript/Java (
@Component,@Test) — mas aqui implementada via convenção de comentário, lida por uma ferramenta externa (swagger-jsdoc), porque JavaScript puro (sem TypeScript) não tem decorators nativos estáveis no runtime do Node.
5. Documentando os schemas reutilizáveis¶
Antes de anotar cada rota, definimos os formatos de objeto que se repetem — assim cada endpoint só referencia ($ref) em vez de redigitar os mesmos campos.
// src/docs/schemas/evento.schema.js
/**
* @openapi
* components:
* schemas:
* Evento:
* type: object
* properties:
* id:
* type: integer
* example: 3
* titulo:
* type: string
* example: Hackathon FACET
* descricao:
* type: string
* example: Maratona de programação de 24 horas aberta a todos os cursos.
* categoria:
* type: string
* enum: [palestra, minicurso, workshop]
* example: workshop
* dataHora:
* type: string
* format: date-time
* example: 2026-10-05T08:00:00
* local:
* type: string
* example: Bloco A, Auditório
* vagas:
* type: integer
* example: 60
* imagemUrl:
* type: string
* format: uri
* example: https://storage.unieventos.dev/eventos/hackathon.jpg
*
* EventoInput:
* type: object
* required: [titulo, categoria, dataHora, local, vagas]
* properties:
* titulo:
* type: string
* minLength: 3
* maxLength: 150
* descricao:
* type: string
* categoria:
* type: string
* enum: [palestra, minicurso, workshop]
* dataHora:
* type: string
* format: date-time
* local:
* type: string
* vagas:
* type: integer
* minimum: 0
* imagemUrl:
* type: string
* format: uri
*
* Erro:
* type: object
* properties:
* mensagem:
* type: string
* example: Evento não encontrado
* detalhes:
* type: array
* items:
* type: object
* properties:
* campo:
* type: string
* mensagem:
* type: string
*
* Paginacao:
* type: object
* properties:
* pagina:
* type: integer
* example: 1
* porPagina:
* type: integer
* example: 20
* total:
* type: integer
* example: 47
*/
export {} // arquivo só existe para hospedar o comentário — sem código de fato
// src/docs/schemas/inscricao.schema.js
/**
* @openapi
* components:
* schemas:
* Inscricao:
* type: object
* properties:
* id:
* type: integer
* example: 12
* eventoId:
* type: integer
* example: 3
* usuarioUid:
* type: string
* example: fY3k9sLp2QaB1cD4eF5gH6iJ7kL8
* criadoEm:
* type: string
* format: date-time
*
* InscricaoInput:
* type: object
* required: [eventoId]
* properties:
* eventoId:
* type: integer
* example: 3
*/
export {}
🔎 Por baixo do capô Esses arquivos
*.schema.jsnão exportam nada útil em termos de código JavaScript — servem só para oswagger-jsdocencontrar o comentário (por isso estão incluídos emapis: [...]na configuração da Seção 3.1). É uma convenção comum para não poluir arquivos de rota reais com blocos de schema grandes.
6. Documentando todos os endpoints do UniEventos¶
6.1 Eventos — as 5 operações (CRUD completo)¶
// src/routes/eventos.routes.js — versão anotada
import { Router } from 'express'
import { validar } from '../middlewares/validar.js'
import { eventoSchema, eventoAtualizacaoSchema } from '../validators/eventoSchema.js'
import { verificarToken } from '../middlewares/autenticacao.js'
export function criarRotasDeEventos({ eventosController }) {
const router = Router()
/**
* @openapi
* /api/eventos:
* get:
* summary: Lista eventos, com filtros opcionais
* tags: [Eventos]
* parameters:
* - in: query
* name: categoria
* schema:
* type: string
* enum: [palestra, minicurso, workshop]
* description: Filtra por categoria do evento
* - in: query
* name: busca
* schema:
* type: string
* description: Filtra por trecho do título
* - in: query
* name: pagina
* schema:
* type: integer
* default: 1
* - in: query
* name: porPagina
* schema:
* type: integer
* default: 20
* responses:
* 200:
* description: Lista de eventos encontrados
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/Evento'
*/
router.get('/', eventosController.listar)
/**
* @openapi
* /api/eventos/{id}:
* get:
* summary: Busca um evento pelo id
* tags: [Eventos]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Id numérico do evento
* responses:
* 200:
* description: Evento encontrado
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Evento'
* 404:
* description: Evento não encontrado
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Erro'
*/
router.get('/:id', eventosController.buscarPorId)
/**
* @openapi
* /api/eventos:
* post:
* summary: Cria um novo evento
* tags: [Eventos]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/EventoInput'
* responses:
* 201:
* description: Evento criado
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Evento'
* 400:
* description: Dados inválidos
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Erro'
* 401:
* description: Token ausente ou inválido
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Erro'
*/
router.post('/', verificarToken, validar(eventoSchema), eventosController.criar)
/**
* @openapi
* /api/eventos/{id}:
* put:
* summary: Atualiza um evento existente
* tags: [Eventos]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/EventoInput'
* responses:
* 200:
* description: Evento atualizado
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Evento'
* 404:
* description: Evento não encontrado
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Erro'
*/
router.put('/:id', verificarToken, validar(eventoAtualizacaoSchema), eventosController.atualizar)
/**
* @openapi
* /api/eventos/{id}:
* delete:
* summary: Remove um evento
* tags: [Eventos]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Evento removido com sucesso, sem corpo de resposta
* 404:
* description: Evento não encontrado
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Erro'
*/
router.delete('/:id', verificarToken, eventosController.remover)
return router
}
6.2 Inscrições¶
// src/routes/inscricoes.routes.js — versão anotada
import { Router } from 'express'
import { validar } from '../middlewares/validar.js'
import { inscricaoSchema } from '../validators/inscricaoSchema.js'
import { verificarToken } from '../middlewares/autenticacao.js'
export function criarRotasDeInscricoes({ inscricoesController }) {
const router = Router()
/**
* @openapi
* /api/inscricoes:
* get:
* summary: Lista as inscrições do usuário autenticado
* tags: [Inscrições]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Lista de inscrições do usuário logado
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/Inscricao'
* 401:
* description: Token ausente ou inválido
*/
router.get('/', verificarToken, inscricoesController.listarMinhas)
/**
* @openapi
* /api/inscricoes:
* post:
* summary: Inscreve o usuário autenticado em um evento
* tags: [Inscrições]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/InscricaoInput'
* responses:
* 201:
* description: Inscrição criada
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Inscricao'
* 409:
* description: Usuário já está inscrito neste evento
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Erro'
*/
router.post('/', verificarToken, validar(inscricaoSchema), inscricoesController.criar)
/**
* @openapi
* /api/inscricoes/{id}:
* delete:
* summary: Cancela uma inscrição do próprio usuário
* tags: [Inscrições]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 204:
* description: Inscrição cancelada
* 403:
* description: A inscrição pertence a outro usuário
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Erro'
*/
router.delete('/:id', verificarToken, inscricoesController.cancelar)
return router
}
6.3 Autenticação¶
O UniEventos não implementa login no back-end — o login acontece no front, direto contra o Firebase Auth (Aula 10). O back-end só verifica o token recebido. Ainda assim, documentamos esse fluxo, porque quem consumir a API precisa saber como obter o token:
// src/routes/autenticacao.routes.js
import { Router } from 'express'
export function criarRotasDeAutenticacao() {
const router = Router()
/**
* @openapi
* /api/auth/verificar:
* get:
* summary: Confirma se o token enviado é válido e devolve os dados do usuário
* description: >
* Não existe endpoint de login nesta API — o login acontece no front-end,
* diretamente contra o Firebase Auth (signInWithEmailAndPassword). Este
* endpoint serve apenas para confirmar que um token de ID do Firebase é válido.
* tags: [Autenticação]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Token válido
* content:
* application/json:
* schema:
* type: object
* properties:
* uid:
* type: string
* email:
* type: string
* 401:
* description: Token ausente, expirado ou inválido
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Erro'
*/
router.get('/verificar', (req, res) => {
res.status(200).json({ uid: req.usuario.uid, email: req.usuario.email })
})
return router
}
7. Segurança com bearerAuth e o botão "Authorize"¶
O esquema bearerAuth já foi declarado em components.securitySchemes (Seção 3.1):
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
Cada endpoint protegido referencia esse esquema com security: [{ bearerAuth: [] }] (como fizemos em POST /api/eventos, PUT /api/eventos/{id}, DELETE /api/eventos/{id} e todas as rotas de inscrições). O efeito no Swagger UI:
- Um cadeado aparece ao lado de cada operação protegida.
- Um botão verde "Authorize" aparece no topo da página.
- Clicar nele abre um campo para colar o token — só o token puro, sem o prefixo
Bearer(o Swagger UI adiciona isso sozinho no cabeçalhoAuthorization). - Depois de autorizado, todo "Try it out" em endpoint protegido já envia o cabeçalho automaticamente.
💡 Dica Para obter um token de teste rápido, abra o console do navegador na sua aplicação front-end já logada e rode:
js import { getAuth } from 'firebase/auth' const token = await getAuth().currentUser.getIdToken() console.log(token)Copie o valor impresso e cole no botão "Authorize" do Swagger UI.
8. Testando pelo Swagger UI ("Try it out")¶
- Abra
http://localhost:3000/api-docs. - Expanda
GET /api/eventos, clique em "Try it out", depois em "Execute" — a resposta real da API aparece embaixo, com status e corpo formatado. - Para testar
POST /api/eventos, clique em "Authorize" primeiro (Seção 7), depois expanda a operação, edite o JSON de exemplo no campo de corpo, e execute.
⚠️ Atenção — CORS e
serversO Swagger UI faz a requisição do navegador, então as mesmas regras de CORS da Aula 13 se aplicam: seserversapontar para uma URL diferente da que está rodando o front (ou se a API não liberar a origem da própria página do Swagger UI), o "Try it out" falha com erro de CORS no console — mesmo a API estando no ar. Garanta queCORS_ORIGEM_PERMITIDAinclua a origem de onde o Swagger UI está sendo servido (geralmente a própria API,http://localhost:3000, o que já é liberado por padrão pelo mesmo processo).
9. Além do Swagger: documentação completa do projeto¶
9.1 README de qualidade¶
<!-- README.md -->
# UniEventos API



API REST da plataforma **UniEventos** — divulgação e inscrição em eventos acadêmicos.
Projeto desenvolvido na disciplina FACET-SNP-310 (UNEMAT/Sinop, 2026.2).
## Requisitos
- Node.js 22 LTS
- MySQL 8 (local ou gerenciado)
- Conta de serviço do Firebase (arquivo de credenciais)
## Instalação
\`\`\`bash
git clone https://github.com/seu-usuario/unieventos-api.git
cd unieventos-api
npm install
cp .env.example .env # preencha com suas credenciais
npm run migrar
npm run dev
\`\`\`
## Variáveis de ambiente
| Variável | Descrição |
|---|---|
| `PORT` | Porta HTTP da API (padrão 3000) |
| `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME` | Credenciais do MySQL |
| `FIREBASE_PROJECT_ID` | Id do projeto Firebase usado na verificação de token |
| `CORS_ORIGEM_PERMITIDA` | Origem do front-end autorizada pelo CORS |
## Scripts disponíveis
| Comando | Efeito |
|---|---|
| `npm run dev` | Sobe a API com recarregamento automático |
| `npm start` | Sobe a API em modo produção |
| `npm test` | Executa a suíte de testes (vitest) |
| `npm run migrar` | Aplica migrations pendentes no banco |
## Endpoints
Documentação interativa completa em `/api-docs` (Swagger UI) com o projeto rodando.
Resumo:
| Método | Rota | Descrição |
|---|---|---|
| GET | `/api/eventos` | Lista eventos, com filtros |
| GET | `/api/eventos/:id` | Detalha um evento |
| POST | `/api/eventos` | Cria evento (autenticado) |
| PUT | `/api/eventos/:id` | Atualiza evento (autenticado) |
| DELETE | `/api/eventos/:id` | Remove evento (autenticado) |
| GET | `/api/inscricoes` | Lista inscrições do usuário logado |
| POST | `/api/inscricoes` | Inscreve o usuário em um evento |
| DELETE | `/api/inscricoes/:id` | Cancela inscrição |
## Licença
MIT — veja o arquivo LICENSE.
9.2 Coleção de API exportada¶
Além do Swagger UI, exporte uma coleção do Insomnia ou Postman e comite no repositório em docs/insomnia-collection.json — facilita quem prefere testar fora do navegador. No Insomnia: menu Application → Preferences → Data → Export Data, escolha a workspace do projeto, formato Insomnia v4, e salve o arquivo na pasta docs/ do repositório.
9.3 CONTRIBUTING.md mínimo¶
<!-- CONTRIBUTING.md -->
# Como contribuir
1. Crie uma branch a partir de `main`: `git checkout -b feature/nome-da-mudanca`.
2. Rode `npm test` antes de abrir o Pull Request — a suíte precisa passar.
3. Siga o padrão de nomes em português para identificadores de domínio (`eventos`, `criarEvento`).
4. Toda rota nova precisa ter anotação `@openapi` correspondente (Aula 14).
5. Abra o Pull Request descrevendo o que mudou e por quê.
9.4 Documentação do front: JSDoc em composables¶
// src/composables/useEventos.js
/**
* Composable que encapsula a busca e o estado de carregamento da lista de eventos.
*
* @param {Object} [opcoes] - opções de filtro inicial
* @param {string} [opcoes.categoria] - categoria para filtrar a busca inicial
* @returns {{
* eventos: import('vue').Ref<Array>,
* carregando: import('vue').Ref<boolean>,
* erro: import('vue').Ref<string|null>,
* buscarEventos: (filtros?: Object) => Promise<void>
* }}
*/
export function useEventos(opcoes = {}) {
// implementação já construída na Aula 06/11 — reaproveitada aqui
}
Comentários JSDoc em composables dão autocomplete e checagem de tipo básica no VS Code, mesmo em projetos JavaScript puro (sem TypeScript) — o editor lê o @param/@returns e sugere os campos corretos a quem consome o composable.
9.5 ADR — Architecture Decision Record¶
Um ADR é um documento curto (10 a 20 linhas) que registra uma decisão técnica, o contexto que levou a ela, e as alternativas consideradas — para que, meses depois, ninguém precise adivinhar "por que fizemos assim?".
Formato em 10 linhas:
# ADR 0001: <título curto da decisão>
**Status:** aceito | proposto | substituído por ADR-000X
## Contexto
<qual problema motivou esta decisão>
## Decisão
<o que foi decidido>
## Consequências
<o que fica mais fácil, o que fica mais difícil, o que foi trocado por quê>
Exemplo real do UniEventos:
<!-- docs/adr/0001-escolha-do-repository-pattern.md -->
# ADR 0001: Usar o padrão Repository para acesso a dados
**Status:** aceito
## Contexto
O service de eventos precisava consultar o MySQL diretamente, o que impedia
testar as regras de negócio (ex.: "vagas não pode ser negativo") sem subir
um banco de dados real, e acoplava o service à sintaxe SQL do mysql2.
## Decisão
Extrair toda a lógica de acesso a dados para `repositories/`, com uma
interface comum (`listar`, `buscarPorId`, `criar`, `atualizar`, `remover`),
injetada no service por parâmetro (Dependency Injection). O ambiente de
teste usa uma implementação em memória; produção usa a implementação MySQL.
## Consequências
Testes de service ficaram instantâneos e sem dependência externa. Trocar o
banco de dados (como fizemos ao avaliar Supabase na Aula 12) passou a exigir
apenas uma nova implementação de repositório, sem tocar em services ou
controllers. Custo: uma camada de indireção a mais para quem está lendo o
código pela primeira vez.
📌 Na prova Um ADR não documenta código — documenta decisão e motivo. Se a resposta para "por que você fez assim?" está só na sua cabeça, ela vai se perder. Escrever ADRs curtos ao longo do desenvolvimento é mais barato do que reconstruir esse raciocínio depois.
🧪 Laboratório¶
1. Configure swagger-jsdoc e swagger-ui-express no seu projeto autoral, com definition (não swaggerDefinition), info, pelo menos uma tag e o securityScheme bearerAuth.
Resultado esperado: http://localhost:3000/api-docs abre com o título e a descrição da sua API.
Dica
Copie src/docs/swaggerSpec.js da Seção 3.1 e troque só o title, description e as tags para o domínio do seu projeto.
2. Documente 3 endpoints do seu projeto autoral com anotações @openapi completas (parâmetros, requestBody quando houver, respostas para pelo menos 2 status diferentes).
Resultado esperado: os 3 endpoints aparecem expansíveis no Swagger UI, com exemplos de corpo preenchidos.
Dica
Comece pelo endpoint de listagem (mais simples, sem requestBody) e depois avance para um de criação (com requestBody e security).
3. Crie os schemas reutilizáveis da entidade principal do seu domínio (equivalente a Evento/EventoInput/Erro) e referencie com $ref nos 3 endpoints do exercício anterior.
Resultado esperado: mudar um campo no schema reflete automaticamente em todos os endpoints que o referenciam.
Dica
Coloque os schemas em src/docs/schemas/*.schema.js e inclua o caminho no array apis da configuração do swagger-jsdoc.
4. Teste um endpoint protegido pelo "Authorize" — obtenha um token do Firebase (Seção 7) e confirme que a requisição autenticada funciona pelo Swagger UI.
Resultado esperado: sem token, a rota protegida retorna 401; com token válido, retorna 200/201.
Dica
Se a resposta continuar 401 mesmo com token colado, confira se você colou só o token puro, sem o prefixo Bearer.
5. Escreva um ADR para uma decisão técnica real do seu projeto (ex.: por que escolheu MySQL ou Supabase, por que escolheu determinado padrão de rota).
Resultado esperado: arquivo docs/adr/0001-<slug>.md seguindo o formato de 10 linhas da Seção 9.5.
Dica
Escolha uma decisão que você realmente tomou e hesitou entre alternativas — é mais fácil escrever o "Contexto" quando a dúvida foi real.
🐛 Erros comuns e como resolver¶
| Sintoma | Causa | Solução |
|---|---|---|
/api-docs abre, mas paths está vazio |
Usou a chave swaggerDefinition em vez de definition nas opções do swagger-jsdoc |
Troque para definition — é a chave exigida na versão 6.x |
| Endpoint documentado não aparece no Swagger UI | O arquivo onde está o comentário @openapi não está listado em apis: [...] |
Adicione o caminho (ou glob) do arquivo à lista apis da configuração |
$ref: '#/components/schemas/Evento' gera erro "not found" |
O schema Evento não foi anotado em nenhum arquivo varrido pelo apis |
Confira se src/docs/schemas/evento.schema.js está no array apis e se o YAML do comentário está corretamente indentado |
| Botão "Authorize" não aparece | Nenhum endpoint tem security: [{ bearerAuth: [] }], ou securitySchemes não foi declarado em components |
Declare securitySchemes.bearerAuth em definition.components e adicione security nos endpoints protegidos |
| "Try it out" falha com erro de CORS | A origem da própria página do Swagger UI não está liberada pelo middleware de CORS da API | Garanta que a origem da API (onde o /api-docs é servido) está coberta pela configuração de CORS, ou sirva o Swagger UI na mesma origem da API |
YAML do comentário @openapi quebra a spec inteira silenciosamente |
Indentação incorreta no bloco YAML dentro do comentário JSDoc | YAML é sensível a espaços — nunca misture tabs, use 2 espaços por nível, consistentemente |
🏠 Atividade assíncrona (1 h)¶
- Documente todos os endpoints do seu projeto autoral com anotações
@openapi(não só os 3 do laboratório). - Garanta que os schemas
Erroe de paginação (se aplicável) estão presentes e referenciados. - Revise o
README.mdseguindo a estrutura da Seção 9.1: badges, requisitos, instalação, variáveis de ambiente, scripts, endpoints (com link para/api-docs), licença. - Escreva pelo menos 1 ADR adicional sobre uma decisão do seu back-end.
Critério de pronto: /api-docs mostra 100% dos endpoints do projeto autoral documentados; README revisado; ao menos 2 ADRs no repositório.
✅ Checkpoint do projeto autoral¶
Ao final desta aula, seu repositório <tema>-api deve ter:
- [ ]
swagger-jsdocconfigurado com a chavedefinitioneswagger-ui-expressservindo em/api-docs. - [ ]
/api-docs.jsonexpondo a spec crua. - [ ] Schemas reutilizáveis (
$ref) para a entidade principal, incluindo um schema deErro. - [ ]
securitySchemebearerAuthconfigurado e usado em todos os endpoints protegidos. - [ ] README revisado com badges, instalação, variáveis de ambiente, scripts e tabela de endpoints.
- [ ] Pasta
docs/adr/com pelo menos 2 registros de decisão.
📚 Para aprofundar¶
- Especificação OpenAPI 3.0 (oficial)
- swagger-jsdoc — repositório no GitHub
- swagger-ui-express — repositório no GitHub
- Swagger.io — guia oficial de OpenAPI
- ADR GitHub organization — modelos de Architecture Decision Record
- Keep a README — checklist do que compõe um bom README
Próxima aula (15, 16/12/2026): fechamos o semestre com deploy real (front e back), CI/CD com GitHub Actions, retrospectiva de todos os padrões de projeto usados, guia de estudo para o exame final e as instruções completas da Avaliação 3. Traga a API documentada e pronta para publicar.
Aula 15 — Deploy, apresentação e finalização
🎯 Objetivos de aprendizagem¶
Ao final desta aula você será capaz de:
- Gerar o build de produção de uma aplicação Vue com Vite e explicar o que existe dentro de
dist/. - Publicar o front-end em um serviço de hospedagem estática (Vercel, Firebase Hosting ou GitHub Pages), configurando variáveis de ambiente e rewrite de SPA.
- Publicar o back-end Express em um serviço de nuvem, com
PORTdinâmico, health check e variáveis de ambiente seguras. - Escrever um
Dockerfilesimples para a API e umdocker-compose.ymlcom API + MySQL. - Diagnosticar e corrigir os erros mais comuns pós-deploy (CORS, mixed content, 404 em rota interna, banco inacessível).
- Configurar um pipeline básico de CI/CD no GitHub Actions que roda lint e testes a cada push.
- Relacionar cada padrão de projeto estudado no semestre ao trecho de código onde ele apareceu no UniEventos.
- Apresentar o projeto autoral em formato de seminário técnico, dentro do tempo e critérios definidos.
- Entregar a Avaliação 3 seguindo integralmente os requisitos e o prazo estabelecidos.
📋 Pré-requisitos desta aula¶
unieventos-api(ou projeto autoral) com arquitetura em camadas (Aula 13) e documentação Swagger (Aula 14) completas.- Front-end (
unieventos-webou equivalente) com build funcionando localmente (npm run buildsem erro). - Conta gratuita em pelo menos um serviço de hospedagem de front (Vercel, Netlify, Firebase Hosting ou GitHub Pages) e um de back (Render, Railway ou Fly.io).
- Repositórios do projeto autoral publicados e atualizados no GitHub.
Checklist antes de começar:
- [ ]
npm run builddo front gera a pastadist/sem erro. - [ ]
npm testdo back passa localmente. - [ ] Você tem acesso de administrador aos dois repositórios (front e back) no GitHub.
🗺️ Roteiro¶
| Bloco | Tempo | Atividade |
|---|---|---|
| 1 | 50 min | Build de produção, deploy do front e do back, Docker, CORS em produção |
| 2 | 50 min | CI/CD com GitHub Actions, retrospectiva de padrões de projeto, guia de estudo do exame final |
| 3 | 50 min | Instruções da Avaliação 3, formato do seminário, encerramento da disciplina |
Retomando a Aula 14¶
Na Aula 14 documentamos a API inteira com OpenAPI e Swagger UI — qualquer pessoa consegue entender e testar o UniEventos sem ler uma linha de código. Falta uma última etapa: tirar o projeto do localhost e colocá-lo no ar, com URL pública, para qualquer pessoa acessar. Hoje fechamos esse ciclo — e fechamos o semestre.
1. Build de produção do front-end¶
1.1 O que npm run build faz¶
cd unieventos-web
npm run build
O Vite lê src/, resolve todos os imports, faz tree-shaking (remove código não utilizado), minifica JavaScript e CSS, gera hashes nos nomes de arquivo (para cache eficiente no navegador) e escreve tudo em dist/:
dist/
├─ assets/
│ ├─ index-BvPPrto3.css ← todo o CSS do projeto, minificado
│ ├─ index-EL0WAqE7.js ← todo o JavaScript, empacotado e minificado
│ └─ materialdesignicons-*.woff2 ← fontes de ícone do @mdi/font
├─ favicon.ico
└─ index.html ← HTML final, já referenciando os assets com hash
🔎 Por baixo do capô O hash no nome do arquivo (
index-BvPPrto3.js) muda sempre que o conteúdo muda. Isso permite configurar cache agressivo e "para sempre" nesses arquivos no servidor: o navegador só baixa de novo se o hash (e portanto o conteúdo) mudou. Oindex.html, em contrapartida, nunca deve ser cacheado agressivamente — ele é o que aponta para os hashes corretos a cada novo deploy.
dist/ é tudo que o servidor de hospedagem precisa: arquivos estáticos, sem Node.js rodando por trás. É por isso que hospedar um front-end Vue construído é barato (ou gratuito) — não é um processo de servidor, é só arquivos.
1.2 Variáveis de ambiente do Vite¶
O Vite só expõe ao código do navegador variáveis de ambiente prefixadas com VITE_ — qualquer outra fica de fora do bundle final, por segurança (evita vazar segredos de build no JavaScript público).
# .env.production — lido automaticamente quando NODE_ENV=production (no build)
VITE_API_URL=https://unieventos-api.onrender.com
VITE_FIREBASE_API_KEY=AIzaSy...
VITE_FIREBASE_AUTH_DOMAIN=unieventos.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=unieventos
# .env.development — lido em npm run dev
VITE_API_URL=http://localhost:3000
VITE_FIREBASE_API_KEY=AIzaSy...
VITE_FIREBASE_AUTH_DOMAIN=unieventos.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=unieventos
// src/services/apiClient.js — uso da variável, como já fazemos desde a Aula 06
import axios from 'axios'
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL, // troca sozinho entre dev e produção
})
⚠️ Atenção
import.meta.env.VITE_*só existe em tempo de build — o Vite substitui essas referências por valores literais no JavaScript final. Trocar a variável depois do build (por exemplo, direto no painel do serviço de hospedagem, sem rebuildar) não tem efeito nenhum: você precisa gerar um novo build para que um novo valor deVITE_API_URLentre no bundle. Serviços como Vercel fazem isso automaticamente a cada push, rodandonpm run buildde novo.
1.3 base no vite.config.js¶
Se o site for publicado em um subcaminho (comum no GitHub Pages, ex.: usuario.github.io/unieventos-web/), configure base:
// vite.config.js — trecho relevante para deploy em subcaminho
export default defineConfig({
base: '/unieventos-web/', // necessário só se NÃO estiver na raiz do domínio
plugins: [
vue({ template: { transformAssetUrls } }),
vuetify({ autoImport: true }),
],
})
Em Vercel, Netlify e Firebase Hosting, o projeto normalmente fica na raiz do domínio (base: '/', o padrão) — só ajuste isso para GitHub Pages em repositório de projeto (não em usuario.github.io).
1.4 Por que SPA precisa de rewrite para index.html¶
Uma SPA como o UniEventos tem uma única página real (index.html); rotas como /eventos/3 ou /minhas-inscricoes só existem no navegador, resolvidas pelo Vue Router (Aula 04) via History API — o servidor nunca teve, e nunca terá, um arquivo físico chamado eventos/3.
O problema: se o usuário aperta F5 (recarrega a página) estando em /eventos/3, o navegador faz uma requisição HTTP real, ao servidor, pedindo o caminho /eventos/3. Um servidor de arquivos estáticos comum não encontra esse arquivo e responde 404.
A solução é configurar o servidor para, em qualquer caminho que não seja um arquivo estático real, devolver index.html — o Vue Router então assume o controle no navegador e resolve a rota /eventos/3 normalmente.
// vercel.json — rewrite de SPA na Vercel
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}
# _redirects — Netlify (arquivo dentro de public/, copiado para dist/ no build)
/* /index.html 200
// firebase.json — trecho relevante do Firebase Hosting
{
"hosting": {
"public": "dist",
"rewrites": [
{ "source": "**", "destination": "/index.html" }
]
}
}
📌 Na prova "F5 em rota interna dá 404" é o sintoma mais clássico de rewrite de SPA mal configurado. Sempre que alguém relatar esse erro pós-deploy, a primeira pergunta é: "o servidor está configurado para devolver
index.htmlem qualquer caminho desconhecido?"
2. Deploy do front-end¶
2.1 Vercel (recomendado — passo a passo testado)¶
- Crie conta em vercel.com usando login do GitHub.
- No painel, clique "Add New... → Project" e selecione o repositório
unieventos-web. - A Vercel detecta automaticamente que é um projeto Vite. Confirme:
- Build Command:
npm run build- Output Directory:dist - Antes de clicar em "Deploy", adicione as variáveis de ambiente na seção Environment Variables:
VITE_API_URL,VITE_FIREBASE_API_KEY,VITE_FIREBASE_AUTH_DOMAIN,VITE_FIREBASE_PROJECT_ID— os mesmos valores do seu.env.productionlocal. - Clique Deploy. Em cerca de 1 minuto, a Vercel devolve uma URL pública (
https://unieventos-web.vercel.app). - A cada
git pushna branchmain, a Vercel refaz o deploy automaticamente.
💡 Dica A Vercel também cria um preview deploy automático para cada Pull Request, com URL própria — ótimo para revisar uma feature antes de mesclar em
main, mas não obrigatório nesta disciplina.
2.2 Alternativa: Firebase Hosting¶
npm install -g firebase-tools
firebase login
firebase init hosting
# Public directory: dist
# Configure as a single-page app (rewrite all urls to /index.html)? Yes
# Set up automatic builds and deploys with GitHub? (opcional, responda conforme preferir)
npm run build
firebase deploy --only hosting
Como o UniEventos já usa Firebase Auth (Aula 10), hospedar no Firebase Hosting mantém tudo no mesmo painel — vantagem organizacional, sem necessidade técnica adicional.
2.3 Alternativa: GitHub Pages¶
npm install -D gh-pages
// package.json — trecho de "scripts"
{
"scripts": {
"deploy": "npm run build && gh-pages -d dist"
}
}
Lembre de configurar base: '/unieventos-web/' no vite.config.js (Seção 1.3) antes de publicar, já que o GitHub Pages de repositório de projeto serve em um subcaminho. Depois:
npm run deploy
A URL pública fica em https://<seu-usuario>.github.io/unieventos-web/. Habilite em Settings → Pages do repositório, escolhendo a branch gh-pages (criada automaticamente pelo pacote gh-pages) como fonte.
3. Deploy do back-end¶
3.1 O essencial, independente do serviço escolhido¶
// src/server.js — PORT precisa vir do ambiente, nunca fixo
import { app } from './app.js'
import { config } from './config/index.js'
// A maioria dos serviços de nuvem injeta a variável PORT automaticamente —
// escutar em uma porta fixa (3000) quebra o deploy nesses ambientes.
const servidor = app.listen(config.PORT, () => {
console.log(`API rodando na porta ${config.PORT}`)
})
// package.json — script "start" é o que o serviço de deploy roda em produção
{
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
}
}
// trecho de src/app.js — health check simples, usado pelo serviço de deploy
// para saber se o processo está de pé (e reiniciar automaticamente se não estiver)
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', ambiente: config.NODE_ENV })
})
Checklist mínimo antes de publicar o back:
- [ ]
PORTvem deprocess.env.PORT(viaconfig, Aula 13), nunca hardcoded. - [ ] Script
startexiste empackage.jsone sobe a API comnodepuro (sem--watch, que é só para desenvolvimento). - [ ]
GET /healthresponde200sem exigir autenticação nem banco de dados obrigatoriamente disponível. - [ ] Banco de dados gerenciado (não
localhost) — MySQL na nuvem (ex.: Railway, PlanetScale-compatível, ou o banco oferecido pelo próprio Render). - [ ] Todas as variáveis de
.envconfiguradas como secrets no painel do serviço, nunca commitadas no Git.
3.2 Opções gratuitas/baratas e o que considerar¶
| Serviço | Ponto forte | Atenção |
|---|---|---|
| Render | Free tier simples, banco MySQL/Postgres gerenciado disponível | Cold start no plano gratuito — primeira requisição após inatividade demora alguns segundos |
| Railway | Deploy rápido a partir do GitHub, bom suporte a MySQL | Free tier limitado por uso mensal, não por tempo |
| Fly.io | Roda containers Docker diretamente, bom controle de infraestrutura | Curva de aprendizado maior, exige fly.toml e CLI própria |
⚠️ Atenção — cold start Planos gratuitos costumam "dormir" o processo após um período sem tráfego. A primeira requisição depois disso demora vários segundos (o serviço precisa religar o container). Isso é normal e esperado no plano gratuito — não é bug do seu código. Avise sobre isso na apresentação se seu projeto usar plano gratuito.
3.3 Deploy na Render (passo a passo)¶
- Crie conta em render.com com login do GitHub.
- New → Web Service, selecione o repositório
unieventos-api. - Configure:
- Runtime: Node
- Build Command:
npm install- Start Command:npm start - Na aba Environment, adicione todas as variáveis do seu
.env(exceto as que só existem localmente). - Se precisar de MySQL gerenciado, crie um New → MySQL (ou Postgres, se preferir migrar) separado na Render e copie a string de conexão para as variáveis
DB_*do Web Service. - Clique Create Web Service. A Render builda, sobe o processo, e devolve uma URL pública (
https://unieventos-api.onrender.com). - Rode as migrations manualmente uma vez, via o Shell da Render (aba disponível no painel do serviço) ou como Build Command combinado:
npm install && npm run migrar.
3.4 Dockerfile simples para a API¶
Mesmo usando um serviço que builda direto do GitHub, ter um Dockerfile documenta exatamente o ambiente de execução e permite rodar a API localmente de forma idêntica à produção.
# Dockerfile
FROM node:22-alpine
WORKDIR /app
# Copiar só os arquivos de manifesto primeiro aproveita o cache de camadas do
# Docker: se package.json não mudou, o npm install não roda de novo no rebuild.
COPY package.json package-lock.json ./
RUN npm install --omit=dev
COPY src ./src
COPY migrations ./migrations
COPY scripts ./scripts
EXPOSE 3000
CMD ["npm", "start"]
# .dockerignore
node_modules
.env
.git
test
docker build -t unieventos-api .
docker run -p 3000:3000 --env-file .env unieventos-api
3.5 docker-compose.yml com API + MySQL¶
# docker-compose.yml
services:
api:
build: .
ports:
- '3000:3000'
environment:
NODE_ENV: development
PORT: 3000
DB_HOST: mysql
DB_PORT: 3306
DB_USER: root
DB_PASSWORD: senha_local
DB_NAME: uni_eventos
FIREBASE_PROJECT_ID: unieventos
CORS_ORIGEM_PERMITIDA: http://localhost:5173
depends_on:
mysql:
condition: service_healthy
mysql:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: senha_local
MYSQL_DATABASE: uni_eventos
ports:
- '3306:3306'
volumes:
- dados_mysql:/var/lib/mysql
healthcheck:
test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost']
interval: 5s
timeout: 5s
retries: 10
volumes:
dados_mysql:
docker compose up --build
# API e MySQL sobem juntos, na mesma rede virtual — a API conversa
# com o banco pelo nome do serviço ("mysql"), não por "localhost"
🔎 Por baixo do capô Dentro da rede criada pelo
docker compose, cada serviço enxerga os outros pelo nome do serviço no YAML (mysql), não porlocalhost— por issoDB_HOST: mysqle nãoDB_HOST: localhost. O valor de "isso funcionar de primeira" é enorme: qualquer pessoa que clonar o repositório sobe o ambiente completo (API + banco, com schema aplicável vianpm run migrar) com um único comando, sem instalar MySQL na própria máquina.
4. CORS em produção e diagnóstico de erros pós-deploy¶
Em produção, restrinja CORS apenas ao domínio real do front publicado — nunca deixe origin: '*' ou o domínio de localhost esquecido em produção:
# .env de produção da API
CORS_ORIGEM_PERMITIDA=https://unieventos-web.vercel.app
4.1 Erros clássicos pós-deploy e como diagnosticar¶
| Sintoma | Causa provável | Como diagnosticar |
|---|---|---|
| Tela em branco após publicar, console mostra "Mixed Content" | Front em HTTPS chamando API em HTTP puro | Confira VITE_API_URL — precisa começar com https://, todo serviço de deploy moderno já expõe HTTPS por padrão |
| Requisições falham com erro de CORS no console | Domínio do front não está em CORS_ORIGEM_PERMITIDA da API, ou variável não foi atualizada em produção |
Abra a aba Network do DevTools, confira o cabeçalho Access-Control-Allow-Origin na resposta; ajuste a variável de ambiente e reinicie o serviço |
| Tela em branco, sem erro óbvio | Uma VITE_* esquecida no painel de deploy — o build usa undefined silenciosamente |
Confira todas as variáveis VITE_* no painel do serviço de hospedagem, comparando com o .env.production local |
F5 numa rota interna (/eventos/3) dá 404 |
Servidor não configurado para rewrite de SPA (Seção 1.4) | Adicione vercel.json/_redirects/firebase.json com o rewrite para index.html |
| API responde, mas toda rota de banco dá erro 500 | Banco inacessível: credenciais erradas, banco não migrado, ou IP não liberado no firewall do provedor | Acesse /health primeiro (não depende de banco); depois confira logs do serviço e rode npm run migrar no ambiente de produção |
| Login funciona local, falha em produção | Domínio de produção não foi adicionado à lista de domínios autorizados do Firebase Auth | No Console do Firebase, Authentication → Settings → Authorized domains, adicione o domínio publicado |
💡 Dica Sempre teste
/healthprimeiro depois de um deploy. Se ele responde200, o processo subiu — o problema está em uma camada específica (banco, CORS, variável de ambiente), não na infraestrutura toda.
5. CI/CD introdutório com GitHub Actions¶
CI (Integração Contínua) roda verificações automáticas a cada mudança no código — lint e testes, neste caso. CD (Entrega Contínua) automatiza a publicação quando essas verificações passam. Juntos, eliminam o "funciona na minha máquina" e o deploy manual esquecido.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: ['**']
pull_request:
branches: [main]
jobs:
lint-e-testes:
runs-on: ubuntu-latest
steps:
- name: Baixar o código do repositório
uses: actions/checkout@v4
- name: Configurar Node.js 22
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Instalar dependências
run: npm ci
- name: Rodar lint
run: npm run lint --if-present
- name: Rodar testes
run: npm test
deploy:
needs: lint-e-testes
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Baixar o código do repositório
uses: actions/checkout@v4
- name: Disparar deploy na Render via deploy hook
run: curl -X POST "${{ secrets.RENDER_DEPLOY_HOOK_URL }}"
Explicando as partes:
on.push.branches: ['**']— roda lint/teste em qualquer push, em qualquer branch, dando feedback rápido antes mesmo de abrir Pull Request.needs: lint-e-testes— o jobdeploysó roda depois quelint-e-testestermina com sucesso; se um teste falhar, o deploy nunca acontece.if: github.ref == 'refs/heads/main' && github.event_name == 'push'— restringe o deploy a pushes diretos (ou merges) na branchmain, nunca em branches de feature.secrets.RENDER_DEPLOY_HOOK_URL— configurado em Settings → Secrets and variables → Actions do repositório GitHub; nunca aparece em texto puro no workflow nem no log de execução.
⚠️ Atenção
npm ci(nãonpm install) dentro de workflows de CI: ele instala exatamente as versões travadas empackage-lock.json, de forma determinística, e falha se o lockfile estiver dessincronizado dopackage.json— evita o "passou no CI, mas com uma versão diferente da que alguém tem local".
Para a Vercel (front-end), normalmente não é preciso workflow de deploy próprio — a integração da Vercel com o GitHub já dispara build e deploy automaticamente a cada push em main, de forma nativa, sem depender do GitHub Actions.
🔎 Por baixo do capô O nome "CI/CD" às vezes confunde por juntar duas ideias distintas. Integração Contínua é sobre confiança: cada push prova, automaticamente, que o código continua íntegro (lint limpo, testes passando) antes de qualquer humano revisar. Entrega/Implantação Contínua é sobre velocidade: reduzir a distância entre "código pronto" e "código no ar" para minutos, não para um ritual manual de deploy que alguém precisa lembrar de fazer. O workflow desta seção faz as duas coisas: garante qualidade antes, entrega depois — e só entrega se a qualidade passou.
💡 Dica Um erro comum de quem está aprendendo CI/CD é tratar o pipeline como "burocracia extra". Na prática, ele é o que permite a um time (ou a você sozinho, meses depois) fazer mudanças com confiança: se o CI ficou verde, você sabe que não quebrou nada que já estava coberto por teste — sem isso, cada mudança pequena vira um momento de ansiedade.
6. Retrospectiva da disciplina¶
6.1 Mapa mental textual do que foi construído¶
UniEventos
│
├─ Front-end (unieventos-web)
│ ├─ Vue 3 (Composition API, <script setup>) — Aulas 01–03
│ ├─ Vuetify 4 (UI, layout, formulários) — Aulas 04–05
│ ├─ Vue Router 5 (SPA navegável, guards) — Aulas 04, 10
│ ├─ Pinia 4 (estado global: usuário, eventos) — Aula 06
│ ├─ Axios (consumo da API, interceptors) — Aula 06
│ ├─ Firebase Auth (login, cadastro) — Aula 10
│ └─ Deploy (Vercel/Firebase Hosting/GitHub Pages) — Aula 15
│
├─ Back-end (unieventos-api)
│ ├─ Node.js + Express 5 (rotas, middlewares) — Aula 07–08
│ ├─ MySQL (mysql2/promise, pool, transações) — Aula 09
│ ├─ Firebase Admin (verificação de token) — Aula 10
│ ├─ CRUD completo (front + back integrados) — Aula 11
│ ├─ Supabase (alternativa: RLS, Storage, Realtime) — Aula 12
│ ├─ Arquitetura em camadas + testes + segurança — Aula 13
│ ├─ Documentação OpenAPI/Swagger — Aula 14
│ └─ Deploy + CI/CD — Aula 15
│
└─ Padrões de projeto (GoF) — ver tabela consolidada abaixo
🧩 Todos os padrões de projeto usados no semestre¶
A ementa exige explicitamente padrões criacionais, estruturais e comportamentais. Aqui está a lista completa, com onde cada um apareceu de verdade no UniEventos.
Criacionais
| Padrão | Onde apareceu | Aula |
|---|---|---|
| Singleton | Store Pinia como instância única do estado global; pool de conexões do MySQL (obterPool() em db/pool.js) |
06, 09, 13 |
| Factory | createPool do mysql2; funções criarServicoDeEventos/criarRepositorioDeEventosMySQL que fabricam objetos configurados |
09, 13 |
| Builder | QueryBuilderDeListagem, que monta a query SQL de listagem incrementando condições opcionais (.comCategoria().comBuscaDeTexto().construir()) |
13 |
| Object Pool | O próprio pool de conexões do mysql2 reutiliza um conjunto fixo de conexões abertas em vez de abrir/fechar uma a cada requisição |
09 |
Estruturais
| Padrão | Onde apareceu | Aula |
|---|---|---|
| Composite | Árvore de componentes Vue (componentes dentro de componentes); rotas aninhadas do Vue Router | 04, 05 |
| Facade | Camada services/ no front (Aula 11) e no back (Aula 13) escondendo a complexidade de várias chamadas atrás de uma interface simples |
11, 13 |
| Adapter | Troca de MySQL por Supabase sem alterar o front — a interface do repositório permanece igual, a implementação muda por baixo | 12 |
| Proxy | reactive()/ref() do Vue usando Proxy do ES6 por baixo dos panos; middleware de autenticação como "proxy de proteção" antes da rota real |
03, 10 |
| Decorator | Interceptors do Axios "decorando" toda requisição (token, log) sem alterar o código de quem chama; anotações @openapi decorando rotas com metadados sem mudar o comportamento |
06, 14 |
Comportamentais
| Padrão | Onde apareceu | Aula |
|---|---|---|
| Observer | Sistema de reatividade do Vue — um ref/reactive muda, tudo que depende dele é notificado e re-renderiza automaticamente |
02 |
| Chain of Responsibility | Pipeline de middlewares do Express — cada app.use decide processar e passar adiante (next()) ou interromper a cadeia |
07, 08 |
| Strategy | Middlewares/validadores intercambiáveis; escolha de qual repositório usar por ambiente (obterRepositorioDeEventos, MySQL vs. memória) |
08, 13 |
| Template Method | Componentes de layout com slots definindo um "esqueleto" fixo e pontos variáveis preenchidos por quem usa o componente | 05 |
📌 Na prova O exame final cobra a definição de cada padrão e um exemplo concreto de onde ele apareceu no semestre — não basta decorar o nome, é preciso saber reconhecer o padrão dentro de um trecho de código real.
7. Guia de estudo para o exame final¶
O exame final é teórico, presencial e individual, cobrindo as três unidades da disciplina. Ele avalia conceitos, não "rodar código" — estude o porquê de cada decisão técnica, não só a sintaxe.
7.1 Lista de 25 a 30 questões de estudo¶
Unidade 1 — Fundamentos de Vue.js
- O que é programação declarativa e como ela difere de manipulação manual do DOM? (Aula 01)
- O que é reatividade no Vue e qual padrão de projeto (GoF) explica seu funcionamento? (Aula 02)
- Qual a diferença entre Options API e Composition API? Quando usar
<script setup>? (Aula 02) - Como
reactive()eref()diferem na forma como armazenam e expõem o valor? (Aula 03) - Por que
v-forsempre precisa de:key, e o que acontece se ela faltar? (Aula 02–03) - O que é uma
computede por que ela é preferível a um método equivalente, em termos de performance? (Aula 03) - Qual é a diferença entre
onMountede o restante do ciclo de vida de um componente? (Aula 03)
Unidade 2 — Vue avançado (Vuetify, Vue Router, Axios, Pinia)
- Como o Vue Router resolve navegação sem recarregar a página inteira (SPA)? (Aula 04)
- O que é um navigation guard e para que serve
beforeEach? (Aula 10) - Por que instanciar
axios.create({ baseURL })com interceptors é melhor do que usaraxiosglobal? (Aula 06) - Que padrão de projeto os interceptors do Axios exemplificam? (Aula 06)
- Qual é o papel do Pinia como single source of truth do estado da aplicação? (Aula 06)
- Por que a store Pinia é um exemplo de Singleton? (Aula 06)
- Como slots permitem que um componente de layout seja reutilizável em vários contextos? (Aula 05)
- O que muda estruturalmente do Vuetify 3 para o Vuetify 4 (tema padrão, tipografia, breakpoints)? (Aula 04–05)
Unidade 3 — Back-end, autenticação, banco de dados, deploy
- Qual a diferença entre autenticação e autorização, e onde cada uma aparece no UniEventos? (Aula 10)
- Como o Express 5 muda o tratamento de erros assíncronos em relação ao Express 4? (Aula 07–08, 13)
- O que é middleware no Express e que padrão de projeto (GoF) o pipeline de middlewares representa? (Aula 07–08)
- Por que usar
mysql2/promisecom queries parametrizadas (?) em vez de concatenar strings? (Aula 09) - O que é uma transação de banco de dados e quando ela é necessária? (Aula 09)
- Como o back-end verifica um token do Firebase, e por que essa verificação precisa acontecer no servidor (nunca só no front)? (Aula 10)
- O que é RLS (Row Level Security) no Supabase, e por que uma tabela com RLS habilitado e sem policies retorna lista vazia sem erro? (Aula 12)
- Que padrão de projeto permite trocar MySQL por Supabase sem alterar o front-end? (Aula 12)
- O que é injeção de dependência, e por que um service que recebe o repositório por parâmetro é mais testável? (Aula 13)
- Qual a diferença entre um erro operacional (esperado) e um erro inesperado, e por que essa diferença importa no log? (Aula 13)
- Por que nunca se deve vazar stack trace em uma resposta de erro em produção? (Aula 13)
- Qual a diferença entre OpenAPI e Swagger? (Aula 14)
- Por que a chave correta no
swagger-jsdoc6.x édefinition, e o que acontece se usarswaggerDefinition? (Aula 14) - O que é uma migration de banco de dados e por que ela substitui um
schema.sqlaplicado manualmente? (Aula 13) - Por que uma SPA precisa de configuração de rewrite no servidor de hospedagem para funcionar corretamente com F5 em rotas internas? (Aula 15)
7.2 Questões objetivas de exemplo, com gabarito comentado¶
1. No Vue 3, o sistema de reatividade (reactive, ref) é implementado, por baixo dos panos, principalmente com:
(A) Object.defineProperty apenas
(B) Proxy do ES6
(C) WeakMap apenas
(D) Getters e setters manuais escritos pelo desenvolvedor
Gabarito comentado
Resposta: B. O Vue 3 usa Proxy do ES6 para interceptar leitura e escrita de propriedades e disparar a reatividade — diferente do Vue 2, que usava Object.defineProperty (com limitações conhecidas, como não detectar adição de novas propriedades). Ver Aula 03.
2. Em Express 5, qual das alternativas abaixo é a forma correta de responder com status 201 e um corpo JSON?
(A) res.json(objeto, 201)
(B) res.status(201).json(objeto)
(C) res.send(201, objeto)
(D) res.json(201, objeto)
Gabarito comentado
Resposta: B. res.json(obj, status) é sintaxe do Express 4, removida no Express 5. A forma correta e atual é encadear res.status(201).json(objeto). Ver Aula 07/13.
3. Uma tabela no Supabase tem RLS (Row Level Security) habilitado, mas nenhuma policy foi criada. Uma consulta SELECT feita por um cliente autenticado retorna:
(A) Um erro 403 Forbidden
(B) Todos os registros da tabela, normalmente
(C) data: [], sem nenhum erro
(D) Um erro 500 Internal Server Error
Gabarito comentado
Resposta: C. É a "causa nº1 de meu código não funciona" no Supabase (Aula 12): RLS sem policy não gera erro, apenas nega acesso silenciosamente, retornando lista vazia. É essencial sempre criar a policy correspondente à operação (SELECT, INSERT etc.).
4. Qual padrão de projeto GoF melhor descreve o pipeline de middlewares do Express, em que cada função decide processar a requisição e passá-la adiante com next(), ou interrompê-la?
(A) Observer (B) Chain of Responsibility (C) Singleton (D) Facade
Gabarito comentado
Resposta: B. Chain of Responsibility: uma cadeia de handlers, cada um com a chance de tratar a requisição ou repassá-la ao próximo. É exatamente o comportamento de app.use(middleware1, middleware2, ...). Ver Aula 07–08.
5. Sobre swagger-jsdoc na versão 6.x usada nesta disciplina, a chave correta dentro das opções para declarar openapi, info e components é:
(A) swaggerDefinition
(B) spec
(C) definition
(D) openApiDefinition
Gabarito comentado
Resposta: C. swaggerDefinition era usado em versões antigas (2.x/3.x). A versão 6.x exige definition. Usar a chave errada não gera erro — só produz uma spec com paths vazio. Ver Aula 14.
6. Por que uma SPA hospedada em produção pode retornar 404 ao usuário apertar F5 em uma rota interna como /eventos/3?
(A) Porque o Vue Router não suporta navegação direta por URL
(B) Porque o servidor de hospedagem, sem configuração de rewrite, procura um arquivo físico eventos/3 que não existe
(C) Porque o Vite não gera index.html no build de produção
(D) Porque import.meta.env não funciona em produção
Gabarito comentado
Resposta: B. O F5 dispara uma requisição HTTP real ao servidor para aquele caminho. Sem rewrite configurado, o servidor de arquivos estáticos não encontra um arquivo físico correspondente e responde 404. A solução é configurar o rewrite para index.html em qualquer caminho desconhecido. Ver Aula 15, Seção 1.4.
7. No padrão de injeção de dependência aplicado na Aula 13, qual é a principal vantagem de um service receber o repository como parâmetro em vez de importá-lo diretamente?
(A) O código fica mais curto
(B) É possível testar o service com um repositório falso, sem depender de um banco de dados real
(C) É a única forma de usar async/await no Node.js
(D) Reduz o número de arquivos do projeto
Gabarito comentado
Resposta: B. A motivação central de DI aqui é testabilidade: o service passa a depender apenas da interface do repositório, não da implementação concreta — em teste, injeta-se uma implementação em memória; em produção, a implementação real. Ver Aula 13, Seção 3.
8. No Vuetify 4, o comportamento padrão da propriedade theme.defaultTheme, se não for explicitamente definida, é:
(A) 'light', igual ao Vuetify 3
(B) 'dark'
(C) 'system' — segue a preferência do sistema operacional do usuário
(D) Não existe tema padrão; é obrigatório declarar
Gabarito comentado
Resposta: C. No Vuetify 4 o padrão mudou de 'light' (v3) para 'system'. Por isso a disciplina sempre declara explicitamente defaultTheme: 'light' na criação da instância, para manter consistência visual em sala. Ver especificação da Aula 04/05.
8. Apresentação dos resultados¶
8.1 Formato do seminário final¶
Cada estudante apresenta seu projeto autoral individualmente, em 8 minutos, cobrindo obrigatoriamente:
- O problema (1 min) — que problema real o projeto resolve, para quem.
- Demonstração ao vivo (3 min) — navegar pela aplicação publicada (URL real, não localhost): listagem com filtro, detalhe, fluxo autenticado, CRUD funcionando.
- Arquitetura (2 min) — diagrama rápido das camadas (front → API → banco), tecnologias escolhidas, e onde a documentação Swagger vive.
- Decisão técnica mais difícil (1 min) — um problema real enfrentado e como foi resolvido (ex.: "por que troquei X por Y", "como resolvi o CORS em produção").
- O que faria diferente (1 min) — autoavaliação honesta: o que ficaria melhor com mais tempo ou outra escolha técnica.
8.2 Critérios de avaliação da apresentação¶
| Critério | O que é observado |
|---|---|
| Clareza da comunicação | Explica o projeto para alguém que nunca viu, sem depender de jargão não explicado |
| Demonstração funcional | A aplicação publicada realmente funciona ao vivo, sem "deixa eu tentar de novo" |
| Profundidade técnica | Consegue justificar decisões (por que essa arquitetura, por que esse banco) |
| Gestão do tempo | Respeita os 8 minutos, sem cortar abruptamente nem sobrar tempo vazio |
⚠️ Atenção A apresentação é sobre o projeto autoral publicado, com URL pública real — não é permitido apresentar rodando em
localhost. Se o deploy falhar no dia, tenha um vídeo curto de backup gravado com antecedência mostrando o fluxo funcionando.
8.3 Ordem e cronograma¶
A ordem de apresentação é definida por sorteio, feito em sala na aula anterior (Aula 14) ou no início desta aula, conforme a quantidade de estudantes matriculados. Com 3 blocos de 50 minutos e 8 minutos por estudante, o tempo permite aproximadamente 15 a 16 apresentações — se a turma for maior, o professor comunica com antecedência um ajuste (ex.: reduzir para 6 minutos ou dividir em dois dias, dentro do que o calendário acadêmico permitir).
💻 Mão na massa — publicando o UniEventos¶
Passo 1 — configure as variáveis de ambiente de produção do front:
# no repositório unieventos-web
touch .env.production
# preencha VITE_API_URL com a URL da API já publicada (Passo 4 abaixo)
Passo 2 — confirme que o build local funciona:
npm run build
npm run preview
# abra http://localhost:4173 e navegue pelas rotas internas — confirme que
# recarregar a página (F5) numa rota interna NÃO quebra localmente
# (o "vite preview" já simula o comportamento de servidor de produção)
Passo 3 — publique o front na Vercel seguindo o passo a passo da Seção 2.1.
Passo 4 — publique o back na Render seguindo o passo a passo da Seção 3.3. Anote a URL pública gerada.
Passo 5 — volte ao front e atualize VITE_API_URL com a URL real da API publicada, faça commit e push — a Vercel refaz o build automaticamente.
Passo 6 — atualize CORS_ORIGEM_PERMITIDA na API publicada com a URL real do front publicado (Seção 4), reinicie o serviço.
Passo 7 — teste o fluxo completo em produção: abra a URL do front publicado, faça login, liste eventos, crie uma inscrição, atualize a página em uma rota interna (F5) e confirme que não dá 404.
Passo 8 — crie o workflow de CI:
mkdir -p .github/workflows
# cole o conteúdo de .github/workflows/ci.yml da Seção 5
git add .github/workflows/ci.yml
git commit -m "adiciona pipeline de CI com lint e testes"
git push
Confira na aba Actions do GitHub que o workflow rodou e passou.
🧪 Laboratório¶
1. Gere o build de produção do seu projeto autoral e rode npm run preview — confirme que todas as rotas funcionam, incluindo F5 em rota interna.
Resultado esperado: nenhum erro no console, navegação idêntica ao ambiente de desenvolvimento.
Dica
Se uma rota der 404 até no preview local, o problema é de configuração de rota no Vue Router, não de hospedagem — resolva isso antes de publicar.
2. Publique o front-end em um dos serviços da Seção 2, com as variáveis VITE_* corretas.
Resultado esperado: URL pública funcionando, aplicação carrega sem tela em branco.
Dica
Se a tela ficar em branco sem erro óbvio, abra o Console do DevTools primeiro — normalmente aponta uma variável de ambiente undefined.
3. Publique o back-end em um dos serviços da Seção 3, com /health respondendo publicamente.
Resultado esperado: curl https://sua-api.onrender.com/health retorna {"status":"ok",...}.
Dica
Rode as migrations manualmente pelo shell do serviço antes de testar qualquer rota que dependa de tabela do banco.
4. Configure CORS restritivo em produção, apontando exatamente para a URL do front publicado.
Resultado esperado: requisições do front publicado funcionam; uma requisição feita a partir de uma origem diferente é bloqueada.
Dica
Teste abrindo o Console do navegador em uma aba com origem diferente (ex.: http://localhost:5500) e tentando um fetch contra sua API publicada — deve falhar por CORS.
5. Crie o workflow de CI no seu repositório de back-end, rodando lint e testes a cada push.
Resultado esperado: aba Actions do GitHub mostra o workflow executando e passando em verde.
Dica
Se você não tiver npm run lint configurado, o --if-present do comando na Seção 5 evita que o workflow falhe por esse motivo — mas vale configurar ESLint se ainda não tiver.
🐛 Erros comuns e como resolver¶
| Sintoma | Causa | Solução |
|---|---|---|
| Build local funciona, produção mostra tela em branco | Variável VITE_* não configurada no painel do serviço de hospedagem |
Confira todas as VITE_* no painel, compare com .env.production local, force um novo build |
Mixed Content no console em produção |
API ainda respondendo em http:// enquanto o front está em https:// |
Troque VITE_API_URL para https://; a maioria dos serviços de deploy de back já expõe HTTPS por padrão |
| CI falha em "npm ci" com erro de lockfile | package-lock.json desatualizado em relação ao package.json |
Rode npm install localmente, comite o package-lock.json atualizado |
| Deploy da API funciona, mas toda rota autenticada falha | Domínio de produção do front não está na lista de domínios autorizados do Firebase Auth | Adicione o domínio em Firebase Console → Authentication → Settings → Authorized domains |
docker compose up falha ao conectar no banco |
API tentando conectar em localhost em vez do nome do serviço mysql |
Ajuste DB_HOST para mysql (o nome do serviço no docker-compose.yml), não localhost |
| Workflow de CI nunca dispara o job de deploy | Condição if do job deploy não bateu (branch errada ou evento errado) |
Confira se o push foi feito diretamente em main ou via merge de PR para main |
🏠 Atividade assíncrona (1 h)¶
- Finalize o deploy completo (front + back) do projeto autoral, se ainda não tiver feito no laboratório.
- Grave um vídeo curto (3 a 5 minutos, pode ser não listado no YouTube ou enviado por link de drive) demonstrando o fluxo publicado, como backup para a apresentação.
- Prepare os slides ou roteiro da apresentação de 8 minutos, seguindo a estrutura da Seção 8.1.
- Revise, uma última vez, o README, garantindo que o link da aplicação publicada e do
/api-docsestejam visíveis.
Critério de pronto: aplicação publicada e acessível publicamente; vídeo de backup gravado; roteiro da apresentação pronto.
✅ Checkpoint do projeto autoral¶
Ao final desta aula, seu projeto deve ter:
- [ ] Front-end publicado com URL pública, variáveis
VITE_*corretas, rewrite de SPA configurado. - [ ] Back-end publicado com URL pública,
/healthrespondendo, banco gerenciado acessível, migrations aplicadas. - [ ] CORS restrito ao domínio real do front publicado.
- [ ] Workflow de CI (
.github/workflows/ci.yml) rodando lint e testes a cada push. - [ ] README com links da aplicação publicada, da API publicada e de
/api-docs. - [ ] Roteiro de apresentação pronto, dentro de 8 minutos.
📝 Avaliação 3 — instruções de entrega¶
Escopo: aplicação full stack completa, construída sobre o projeto autoral definido na Aula 01 e evoluído ao longo de todo o semestre.
Requisitos obrigatórios:
- Back-end próprio, em Express ou usando Supabase como back-end gerenciado (ou uma combinação dos dois, desde que a arquitetura em camadas da Aula 13 esteja presente onde houver código Express).
- Banco de dados persistente (MySQL ou Supabase/Postgres), com schema versionado (migrations ou scripts SQL organizados).
- Autenticação funcional (Firebase Auth ou autenticação nativa do Supabase), protegendo pelo menos as rotas de escrita (criação/edição/remoção).
- CRUD completo de pelo menos 2 entidades relacionadas (ex.: "Evento" e "Inscrição"), com relacionamento real no banco (chave estrangeira ou equivalente).
- Documentação Swagger (OpenAPI 3) cobrindo todos os endpoints, ou documentação equivalente de todas as políticas/endpoints se o back for majoritariamente Supabase.
- Deploy funcionando com URL pública — tanto do front quanto do back (ou só do front, se usando Supabase como back completo).
- README completo, seguindo a estrutura da Aula 14 (badges, requisitos, instalação, variáveis de ambiente, scripts, endpoints, licença), com os links de aplicação publicada e repositório.
Rubrica (pesos somam 10 pontos):
| Critério | Peso | O que precisa estar funcionando |
|---|---|---|
| CRUD completo de 2+ entidades relacionadas | 2,5 | Criar, listar, editar e remover funcionando de ponta a ponta, com relacionamento real entre as entidades |
| Banco de dados persistente e corretamente modelado | 1,5 | Schema versionado (migrations/scripts), queries corretas, sem duplicação nem inconsistência de dados |
| Autenticação protegendo rotas sensíveis | 1,5 | Rotas de escrita exigem usuário autenticado; identidade do usuário usada corretamente (ex.: só o dono edita/remove seu próprio recurso) |
| Documentação Swagger/OpenAPI completa | 1,5 | Todos os endpoints documentados, schemas reutilizáveis, segurança declarada, /api-docs acessível |
| Deploy funcionando com URL pública | 2,0 | Front e back publicados, acessíveis externamente, sem depender de localhost |
| README e qualidade geral do código | 1,0 | README completo conforme estrutura da Aula 14; arquitetura em camadas aplicada; ao menos alguns testes automatizados presentes |
Formato de entrega: via SIGAA, até 16/12/2026, 23h59, contendo:
- Link do repositório do front-end.
- Link do repositório do back-end (ou anotação de que o back é 100% Supabase, com link do projeto Supabase se aplicável).
- Link da aplicação publicada (URL pública funcionando).
- Link de
/api-docs(se aplicável) ou da documentação equivalente.
⚠️ Atenção A entrega é considerada incompleta se qualquer um dos quatro links acima estiver ausente ou não funcionar no momento da correção. Teste os links em uma aba anônima do navegador antes de enviar, simulando o que o avaliador vai ver.
Política de atraso: entregas após 16/12/2026, 23h59 têm desconto de 1,0 ponto (sobre a nota final da Avaliação 3) por dia corrido de atraso, até o limite de 3 dias — após esse prazo, a avaliação recebe nota zero, exceto em casos de justificativa formal e documentada junto à coordenação do curso, conforme o regimento da UNEMAT.
Política de plágio e uso de IA: é permitido e esperado o uso de ferramentas de IA (como assistentes de código) como apoio ao desenvolvimento — é exatamente essa prática que a indústria de software usa hoje. O que não é aceito: (1) entregar código que você não é capaz de explicar linha a linha na apresentação; (2) copiar o projeto de outro colega, com ou sem alterações cosméticas; (3) apresentar como próprio um projeto gerado quase integralmente por IA sem compreensão do que foi produzido. A apresentação de 8 minutos (Seção 8) é, entre outras coisas, o mecanismo de verificação de autoria: perguntas técnicas sobre decisões do próprio código fazem parte da avaliação.
9. Encerramento: caminhos depois da disciplina¶
O que foi construído neste semestre é uma base real de desenvolvimento full stack moderno — mas é só o começo. Caminhos naturais de continuidade:
- Nuxt — framework full stack sobre o Vue, com SSR (Server-Side Rendering) e SSG (Static Site Generation) nativos, útil quando SEO ou performance de primeira carga importam mais do que em uma SPA pura.
- TypeScript — adicionar tipagem estática ao que hoje é JavaScript puro; o Vue 3 e o Vuetify 4 têm suporte de primeira classe a TS, e o ganho em projetos maiores (detecção de erro em tempo de escrita, autocomplete mais forte) é significativo.
- Testes E2E — Cypress ou Playwright, testando a aplicação inteira pela interface, como um usuário real faria — o topo da pirâmide de testes que só citamos na Aula 13.
- Vue 3.6 — acompanhar o roadmap oficial do Vue (Vapor Mode e otimizações de compilador são a fronteira de pesquisa ativa do framework no momento).
- Mobile com Capacitor/Ionic — reaproveitar o conhecimento de Vue para publicar o mesmo código (ou uma variação) como app nativo Android/iOS.
- Back-end com NestJS — um framework Node.js opinativo, construído sobre Express (ou Fastify), que formaliza com decorators e módulos exatamente a arquitetura em camadas que construímos manualmente na Aula 13.
9.1 Como montar um portfólio a partir deste semestre¶
- Deixe o projeto autoral publicado e funcionando — um link ao vivo vale mais, para quem recruta, do que um repositório que só roda localmente.
- Escreva um README que conte a história do projeto: problema, decisões técnicas, dificuldades reais (os ADRs da Aula 14 são ótimo material bruto para isso).
- Grave um vídeo curto de demonstração e fixe no topo do repositório (ou no README, como GIF).
- Continue commitando — um projeto "morto" no GitHub (sem commit há meses) comunica menos do que um projeto pequeno e ativo.
9.2 Convite para iniciação científica e extensão¶
Muitos dos temas tocados de leve neste semestre — arquitetura de software, segurança de aplicações web, engenharia de dados, IA aplicada a desenvolvimento — são linhas de pesquisa ativas na FACET. Se algum tópico desta disciplina despertou curiosidade além do prazo de uma avaliação, procure o professor para conversar sobre projetos de iniciação científica ou extensão relacionados — é o próximo passo natural para quem quer ir além do conteúdo obrigatório da ementa.
📚 Para aprofundar¶
- Documentação oficial da Vercel
- Documentação oficial do Firebase Hosting
- GitHub Pages — documentação oficial
- Documentação oficial da Render
- Docker — documentação oficial
- GitHub Actions — documentação oficial
- Vite — variáveis de ambiente e modos
- Vue Router — histórico HTML5 e configuração de servidor
- Nuxt — site oficial
- NestJS — site oficial
Fim do semestre. Obrigado pelo empenho nas 15 aulas — do primeiro console.log da Aula 01 até uma aplicação full stack publicada, com autenticação, banco de dados e documentação. O exame final cobre teoria das três unidades; revise o guia de estudo da Seção 7 com antecedência, não na véspera. Bom exame, e bom portfólio.
Prof. Ivan Luiz Pedroso Pires · Material didático de uso educacional; livre para consulta, estudo e reuso com atribuição.
Início · Banco de Desafios · Links úteis · Fontes no GitHub