Conhecimento prévio de lógica de programação e alguma exposição a HTML/CSS/JS (pré-requisito formal: a disciplina de Nível 2 desta sequência, ou conhecimento equivalente).
⚠️ 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.
Esta trilha nasceu de uma disciplina de graduação em frameworks modernos e integração de sistemas e é publicada aberta: são 15 aulas de cerca de 150 minutos cada, mais uma hora de prática por aula. Quem cursa a disciplina acompanha o calendário da turma; quem estuda por conta própria avança no ritmo que conseguir sustentar — a ordem das aulas é a mesma nos dois casos, porque cada uma depende da anterior. Quem escreveu e revisou cada parte está na página Autoria e créditos.
O que esta trilha cobre:
"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 isso é um compromisso central desta trilha, e porque entender o padrão por trás da ferramenta é o que separa quem usa framework de quem entende framework.
Introdução ao Vue: lifecycle, instância, data e methods, diretivas básicas
03
U1
Vue: v-if, v-else, v-for, computed e onMounted
04
U1
Introdução a Vuetify e Vue Router — Marco 1 do projeto
05
U2
Componentes, Vue Router e Vuetify
06
U2
Axios e Pinia
07
U3
Firebase, Node.js e Express
08
U3
Endpoints e middlewares — Marco 2 do projeto
09
U3
Integração com MySQL
10
U3
Requisições autenticadas com Firebase
11
U3
CRUD front-end + back-end
12
U3
CRUD com banco de dados em nuvem (Supabase)
13
U3
Desenvolvimento do back-end em camadas
14
U3
Documentação com Swagger
15
U3
Apresentação dos resultados — Marco 3 do projeto
O conteúdo abaixo é o mesmo, esteja você seguindo o calendário de uma turma ou estudando por conta própria, em qualquer época — as datas de uma oferta específica, quando existem, ficam de fora deste texto.
Todas as aulas 01 a 15 constroem, passo a passo, 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 junto com o material, mas seu projeto autoral (o que evolui até cada marco) terá a mesma arquitetura aplicada a um domínio diferente, escolhido por você. Falamos disso na Seção 1.6.
Esta trilha está dividida em três marcos, um ao final de cada unidade. Cada marco é um estado que o seu projeto autoral precisa alcançar — não uma prova separada — e mostra que você domina o conteúdo daquela unidade.
Marco
Escopo
Marco 1
Vue 3 com CLI: estrutura de projeto, componentes, diretivas
Back-end: Firebase/Express/Supabase, banco de dados, autenticação
Os requisitos completos de cada marco — o que precisa estar pronto e como conferir — são detalhados na aula que fecha a unidade correspondente: o Marco 1 na Aula 04, o Marco 2 na Aula 08, o Marco 3 na Aula 15. Se você está seguindo esta trilha em uma disciplina com professor, é dele que vêm os prazos e a forma de acompanhamento de cada marco; se está estudando por conta própria, use os marcos como metas de progresso no seu próprio ritmo.
GitHub — mantenha um repositório público do projeto autoral; é ali que o progresso fica visível ao longo do tempo, para você mesmo, para outra pessoa que revise seu código, ou para quem eventualmente lhe orienta (um professor, um mentor, um colega mais experiente).
Se você está cursando esta trilha como disciplina em uma instituição, ela também é quem fornece os avisos, prazos e canais complementares próprios daquela oferta.
💡 Dica
Comite no seu repositório do projeto autoral toda semana, mesmo que pouco. Um histórico de commits ao longo do tempo mostra evolução de verdade — vale muito mais do que um único commit gigante na véspera de um marco.
Escolha, logo no início desta trilha, um domínio de aplicação diferente do UniEventos (o projeto que construiremos ao longo do material), mas siga 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.
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.
Vamos instalar, nesta aula, tudo que será usado até o fim da trilha. As versões abaixo foram testadas no ambiente real desta trilha — use exatamente estas.
Ferramenta
Versão usada nesta trilha
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:
Terminal
# 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
nvminstall22
nvmuse22
⚠️ Atenção
Se node -v mostrar uma versão 16, 18 ou 20, desinstale-a ou troque com o nvm antes de continuar. Ferramentas que usaremos mais à frente, como o create-vue, exigem Node ^22.18.0 ou >=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) do início ao fim da trilha.
Git e GitHub.
Terminal
git--version
# se não tiver, instale: sudo apt install git (Linux) ou baixe em git-scm.com
gitconfig--globaluser.name"Seu Nome"
gitconfig--globaluser.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 nesta trilha, para o deploy.
💡 Dica
Depois de instalar tudo, rode node -v, npm -v e git --version e tire um print. Cole no seu README como evidência de ambiente pronto — é o primeiro item do checkpoint desta aula.
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:
HTML
<!-- index.html --><!DOCTYPE html><htmllang="pt-BR"><head><metacharset="UTF-8"/><title>Eventos — DOM manual</title></head><body><inputid="busca"type="text"placeholder="Buscar evento..."/><selectid="categoria"><optionvalue="">Todas as categorias</option><optionvalue="palestra">Palestra</option><optionvalue="minicurso">Minicurso</option><optionvalue="workshop">Workshop</option></select><ulid="lista-eventos"></ul><scriptsrc="app-dom-manual.js"></script></body></html>
JavaScript
// app-dom-manual.jsconsteventos=[{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 de Tecnologia',categoria:'workshop',vagas:60},{id:4,titulo:'Introdução a IA',categoria:'palestra',vagas:80},]constinputBusca=document.getElementById('busca')constselectCategoria=document.getElementById('categoria')constlistaEl=document.getElementById('lista-eventos')// Função que decide QUAIS eventos mostrar e depois// PRECISA, na mão, apagar o DOM antigo e reconstruir tudo.functionrenderizar(){consttermo=inputBusca.value.toLowerCase()constcategoria=selectCategoria.valueconstfiltrados=eventos.filter((evento)=>{constbateTexto=evento.titulo.toLowerCase().includes(termo)constbateCategoria=categoria===''||evento.categoria===categoriareturnbateTexto&&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){constli=document.createElement('li')li.textContent='Nenhum evento encontrado.'listaEl.appendChild(li)return}filtrados.forEach((evento)=>{constli=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 chamar renderizar().
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.
Compare com o que faremos a partir da Aula 02, em Vue:
Vue SFC
<!-- 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 de toda esta trilha. Ainda não vamos escrever Vue hoje (isso começa na Aula 02); hoje construímos a base de JavaScript que sustenta tudo isso.
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. O npm (Node Package Manager) instala bibliotecas de terceiros. O package.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 de MAIOR podem quebrar compatibilidade; MENOR adiciona funcionalidade sem quebrar; PATCH corrige bugs. É por isso que, nesta trilha, fixamos versões exatas — evita que seu projeto quebre por uma atualização automática inesperada.
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.
🧠 Você sabia?
O JavaScript foi criado por Brendan Eich em 10 dias, em maio de 1995, para a Netscape — daí virem tantas decisões de design "estranhas" que a linguagem carrega até hoje, como o == fazer coerção de tipo. O nome "JavaScript" foi uma decisão de marketing da Netscape para surfar na popularidade do Java, sem relação técnica real entre as duas linguagens; a especificação oficial se chama ECMAScript, e é por isso que falamos em "ES2015", "ES2020" — cada ano, uma revisão da especificação.
// 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.constnomeEvento='Semana da Computação'// não pode ser reatribuídaletvagasDisponiveis=40// pode ser reatribuídavagasDisponiveis=vagasDisponiveis-1console.log(vagasDisponiveis)// 39// `let` e `const` respeitam escopo de bloco { }if(vagasDisponiveis>0){constmensagem='ainda há vagas'console.log(mensagem)}// console.log(mensagem) aqui daria ReferenceError: mensagem não existe fora do bloco
⚠️ Atenção
Nunca use var em código novo. var "vaza" para fora de blocos if/for, o que gera bugs difíceis de rastrear. Esta trilha inteira usa apenas let e const.
// Função tradicionalfunctiondobrar(numero){returnnumero*2}// Arrow function equivalenteconstdobrarArrow=(numero)=>numero*2// Com múltiplos parâmetros e corpo de blococonstsomarVagas=(a,b)=>{consttotal=a+breturntotal}// Sem parâmetros, precisa dos parênteses vaziosconstgerarId=()=>Math.floor(Math.random()*1000)
A diferença mais importante não é a sintaxe curta — é o comportamento do this.
JavaScript
constcontador={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 o this do 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 de this deixa de ser um problema porque a Composition API não depende dele.
constevento={id:2,titulo:'Oficina de Vue.js',categoria:'minicurso',vagas:25,local:'Bloco B, sala 12',}// Desestruturação de objeto: extrai propriedades para variáveisconst{titulo,vagas}=eventoconsole.log(titulo,vagas)// Oficina de Vue.js 25// Renomear ao desestruturarconst{titulo:nomeDoEvento}=eventoconsole.log(nomeDoEvento)// Oficina de Vue.js// Valor default se a propriedade não existirconst{imagemUrl='/img/padrao.png'}=eventoconsole.log(imagemUrl)// /img/padrao.png// Desestruturação de array: extrai por posiçãoconstcoordenadas=[-16.0736,-57.6789]const[latitude,longitude]=coordenadasconsole.log(latitude,longitude)// -16.0736 -57.6789// Desestruturação em parâmetros de função — muito comum no Vue com propsfunctionexibirEvento({titulo,vagas}){return`${titulo}: ${vagas} vagas`}console.log(exibirEvento(evento))// Oficina de Vue.js: 25 vagas
// Spread (...) em array: "espalha" os elementosconstcategoriasBase=['palestra','minicurso']consttodasCategorias=[...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)consteventoOriginal={id:1,titulo:'Semana da Computação',vagas:40}consteventoAtualizado={...eventoOriginal,vagas:39}console.log(eventoOriginal.vagas)// 40 — original intocadoconsole.log(eventoAtualizado.vagas)// 39 — cópia com a mudança// Rest (...) em parâmetros: agrupa "o resto" dos argumentos em arrayfunctionsomarTodasAsVagas(...quantidades){returnquantidades.reduce((total,atual)=>total+atual,0)}console.log(somarTodasAsVagas(10,20,30))// 60// Rest em desestruturação: agrupa "o resto" das propriedadesconst{id,...detalhesDoEvento}=eventoOriginalconsole.log(id)// 1console.log(detalhesDoEvento)// { titulo: 'Semana da Computação', vagas: 40 }
functioncriarEvento(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 (??)¶
JavaScript
constevento={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 erroconsole.log(evento.organizador?.nome)// undefinedconsole.log(evento.local?.sala)// undefinedconsole.log(evento.local?.predio)// Bloco A// Funciona também para chamar métodos que podem não existirconstrelatorio=nullconsole.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).constvagasInformadas=0console.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/undefinedconstsala=evento.local?.sala??'a definir'console.log(sala)// a definir
⚠️ Atenção|| e ?? parecem intercambiáveis, mas não são. Use ?? sempre que 0, '' ou false forem 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").
Vamos usar o mesmo array de eventos em todos os exemplos — é o dado que sustentará o UniEventos a partir da Aula 02.
JavaScript
consteventos=[{id:1,titulo:'Semana da Computação',categoria:'palestra',dataHora:'2030-09-10T19:00:00',vagas:40,inscritos:12},{id:2,titulo:'Oficina de Vue.js',categoria:'minicurso',dataHora:'2030-08-20T14:00:00',vagas:25,inscritos:25},{id:3,titulo:'Hackathon de Tecnologia',categoria:'workshop',dataHora:'2030-10-05T08:00:00',vagas:60,inscritos:18},{id:4,titulo:'Introdução a IA',categoria:'palestra',dataHora:'2030-08-18T19:30:00',vagas:80,inscritos:55},]
map — transforma cada item em outra coisa, sem mudar o tamanho do array:
JavaScript
consttitulos=eventos.map((evento)=>evento.titulo)console.log(titulos)// ['Semana da Computação', 'Oficina de Vue.js', 'Hackathon de Tecnologia', 'Introdução a IA']// map devolvendo objetos NOVOS (sem mutar os originais) — padrão que// vamos repetir sempre que precisarmos "decorar" dados para a telaconsteventosComVagasRestantes=eventos.map((evento)=>({...evento,vagasRestantes:evento.vagas-evento.inscritos,}))console.log(eventosComVagasRestantes[0].vagasRestantes)// 28
filter — seleciona um subconjunto:
JavaScript
constpalestras=eventos.filter((evento)=>evento.categoria==='palestra')console.log(palestras.length)// 2constcomVagas=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:
JavaScript
consttotalDeVagas=eventos.reduce((acumulado,evento)=>acumulado+evento.vagas,0)console.log(totalDeVagas)// 205// reduce também serve para agrupar por categoriaconstporCategoria=eventos.reduce((grupos,evento)=>{constchave=evento.categoriaif(!grupos[chave]){grupos[chave]=[]}grupos[chave].push(evento)returngrupos},{})console.log(Object.keys(porCategoria))// ['palestra', 'minicurso', 'workshop']
find — retorna o primeiro item que bate na condição (ou undefined):
JavaScript
consteventoBuscado=eventos.find((evento)=>evento.id===3)console.log(eventoBuscado.titulo)// Hackathon de Tecnologiaconstinexistente=eventos.find((evento)=>evento.id===999)console.log(inexistente)// undefined
some e every — testam a coleção e retornam boolean:
JavaScript
constexisteEventoLotado=eventos.some((evento)=>evento.inscritos>=evento.vagas)console.log(existeEventoLotado)// true (a Oficina de Vue.js está lotada)consttodosTemVagas=eventos.every((evento)=>evento.inscritos<evento.vagas)console.log(todosTemVagas)// false
sort — ordena o array (⚠️ muta o array original):
JavaScript
// sort() muda o array ORIGINAL. Para não afetar `eventos`, copie antes com spread.consteventosPorData=[...eventos].sort((a,b)=>newDate(a.dataHora)-newDate(b.dataHora),)console.log(eventosPorData.map((e)=>e.titulo))// ['Introdução a IA', 'Oficina de Vue.js', 'Semana da Computação', 'Hackathon de Tecnologia']console.log(eventos.map((e)=>e.titulo))// ainda na ordem original — porque ordenamos a CÓPIA, não `eventos`
🔬 Investigue
Abra o Console do navegador (F12) e cole o array eventos da Seção 3.8 acima. Rode console.table(eventos) para ver a ordem atual, depois rode eventos.sort((a, b) => a.vagas - b.vagas) e console.table(eventos) de novo — a ordem mudou. Agora rode só eventos mais uma vez: ele continua alterado, porque .sort() muta o array original em vez de devolver uma cópia. É exatamente o bug que o item A4 do Laboratório desta aula pede para você achar.
Encadeando métodos — o padrão mais comum no dia a dia:
JavaScript
constresumoDePalestrasComVaga=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)']
consttitulo='Semana da Computação'constvagas=40// Antes: repetir a chave e o valorconsteventoAntigo={titulo:titulo,vagas:vagas}// Shorthand: quando o nome da variável é igual ao nome da propriedadeconsteventoModerno={titulo,vagas}console.log(eventoModerno)// { titulo: 'Semana da Computação', vagas: 40 }// Shorthand também funciona para métodosconstgerenciadorDeEventos={eventos:[],adicionar(evento){// em vez de: adicionar: function (evento) { ... }this.eventos.push(evento)},contar(){returnthis.eventos.length},}gerenciadorDeEventos.adicionar({titulo:'Novo evento'})console.log(gerenciadorDeEventos.contar())// 1// Nomes de propriedade computadosconstchave='categoria'constfiltro={[chave]:'palestra'}console.log(filtro)// { categoria: 'palestra' }
Organizar código em módulos é essencial — é assim que um projeto Vue inteiro é estruturado, um arquivo por responsabilidade.
JavaScript
// arquivo: eventos.js// export nomeado: pode haver vários por arquivoexportconstCATEGORIAS=['palestra','minicurso','workshop']exportfunctionfiltrarPorCategoria(eventos,categoria){if(!categoria)returneventosreturneventos.filter((evento)=>evento.categoria===categoria)}exportfunctioncalcularVagasRestantes(evento){returnevento.vagas-evento.inscritos}// export default: no máximo um por arquivo — geralmente a "coisa principal"exportdefaultclassGerenciadorDeEventos{constructor(eventosIniciais=[]){this.eventos=eventosIniciais}adicionar(evento){this.eventos.push(evento)}}
JavaScript
// arquivo: main.js// import nomeado: usa chaves { } e o mesmo nome do exportimport{CATEGORIAS,filtrarPorCategoria}from'./eventos.js'// import default: sem chaves, você escolhe o nomeimportGerenciadorDeEventosfrom'./eventos.js'// import combinando os dois na MESMA linha (é assim que se escreve na prática —// não repita o import default do mesmo módulo, como fizemos acima só para separar os casos)// import GerenciadorDeEventos, { calcularVagasRestantes } from './eventos.js'import{calcularVagasRestantes}from'./eventos.js'console.log(CATEGORIAS)// ['palestra', 'minicurso', 'workshop']constgerenciador=newGerenciadorDeEventos()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":
HTML
<scripttype="module"src="main.js"></script>
📌 Vale gravar
A partir da Aula 02, todo componente .vue é, por baixo, um módulo ES: ele importa outros componentes com import e é importado por quem o usa. Entender import/export agora evita confusão depois.
classEvento{// Campos de instância (sintaxe moderna, sem precisar declarar no constructor)inscritos=0constructor(titulo,categoria,vagas){this.titulo=titulothis.categoria=categoriathis.vagas=vagas}// Método de instânciainscrever(){if(this.inscritos>=this.vagas){thrownewError('Evento lotado')}this.inscritos+=1}// Getter: parece uma propriedade, mas é calculadogetvagasRestantes(){returnthis.vagas-this.inscritos}}// Herança com extendsclassMinicursoextendsEvento{constructor(titulo,vagas,cargaHoraria){super(titulo,'minicurso',vagas)// chama o constructor da classe-mãethis.cargaHoraria=cargaHoraria}}constoficina=newMinicurso('Oficina de Vue.js',25,4)oficina.inscrever()oficina.inscrever()console.log(oficina.vagasRestantes)// 23console.log(oficina.cargaHoraria)// 4console.log(oficinainstanceofEvento)// true
constevento={id:1,titulo:'Semana da Computação',vagas:40}// Objeto JavaScript → texto JSON (para enviar em uma requisição, por exemplo)consttextoJson=JSON.stringify(evento)console.log(textoJson)// '{"id":1,"titulo":"Semana da Computação","vagas":40}'// Com indentação, útil para debug/logconsole.log(JSON.stringify(evento,null,2))// Texto JSON → objeto JavaScript (o inverso — comum ao ler resposta de API)consttextoRecebido='{"id":2,"titulo":"Oficina de Vue.js","vagas":25}'constobjetoRecebido=JSON.parse(textoRecebido)console.log(objetoRecebido.titulo)// Oficina de Vue.js
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):
JavaScript
functionbuscarEventoComCallback(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):
JavaScript
functionbuscarEventoComPromise(id){returnnewPromise((resolve,reject)=>{setTimeout(()=>{if(id>0){resolve({id,titulo:'Semana da Computação'})}else{reject(newError('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):
🔎 Por baixo do capôasync/await não é uma tecnologia nova e diferente de Promise — é açúcar sintático sobre Promise. await pausa a execução da função async até a Promise resolver ou rejeitar, sem bloquear o restante do programa. Todo await precisa estar dentro de uma função marcada async.
fetch com async/await e try/catch — o padrão que vamos usar do início ao fim da trilha:
JavaScript
asyncfunctionbuscarEventosDaApi(){try{constresposta=awaitfetch('https://jsonplaceholder.typicode.com/posts?_limit=5')if(!resposta.ok){thrownewError(`Erro HTTP: ${resposta.status}`)}constdados=awaitresposta.json()console.log('eventos recebidos:',dados.length)returndados}catch(erro){console.error('falha ao buscar eventos:',erro.message)return[]}}buscarEventosDaApi()
Promise.all — disparar várias requisições em paralelo e esperar todas:
JavaScript
asyncfunctioncarregarDadosDaHome(){try{const[respostaEventos,respostaCategorias]=awaitPromise.all([fetch('https://jsonplaceholder.typicode.com/posts?_limit=5'),fetch('https://jsonplaceholder.typicode.com/users?_limit=3'),])consteventos=awaitrespostaEventos.json()constcategorias=awaitrespostaCategorias.json()console.log('eventos:',eventos.length,'categorias:',categorias.length)}catch(erro){// Promise.all rejeita assim que QUALQUER uma das promises falharconsole.error('alguma requisição falhou:',erro.message)}}carregarDadosDaHome()
⚠️ AtençãoPromise.all falha 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, use Promise.allSettled (não obrigatório nesta trilha, mas bom saber que existe).
🧩 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:
JavaScript
// Revealing Module Pattern — jeito pré-ES2015 de encapsularconstGerenciadorDeEventos=(function(){// "privado": só existe dentro deste escopo de funçãoleteventos=[]functionadicionar(evento){eventos.push(evento)}functioncontarVagas(){returneventos.reduce((total,e)=>total+e.vagas,0)}// "revela" (expõe) só o que deve ser públicoreturn{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 com export é automaticamente privado ao arquivo. É o mesmo padrão de projeto, com sintaxe de linguagem em vez de truque de engenharia. Todo componente .vue que 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.
<!-- index.html --><!DOCTYPE html><htmllang="pt-BR"><head><metacharset="UTF-8"/><title>Aula 01 — Revisão de JavaScript</title></head><body><h1>Abra o Console do navegador (F12) para ver os resultados</h1><ulid="saida"></ul><scripttype="module"src="main.js"></script></body></html>
Passo 3 — o módulo com os dados e as funções:
JavaScript
// eventos.jsexportconsteventos=[{id:1,titulo:'Semana da Computação',categoria:'palestra',dataHora:'2030-09-10T19:00:00',vagas:40,inscritos:12},{id:2,titulo:'Oficina de Vue.js',categoria:'minicurso',dataHora:'2030-08-20T14:00:00',vagas:25,inscritos:25},{id:3,titulo:'Hackathon de Tecnologia',categoria:'workshop',dataHora:'2030-10-05T08:00:00',vagas:60,inscritos:18},{id:4,titulo:'Introdução a IA',categoria:'palestra',dataHora:'2030-08-18T19:30:00',vagas:80,inscritos:55},]exportfunctionfiltrarPorCategoria(lista,categoria){if(!categoria)returnlistareturnlista.filter((evento)=>evento.categoria===categoria)}exportfunctionordenarPorData(lista){return[...lista].sort((a,b)=>newDate(a.dataHora)-newDate(b.dataHora))}exportfunctiontotalDeVagas(lista){returnlista.reduce((total,evento)=>total+evento.vagas,0)}exportfunctionformatarData(dataIso){constdata=newDate(dataIso)returnnewIntl.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:
JavaScript
// main.jsimport{eventos,filtrarPorCategoria,ordenarPorData,totalDeVagas,formatarData}from'./eventos.js'constlistaEl=document.getElementById('saida')functionrenderizarLista(lista){listaEl.innerHTML=''lista.forEach((evento)=>{constitem=document.createElement('li')item.textContent=`${evento.titulo} — ${formatarData(evento.dataHora)} (${evento.vagas} vagas)`listaEl.appendChild(item)})}constpalestras=filtrarPorCategoria(eventos,'palestra')constordenados=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 verdadeasyncfunctionbuscarPostsDeExemplo(){try{constresposta=awaitfetch('https://jsonplaceholder.typicode.com/posts?_limit=3')if(!resposta.ok)thrownewError(`HTTP ${resposta.status}`)constposts=awaitresposta.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).
Com a página aberta pelo Live Server, abra o DevTools (F12) e vá ao Console.
Resultado esperado, nesta ordem:
Texto
total de palestras: 2
total de vagas em todos os eventos: 205
posts de exemplo recebidos da API: (3) [{…}, {…}, {…}]
E, na página, a lista de eventos renderizada em ordem de data, do mais próximo ao mais distante. Confira também os dois sinais de que os módulos ES estão funcionando: (1) o <script type="module"> não reclama de import; (2) abrir o mesmo arquivo com duplo clique (file://) quebra com erro de CORS — é exatamente o motivo de usar um servidor local. A linha dos posts chegar por último, depois das duas primeiras, é a prova visual de que fetch é assíncrono: o console.log de baixo do buscarPostsDeExemplo() não espera a resposta da rede.
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.
Resultado esperado: a linha a imprime undefined (a function tradicional perde o this de contador dentro do setTimeout); a linha b imprime 5 (a arrow function herda o this de reduzir).
A2. Complete a linha que falta para que resumo traga só os títulos dos eventos de categoria 'palestra' que ainda têm vaga (inscritos < vagas), na mesma ordem em que aparecem no array eventos:
Resultado esperado: a linha que falta é .filter((evento) => evento.inscritos < evento.vagas), resultando em ['Semana da Computação', 'Introdução a IA'].
A3. Em uma frase: por que vagasInformadas || 10 é um bug quando vagasInformadas vale 0, mas vagasInformadas ?? 10 não é?
Resultado esperado: porque || cai no valor padrão para qualquer valor "falsy" — incluindo 0, que é um número de vagas válido — enquanto ?? só usa o padrão quando o valor é null ou undefined.
A4. Ache o erro nas linhas abaixo. A função deveria devolver os eventos ordenados por vagas restantes sem alterar o array eventos original, mas, depois de chamá-la, um console.log(eventos[0].titulo) seguinte mostra uma ordem diferente da original:
Resultado esperado: falta copiar o array antes de ordenar — lista.sort(...) muta lista, que é a mesma referência de eventos. A correção é return [...lista].sort(...).
A5. Preveja a saída do trecho abaixo usando o que a Seção 3.7 explica sobre optional chaining e nullish coalescing:
JavaScript
constevento={titulo:'Hackathon de Tecnologia',local:{predio:'Bloco B'}}console.log(evento.local?.sala??'a definir')console.log(evento.organizador?.contato?.email??'sem contato')
Resultado esperado: a definir (a propriedade sala não existe dentro de local, mas ?. evita o erro) e sem contato (nem organizador existe, e a cadeia inteira encurta sem quebrar).
B2. 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)).
B3. Calcular vagas totais com reduce. Escreva vagasRestantesTotais(lista) que retorne a soma de vagas - inscritos de todos os eventos.
Resultado esperado: 95 (para o array de exemplo: 28 + 0 + 42 + 25).
Dica
lista.reduce((total, evento) => total + (evento.vagas - evento.inscritos), 0)
B4. 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 2030".
Resultado esperado: 4 strings de data em português.
C1. 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. Se a rede falhar (desligue o Wi-Fi um instante e tente de novo), a página não pode travar nem ficar em branco — mostre uma mensagem de erro no lugar da lista.
Resultado esperado: 3 nomes de usuários aparecendo na página; com a rede desligada, uma mensagem de erro visível em vez de tela em branco, e nenhum erro não tratado no Console.
Dica
const usuarios = await (await fetch(url)).json(), depois usuarios.slice(0, 3).forEach(...) criando <li> como no Passo 4. Para o tratamento de erro, envolva a busca e a renderização em try/catch e, no catch, escreva a mensagem de erro no mesmo elemento <ul> com innerHTML.
Um colega escreveu vagasParaExibir(evento) para mostrar o número de vagas no card do evento, usando o padrão que a Seção 3.7 alertou ser perigoso: evento.vagasDisponiveis || 10. Um evento com exatamente zero vagas está aparecendo na tela como "10 vagas disponíveis" — e alunos estão tentando se inscrever em um evento lotado. Ache a causa raiz e corrija sem quebrar o caso em que o campo realmente não foi informado.
Critérios de pronto
vagasParaExibir({ vagasDisponiveis: 0 }) retorna 0 (evento esgotado, sem valor padrão).
vagasParaExibir({}) (campo ausente) retorna 10 (o valor padrão continua funcionando quando faz sentido).
Um comentário de 2 linhas acima da função explica, em português, por que || escondia esse bug e por que ?? resolve.
Pistas
Releia a Seção 3.7 — a diferença entre || e ?? está exatamente nos valores "falsy" que não são null/undefined.
Teste os três casos no Console antes de mexer no código: 0 || 10, undefined || 10, 0 ?? 10, undefined ?? 10.
A troca é de um único operador — mas escreva o teste dos três casos antes de trocar, para provar que a correção não quebrou o caso do valor ausente.
⭐⭐
⭐⭐ Quanto custa recriar a lista inteira a cada tecla?¶
performancedomjavascriptdevtools
O código da Seção 2.1 (DOM manual) apaga listaEl.innerHTML e reconstrói tudo a cada tecla digitada na busca — funciona bem com 4 eventos, mas e com mil? Gere uma lista grande, meça o custo real de renderizar() a cada tecla e decida, com números, se vale a pena adicionar um debounce (atraso antes de reagir) antes de otimizar de verdade.
Critérios de pronto
Um array eventosGrandes com 1.000 itens gerados por código (Array.from({ length: 1000 }, (_, i) => ({ ... }))), reaproveitando os campos de eventos.js.
renderizar() adaptada para eventosGrandes, com performance.now() antes e depois da reconstrução do DOM, logando o tempo de cada chamada no Console.
Uma tabela (fora do código, no comentário ou no README) com o tempo de 5 renderizações digitando rápido, sem debounce.
A mesma medição depois de adicionar um debounce de 300 ms no input (só chama renderizar() 300 ms depois da última tecla), com uma frase concluindo se, neste caso, o ganho compensou a complexidade extra.
Pistas
performance.now() retorna milissegundos; chame uma vez antes e uma vez depois de renderizar() e subtraia.
Para gerar 1.000 eventos variados, alterne categoria entre 'palestra', 'minicurso' e 'workshop' usando o resto da divisão (i % 3).
O padrão de debounce é o mesmo setTimeout + clearTimeout que reaparece na aula sobre CRUD (Aula 11) — pesquise "debounce javascript" se quiser ver outras implementações antes de escrever a sua.
Ligue a aba Performance do DevTools durante uma digitação rápida sem debounce — o gráfico de "Scripting" mostra visualmente o custo que você já mediu com números.
Toda a Seção 3 revisou ferramentas de JavaScript moderno usando eventos como exemplo — mas o seu projeto autoral (Seção 1.6) tem outro domínio. Construa o dominio.js real do seu projeto, aplicando classes, módulos, os métodos de array e uma busca assíncrona a dados reais (ou simulados) do seu tema.
Critérios de pronto
Um arquivo dominio.js define ao menos duas classes relacionadas por composição ou herança (ex.: Quadra e Reserva, Planta e Floracao), cada uma com pelo menos um get calculado (equivalente a vagasRestantes da Seção 3.11).
Três funções exportadas (export function) que usam filter, map, reduce ou sort sobre uma lista de pelo menos 8 itens de exemplo do seu domínio, sem mutar o array recebido.
Uma função async que busca dados de uma API pública (do seu tema, ou jsonplaceholder.typicode.com como placeholder) com try/catch, sem deixar a página quebrada se a busca falhar.
Um main.js que importa tudo de dominio.js e renderiza pelo menos 5 itens na tela, em HTML puro (sem framework — isso só começa na Aula 02).
Um README.md de 5 a 10 linhas explicando as entidades escolhidas e o que cada função exportada faz.
Pistas
Reveja a Seção 3.10 (módulos) e 3.11 (classes) — a estrutura é a mesma do eventos.js/GerenciadorDeEventos do Passo 3 do "Mão na massa", só que com as entidades do seu tema.
Comece pelas classes e pelos dados de exemplo (um array com 8 objetos criados na mão) antes de pensar na API — é mais fácil testar filter/map/reduce sobre dados que você já conhece.
Se seu tema não tiver uma API pública específica, use o JSONPlaceholder mesmo (/posts, /users, /comments) só para provar que o fetch com try/catch funciona — a ligação semântica com o tema pode vir depois, na Unidade 3.
Teste o catch de propósito: chame a função com uma URL errada (ex.: troque .com por .com.br/inexistente) e confira que a página continua funcionando, só sem os dados da API.
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.md na 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 com Evento/Inscricao/Usuario do UniEventos).
- As telas previstas: pelo menos listagem com filtro, detalhe, e uma área autenticada.
Guarde no seu repositório: commit + push.
Critério de pronto: repositório público criado, README.md com descrição, modelo de dados (entidades + campos) e lista de telas.
Na próxima aula 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.
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 createApp e entender o ciclo montar/renderizar.
Criar um projeto Vue com Vite usando npm create vue@latest e explicar cada arquivo gerado.
Distinguir ref() de reactive() e explicar por que .value existe.
Usar as diretivas v-bind, v-on, v-model, v-if/v-else-if/v-else, v-show, v-for (com :key) e v-text/v-html corretamente, cada uma com seus casos de uso e armadilhas.
Descrever as fases do ciclo de vida de um componente e usar os hooks onMounted e onUnmounted.
Construir a primeira versão navegável do UniEventos: lista, busca, filtro e inscrição.
Na Aula 01 revisamos o JavaScript moderno que o Vue exige o tempo todo — const/let, arrow functions, desestruturação, spread, map/filter/reduce, módulos ES e async/await. Também montamos, à mão, uma pequena lista de eventos manipulando o DOM com document.createElement e innerHTML, e comparamos esse estilo imperativo com o estilo declarativo, em que você descreve o que a tela deve mostrar e o framework cuida do como.
Hoje esse estilo declarativo ganha nome, ferramenta e projeto: Vue 3, criado com Vite, com a primeira versão navegável do UniEventos no fim da aula. Tudo o que você escreveu na Aula 01 continua valendo — o Vue não substitui o JavaScript, ele organiza o JavaScript que você já sabe.
Ambiente instalado na Aula 01: Node 22 LTS, VS Code com Vue - Official/ESLint/Prettier, Git.
Repositório do projeto autoral criado com README.md.
⚠️ Atenção
Verifique agora, antes de começar: node -v precisa mostrar uma versão 22.18.0 ou superior (ou 24.12.0+). O create-vue desta aula exige isso.
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 versões fixadas para esta trilha, as mesmas em que todos os exemplos deste material foram testados.
🧠 Você sabia?
O Vue foi criado por Evan You em 2014, um ex-funcionário do Google que trabalhava com AngularJS e queria algo mais leve para prototipar interfaces rapidamente. A ideia deu tão certo que hoje o Vue é mantido por uma organização independente (a Vue.js), financiada por patrocinadores e por uma comunidade global — sem estar amarrado a nenhuma big tech, diferente do React (Meta) ou do Angular (Google). É por isso que a documentação oficial é, historicamente, uma das mais elogiadas do ecossistema JavaScript: escrever documentação clara sempre foi parte da estratégia de adoção do projeto.
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.
JavaScript
// Options API — cada preocupação vai em uma "caixa" pré-definidaexportdefault{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>.
Vue SFC
<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>
📌 Vale gravarEsta trilha usa Composition API com <script setup> do início ao fim, porque é o padrão gerado pelo create-vue e 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 — vale reconhecer os dois estilos, já que você vai encontrá-los em código real — mas a partir da Aula 03 falamos só Composition API.
Antes de qualquer ferramenta de build, vamos ver o Vue rodando com o mínimo possível: um único arquivo HTML.
HTML
<!-- cdn/index.html --><!DOCTYPE html><htmllang="pt-BR"><head><metacharset="UTF-8"/><title>Primeiro contato com Vue</title></head><body><divid="app"><h1>{{ titulo }}</h1><p>Você clicou {{ contador }} vez(es).</p><buttonv-on:click="incrementar">Clicar</button></div><scriptsrc="https://unpkg.com/vue@3.5.41/dist/vue.global.js"></script><script>const{createApp}=VuecreateApp({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 reativa titulo no HTML. Sempre que titulo muda, o texto na tela muda sozinho — sem innerHTML, sem addEventListener manual.
🔎 Por baixo do capô{{ }} só funciona dentro do elemento montado (#app e 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:
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".
CDN é ótimo para aprender o conceito, mas nenhuma aplicação real desta trilha vai ser um único HTML. A partir de agora usamos Vite com Single File Components (.vue).
O create-vue (versão 3.23.0) pergunta interativamente o nome do projeto e quais recursos incluir. Para o UniEventos que vamos construir ao longo da trilha, as respostas são:
Texto
✔ 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
💡 Dica
Já habilitamos --router e --pinia mesmo 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á.
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:
Terminal
npmrundev
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.
<script setup> — lógica do componente em Composition API. Tudo declarado aqui (variáveis, funções) fica automaticamente disponível no <template>, sem return explícito — é o compilador de SFC do Vue, via @vitejs/plugin-vue, que escreve esse return implícito por você na hora do build.
<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). Sem scoped, o estilo vaza para a aplicação inteira.
⚠️ Atenção<script setup> só existe dentro de arquivos .vue processados pelo Vite — não existe fora desse contexto. Por isso o exemplo CDN da Seção 2 usou setup() { return {...} } explícito.
<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 interceptar contador = contador + 1 para 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 de ref() precisa ser desembrulhada e faz isso por você automaticamente.
<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 um Proxy do 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ê escreve evento.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.
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
Vue SFC
<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 tentasse eventos = [...] (reatribuir a variável), perderia a conexão reativa — o template continuaria olhando para o array antigo. Com reactive, 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:
JavaScript
// Options API — Vue decide a reatividade por trás das cortinasexportdefault{data(){return{contadorVagas:40,// equivale a um refevento:{titulo:'Semana da Computação',inscritos:12},// equivale a um reactive}},methods:{inscrever(){this.evento.inscritos++// this.<propriedade>, sem .value},},}
<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.
<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.
Vue SFC
<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('')
const vagas = ref(0)
</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 — repare no ref próprio, numérico -->
<input v-model.number="vagas" type="number" />
<p>Vagas (tipo): {{ typeof vagas }}</p>
<!-- .lazy: sincroniza no evento "change" (ao sair do campo), não a cada tecla -->
<input v-model.lazy="busca" type="text" />
</template>
💡 Dicav-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 único addEventListener.
<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)
<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 de Tecnologia', 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:
Vue SFC
<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 de Tecnologia' },
])
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 do v-for. O índice muda quando a lista é reordenada, filtrada ou tem itens removidos — e o Vue usa a key exatamente para saber "isso é o mesmo item de antes ou é outro?".
🔬 Investigue
Rode o exemplo do bug acima (com :key="indice") no navegador. Abra a aba Elements do DevTools, marque o checkbox do primeiro item e clique em "Remover o primeiro". Observe qual <input> do DOM continua marcado — o do texto que você via na tela, ou o que ficou na mesma posição? Agora troque :key="indice" de volta para :key="evento.id", repita o teste e compare o <div> que o Vue recria (ou não) na aba Elements a cada clique.
<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 o conteúdo inteiro 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 XSSv-html injeta 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 como onerror=, isso executa no navegador de quem visualiza a página — um ataque de Cross-Site Scripting (XSS). Use v-html só 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.
Todo componente Vue passa por fases previsíveis, do momento em que é criado até ser destruído.
Texto
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
Vue SFC
<!-- 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()
JavaScript
// Options API — os mesmos hooks, como métodos especiais do objetoexportdefault{data(){return{segundos:0,intervaloId:null}},mounted(){console.log('mounted')this.intervaloId=setInterval(()=>{this.segundos++},1000)},unmounted(){clearInterval(this.intervaloId)},}
📌 Vale gravaronMounted é, de longe, o hook mais usado na prática — é onde disparamos requisições fetch (Aula 03) porque é o primeiro momento em que temos garantia de que o DOM existe. onUnmounted é onde limpamos qualquer recurso externo (setInterval, addEventListener em window, conexões abertas) para não vazar memória quando o componente sai de cena.
🧩 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:
JavaScript
// Um EventTarget simplificado — a base do Observer em JS puroclassContadorObservavel{constructor(){this.valor=0this.observadores=[]}observar(funcaoCallback){this.observadores.push(funcaoCallback)}incrementar(){this.valor++// notifica TODOS os observadores registradosthis.observadores.forEach((callback)=>callback(this.valor))}}constcontador=newContadorObservavel()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ável contador. Quando você escreve contador.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ê escrever observar() ou notificar() manualmente. ref e reactive sã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.
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).
JavaScript
// src/data/eventos.jsexportconsteventosIniciais=[{id:1,titulo:'Semana da Computação',categoria:'palestra',dataHora:'2030-09-10T19:00:00',local:'Auditório Central',vagas:40,inscritos:12,},{id:2,titulo:'Oficina de Vue.js',categoria:'minicurso',dataHora:'2030-08-20T14:00:00',local:'Laboratório 3',vagas:25,inscritos:25,},{id:3,titulo:'Hackathon de Tecnologia',categoria:'workshop',dataHora:'2030-10-05T08:00:00',local:'Bloco B',vagas:60,inscritos:18,},{id:4,titulo:'Introdução a IA',categoria:'palestra',dataHora:'2030-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).
⚠️ Atenção
Repare que obterEventosFiltrados() é chamada três vezes no template (na v-for, e de novo para checar se está vazio). Cada chamada refaz o filter duas vezes do zero — funciona, mas é desperdício de processamento e, pior, dificulta manter os resultados sincronizados. Vamos resolver isso com computed() já na próxima aula.
Abra http://localhost:5173 e confira os cinco comportamentos:
Os quatro eventos aparecem na tela, cada card com título, categoria e vagas restantes.
Digitar "vue" no campo de busca reduz a lista enquanto você digita, sem apertar nada.
Trocar o filtro de categoria combina com a busca (os dois critérios valem ao mesmo tempo).
Clicar em "Inscrever-se" aumenta inscritos em 1 e as vagas restantes caem na hora; quando chegam a zero, o card mostra "Evento lotado" e o botão fica desabilitado.
Uma busca sem resultado mostra a mensagem de lista vazia, e não uma área em branco.
Resultado esperado: os cinco funcionam sem uma única linha de document.querySelector — é o ponto da aula. Se a tela não reagir a um clique, o suspeito nº 1 é um .value esquecido dentro do <script setup> (no template ele é automático; no script, não).
Resultado esperado: no script, console.log(contadorVagas) imprime o objeto ref inteiro (algo como RefImpl { value: 40, ... }), não o número puro — porque fora do <template> o Vue não desembrulha automaticamente. Na tela aparece Vagas: 40, porque o compilador do template desembrulha refs de nível superior sozinho.
A2. Complete a linha que falta para que o aviso "Últimas vagas!" apareça só quando restarem de 1 a 5 vagas (nem lotado, nem mais de 5):
A3. Em uma frase: por que v-show é mais indicado que v-if para um painel de filtros avançados que o usuário abre e fecha várias vezes na mesma visita à página?
Resultado esperado: porque v-show só alterna display: none via CSS (barato de alternar repetidamente), enquanto v-if recria o elemento inteiro no DOM a cada troca — mais caro quando a alternância é frequente.
A4. Ache o erro nas linhas abaixo — depois de chamar recarregar(), o <template> não mostra a nova lista de eventos:
Resultado esperado: reactive() torna o conteúdo do array reativo, mas reatribuir a variável inteira (eventos = [...]) quebra a conexão — o template continua olhando para o array antigo, agora sem ninguém apontando para ele. (Se você escrever const eventos = reactive([]), como é o mais comum, o sintoma nem chega a ser esse: a própria reatribuição estoura TypeError: Assignment to constant variable. O let acima existe para o bug aparecer em silêncio, que é o caso difícil de achar.) A correção troca reactive([]) por ref([]) e usa eventos.value = [...], ou mantém reactive e faz eventos.splice(0, eventos.length, ...novaLista) para trocar o conteúdo sem trocar a referência.
A5. Preveja o que aparece na tela para cada linha abaixo, usando o que a Seção 5.7 explica sobre v-html:
Resultado esperado: a primeira linha mostra o texto literal <em>promoção</em> hoje (a interpolação {{ }} sempre escapa HTML); a segunda mostra a palavra "promoção" em itálico, porque v-html interpreta as tags como HTML de verdade.
B1. 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.
B2. 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".
B3. 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).
B4. 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
O template só enxerga o que está declarado no <script setup> — globais do navegador como alertnão estão nessa lista, e @click.once="alert('...')" compila para _ctx.alert(...), quebrando com alert is not a function. Declare a função no script e passe o nome dela:
Vue SFC
<script setup>
function mostrarDica() {
alert('Dica: use os filtros para encontrar eventos mais rápido!')
}
</script>
<template>
<button @click.once="mostrarDica">Ver dica</button>
</template>
C1. 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 e prove, na aba Elements do DevTools, que o elemento correto agora é reaproveitado pelo id, não pela posição.
Resultado esperado: você reproduz o bug descrito na Seção 5.6, documenta em uma frase por que ele acontece, e confirma que voltar para :key="evento.id" resolve — inclusive filtrando a lista com um checkbox marcado.
Dica
O bug aparece quando a lista filtrada muda de tamanho/ordem — os checkboxes "grudam" na posição do DOM, não no evento. Na aba Elements, observe qual <div> o Vue recria (ou não) a cada filtro, com cada uma das duas versões da :key.
O formulário de criação de evento do UniEventos usa <input v-model.number="vagas" type="text"> para o campo de vagas. Um colega testou digitando "quarenta" em vez de 40 — e o card passou a mostrar "NaN vaga(s) restante(s)" em vez de recusar a entrada. Descubra por que isso acontece e feche a brecha.
Critérios de pronto
Reproduzido: digitar um texto não numérico no campo de vagas e confirmar, no Console, que o valor vira NaN.
O formulário passa a rejeitar (ou impedir) uma entrada não numérica antes de criar o evento, com uma mensagem clara para quem está digitando.
Um comentário de 2 linhas no código explica por que v-model.number sozinho, em um <input type="text">, permite isso.
Testado que uma entrada numérica válida (ex.: 40) continua criando o evento normalmente.
Pistas
v-model.number tenta converter o texto digitado para Number — e Number('quarenta') não é um erro, é NaN, que passa despercebido por muitas condições (if (vagas) não pega NaN como você esperaria).
Compare <input type="number"> com <input type="text"> quanto ao que o próprio navegador já bloqueia de digitar.
Number.isNaN(valor) é o jeito certo de checar — nunca valor === NaN (essa comparação é sempre false, mesmo quando valor é NaN).
Guarde o mouse. Navegue pela tela de eventos construída hoje usando só Tab, Shift+Tab, Enter e as setas. Encontre pelo menos três barreiras de acessibilidade por teclado — foco que desaparece visualmente, campo sem rótulo associado, ordem de tabulação que não segue a leitura da tela — e corrija cada uma.
Critérios de pronto
Uma lista com pelo menos três barreiras encontradas, cada uma citando o elemento afetado (ex.: "select de categoria — sem <label for> associado").
Todo campo de formulário (input, select) tem um <label> associado, via for/id ou envolvendo o campo.
Um indicador visual de foco continua visível em todos os elementos interativos — se algum CSS tinha outline: none, ele ganhou um substituto (:focus-visible com contorno ou sombra) em vez de simplesmente remover o indicador.
A ordem de tabulação (Tab repetido a partir do topo) segue a ordem visual e lógica da página, sem saltos estranhos.
Pistas
No navegador, clique em qualquer lugar vazio da página e pressione Tab repetidamente a partir do topo — anote a ordem em que o foco se move.
Um <label> sem for correspondente ao id do campo (ou sem envolver o <input>) não é associado a ele — clicar no texto do rótulo não foca o campo, e leitores de tela não anunciam o rótulo certo.
A aba Lighthouse do DevTools tem uma categoria de acessibilidade que já aponta boa parte desses problemas automaticamente — rode antes de procurar na mão, para conferir depois se a correção resolveu.
⭐⭐⭐
⭐⭐⭐ A primeira tela navegável do seu projeto autoral¶
vueprojetojavascript
Aplique tudo desta aula — ref/reactive, v-bind/v-on/v-model, v-if/v-show, v-for com :key estável e um hook de ciclo de vida real — na entidade principal do seu projeto autoral, com a mesma profundidade da Seção "Mão na massa" de hoje.
Critérios de pronto
App.vue do seu projeto lista pelo menos 5 itens reais do seu domínio, com um campo de busca (v-model) e pelo menos um filtro (v-model em <select>).
v-for usa :key estável (o id do dado), nunca o índice do laço.
Um hook onMounted dispara algo real (ex.: um setInterval que atualiza um "atualizado há N segundos", ou um log de auditoria) e onUnmounted limpa esse recurso — comprovado no Console, sem erros nem timers acumulando.
Pelo menos uma ação de interação (@click) muda um estado reativo (reservar, favoritar, adicionar — o verbo do seu domínio) refletido imediatamente na tela.
Prints em sequência (ou um vídeo curto) mostrando: app carregado, busca funcionando, filtro funcionando, a ação de interação, e o Console com os logs do ciclo de vida.
Pistas
Reaproveite a estrutura do App.vue da Seção "Mão na massa" — troque eventos pelas entidades do seu domínio, campo por campo.
Para provar a limpeza do onUnmounted, o hot module replacement do Vite já desmonta/remonta o componente a cada edição salva — abra o Console, edite um espaço em branco no arquivo e observe os logs de onMounted/onUnmounted se alternando.
Não esqueça a :key com o id real do seu dado, nunca o índice do v-for — é o erro mais comum desta aula, e o item C1 do Laboratório mostra exatamente o que dá errado.
Próxima aula (03): 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.
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().
Projeto unieventos-web funcionando, com a listagem, busca e filtro da Aula 02.
Domínio de ref(), v-model, v-for+:key, v-if/v-show e dos hooks onMounted/onUnmounted (Aula 02).
⚠️ Atenção
Se seu App.vue da Aula 02 ainda não estiver rodando com npm run dev sem erros, resolva isso antes de continuar — hoje vamos editar esse mesmo arquivo.
<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¶
Vue SFC
<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>
<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-if e v-for estão no mesmo elemento, v-if tem prioridade mais alta na avaliação, mas isso significa que ele tenta avaliar a condição antes da variável do v-for (evento) estar disponível no escopo — um erro fácil de disparar sem perceber.
Desempenho. O Vue recria a checagem v-if a 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.
Vue SFC
<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().
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.
Vue SFC
<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>
Vue SFC
<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 computed sabe 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.
🔬 Investigue
Rode o segundo exemplo (com computed) no navegador e abra o Console. Some mais um <p>{{ totalDeVagas }}</p> ao template, salve, e conte quantas vezes a mensagem "calculando total de vagas (computed)..." aparece no Console ao carregar a página — deveria continuar sendo uma vez só, mesmo com três usos no template. Agora clique em algo que altere eventos (ou rode eventos.value.push({ id: 3, titulo: 'Teste', vagas: 10, inscritos: 0 }) direto no Console) e veja o log aparecer de novo — só quando a dependência realmente muda.
Por padrão, um computed é somente leitura. Mas é possível criar um que também aceita escrita, definindo get e set:
Vue SFC
<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>
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.
<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¶
Vue SFC
<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¶
Vue SFC
<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>
<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
📌 Vale gravar
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, use watch ou watchEffect.
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.
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.
Vue SFC
<!-- 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):
JSON
[{"id":1,"titulo":"Semana da Computação","categoria":"palestra","dataHora":"2030-09-10T19:00:00","local":"Auditório Central","vagas":40,"inscritos":12},{"id":2,"titulo":"Oficina de Vue.js","categoria":"minicurso","dataHora":"2030-08-20T14:00:00","local":"Laboratório 3","vagas":25,"inscritos":25},{"id":3,"titulo":"Hackathon de Tecnologia","categoria":"workshop","dataHora":"2030-10-05T08:00:00","local":"Bloco B","vagas":60,"inscritos":18},{"id":4,"titulo":"Introdução a IA","categoria":"palestra","dataHora":"2030-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 fetch para 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¶
💡 Dica
Em vez de instanciar Intl.DateTimeFormat/Intl.NumberFormat de 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.
🧠 Você sabia?
Antes do Intl ser amplamente suportado pelos navegadores (ele existe desde 2012, mas só ganhou adoção maciça depois), praticamente todo projeto JavaScript trazia uma biblioteca externa — a mais famosa era o Moment.js — só para formatar datas e números. O Moment.js foi oficialmente descontinuado em 2020, e a própria documentação recomenda migrar para as APIs nativas (Intl, Temporal no futuro) exatamente pelo que você acabou de fazer: nenhuma dependência, nenhum KB extra no bundle, formatação em português correta por padrão.
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:
JavaScript
constevento={titulo:'Semana da Computação',vagas:40}consteventoComLog=newProxy(evento,{get(alvo,propriedade){console.log(`[leitura] alguém acessou "${propriedade}"`)returnalvo[propriedade]},set(alvo,propriedade,novoValor){console.log(`[escrita] "${propriedade}" mudou de "${alvo[propriedade]}" para "${novoValor}"`)alvo[propriedade]=novoValorreturntrue},})console.log(eventoComLog.titulo)// dispara o "get" -> loga e retorna o valoreventoComLog.vagas=39// dispara o "set" -> loga e altera o valor real
É exatamente este mecanismo que reactive() usa por dentro. Quando você chama reactive(objeto), o Vue devolve um Proxy que envolve o objeto original. Toda leitura de propriedade (evento.titulo) passa pelo get do Proxy, que registra "este trecho de template/computed depende de titulo" (o rastreamento de dependências que sustenta o padrão Observer da Aula 02). Toda escrita (evento.vagas = 39) passa pelo set, 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 um Proxy completo, 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.
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").
📌 Vale gravar
Observe que eventosFiltrados e eventosOrdenados são duas computed encadeadas, e totalFiltrado/totalVagasLivres dependem de eventosFiltrados. Se você mudar busca, o Vue recalcula eventosFiltrados (porque ela lê busca), o que por sua vez invalida o cache de eventosOrdenados, totalFiltrado e totalVagasLivres — tudo automático, seguindo a cadeia de dependências. Você não escreve nenhuma chamada manual de "atualizar".
Carregamento — ao abrir a página, a mensagem de "carregando" aparece por um instante antes da lista: é o onMounted com o carregamento assíncrono simulado.
Filtro e ordenação — digitar na busca e trocar o critério de ordenação mudam a lista imediatamente, e os contadores (totalFiltrado, totalVagasLivres) acompanham.
Cache da computed — acrescente temporariamente um console.log('recalculou') na primeira linha de eventosFiltrados e recarregue: a mensagem aparece uma vez por mudança de dependência, não uma vez por leitura no template. Comparar com a Aula 02, em que a função era chamada três vezes por render, é o ponto da aula. Apague o console.log depois.
Selo "em breve" — só os eventos dentro dos próximos 7 dias mostram o selo, e a barra de ocupação fica vermelha quando não há mais vagas.
Lista vazia — uma busca sem resultado mostra a mensagem de vazio, não uma área em branco.
Resultado esperado: os cinco passam. Se a lista não reagir a uma mudança de busca, o suspeito é uma computed que esqueceu de ler a ref reativa dentro do corpo (uma dependência não lida nunca é rastreada).
Resultado esperado: categoria selecionada: (nenhuma) — porque { immediate: true } executa o callback uma vez assim que o watch é criado, mesmo sem categoriaFiltro ainda ter mudado.
A2. Complete a linha que falta para resolver o antipadrão da Seção 2 — o <template> abaixo já foi corrigido para iterar sobre eventosComVaga, mas falta declarar essa computed:
Vue SFC
<script setup>
import { ref, computed } from 'vue'
const eventos = ref([
{ id: 1, titulo: 'Semana da Computação', vagas: 40, inscritos: 40 },
{ id: 2, titulo: 'Oficina de Vue.js', vagas: 25, inscritos: 10 },
])
// complete aqui: computed que filtra os eventos com vaga disponível
</script>
<template>
<li v-for="evento in eventosComVaga" :key="evento.id">{{ evento.titulo }}</li>
</template>
A3. Em uma frase: por que watchEffect(() => { console.log(busca.value) }) não precisa declarar explicitamente qual variável está observando, enquanto watch(busca, callback) precisa apontar busca?
Resultado esperado: porque watchEffect executa a função imediatamente e registra sozinho, durante essa execução, quais variáveis reativas foram lidas — descobrindo as dependências automaticamente; watch exige a fonte explícita porque só reage a mudanças naquilo que você apontou.
A4. Ache o erro nas linhas abaixo — o valor mostrado na tela nunca muda, mesmo depois de chamar inscrever() várias vezes:
JavaScript
lettotalInscritos=0constresumo=computed(()=>`Total de inscritos: ${totalInscritos}`)functioninscrever(){totalInscritos++}
Resultado esperado: totalInscritos é uma variável comum (let), não reativa — o computed não tem como perceber que ela mudou, porque não é um ref/reactive. A correção troca let totalInscritos = 0 por const totalInscritos = ref(0) e usa totalInscritos.value++ dentro de inscrever.
A5. Preveja o valor de percentualOcupado e a cor do texto no trecho abaixo, para vagas = 40 e inscritos = 34:
Resultado esperado: uma constante eventosPalestrasComVaga criada com computed(() => ...), usada no template sem parênteses (v-for="evento in eventosPalestrasComVaga", não eventosPalestrasComVaga()).
B2. Corrigir uma lista sem :key. Dado este trecho com um bug proposital, corrija-o:
Vue SFC
<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.
B3.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.
B4. 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.
C1. 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. Ligue essa computed a um segundo <input> (além do campo de busca normal) e prove que os dois campos ficam sincronizados nos dois sentidos.
Resultado esperado: digitar "VUE" no campo ligado a buscaEmMaiusculas faz busca.value valer "vue"; e alterar o campo de busca original (em minúsculas) atualiza o outro campo para a versão em maiúsculas, sem nenhum watch envolvido.
Ligue os dois campos com v-model="busca" e v-model="buscaEmMaiusculas" — a sincronia acontece porque os dois computeds/refs leem e escrevem a mesma fonte de verdade (busca).
A store de filtros do seu colega usa um objeto reactive com busca, categoria e apenasComVaga, observado por um único watch(filtros, callback) (sem a opção da Seção 4.2). Ele jura que testou e "funcionava" — mas agora, ao mudar só a categoria no <select>, o callback simplesmente não dispara, e a preferência do usuário nunca é salva. Ache a causa e corrija.
Critérios de pronto
Reproduzido: alterar filtros.categoria (uma propriedade interna do objeto) sem que o callback do watch rode — comprovado com um console.log que nunca aparece.
Corrigido: alterar qualquer propriedade de filtros agora dispara o callback.
Um comentário de 2 linhas no código explica por que um watch sobre um reactive não pega mudanças internas por padrão.
Testado um caso extra: substituir o objeto inteiro (Object.assign(filtros, { busca: 'x' }) vs. recriar a referência) e uma frase documentando se o comportamento muda.
Pistas
Releia a Seção 4.2 — por padrão, watch em um objeto reactive só reage a uma troca de referência, não a uma mudança de propriedade interna.
A opção que falta é uma só, e o nome já sugere o que ela faz: "olhar fundo" no objeto.
Existe um custo em observar objetos grandes dessa forma — o Vue precisa varrer recursivamente todas as propriedades a cada checagem. Documente esse trade-off em uma frase.
A Seção 3 provou, com um console.log, que um computed roda uma vez só, mesmo usado três vezes no template. Mas quanto tempo isso realmente economiza quando a lista é grande? Meça com 5.000 eventos e descubra a partir de que escala a diferença passa a importar de verdade.
Critérios de pronto
Um array eventosGrandes com 5.000 itens gerados por código (reaproveite o padrão de geração da Aula 01).
Duas versões lado a lado do mesmo filtro combinado (texto + categoria + vaga): uma como método comum, chamado três vezes no template (resumo, contador, lista); outra como computed, usada nos mesmos três lugares.
Tempo medido com performance.now() (ou a aba Performance do DevTools) de um ciclo de re-renderização completo em cada versão, com os números documentados em um comentário ou no README.
Uma frase concluindo, com os números medidos, se a diferença é perceptível ao usuário nesta escala — e uma estimativa de a partir de quantos itens ela passaria a importar.
Pistas
Para forçar uma re-renderização sem recarregar a página, altere qualquer ref usada no template (ex.: busca.value += ' ' e depois volte) e meça o tempo entre a mudança e o próximo console.log dentro da função/computed.
console.count('recalculando') dentro da função ajuda a confirmar quantas vezes cada versão realmente roda por interação.
A aba Performance do DevTools grava um perfil de execução real — grave uma interação (digitar no campo de busca) nas duas versões e compare o tempo total de "Scripting".
⭐⭐⭐
⭐⭐⭐ Estados de carregamento no seu projeto autoral¶
vueprojetoasync
Leve todo o padrão desta aula — computed encadeadas, watch persistindo uma preferência, e os três estados de tela (carregando/erro/vazio) — para o domínio do seu projeto autoral.
Critérios de pronto
O App.vue do seu projeto carrega dados via fetch dentro de onMounted, a partir de um JSON simulado em public/, implementando os três estados: carregando, erro (com botão de repetir) e vazio.
Pelo menos duas computed encadeadas: uma filtra a lista, a outra deriva um total/resumo a partir da primeira (como eventosFiltrados → totalVagasLivres na Seção "Mão na massa").
Um watch que persiste alguma preferência do usuário (o último filtro escolhido, o critério de ordenação) em localStorage, recuperada ao recarregar a página.
Um destaque visual condicional (:class ou :style) usando Intl para formatar pelo menos um valor exibido.
Prints em sequência (ou vídeo curto) demonstrando: carregamento normal, o erro forçado (URL errada) com o botão de repetir funcionando, e a preferência sobrevivendo a um F5.
Pistas
Reaproveite a estrutura do Passo 3 da Seção "Mão na massa" — troque eventos pela entidade do seu domínio, campo por campo.
Para forçar o erro de propósito, troque a URL do fetch por um caminho inexistente, teste a tela de erro, depois desfaça.
localStorage.getItem retorna a string salva ou null — trate o caso "nunca salvo antes" com um valor padrão ao inicializar o ref.
Substitua a listagem estática do seu domínio por dados carregados via fetch dentro de onMounted, a partir de um arquivo em public/<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 Intl para 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.
[ ] Dados carregados via fetch dentro de onMounted, a partir de um JSON em public/.
[ ] Estados de carregando, erro e vazio implementados e testados.
[ ] Pelo menos um computed combinando dois ou mais critérios de filtro.
[ ] Formatação de data e/ou valor com Intl aplicada em pelo menos um campo.
[ ] Destaque visual condicional com :class em pelo menos um cenário do domínio.
[ ] Todo v-for do projeto usa :key com 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.
⚠️ Atenção
O Marco 1 do projeto fecha na próxima aula — veja o quadro de marcos em ../nivel-3/#marcos. Os requisitos completos estão na Aula 04.
Próxima aula (04): introdução a Vuetify e Vue Router, transformando o UniEventos em uma SPA navegável com componentes visuais prontos — e os requisitos completos do Marco 1.
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ê chega ao Marco 1 do seu projeto autoral.
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 com fetch).
📌 Vale gravar: 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.
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.
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) e background. 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 tamanhos large/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-h6 continuam existindo, mas mudaram de tamanho e semântica. Os equivalentes MD3 são nomes como text-display-large, text-headline-medium, text-title-large, text-body-medium, text-label-large. As text-h* continuam sendo classes válidas e suportadas no Vuetify 4 — é o que usamos no código desta aula, por serem mais curtas e já conhecidas —, mas o tamanho que elas produzem mudou em relação ao Vuetify 3. Não assuma que o text-h4 de um tutorial antigo vai parecer do jeito que você viu em vídeo: confira na tela, e prefira as classes MD3 (text-headline-medium, text-title-large…) quando quiser amarrar o texto à escala tipográfica do design system.
🧠 Você sabia?
O Material Design nasceu em 2014, no Google I/O, com uma metáfora central: a interface é feita de "papel digital" que pode se sobrepor, projetar sombra e se mover fisicamente — daí a importância da elevação nesse design system. A versão 3 (2021), a que o Vuetify 4 implementa, ganhou o apelido "Material You": a partir do Android 12, o sistema gera a paleta de cores do aplicativo inteiro extraindo tons do papel de parede do usuário. O Vuetify não faz essa extração automática, mas herda a mesma filosofia de "cores como papéis" (primary, secondary, surface...) que você configurou na seção 5 desta aula.
Vamos instalar o Vuetify no projeto UniEventos que você já tem. Os comandos abaixo são os mesmos testados no ambiente desta trilha — siga exatamente esta ordem.
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.
transformAssetUrls é passado ao plugin do Vue para que caminhos de imagem usados dentro de props do Vuetify (como src de v-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 fazer import * as components from 'vuetify/components' e registrar tudo manualmente (ou, pior, registrar tudo globalmente e inflar o bundle).
Agora o src/main.js:
JavaScript
// src/main.jsimport{createApp}from'vue'import{createPinia}from'pinia'importAppfrom'./App.vue'importrouterfrom'./router'import'@mdi/font/css/materialdesignicons.css'import'vuetify/styles'import{createVuetify}from'vuetify'constvuetify=createVuetify({theme:{defaultTheme:'light'},// v4: o padrão virou 'system'})constapp=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 ao createPinia() ou createRouter(): você a registra na aplicação com app.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 um material escrito com capturas de tela: metade dos leitores veria uma tela clara e a outra metade, escura, sem que ninguém tivesse mudado nada — e as telas deste material deixariam de bater com o que aparece no seu navegador. Por isso declaramos defaultTheme: '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 como app.use(router) ou app.use(pinia) — é o mecanismo de plugin do Vue. Um plugin é um objeto com um método install(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:
Terminal
npmrundev
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).
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
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.
O Vuetify usa um grid de 12 colunas, parecido com o Bootstrap, mas com props reativas a breakpoints:
Vue SFC
<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:
Vue SFC
<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 props align, justify e dense do <v-row>foram removidas. No lugar delas, use classes utilitárias de flexbox (class="justify-space-between", class="align-center") ou a prop density="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.
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:
Vue SFC
<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>Auditório Central · 19h</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.
⚠️ 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ê escrever Inscrever-se, o texto aparece exatamente assim — não INSCREVER-SE. Isso é intencional: o MD3 abandonou a caixa alta como padrão de botão.
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-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:
JavaScript
// src/main.js (trecho — configuração de tema)constvuetify=createVuetify({theme:{defaultTheme:'light',themes:{light:{dark:false,colors:{primary:'#1B5E20',// verde institucionalsecondary:'#F9A825',// amarelo de destaqueerror:'#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():
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 localStorage para 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.
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.
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 fizemos na Aula 02, no npm create vue@latest), já vem com Vue Router 5.2.0 instalado e configurado. Vamos entender e expandir essa configuração.
createRouter monta a instância do roteador — assim como createVuetify e createPinia, ela é registrada com app.use(router) no main.js (isso já vem pronto no scaffold).
createWebHistory usa 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 para index.html — trataremos disso na Unidade 3, ao falar de deploy.
routes é um array de objetos { path, name, component }. name permite navegar por nome em vez de string de URL, o que evita erros de digitação espalhados pelo código.
<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. Em App.vue, ele normalmente fica dentro de v-main.
Dentro do componente, o parâmetro é lido com o composable useRoute():
Vue SFC
<!-- 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.id sempre 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 com Number(rota.params.id).
Toda SPA precisa de uma rota que capture qualquer caminho não mapeado:
JavaScript
// 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.
Além de <RouterLink>, você pode navegar via código — por exemplo, depois de confirmar uma inscrição:
JavaScript
import{ref}from'vue'import{useRouter}from'vue-router'constrouter=useRouter()constinscricaoConfirmada=ref(false)functionconfirmarInscricao(){inscricaoConfirmada.value=trueconsole.log('Inscrição registrada localmente — na Unidade 3 isso vira uma chamada real à API.')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.
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.
🔬 Investigue
Com npm run dev rodando, abra o DevTools, aba Network, marque "Preserve log" e filtre por Doc. Clique em duas ou três <RouterLink> diferentes: nenhuma requisição de documento aparece — é a SPA trocando de tela só com JavaScript, sem recarregar a página. Agora, na barra de endereço do navegador, digite /eventos/3 diretamente e aperte Enter: dessa vez aparece uma requisição Doc. Por que essa diferença acontece? E o que quebraria se você fizesse esse mesmo teste em produção, atrás de um servidor que não sabe redirecionar toda URL desconhecida para o index.html?
JavaScript
// src/router/index.js — versão completa recomendada, com lazy loading em tudoimport{createRouter,createWebHistory}from'vue-router'constrouter=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')},],})exportdefaultrouter
🧩 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-app contém v-app-bar, v-main e v-navigation-drawer; v-main contém RouterView; RouterView renderiza uma view, que contém v-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 de RouterViews 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.
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.
⚠️ Atenção — o modelo de dados muda aqui, e é de propósito
Nas Aulas 02 e 03 cada evento tinha dois campos de lotação: vagas (capacidade total) e inscritos (quantos já se inscreveram), e a tela calculava vagasRestantes com uma função. A partir de agora o array local guarda apenas vagas, já no sentido de "vagas ainda disponíveis" — o campo inscritos sai de cena. O motivo é honesto: contagem de inscritos é informação que só um servidor consegue manter correta (dois navegadores inscrevendo ao mesmo tempo no mesmo evento), e manter um contador falso num array local só ensina um hábito ruim. Na Unidade 3, quando os eventos vierem da API (Aulas 09 e 11), vagas volta a ser a capacidade total e a contagem de inscritos passa a ser derivada da tabela inscricoes, no banco. Até lá, trate este arquivo como uma maquete de dados.
JavaScript
// src/data/eventos.jsexportconsteventos=[{id:1,titulo:'Semana Acadêmica de Computação',descricao:'Palestras e minicursos sobre tendências em tecnologia.',categoria:'palestra',dataHora:'2030-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:'2030-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:'2030-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:'2030-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:'2030-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:'2030-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:'2030-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:'2030-09-25T18:30:00',local:'Laboratório 1',vagas:25,imagemUrl:'https://picsum.photos/seed/evento8/600/300'},]
Estes oito eventos passam a ser a base do UniEventos daqui em diante, até a Unidade 3, quando virão de uma API de verdade.
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.
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.
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¶
Vue SFC
<!-- 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 acadêmico desenvolvido como estudo de caso na disciplina em
que esta trilha nasceu.
</p>
</v-card-text>
</v-card>
</v-container>
</template>
Vue SFC
<!-- 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 §3 sobre a mudança de comportamento do fill-height no Vuetify 4.
A home lista os oito eventos em cards Vuetify, e o campo de busca filtra a lista enquanto você digita.
Clicar em um card navega para o detalhe, e a URL vira /eventos/3 — sem recarregar a página (é uma SPA).
O ícone de hambúrguer abre e fecha o v-navigation-drawer.
O botão de sol/lua alterna entre os temas claro e escuro, e as cores institucionais mudam junto.
Acessar uma URL inexistente (/qualquer-coisa) mostra a tela 404 com o botão "Voltar para o início" funcionando.
Resultado esperado: os cinco passam sem nenhum erro no console. Um Failed to resolve component aponta um import faltando; uma tela em branco no detalhe costuma ser props: true esquecido na rota.
A1. Sem vuetify({ autoImport: true }) no vite.config.js, você escreve <v-btn color="primary">Testar</v-btn> em um template e recarrega a página. O que aparece na tela?
Resultado esperado: nenhum botão estilizado — a tag <v-btn> fica sem CSS nenhum (ou nem chega a ser reconhecida como componente), porque é o autoimport quem registra os componentes do Vuetify.
A2. Complete a linha que falta para que o card abaixo ocupe a tela inteira no celular, metade em tablets e um terço a partir de telas médias:
Vue SFC
<v-col cols="12" ____ md="4">
Resultado esperado: sm="6" — 12/12 colunas até 599px, 6/12 a partir de 600px (sm), 4/12 a partir de 840px (md).
A3. Em uma frase: por que esta aula declara theme: { defaultTheme: 'light' } explicitamente em vez de deixar o Vuetify 4 no comportamento padrão?
Resultado esperado: porque o padrão do Vuetify 4 mudou para 'system', e sem declarar um tema fixo cada estudante veria uma cor diferente (clara ou escura) dependendo do sistema operacional, sem que ninguém tivesse clicado em nada.
A4. Ache o erro nas linhas abaixo — a rota nao-encontrado nunca aparece, mesmo acessando uma URL claramente inválida como /oi:
Resultado esperado: a rota catch-all está na primeira posição — o Vue Router testa rotas na ordem declarada, então ela intercepta qualquer URL antes de home e sobre serem avaliadas. A correção é mover { path: '/:pathMatch(.*)*', ... } para o final do array.
A5. Preveja a saída: em EventoDetalheView.vue, a URL acessada é /eventos/3, e o código roda console.log(typeof rota.params.id) antes de qualquer conversão.
Resultado esperado: "string" — parâmetros de rota do Vue Router sempre chegam como texto, mesmo quando representam um número; por isso a seção 7 usa Number(rota.params.id) antes de comparar ou buscar no array.
B1. 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.
Resultado esperado: o card do evento com vagas: 0 mostra um chip vermelho com o texto "Esgotado"; os demais eventos continuam mostrando o número de vagas normalmente.
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.
B2. 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.
Resultado esperado: acessar /eventos diretamente pela barra de endereço mostra a mesma listagem que /; o link do menu leva até lá sem recarregar a página.
Dica
Você pode apontar duas entradas de routes para o mesmo component, com path e name diferentes.
B3. 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.
Resultado esperado: o v-chip no topo mostra "8" (ou o total atual do array); adicionando um evento pelo console do navegador e recarregando a página, o número muda.
Dica
import { eventos } from './data/eventos' e depois {{ eventos.length }} dentro de um v-chip.
B4. 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.
Resultado esperado: clicar repetidamente no botão de tema percorre os três temas nessa ordem e volta ao início; o tema contraste é visivelmente diferente dos outros dois, com fundo escuro e texto/ações em amarelo vibrante.
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.
C1. 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. Não confunda esse caso com o de um ID numericamente válido, mas inexistente no array (ex.: /eventos/999) — esse continua mostrando o alerta "Evento não encontrado" que já existe.
Resultado esperado: /eventos/abc redireciona para a tela 404 sem erro no console; /eventos/999 continua mostrando o alerta local "Evento não encontrado", sem redirecionar.
Dica
Number.isNaN(Number(rota.params.id)) dentro de um onMounted (ou de um watch sobre rota.params.id, caso o usuário troque de evento sem sair do componente).
O botão de sol/lua da seção 5 troca o tema na hora — mas dê um F5: a aplicação volta para 'light', mesmo que você tenha deixado no escuro há dois segundos. Ninguém espera reconfigurar a aparência do site toda vez que recarrega a página. Resolva isso lendo e escrevendo a preferência de tema em localStorage, sem usar Pinia (isso vem na Aula 06).
Critérios de pronto
Ao carregar a aplicação, o tema aplicado é o que estava salvo na última visita (ou 'light', na primeira vez).
Trocar o tema pelo botão atualiza o localStorage imediatamente, não só na próxima navegação.
Abrir a aplicação em uma aba anônima nova (sem localStorage prévio) não gera erro no console — o valor padrão é usado normalmente.
Um comentário de uma linha no componente do alternador explica qual chave do localStorage guarda a preferência.
Pistas
localStorage.getItem('uniEventosTema') na inicialização do componente, usado para definir tema.global.name.value antes mesmo de o usuário clicar em qualquer botão.
localStorage.setItem('uniEventosTema', novoValor) dentro da própria função alternarTema.
localStorage.getItem retorna null quando a chave nunca foi salva — trate esse caso com ?? 'light' antes de atribuir a tema.global.name.value.
Desconecte o mouse (ou apenas prometa a si mesmo não tocar nele) e tente abrir o menu lateral do UniEventos só com o teclado: Tab até o ícone de hambúrguer, Enter para abrir, Tab pelos itens, Esc para fechar. Em quantos passos você trava? O ícone de tema tem algum texto que um leitor de tela consiga anunciar, ou é só um ícone mudo?
Critérios de pronto
O ícone de abrir/fechar o menu (v-app-bar-nav-icon) e o botão de alternar tema recebem foco visível com Tab, na ordem em que aparecem na tela.
Os dois ganham um aria-label descritivo (ex.: "Abrir menu de navegação", "Alternar para tema escuro"/"Alternar para tema claro", trocando conforme o estado atual).
O menu lateral fecha com Esc e devolve o foco ao ícone que o abriu.
Uma extensão de auditoria (Lighthouse, no próprio Chrome DevTools, ou axe DevTools) roda na tela inicial, e o print do resultado da categoria "Acessibilidade" vai para o README do projeto autoral, junto de pelo menos um problema real corrigido a partir do relatório.
Pistas
v-app-bar-nav-icon e v-btn aceitam qualquer atributo HTML padrão via fallthrough — aria-label="Abrir menu" funciona direto no template.
Para o aria-label do botão de tema mudar dinamicamente, use um computed que retorna a string certa com base em ehEscuro.
O retorno de foco ao fechar com Esc geralmente exige guardar uma referência ao elemento que tinha foco antes de abrir o menu, e chamar .focus() nele ao fechar.
O Lighthouse já vem embutido no Chrome DevTools, aba "Lighthouse" — rode com a categoria "Accessibility" marcada.
A seção 7 recomenda lazy loading (() => import(...)) para toda view, "porque o padrão é esse". Mas quanto isso realmente economiza no UniEventos, hoje, com só quatro views? Meça de verdade antes de confiar na recomendação.
Critérios de pronto
Uma versão do router/index.js com todos os component trocados para import estático no topo do arquivo (eager loading), rodando npm run build e anotando o tamanho e a quantidade de arquivos .js gerados em dist/assets.
A versão original com lazy loading, com o mesmo npm run build, anotando os mesmos números.
Uma tabela no README do projeto autoral comparando as duas: número de arquivos JS gerados, tamanho do maior chunk, e o tempo de carregamento da rota inicial reportado pela aba Network do DevTools (com throttling "Fast 3G" ativado, para exagerar a diferença).
Um parágrafo concluindo se a diferença justifica a complexidade extra neste projeto específico, e a partir de quantas views (na sua opinião, justificada) ela passaria a valer a pena.
Pistas
npm run build gera a pasta dist/; abra dist/assets e compare os nomes e tamanhos dos arquivos .js entre as duas versões.
Para forçar o throttling, DevTools → Network → menu de velocidade (geralmente "No throttling" por padrão) → escolha "Fast 3G".
Com poucas views pequenas, a diferença tende a ser mínima — é exatamente esse resultado, medido, que responde à pergunta. Meça antes de concluir.
A Unidade 1 terminou: você tem uma SPA com Vuetify, rotas, tema e grid responsivo. Antes de a Unidade 2 trazer componentização séria e a Unidade 3 trazer um back-end de verdade, prove que consegue montar uma funcionalidade nova do zero usando só o que aprendeu até aqui — sem Pinia, sem Axios, sem API: tudo em memória, com ref/computed e o array local de dados.
Critérios de pronto
Um ícone de "favoritar" (mdi-star/mdi-star-outline) aparece em cada v-card de evento, tanto na home quanto na tela de detalhe, e alterna visualmente ao clicar.
Uma nova rota /favoritos (nomeada favoritos), acessível pelo menu lateral, lista só os eventos marcados como favoritos.
A tela de favoritos mostra um estado vazio claro (v-alert ou similar) quando nenhum evento foi favoritado ainda — nunca uma tela em branco.
O app-bar mostra, em um v-chip, quantos eventos estão favoritados no momento (atualiza reativamente ao favoritar/desfavoritar).
O grid de favoritos é responsivo (mesmos breakpoints cols/sm/md usados no restante da aplicação) e cada card mantém a navegação para o detalhe (:to).
Favoritar um evento na home e depois abrir /favoritos mostra o evento imediatamente — sem F5.
Pistas
Guarde os favoritos como um ref([]) de IDs (não de objetos completos) em um arquivo compartilhado, ex. src/data/favoritos.js, exportando o ref para que qualquer componente que o importe compartilhe a mesma instância — isso é o "estado compartilhado manual" que o Pinia vai formalizar na Aula 06.
Um computed na tela de favoritos filtra o array eventos completo, mantendo só os que têm id presente no array de IDs favoritados.
O ícone alterna comparando favoritos.value.includes(evento.id) e usando push/splice (ou filter) para adicionar/remover.
Lembre de adicionar a rota favoritosantes da rota catch-all /:pathMatch(.*)* no array routes.
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) e v-main.
Configure um tema com pelo menos primary e secondary customizados, 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.
O Marco 1 fecha 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. Ao final deste marco, o seu projeto autoral (o que vem evoluindo desde a Aula 01) deve ser uma SPA de verdade, com interface Vuetify e navegação por rotas, aplicada a um domínio de dados diferente do UniEventos construído ao longo da trilha (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 definido na Aula 01).
Projeto criado com npm create vue@latest (ou npx create-vue@latest), com a flag --router no mínimo — Aula 01/02.
Mínimo de 6 componentes .vue próprios (views + componentes reutilizáveis), além do App.vue — Aula 02.
Uso comprovado — em código, não só em teoria — de: v-if/v-else, v-for com :key, v-model, v-bind (ou o atalho :), v-on (ou o atalho @) — Aula 02; computed e onMounted — Aula 03.
Dados de pelo menos 8 registros do domínio escolhido, em um arquivo separado (src/data/*.js) ou vindos de fetch a uma API pública/mock — Aula 03.
Interface visual com Vuetify em toda a aplicação (não vale CSS puro substituindo os componentes do Vuetify nas telas principais) — Aula 04.
Roteamento com Vue Router: no mínimo 3 rotas, sendo uma delas com parâmetro dinâmico e uma delas a rota 404 — Aula 04.
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").
Rode npm install && npm run dev em uma cópia limpa do repositório (ou peça a um colega para fazer isso) — se o projeto não sobe de primeira, o README está incompleto.
Abra o DevTools (aba Console) navegando por todas as rotas: zero erros e zero warnings do Vue Router.
Navegue para uma URL inexistente e confirme que a rota 404 aparece, não uma tela em branco.
Redimensione a janela (ou use o modo responsivo do DevTools) e confira que o grid Vuetify se reorganiza em telas menores.
Releia o próprio código um dia depois de terminar: se você não consegue explicar por que um computed ou um v-if está ali, reescreva-o.
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.
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, com required/default/validator) e defineEmits, incluindo v-model customizado com defineModel() e a forma clássica.
Usar provide/inject para dados compartilhados em profundidade e controlar atributos fallthrough com defineOptions({ 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 com v-data-table e usar diálogos de confirmação, tabs, menus e skeleton loaders do Vuetify.
[ ] UniEventos da Aula 04 rodando: Vuetify instalado, tema configurado, rotas home, evento-detalhe, sobre, nao-encontrado funcionando.
[ ] Marco 1 do projeto alcançado (ou em fase final de conclusão).
[ ] Domínio confortável de <script setup>, defineProps/defineEmits básicos (vistos rapidamente na Aula 02), computed, onMounted.
Na Aula 04 você transformou o UniEventos em uma SPA navegável: Vuetify instalado com tema institucional, v-app-bar/v-navigation-drawer/v-main no lugar, quatro rotas registradas e as views migradas para componentes Vuetify. O que ficou pendente é a organização interna: cada view ainda concentra marcação, dados e lógica no mesmo arquivo.
Hoje o UniEventos fica modular. Em vez de views monolíticas com tudo dentro, cada pedaço de interface vira um componente com contrato próprio — props de entrada, eventos de saída, slots para o que varia —, e a lógica reativa repetida sai das views para composables.
Na segunda metade da aula a área administrativa entra em cena, com rotas aninhadas, navigation guards, formulários validados com v-form e uma v-data-table de eventos. É o esqueleto que a Aula 06 vai conectar a uma API de verdade.
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).
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.
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 objeto evento e 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 um EventoCard.
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.
type habilita checagem em tempo de desenvolvimento — o Vue avisa no console se você passar um tipo errado.
required: true faz o Vue emitir um aviso se a prop não for passada.
default define um valor quando a prop não é informada (obrigatório para props opcionais que não são required).
validator é uma função que recebe o valor e retorna true/false — útil para restringir um número a uma faixa, ou uma string a um conjunto de valores permitidos (enum informal).
💡 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 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:
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.
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.
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 trilha 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.
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/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).
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:
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:
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.
🔬 Investigue
Renderize <EventoCard :evento="evento" class="destaque" data-testid="card-evento" /> sem declarar class nem data-testid como props em EventoCard. Abra o DevTools, aba Elements, e inspecione o <v-card> renderizado: a classe destaque e o atributo data-testid apareceram nele, mesmo sem você ter escrito nada a mais no template do componente — esse é o fallthrough automático. Agora adicione defineOptions({ inheritAttrs: false }) ao EventoCard, sem adicionar v-bind="$attrs" em lugar nenhum, e inspecione de novo: para onde os atributos foram?
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.
<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>.
À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:
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.
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*.
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).
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:
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.
🧠 Você sabia?
A Composition API do Vue 3 (2020) foi diretamente influenciada pelos React Hooks, lançados em 2018 — ambos resolvem o mesmo problema (reutilizar lógica com estado sem herança nem mixins) com uma ideia parecida: funções que encapsulam ref/state, computed/useMemo, watch/useEffect. A diferença prática que mais ajuda no dia a dia: hooks do React têm regras rígidas de ordem de chamada (não pode chamar dentro de if), enquanto composables do Vue são só funções JavaScript comuns — sem essa restrição, porque a reatividade do Vue não depende da ordem em que os hooks foram chamados na renderização anterior.
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:
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.
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:
JavaScript
// src/router/index.js (trecho, após criar o router)router.beforeEach((to,from)=>{document.title=to.meta.titulo?`${to.meta.titulo} · UniEventos`:'UniEventos'constautenticado=false// substituiremos por estado real com Pinia na Aula 06if(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:
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.
Vue SFC
<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.
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):
JavaScript
// src/router/index.js (trecho, dentro de createRouter)constrouter=createRouter({history:createWebHistory(import.meta.env.BASE_URL),scrollBehavior(to,from,savedPosition){if(savedPosition)returnsavedPosition// navegação por botão voltar/avançarreturn{top:0}},routes:[/* ... */],})
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.
<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
console.log('Formulário válido — dados prontos para envio:', { titulo: titulo.value, vagas: vagas.value })
}
</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>
Nesta seção o objetivo é validação — o envio real (para uma API ou para uma store) aparece completo no Mão na massa desta aula, e de novo, com Axios, na Aula 06.
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 .value nelas dentro do template. Compare:
```vue
Salvar
Salvar
```
Se você copiar um exemplo antigo com .value dentro do template do v-form, o botão nunca habilita — isValid deixou de ser um objeto ref e passou a ser o valor puro.
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¶
Vue SFC
<!-- 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>
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.
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.
🧩 Padrão de projeto em uso — Composite e Template Method¶
Composite aparece de novo hoje, agora na composição de componentes de layout: CartaoBase não sabe o que vai dentro dele — apenas define a "moldura" (v-card com 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 #item de EventoLista — é 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¶
⚠️ Atenção
O vite-plugin-vuetify faz auto-import só dos componentes do Vuetify (v-card, v-row…). Os seus componentes, mesmo no mesmo diretório, não são registrados automaticamente pelo create-vue: usar <EventoCard /> sem importar produz o aviso Failed to resolve component: EventoCard no console e um espaço em branco na tela. Todo componente autoral que você usar em um <template> precisa de um import no <script setup> do arquivo que o usa.
defineModel('busca', ...) e defineModel('categoria', ...) são a forma de defineModel() para múltiplosv-models no mesmo componente — cada nome vira um par prop/evento independente:
Passo 7 — reescrever HomeView.vue usando os componentes e o composable¶
Vue SFC
<!-- 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.
A área administrativa que começa aqui altera a lista de eventos: exclui, cria e edita. O arquivo criado na Aula 04 exporta um array JavaScript comum — e o Vue não observa arrays comuns. Se a área administrativa mexer nele como está, o dado até muda na memória, mas a v-data-table, o contador do AdminHomeView e o chip do v-app-bar continuam mostrando o valor antigo: o CRUD "não funciona" sem nenhum erro no console. Envolva o array em reactive() antes de seguir:
JavaScript
// src/data/eventos.js — agora reativoimport{reactive}from'vue'exportconsteventos=reactive([{id:1,titulo:'Semana Acadêmica de Computação',descricao:'Palestras e minicursos sobre tendências em tecnologia.',categoria:'palestra',dataHora:'2030-09-29T19:00:00',local:'Auditório Central',vagas:40,imagemUrl:'https://picsum.photos/seed/evento1/600/300'},// … os outros sete eventos, sem alteração])
⚠️ Atenção
Continue mutando o array no lugar (push, splice, Object.assign), nunca reatribuindo (eventos = [...]) — reactive() protege o conteúdo, não a variável, e a reatribuição quebraria a ligação com todas as telas de uma vez (é exatamente o bug do item A4 da Aula 02). Este arquivo é uma "store caseira": funciona bem para uma maquete, e na Aula 06 ele dá lugar a uma store Pinia de verdade.
Com npm run dev rodando, percorra os dois lados da aplicação:
Público — a HomeView mostra os oito cards vindos de EventoLista/EventoCard; digitar no FiltroEventos reduz a lista e a query string da URL acompanha (?busca=vue); recarregar a página com a query string preservada devolve a mesma lista filtrada.
Administrativo — acesse /admin: a v-data-table lista os mesmos oito eventos. Clique em "Novo evento", salve um evento válido e confira que ele aparece na tabela imediatamente, que o contador do AdminHomeView sobe de 8 para 9 e que o evento novo também aparece na Home pública.
Exclusão — exclua esse evento pelo diálogo de confirmação: a linha some da tabela na hora e o contador volta a 8.
Guard de saída — comece a editar um evento, altere um campo e clique em "Cancelar": o onBeforeRouteLeave pergunta se você quer mesmo sair.
Resultado esperado: os quatro itens acima passam sem recarregar a página. Se a tabela e o contador não mudarem depois de criar ou excluir, o reactive() do Passo 8 não foi aplicado — é o sintoma exato descrito lá.
A1. Preveja a saída no console: EventoCard declara defineProps({ evento: { type: Object, required: true } }), e o componente pai usa <EventoCard /> sem passar a prop evento.
Resultado esperado: um aviso no console ([Vue warn]: Missing required prop: "evento"), e o template do componente provavelmente quebra ao tentar ler evento.titulo de undefined — props required não impedem a renderização, só avisam.
A2. Complete a linha que falta para que o evento favoritar só seja aceito se o payload for um número:
JavaScript
constemit=defineEmits({favoritar:____,})
Resultado esperado: (idEvento) => typeof idEvento === 'number' — uma função validadora que recebe o payload do evento e retorna true/false, no mesmo espírito do validator de defineProps.
A3. Em uma frase: por que useEventos() chamado duas vezes, em dois componentes diferentes, resulta em dois estados de carregamento independentes — enquanto uma store Pinia, chamada duas vezes, resulta num único estado compartilhado?
Resultado esperado: porque um composable é só uma função JavaScript comum — cada chamada executa o corpo de novo e cria refs novos; uma store Pinia é um singleton gerenciado pelo framework, então toda chamada de useXStore() devolve a mesma instância.
A4. Ache o erro nas linhas abaixo — a rota admin-eventos nunca renderiza nada quando o usuário acessa /admin diretamente (só a URL-base, sem sub-caminho):
Resultado esperado: falta uma rota-filha com path: '' (caminho vazio) para cobrir exatamente /admin sem sub-caminho nenhum — hoje /admin sozinho não bate com nenhuma rota-filha declarada, porque todas exigem um segmento extra (/admin/home, /admin/eventos).
A5. Preveja o comportamento: o scrollBehavior do router não foi declarado (a opção inteira foi omitida de createRouter). O usuário rola a página até o rodapé e clica em um <RouterLink> para outra rota.
Resultado esperado: a nova página aparece já rolada — o Vue Router preserva a posição de scroll atual por padrão quando scrollBehavior não está definido; é preciso declará-lo explicitamente (retornando { top: 0 }) para voltar ao topo a cada navegação.
B1.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.
Resultado esperado: SobreView.vue usa <CartaoBase> com <template #titulo> e <template #acoes>, e a tela renderiza visualmente igual (ou melhor) do que antes.
Dica
<template #titulo>, conteúdo solto (sem <template>) cai no slot padrão, <template #acoes>.
B2. 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.
Resultado esperado: CabecalhoApp.vue e a nova tela de configurações chamam useAlternanciaTema() e o clique em qualquer um dos dois lugares alterna o tema da aplicação inteira (porque useTheme() internamente já é global — o composable só organiza o acesso a ele).
Dica
O composable recebe useTheme() internamente e retorna { ehEscuro, alternarTema }.
B3. 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.
Resultado esperado: alterar qualquer campo do formulário e tentar sair da rota (clicar em "Cancelar" ou em um link do menu) dispara o window.confirm; salvar o formulário com sucesso não dispara mais o aviso ao sair em seguida.
B4.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.
Resultado esperado: clicar no botão de três pontinhos abre um menu com as duas opções; clicar em cada uma emite o evento correspondente, capturável com @compartilhar/@favoritar em quem usa o EventoCard.
Dica
Use o slot #activator="{ props }" do v-menu, como no exemplo da §5.
C1. 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. A URL precisa ser a fonte da verdade: recarregar a página em /admin/eventos?pagina=3 deve abrir já na página 3, e voltar/avançar no navegador entre páginas visitadas deve funcionar sem recarregar a tela.
Resultado esperado: /admin/eventos?pagina=2 abre direto na página 2 do v-pagination; clicar em outra página atualiza a URL sem recarregar; o botão "voltar" do navegador retorna à página anterior corretamente.
Dica
ref(Number(rota.query.pagina) || 1) inicializa o estado a partir da URL; um watch sobre esse ref chama router.push({ query: { ...rota.query, pagina } }) para refletir de volta; e um watch sobre rota.query.pagina (o caminho inverso) é o que faz o botão "voltar" do navegador também atualizar o v-pagination — sem ele, só a URL muda ao clicar em "voltar", não a tela.
Um colega criou DialogoConfirmacao.vue reaproveitando o desta aula, mas trocou um detalhe sem perceber. Ao clicar em "Excluir" no diálogo, nada acontece — o evento aparentemente nunca chega ao componente pai. Este é o trecho relevante:
Vue SFC
<!-- src/components/DialogoConfirmacao.vue — trecho com o bug plantado -->
<script setup>
const emit = defineEmits(['confirmar'])
function confirmar() {
emit('confirmado')
}
</script>
Abra o Vue DevTools (aba Components), selecione o DialogoConfirmacao e observe a lista de eventos emitidos ao clicar em "Excluir". O nome que aparece bate com o que o componente pai está escutando?
Critérios de pronto
Um comentário no topo do arquivo registra qual nome de evento o defineEmits declarava, qual nome estava realmente sendo emitido, e qual dos dois estava errado.
Clicar em "Excluir" agora dispara a função confirmarExclusao (ou equivalente) no componente pai, de forma confirmável no Vue DevTools ou com um console.log temporário.
Uma frase explica por que defineEmitsnão impede emitir um evento com nome diferente do declarado — e por que isso torna esse tipo de bug silencioso (sem erro, sem aviso).
Pistas
defineEmits(['confirmar']) só documenta e valida payloads — ele não bloqueia emit('outroNome'), mesmo que 'outroNome' não esteja na lista.
No Vue DevTools, a aba Components tem uma seção "Events" no painel de detalhes do componente selecionado — ela mostra o nome exato de cada evento emitido, em tempo real.
Compare, char por char, o nome usado em @confirmar="..." no componente pai com o nome usado em emit(...) no filho.
O v-menu de ações rápidas do Laboratório B4 funciona perfeitamente no mouse. Agora teste só com teclado: Tab até o botão de três pontinhos, Enter para abrir, Tab/setas para navegar pelas opções, Enter para escolher, Esc para fechar sem escolher nada. Em qual desses passos a experiência quebra?
Critérios de pronto
O botão de três pontinhos recebe foco visível com Tab e tem um aria-label descritivo (ex.: "Mais ações para o evento X" — o nome do evento entra dinamicamente no rótulo).
Abrir o menu com Enter (não só com clique) funciona, e o foco move para dentro do menu.
Esc fecha o menu e devolve o foco ao botão de três pontinhos — sem deixar o foco "perdido" em um elemento que sumiu da tela.
Um vídeo curto (ou GIF) de 10-15 segundos, gravado sem tocar no mouse, mostra o fluxo completo funcionando, anexado ao README do projeto autoral.
Pistas
v-btn aceita aria-label como qualquer atributo HTML — inclua o título do evento na string, usando template literal.
Verifique a documentação de acessibilidade do v-menu na versão do Vuetify instalada — componentes de menu geralmente já implementam boa parte da navegação por teclado, mas o aria-label do ativador é responsabilidade sua.
Para gravar sem mouse, o gravador de tela nativo do sistema operacional (ou a gravação de tela do próprio DevTools) já basta — não precisa de ferramenta especial.
O código abaixo passa o usuário logado por três componentes até chegar a quem realmente precisa dele — clássico prop drilling. PainelAdmin e CabecalhoSecao não usam usuarioLogado para nada além de repassar adiante:
Refatore essa cadeia usando provide/inject (§1), e depois responda: o que muda se PainelPerfil for renderizado em um lugar da árvore onde ninguém chamou provide('usuarioLogado', ...) acima dele?
Critérios de pronto
PainelAdmin e CabecalhoSecao não recebem mais usuarioLogado como prop — o dado só é declarado uma vez, no ancestral comum, com provide.
Um teste deliberado: renderize PainelPerfil em uma tela isolada, sem nenhum ancestral chamando provide. inject recebe um segundo argumento de valor padrão que evita a aplicação quebrar nesse caso — implemente e documente esse valor padrão.
Um parágrafo no README compara as duas abordagens: em quantos arquivos você precisou tocar para adicionar um novo dado "ambiental" (ex.: idioma da interface) em cada uma, e qual delas você escolheria para o seu projeto autoral, e por quê.
Pistas
provide('usuarioLogado', usuarioLogado) no componente ancestral mais alto que faz sentido (geralmente App.vue); inject('usuarioLogado', valorPadrao) em qualquer descendente, não importa a profundidade.
O segundo argumento de inject é o valor usado quando nenhum ancestral fez provide daquela chave — útil para não quebrar em testes isolados ou em Storybook.
Prop drilling não é "sempre errado" — em cadeias curtas (um ou dois níveis), a prop explícita ainda é mais fácil de rastrear do que provide/inject. O parágrafo do README deve refletir esse trade-off, não só repetir "provide/inject é melhor".
Extraia pelo menos um componente reutilizável de apresentação (equivalente ao EventoCard), com defineProps tipado 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-form e rules para 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.
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.
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 distinguindo error.response de error.request.
Cancelar requisições com AbortController e enviar arquivos com FormData.
Organizar chamadas HTTP em uma camada de serviços, mantendo os componentes livres de detalhes de rede.
Subir uma API falsa com json-server para desenvolvimento e testes.
Criar stores Pinia no estilo setup store, com storeToRefs, ações assíncronas, $reset, $patch, $subscribe e persistência em localStorage.
Conectar o UniEventos a uma API real via camada de serviços e stores, com feedback visual de carregamento, erro e sucesso.
Na Aula 05 você quebrou o UniEventos em componentes com contrato próprio (EventoCard, EventoLista, FiltroEventos, DialogoConfirmacao), extraiu a lógica de dados para o composable useEventos e montou a área administrativa com rotas aninhadas, guards e formulário validado.
Duas limitações ficaram evidentes lá. A primeira: os dados ainda saem de um array estático importado de src/data/eventos.js, que só existe dentro do navegador de quem abriu a página. A segunda: cada componente que chama useEventos() recebe uma cópia própria do estado — o que a área administrativa altera não é necessariamente o que a home enxerga.
Hoje resolvemos as duas. O array vira uma API de verdade, consumida por uma instância dedicada do Axios com interceptors, e o estado sai das refs locais para uma store Pinia, compartilhada por toda a aplicação.
[ ] UniEventos da Aula 05 com componentes extraídos, composable useEventos, rotas aninhadas e formulário validado funcionando.
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.
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
📌 Vale gravar: a diferença entre 400 e 422 é sutil, mas costuma confundir: 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).
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 (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-server que vamos usar já vem com CORS liberado por padrão.
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 500nã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.
⚠️ Atenção: nunca use o axios importado 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.
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ó.
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):
JavaScript
// src/services/http.js (trecho — adicionar após criar a instância)http.interceptors.request.use((config)=>{consttoken=localStorage.getItem('uniEventosToken')if(token){config.headers.Authorization=`Bearer ${token}`}returnconfig})
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¶
JavaScript
// 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'}returnPromise.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:
JavaScript
try{constresposta=awaithttp.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)}elseif(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".
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.
JavaScript
letcontrolador=nullasyncfunctionbuscar(termo){if(controlador)controlador.abort()// cancela a busca anterior, se existircontrolador=newAbortController()try{constresposta=awaithttp.get('/eventos',{params:{titulo_like:termo},signal:controlador.signal,})returnresposta.data}catch(erro){if(axios.isCancel(erro)||erro.code==='ERR_CANCELED'){return[]// busca cancelada, não é um erro de verdade}throwerro}}
🔬 Investigue
Remova temporariamente o if (controlador) controlador.abort() acima (ou comente essa linha) e abra a aba Network do DevTools. Digite um termo de busca rápido, letra por letra, sem pausar (ex.: "workshop"). Quantas requisições GET /eventos?titulo_like=... aparecem? Agora observe a coluna de tempo: alguma requisição mais antiga (por uma letra a menos) termina depois de uma mais nova? Se sim, a tela pode acabar mostrando o resultado da busca errada — a resposta que "chegou por último" nem sempre é a da última letra digitada. Restaure a linha removida e repita o teste.
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:
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.
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.
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 pela HomeView, pela busca administrativa e por um composable, sem repetir a URL em três lugares.
Um ponto único de mudança — se o endpoint /eventos virar /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.
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.
Crie o arquivo db.json na raiz do projeto, com este conteúdo (JSON não aceita comentários — não copie nenhuma linha de // para dentro dele):
JSON
{"eventos":[{"id":1,"titulo":"Semana Acadêmica de Computação","descricao":"Palestras e minicursos sobre tendências em tecnologia.","categoria":"palestra","dataHora":"2030-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":"2030-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":"2030-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":"2030-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":"2030-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":"2030-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":"2030-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":"2030-09-25T18:30:00","local":"Laboratório 1","vagas":25,"imagemUrl":"https://picsum.photos/seed/evento8/600/300"}],"inscricoes":[]}
⚠️ Atenção
A versão está fixada de propósito. O json-server 1.x (o que npx json-server baixa hoje, sem a versão) removeu a flag --watch e aborta com "unknown argument". Usamos a linha 0.17 porque é a que casa com as rotas de filtro (?titulo_like=) usadas nesta aula. Se preferir a versão nova, a sintaxe passa a ser npx json-server db.json --port 3000 — e alguns filtros mudam de nome.
Isso sobe uma API completa em http://localhost:3000, com:
GET /eventos — lista todos os eventos.
GET /eventos/3 — retorna o evento com id: 3, ou 404 se 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 (retorna 201).
PUT /eventos/3 — substitui o evento 3 inteiro.
PATCH /eventos/3 — atualiza campos específicos do evento 3.
DELETE /eventos/3 — remove o evento 3 (retorna 200 com 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-server e o npm run dev do 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-server nã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.
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.
O Pinia já vem instalado e registrado se você criou o projeto com a flag --pinia (como fizemos na Aula 02, no npm create vue@latest):
JavaScript
// src/main.js (trecho, já presente no scaffold)import{createApp}from'vue'import{createPinia}from'pinia'importAppfrom'./App.vue'constapp=createApp(App)app.use(createPinia())
🧠 Você sabia?
"Pinia" é a palavra em espanhol para "abacaxi" (piña). O nome foi escolhido por Eduardo San Martin Morote, membro da equipe do Vue, quando ele começou a biblioteca em 2019 como uma experiência pessoal de "como seria o Vuex 5". A experiência foi tão bem recebida pela comunidade que a própria equipe do Vue a adotou oficialmente como gerenciador de estado recomendado no Vue 3 — hoje Vuex está em modo de manutenção, e todo projeto novo usa Pinia.
Pinia suporta dois estilos de declaração de store. Esta trilha 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):
JavaScript
// exemplo — NÃO é o estilo usado nesta trilha, mas você deve reconhecê-loimport{defineStore}from'pinia'exportconstuseContadorStore=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):
JavaScript
// src/stores/contadorStore.js — estilo usado nesta trilhaimport{defineStore}from'pinia'import{ref,computed}from'vue'exportconstuseContadorStore=defineStore('contador',()=>{constvalor=ref(0)constdobro=computed(()=>valor.value*2)functionincrementar(){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.
📌 Vale gravar: 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¶
JavaScript
import{useEventosStore}from'../stores/eventosStore'conststore=useEventosStore()// ERRADO — quebra a reatividadeconst{eventos,carregando}=store
Desestruturar propriedades reativas diretamente de storequebra 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.
JavaScript
import{storeToRefs}from'pinia'import{useEventosStore}from'../stores/eventosStore'conststore=useEventosStore()const{eventos,carregando}=storeToRefs(store)// CORRETO — mantém reatividade// ações continuam sendo chamadas direto da store, sem storeToRefsstore.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 estudar reactive vs. ref): a store internamente é um objeto reactive, e desestruturar um reactive extrai o valor primitivo naquele instante, perdendo o Proxy que rastreia mudanças. storeToRefs contorna isso criando um ref para cada propriedade, que continua "ligado" ao Proxy original.
// src/stores/eventosStore.jsimport{defineStore}from'pinia'import{ref,computed}from'vue'importeventosServicefrom'../services/eventosService'exportconstuseEventosStore=defineStore('eventos',()=>{consteventos=ref([])constcarregando=ref(false)consterro=ref(null)constcategoriaFiltro=ref('Todas')constbusca=ref('')consteventosFiltrados=computed(()=>{returneventos.value.filter((evento)=>{constbateCategoria=categoriaFiltro.value==='Todas'||evento.categoria===categoriaFiltro.value.toLowerCase()constbateBusca=evento.titulo.toLowerCase().includes(busca.value.toLowerCase())returnbateCategoria&&bateBusca})})asyncfunctioncarregarEventos(){carregando.value=trueerro.value=nulltry{eventos.value=awaiteventosService.listar()}catch(e){erro.value='Não foi possível carregar os eventos. Tente novamente.'}finally{carregando.value=false}}asyncfunctionremoverEvento(id){awaiteventosService.remover(id)eventos.value=eventos.value.filter((e)=>e.id!==id)}asyncfunctionsalvarEvento(dadosEvento){if(dadosEvento.id){constatualizado=awaiteventosService.atualizar(dadosEvento.id,dadosEvento)constindice=eventos.value.findIndex((e)=>e.id===dadosEvento.id)if(indice!==-1)eventos.value[indice]=atualizado}else{constcriado=awaiteventosService.criar(dadosEvento)eventos.value.push(criado)}}function$reset(){eventos.value=[]carregando.value=falseerro.value=nullcategoriaFiltro.value='Todas'busca.value=''}return{eventos,carregando,erro,categoriaFiltro,busca,eventosFiltrados,carregarEventos,removerEvento,salvarEvento,$reset,}})
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.
conststore=useEventosStore()// $reset — no setup store, você define sua própria função $reset (como acima),// pois o Pinia só gera $reset automaticamente para options storesstore.$reset()// $patch — atualiza várias propriedades de uma vez, útil para mudanças em lotestore.$patch({categoriaFiltro:'Palestra',busca:''})// $patch também aceita uma função, útil quando a mudança depende do estado atualstore.$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 $reset manualmente e a expõe no return. É um detalhe pequeno, mas comum de esquecer.
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.
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:
JavaScript
// src/stores/inscricoesStore.js (trecho — alternativa com $subscribe)exportconstuseInscricoesStore=defineStore('inscricoes',()=>{constidsInscritos=ref(JSON.parse(localStorage.getItem('uniEventosInscricoes')||'[]'))functioninscrever(idEvento){if(!idsInscritos.value.includes(idEvento)){idsInscritos.value.push(idEvento)}}functioncancelarInscricao(idEvento){idsInscritos.value=idsInscritos.value.filter((id)=>id!==idEvento)}return{idsInscritos,inscrever,cancelarInscricao}})
JavaScript
// src/main.js (trecho — assinatura global, fora da store)import{useInscricoesStore}from'./stores/inscricoesStore'constinscricoesStore=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 para quem está começando — é a que vamos usar no Mão na massa.
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 — 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 de defineStore, '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 de 401. O componente que chama http.get('/eventos') não sabe (nem precisa saber) que essas camadas extras existem — elas são "decoradas" por fora, de forma transparente.
// src/stores/eventosStore.jsimport{defineStore}from'pinia'import{ref,computed}from'vue'importeventosServicefrom'../services/eventosService'exportconstuseEventosStore=defineStore('eventos',()=>{consteventos=ref([])constcarregando=ref(false)consterro=ref(null)constcategoriaFiltro=ref('Todas')constbusca=ref('')consteventosFiltrados=computed(()=>{returneventos.value.filter((evento)=>{constbateCategoria=categoriaFiltro.value==='Todas'||evento.categoria===categoriaFiltro.value.toLowerCase()constbateBusca=evento.titulo.toLowerCase().includes(busca.value.toLowerCase())returnbateCategoria&&bateBusca})})asyncfunctioncarregarEventos(){carregando.value=trueerro.value=nulltry{eventos.value=awaiteventosService.listar()}catch{erro.value='Não foi possível carregar os eventos. Verifique se o json-server está rodando.'}finally{carregando.value=false}}asyncfunctionremoverEvento(id){awaiteventosService.remover(id)eventos.value=eventos.value.filter((e)=>e.id!==id)}asyncfunctionsalvarEvento(dadosEvento){if(dadosEvento.id){constatualizado=awaiteventosService.atualizar(dadosEvento.id,dadosEvento)constindice=eventos.value.findIndex((e)=>e.id===dadosEvento.id)if(indice!==-1)eventos.value[indice]=atualizado}else{constcriado=awaiteventosService.criar(dadosEvento)eventos.value.push(criado)}}function$reset(){eventos.value=[]carregando.value=falseerro.value=nullcategoriaFiltro.value='Todas'busca.value=''}return{eventos,carregando,erro,categoriaFiltro,busca,eventosFiltrados,carregarEventos,removerEvento,salvarEvento,$reset,}})
Passo 6 — atualizar HomeView.vue para usar a store¶
Vue SFC
<!-- 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.
Com o json-server rodando em um terminal e npm run dev em outro:
A home carrega os eventos da API — confira na aba Network que a requisição GET http://localhost:3000/eventos acontece de verdade e volta 200.
Inscrever-se em um evento persiste em localStorage: recarregue a página e a inscrição continua marcada.
Editar um evento na área administrativa reflete na home imediatamente (a store é única, compartilhada).
Editar um evento não muda a data nem a imagem dele — só os campos do formulário.
Derrube o json-server (Ctrl+C) e recarregue a home.
Resultado esperado: nos quatro primeiros, tudo funciona sem F5 manual; no quinto, aparece o alerta de erro tratado pela store (erro.value), não uma tela quebrada nem um erro solto no console.
A1. Preveja: com fetch('http://localhost:3000/eventos/999') (id inexistente, responde 404), o código abaixo roda até o fim, sem lançar exceção:
JavaScript
// URL absoluta de propósito: uma URL relativa ('/eventos/999') seria servida// pelo dev server do Vite, que devolve o index.html com status 200 — não o 404 da APIconstresposta=awaitfetch('http://localhost:3000/eventos/999')console.log('Cheguei aqui:',resposta.status)
Trocando fetch pela instância http do Axios, dentro de um try/catch, a mesma chamada cai no catch. Por que essa diferença?
Resultado esperado: com fetch, qualquer status HTTP (incluindo 404/500) resolve a Promise normalmente — só falha de rede rejeita; é preciso checar resposta.ok manualmente. Com Axios, qualquer status fora da faixa 2xx já rejeita a Promise automaticamente, então cai no catch.
A2. Complete a linha que falta no interceptor de request, para que o token salvo seja anexado ao cabeçalho correto:
Resultado esperado: config.headers.Authorization = \Bearer ${token}`— o padrãoBearer ` é o esperado pela maioria das APIs que leem esse cabeçalho.
A3. Em uma frase: por que const { eventos, carregando } = store quebra a reatividade, mas store.carregarEventos() continua funcionando normalmente, sem precisar de storeToRefs?
Resultado esperado: eventos/carregando são state — desestruturar copia o valor naquele instante, perdendo a ligação com o Proxy reativo da store; carregarEventos é uma ação (função), e chamá-la direto de store.carregarEventos() sempre executa o código atual da store, sem precisar de nenhum "encanamento" reativo.
A4. Ache o erro nas linhas abaixo — nenhum componente que usa essa store percebe quando valor muda:
Resultado esperado: return { valor: valor.value, ... } devolve o número puro (0) capturado no instante da criação da store, não o ref — a store deveria devolver valor (o ref inteiro), não valor.value. Assim como desestruturar quebra a reatividade fora da store, devolver .value de dentro da própria store causa o mesmo problema.
A5. Preveja: seu componente chama http.delete('/eventos/7'). A aba Network mostra duas requisições: uma OPTIONS e uma DELETE. Qual delas carrega o cabeçalho Authorization, e por quê?
Resultado esperado: só a requisição DELETE real carrega Authorization — o interceptor de request do Axios roda apenas na chamada que o seu código de fato disparou. O OPTIONS é o preflight, gerado automaticamente pelo navegador (não pelo Axios) para perguntar ao servidor se ele aceita aquele método com aqueles cabeçalhos, antes de enviar a requisição real.
B1. 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.
Resultado esperado: os três v-chip mostram a contagem correta de cada categoria e atualizam sozinhos (sem F5) se um evento for criado, editado ou excluído durante a sessão.
B2.$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.
Resultado esperado: toda ação que muda eventos (carregar, criar, editar, excluir) imprime uma linha no console com o novo total, sem que você precise espalhar console.log dentro de cada ação da store.
Dica
store.$subscribe((mutation, state) => console.log('eventos:', state.eventos.length)), chamado após app.mount('#app').
B3. 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.
Resultado esperado: com o json-server no ar e um erro de validação simulado, a mensagem exibida fala em "dados inválidos"; com o json-server derrubado, a mensagem é claramente outra (ex.: "não foi possível conectar ao servidor"), sem misturar as duas.
Dica
Dentro do catch, verifique if (e.response) { ... } else if (e.request) { ... }, como na §2.
B4. 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.
Resultado esperado: o tema escolhido persiste entre recarregamentos de página (F5), lido de preferenciasStore — e não de um ref local isolado que reiniciaria a cada visita.
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.
C1. 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. Prove que funciona sob condições realistas: digite rapidamente, sem pausar, e confirme na aba Network que as respostas antigas não sobrescrevem a lista com resultados desatualizados.
Resultado esperado: digitar uma palavra inteira rapidamente gera várias requisições na aba Network, mas só a última é exibida como bem-sucedida — as anteriores aparecem como "canceled" (ou equivalente), e a lista de eventos na tela sempre corresponde ao último termo digitado, nunca a um termo anterior.
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. Trate o erro de cancelamento (axios.isCancel(erro)) separadamente de um erro de verdade — ele não deve acionar a mensagem de erro da store.
Um colega "simplificou" o interceptor de request removendo a checagem, para deixar o código mais enxuto:
JavaScript
// src/services/http.js — trecho com o bug plantadohttp.interceptors.request.use((config)=>{consttoken=localStorage.getItem('uniEventosToken')config.headers.Authorization=`Bearer ${token}`returnconfig})
Agora, mesmo sem nenhum usuário logado, toda requisição sai com o cabeçalho Authorization: Bearer null. Abra a aba Network e confirme. Por que isso é um problema pior do que "só um cabeçalho inútil"?
Critérios de pronto
O interceptor volta a só adicionar o cabeçalho quando existe um token de verdade salvo.
Um teste manual: com localStorage vazio, a aba Network confirma que a requisição sai sem o cabeçalho Authorization (não com o valor literal "null").
Um comentário no código explica por que enviar Authorization: Bearer null pode ser pior do que não enviar cabeçalho nenhum (ex.: um servidor mal implementado poderia tratar a string "null" como um token válido, ou logs de erro ficam poluídos com um "token" que não existe).
Uma segunda checagem: se alguém salvar por engano a string literal "null" no localStorage (com localStorage.setItem('uniEventosToken', null)), seu código também trata esse caso — e não só o null de verdade do JavaScript.
Pistas
localStorage.getItem retorna o valor null do JavaScript quando a chave não existe — mas se alguém já salvou a string "null" por engano, if (token) não pega esse caso, porque a string "null" é truthy.
Confira o valor exato de token (não só sua truthiness) antes de decidir se o cabeçalho deve ser adicionado.
Uma verificação mais robusta: if (token && token !== 'null').
inscricoesStore (e, se você fez o Laboratório B4, preferenciasStore também) implementam persistência em localStorage cada uma com sua própria lógica de leitura/escrita repetida. Extraia isso para uma função reutilizável — no mesmo espírito estrutural dos interceptors do Axios, vistos no box de padrões desta aula.
Critérios de pronto
Uma função usarPersistencia(store, chave) em src/stores/plugins/persistencia.js que: lê o valor salvo do localStorage e usa store.$patch para inicializar o state, e assina store.$subscribe para salvar automaticamente a cada mudança.
inscricoesStore e ao menos uma outra store passam a chamar só usarPersistencia(useInscricoesStore(), 'uniEventosInscricoes') (uma linha), sem repetir localStorage.getItem/setItem manualmente dentro de cada ação.
Remover a chamada de usarPersistencia de uma store faz ela parar de persistir, sem quebrar nenhuma outra funcionalidade — prova de que a persistência está de fato desacoplada da lógica de negócio da store.
Um comentário no arquivo persistencia.js explica por que essa função é, estruturalmente, um Decorator: ela "envolve" uma store existente adicionando um comportamento (persistência) sem que a store precise saber disso.
Pistas
store.$subscribe((mutation, state) => localStorage.setItem(chave, JSON.stringify(state))) cobre a parte de salvar.
Para inicializar, leia o localStorageantes de assinar o $subscribe (senão a leitura inicial dispara uma escrita desnecessária), e use store.$patch(JSON.parse(valorSalvo)) só se valorSalvo existir.
Pinia tem um conceito oficial de "plugin" (pinia.use(...)) que resolve exatamente esse tipo de problema para todas as stores de uma vez — se quiser ir além, pesquise "Pinia plugins" na documentação oficial.
⭐⭐⭐
⭐⭐⭐ Cache de 30 segundos para não repetir a mesma pergunta¶
performanceaxiosjavascript
Toda vez que o usuário volta para a Home vindo do detalhe de um evento, carregarEventos() dispara um novo GET /eventos — mesmo que a lista não tenha mudado nos últimos segundos. Em uma API de verdade (não o json-server local), cada requisição desnecessária custa tempo de rede e carga no servidor. Implemente um cache simples: se os mesmos parâmetros de busca já foram pedidos há menos de 30 segundos, devolva o resultado guardado, sem nova requisição.
Critérios de pronto
Uma camada de cache (um Map em memória, chave = URL + parâmetros, valor = { dados, expiraEm }) na frente de eventosService.listar, ou dentro da própria store.
Chamar carregarEventos() duas vezes seguidas, em menos de 30 segundos, gera uma requisição na aba Network — a segunda vem do cache.
Depois de 30 segundos, uma nova chamada gera uma requisição de verdade — o cache expira, não é permanente.
Uma ação explícita (ex.: botão "Atualizar" na tela) ignora o cache e força uma requisição nova, mesmo dentro da janela de 30 segundos.
Uma tabela no README do projeto autoral compara o número de requisições feitas em um minuto de uso típico, antes e depois do cache (contado na aba Network).
Pistas
Um Map declarado no próprio módulo do serviço (fora de qualquer função) sobrevive entre chamadas — exatamente como a variável controlador do exemplo de AbortController na §2.
Guarde Date.now() + 30000 como o momento de expiração, e compare com Date.now() antes de decidir se serve do cache ou busca de novo.
O botão "Atualizar" pode simplesmente apagar a entrada do cache (ou passar um parâmetro forcar: true que pula a checagem) antes de chamar o serviço normalmente.
🔥
🔥 Boss — Painel de inscrições com filtros persistentes na URL¶
vuepiniaaxiosprojeto
A Unidade 2 terminou. Você sabe componentizar de verdade, sincronizar filtros com a URL (Aula 05), e agora consumir uma API real com Axios e Pinia (hoje). Prove que tudo isso funciona junto, numa única funcionalidade nova: um painel administrativo que mostra quem se inscreveu em cada evento, com filtros que sobrevivem a um F5.
Critérios de pronto
Uma nova rota aninhada /admin/inscricoes (rota-filha de AdminLayoutView), listando, em uma v-data-table, todas as inscrições (useInscricoesStore) já cruzadas com os dados do evento correspondente (useEventosStore) — cada linha mostra o título do evento e a categoria, não só o ID.
Um filtro de categoria (v-select) e um campo de busca por título (v-text-field), ambos sincronizados com a URL via query string (?categoria=...&busca=...), seguindo o padrão da Aula 05 — recarregar a página com uma URL filtrada mantém o filtro aplicado.
Uma store Pinia (setup store) dedicada a essa tela, que compõeuseEventosStore e useInscricoesStore (nenhuma duplica dados das outras duas).
Estados de carregando, erro e "nenhuma inscrição encontrada" tratados visualmente (skeleton/spinner, v-alert de erro, v-alert informativo), exatamente como no restante da aplicação.
Uma ação de "cancelar inscrição" direto da tabela, com v-dialog de confirmação (reaproveitando DialogoConfirmacao.vue da Aula 05) e feedback de v-snackbar ao concluir.
Um teste documentado no README: derrubar o json-server, recarregar a tela e confirmar que aparece uma mensagem de erro clara — nunca uma tela em branco ou quebrada.
Pistas
A store nova pode ter um computed que cruza inscricoesStore.idsInscritos com eventosStore.eventos, parecido com o que eventosInscritos já faz na inscricoesStore — só que organizando por inscrição, não por evento.
Reaproveite a técnica de query string ↔ filtro da Aula 05 (watch + router.push({ query: {...} })) para os dois campos de filtro juntos, não um de cada vez.
useEventosStore() e useInscricoesStore() chamados dentro da nova store funcionam exatamente como no exemplo de composição de stores da seção 5 — Pinia garante que é a mesma instância em qualquer lugar que você chame.
Para o teste de API fora do ar, erro.request (sem erro.response) é o sinal de que não houve resposta nenhuma — mostre uma mensagem diferente desse caso comparado a um erro HTTP normal (ver seção 2).
Crie um db.json com os mesmos dados do seu domínio (mínimo 8 registros) e suba com json-server.
Crie src/services/http.js com instância dedicada, interceptor de request e de response.
Crie um serviço (*Service.js) com pelo menos listar, buscarPorId, criar, remover.
Crie uma store Pinia (setup store) para o recurso principal do seu domínio, com carregando, erro e ao menos uma ação assíncrona.
Conecte pelo menos uma tela à store usando storeToRefs, com feedback de v-snackbar em 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.
Referências básicas do plano de curso: capítulos sobre consumo de API e gerenciamento de estado.
Isso encerra a Unidade 2. O Marco 2 do projeto fecha na Aula 08 — veja o quadro de marcos em ../nivel-3/#marcos — com os requisitos completos lá, 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.
Na próxima aula o json-server sai de cena: você escreve a unieventos-api de verdade, com Node.js e Express 5, conhece o Firebase (autenticação e Firestore) e aponta o baseURL do http.js desta aula para o seu próprio back-end. É a virada da Unidade 3 — do front que consome uma API falsa para o desenvolvedor full-stack que escreve as duas pontas.
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.
[ ] Front-end unieventos-web da Aula 06 rodando localmente com npm run dev, consumindo json-server via 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, use nvm 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 curl disponível (já vem no Linux/macOS; no Windows use o curl do PowerShell ou WSL).
[ ] Editor com abas suficientes para acompanhar dois projetos abertos ao mesmo tempo (unieventos-web e, 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 express traz 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).
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.
Texto
┌─────────────────────┐ 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
📌 Vale gravar
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:
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):
E a resposta, também texto puro, com cabeçalhos seguidos de uma linha em branco e depois o corpo:
HTTP
HTTP/1.1200OKContent-Type:application/json; charset=utf-8Content-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.
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.
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.
Texto
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 for gigantesco 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 trilha.
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.
🧠 Você sabia?
O Node.js nasceu em 2009 porque seu criador, Ryan Dahl, ficou incomodado com um detalhe específico de servidores tradicionais da época: fazer upload de um arquivo grande travava a thread inteira até os bytes terminarem de chegar — mesmo que o servidor não estivesse "fazendo" nada além de esperar. Dahl queria um runtime em que esperar nunca travasse o resto do programa. É exatamente o event loop que você acabou de estudar: a ideia central do Node, desde o primeiro dia, sempre foi tratar "esperar" como uma operação barata.
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.
JavaScript
// estilo CommonJS (antigo, ainda muito comum em tutoriais e pacotes legados)constexpress=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.
JavaScript
// estilo ES Modules (o que esta trilha usa no back-end)importexpressfrom'express'exportfunctionminhaFuncao(){/* ... */}
Nesta trilha, 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:
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" no package.json, o Node lança ReferenceError: require is not defined in ES module scope. A correção é reescrever para import 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.
Terminal
mkdirunieventos-api&&cdunieventos-api
npminit-y
O npm init -y gera um package.json com valores padrão. Ajuste-o para o que esta trilha usa:
JSON
{"name":"unieventos-api","version":"1.0.0","description":"API do projeto UniEventos","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.
Terminal
node--watchsrc/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:
Terminal
node--env-file=.envsrc/servidor.js
Terminal
# .env (nunca commitar este arquivo)PORTA=3000
JavaScript
// uso de process.env em qualquer arquivo do projetoconstporta=process.env.PORTA||3000
💡 Dica
Crie sempre um .env.example versionado, com as chaves (sem os valores sigilosos), para quem clonar o repositório saber o que configurar. E adicione .env ao .gitignore imediatamente — 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:
Terminal
npminstallexpresscors# vai para "dependencies" — necessário em produção
npminstall-Dalgum-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çãonode_modules/ nunca é commitado — é sempre reconstruído com npm install a partir do package.json e do package-lock.json. Ele já está no .gitignore do projeto.
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 trilha:
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 trilha, 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 trilha usa exclusivamente a API modular, versão 12, com import nomeado de funções: import { initializeApp } from 'firebase/app'.
Clique em Adicionar projeto, dê o nome unieventos (ou unieventos-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 esta trilha).
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):
JavaScript
// exemplo de firebaseConfig — o seu terá valores diferentesconstfirebaseConfig={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.
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.
Terminal
npminstallfirebase@12
JavaScript
// src/firebase.js — em unieventos-webimport{initializeApp}from'firebase/app'import{getFirestore}from'firebase/firestore'constfirebaseConfig={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 FirebaseexportconstappFirebase=initializeApp(firebaseConfig)// instância do Firestore usada em todo o projetoexportconstdb=getFirestore(appFirebase)
Terminal
# .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=123456789012VITE_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.
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.
JavaScript
// src/testeFirestore.js — script de exploração, não faz parte da app finalimport{collection,addDoc,getDocs,doc,updateDoc,deleteDoc,query,where,orderBy,}from'firebase/firestore'import{db}from'./firebase.js'// referência para a coleção "eventos"constcolecaoEventos=collection(db,'eventos')asyncfunctioncriarEventoDeTeste(){// addDoc gera um id automático e grava o documentoconstreferencia=awaitaddDoc(colecaoEventos,{titulo:'Semana Acadêmica de Computação',categoria:'palestra',vagas:80,dataHora:'2030-10-15T19:00:00',})console.log('documento criado com id:',referencia.id)}asyncfunctionlistarTodosOsEventos(){constsnapshot=awaitgetDocs(colecaoEventos)consteventos=snapshot.docs.map((d)=>({id:d.id,...d.data()}))console.log('eventos encontrados:',eventos)}asyncfunctionlistarPalestrasOrdenadasPorData(){// query + where + orderBy formam uma consulta filtrada e ordenadaconstconsulta=query(colecaoEventos,where('categoria','==','palestra'),orderBy('dataHora','asc'),)constsnapshot=awaitgetDocs(consulta)returnsnapshot.docs.map((d)=>({id:d.id,...d.data()}))}asyncfunctionatualizarVagas(idDoEvento,novasVagas){// doc() aponta para um documento específico dentro da coleçãoconstreferenciaDoDocumento=doc(db,'eventos',idDoEvento)awaitupdateDoc(referenciaDoDocumento,{vagas:novasVagas})}asyncfunctionremoverEvento(idDoEvento){constreferenciaDoDocumento=doc(db,'eventos',idDoEvento)awaitdeleteDoc(referenciaDoDocumento)}awaitcriarEventoDeTeste()awaitlistarTodosOsEventos()
🔎 Por baixo do capôgetDocs devolve um QuerySnapshot, não um array direto. Cada item é um QueryDocumentSnapshot, 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.
🔬 Investigue
No console do Firebase, abra a coleção eventos e adicione manualmente um novo documento com um campo a mais que os outros (ex.: destaque: true). Rode listarTodosOsEventos() de novo — o que aparece no console para esse documento em especial, comparado com os outros, que não têm esse campo? O Firestore reclama de algum documento estar "faltando" o campo destaque? Anote sua conclusão sobre o que "banco de dados sem schema fixo" realmente significa na prática, comparado com uma tabela MySQL (que você vai conhecer na Aula 09).
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:
Texto
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if request.time < timestamp.date(<data gerada pelo console>);
}
}
}
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).
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.
Terminal
mkdir-psrc
npminstallexpresscors
JavaScript
// src/servidor.jsimportexpressfrom'express'importcorsfrom'cors'constapp=express()// middlewares de aplicação: rodam em toda requisição, nesta ordemapp.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'})})constporta=process.env.PORTA||3000app.listen(porta,()=>{console.log(`unieventos-api rodando em http://localhost:${porta}`)})
Suba o servidor:
Terminal
node--watchsrc/servidor.js
Teste no navegador acessando http://localhost:3000/, ou no terminal:
Terminal
curlhttp://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.
Vamos crescer essa estrutura nas próximas duas aulas (routes/, middlewares/, repositories/, services/, controllers/). Por enquanto, um único arquivo é suficiente.
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 trilha — 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
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:
JavaScript
// Express 5: pode dar throw dentro de um handler async — cai direto no error handlerapp.get('/api/eventos/:id',async(req,res)=>{constevento=awaitbuscarEventoPorId(req.params.id)if(!evento){thrownewError('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:
JavaScript
// ERRADO no Express 5: lança TypeError// req.query.pagina = Number(req.query.pagina)// CORRETO: crie uma variável nova a partir do valor lidoconstpagina=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:
JavaScript
// Express 4 (não use): curinga solto// app.get('/arquivos/*', (req, res) => { ... })// Express 5: curinga nomeado — req.params.splat vem como ARRAY de segmentosapp.get('/arquivos/*splat',(req,res)=>{console.log(req.params.splat)// ex.: ['pdf', 'edital-2030.pdf']})// Express 4 (não use): segmento opcional com "?"// app.get('/relatorio/:ano?', (req, res) => { ... })// Express 5: segmento opcional entre chavesapp.get('/relatorio{/:ano}',(req,res)=>{// GET /relatorio -> req.params.ano é undefined// GET /relatorio/2030 -> req.params.ano é '2030'constano=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.
📌 Vale gravar
Se aparecer um trecho de código com app.del(...), res.json(obj, 201) ou req.param('id'), é Express 4 — identifique a sintaxe errada e corrija para a equivalente do Express 5.
🧩 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 com next(), 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.
💻 Mão na massa — criando a unieventos-api e conectando o front¶
{"name":"unieventos-api","version":"1.0.0","description":"API do projeto UniEventos","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"}}
Semeie a API com os mesmos oito eventos que estavam no db.json da Aula 06 — assim, ao trocar o json-server pela unieventos-api no Passo 5, a tela do front continua idêntica e qualquer diferença que apareça é bug, não mudança de dados. Abaixo estão os três primeiros; copie os cinco restantes do seu db.json, convertendo as chaves para a sintaxe de objeto JavaScript (sem aspas nas chaves).
JavaScript
// src/dados/eventos.js// dados em memória — nesta aula ainda não temos banco de dados (chega na Aula 09)exportconsteventos=[{id:1,titulo:'Semana Acadêmica de Computação',descricao:'Palestras e minicursos sobre tendências em tecnologia.',categoria:'palestra',dataHora:'2030-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:'2030-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:'2030-09-20T14:00:00',local:'Sala 12',vagas:30,imagemUrl:'https://picsum.photos/seed/evento3/600/300',},// … eventos 4 a 8, copiados do db.json da Aula 06]
// src/servidor.jsimportexpressfrom'express'importcorsfrom'cors'import{eventos}from'./dados/eventos.js'constapp=express()app.use(cors())app.use(express.json())// GET /api/eventos — lista todos os eventosapp.get('/api/eventos',(req,res)=>{res.json(eventos)})// GET /api/eventos/:id — busca um evento específicoapp.get('/api/eventos/:id',(req,res)=>{// req.params.id sempre chega como string — convertemos para comparar com o id numéricoconstid=Number(req.params.id)constevento=eventos.find((e)=>e.id===id)if(!evento){returnres.status(404).json({erro:'Evento não encontrado'})}res.json(evento)})constporta=process.env.PORTA||3000app.listen(porta,()=>{console.log(`unieventos-api rodando em http://localhost:${porta}`)})
Suba com npm run dev e teste:
Terminal
curlhttp://localhost:3000/api/eventos
curlhttp://localhost:3000/api/eventos/1
curlhttp://localhost:3000/api/eventos/999
# {"erro":"Evento não encontrado"}
Você vai testar a mesma API de três jeitos diferentes ao longo desta trilha. 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:
Terminal
curl-XPOSThttp://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 trilha). Elas servem para verificação manual durante o desenvolvimento — e o arquivo requests.http tem a vantagem extra de documentar a API para quem vai usá-la depois, inclusive você mesmo daqui a um mês.
Crie um arquivo de requisições versionado, que serve de documentação viva da API:
HTTP
### 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 eventosGET http://localhost:3000/api/eventos### buscar um evento específicoGET 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 em src/services/http.js (Aula 06). Não crie arquivo novo e não apague os interceptors — muda um valor, uma linha:
JavaScript
// src/services/http.js — em unieventos-web (o MESMO arquivo da Aula 06)importaxiosfrom'axios'consthttp=axios.create({// antes: baseURL: 'http://localhost:3000' (json-server, sem prefixo /api)// agora: a unieventos-api sobe na mesma porta 3000, mas as rotas vivem sob /apibaseURL:'http://localhost:3000/api',timeout:8000,headers:{'Content-Type':'application/json',},})// os dois interceptors da Aula 06 continuam exatamente como estavamhttp.interceptors.request.use((config)=>{consttoken=localStorage.getItem('uniEventosToken')if(token){config.headers.Authorization=`Bearer ${token}`}returnconfig})http.interceptors.response.use((response)=>response,(error)=>{if(error.response?.status===401){localStorage.removeItem('uniEventosToken')}returnPromise.reject(error)})exportdefaulthttp
Nada mais no front-end precisa mudar — nem o eventosService.js, nem a store Pinia, nem os componentes. O service continua chamando http.get('/eventos') e http.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.
⚠️ Atenção
Como o json-server também rodava em localhost:3000, pare o terminal do json-server antes de subir a unieventos-api — senão a porta está ocupada e o Express morre com EADDRINUSE. A partir de hoje o json-server não é mais usado no UniEventos.
Terminal
# em dois terminais separados:# terminal 1 — dentro de unieventos-api
npmrundev
# terminal 2 — dentro de unieventos-web
npmrundev
Resultado esperado: 8 na primeira linha; 200 com o objeto do evento 1 na segunda; 404 na terceira.
Depois o front no navegador (http://localhost:5173): a lista de eventos carrega exatamente como antes, com os mesmos oito cards — só que agora vindos de um servidor Express que você escreveu, não do json-server. Confirme na aba Network que a requisição sai para http://localhost:3000/api/eventos e volta 200. Se o console mostrar erro de CORS, revise o app.use(cors()) do servidor.js; se voltar 404, confira se o baseURL do http.js terminou em /api.
A1. Preveja a ordem exata de saída no console, sem rodar:
JavaScript
console.log('1. início')setTimeout(()=>console.log('2. timeout'),0)Promise.resolve().then(()=>console.log('3. promise'))console.log('4. fim do código síncrono')
Resultado esperado: a ordem impressa é 1, 4, 3, 2 — o event loop só processa o setTimeout e a Promise depois que todo o código síncrono termina, e microtarefas (Promise) sempre vêm antes de macrotarefas (setTimeout), mesmo com atraso 0.
A2. Complete a linha que falta para a rota abaixo responder corretamente ao buscar um evento inexistente:
JavaScript
app.get('/api/eventos/:id',(req,res)=>{constid=Number(req.params.id)constevento=eventos.find((e)=>e.id===id)// linha que falta aquires.json(evento)})
Resultado esperado: if (!evento) { return res.status(404).json({ erro: 'Evento não encontrado' }) } — sem o return, o código continua até res.json(evento) e tenta responder com undefined, devolvendo 200 com corpo vazio em vez de 404.
A3. Em uma frase: por que um arquivo com require('express') lança ReferenceError: require is not defined in ES module scope num projeto cujo package.json tem "type": "module"?
Resultado esperado: porque "type": "module" faz o Node tratar todo .js do projeto como ES Module, um sistema de módulos em que require simplesmente não existe — a única forma de importar é import.
A4. Ache o erro nas linhas abaixo (sintaxe do Express 4, incompatível com o Express 5.2.1 instalado hoje) e diga a correção de cada uma:
Resultado esperado: duas sintaxes do Express 4 aparecem — /relatorio/:ano? (segmento opcional com ?) precisa virar /relatorio{/:ano}, e app.del(...) precisa virar app.delete(...), porque app.del foi removido no Express 5.
A5. Verdadeiro ou falso, com justificativa de uma linha: "no Firestore, getDocs(colecaoEventos) devolve diretamente um array de objetos, no mesmo formato que snapshot.docs.map(...) produz."
Resultado esperado: falso — getDocs devolve um QuerySnapshot; é preciso snapshot.docs.map((d) => ({ id: d.id, ...d.data() })) para obter o array de objetos "normais", com o id incluso.
B1. Rota de saudação personalizada. Crie GET /api/saudacao/:nome que responde { "mensagem": "Olá, <nome>!" }, capitalizando a primeira letra do nome recebido.
Resultado esperado: GET /api/saudacao/joao responde 200 com { "mensagem": "Olá, Joao!" } — a primeira letra maiúscula, o resto do nome preservado.
Dica
Use req.params.nome e uma função para capitalizar: nome.charAt(0).toUpperCase() + nome.slice(1).
B2. 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.
Resultado esperado: GET /api/eventos?categoria=palestra devolve só os eventos com essa categoria (1 evento, nos dados de exemplo); GET /api/eventos sem parâmetro continua devolvendo os 3.
Dica
Leia req.query.categoria (lembre-se: é somente leitura, não reatribua). Se estiver presente, filtre o array com .filter() antes de responder.
B3. 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)).
Resultado esperado: curl -i http://localhost:3000/api/eventos mostra, entre os cabeçalhos da resposta, a linha X-Total-Count: 3.
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.
B4. 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.
Resultado esperado: o terminal mostra o stack trace do erro e o cliente recebe uma resposta 500 com um HTML padrão de erro, mas o processo do Node continua rodando — o servidor não cai.
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.
B5. 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).
Resultado esperado: escolher uma categoria no <v-select> refaz a requisição incluindo ?categoria=... na URL (visível na aba Network), e a lista na tela passa a mostrar só os eventos daquela categoria.
Dica
api.get('/eventos', { params: { categoria: valorSelecionado } }) — se valorSelecionado for undefined ou string vazia, o Axios omite o parâmetro da URL automaticamente.
C1. 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).
Resultado esperado: listarOrganizadores() imprime no console um array com os 2+ documentos da coleção, cada um com id incluso; buscarOrganizadorPorEmail('ana@exemplo.com') devolve só o documento correspondente àquele e-mail, e um e-mail inexistente devolve um array vazio.
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'.
Abra o DevTools (F12) no seu unieventos-web rodando com npm run dev, vá na aba Sources e procure pelo valor de uma das variáveis VITE_FIREBASE_* que você configurou no .env. Ela aparece ali, legível, para qualquer pessoa que abrir a mesma aba. Investigue até onde vai essa exposição e explique, com evidência real do seu próprio projeto, por que uma credencial de servidor (a senha do MySQL, por exemplo) nunca poderia estar num arquivo prefixado com VITE_.
Critérios de pronto
Print (ou trecho colado) do DevTools mostrando o valor de uma variável VITE_ visível no código-fonte servido ao navegador.
Uma frase explicando por que o prefixo VITE_ existe (o Vite só expõe ao bundle do navegador as variáveis que começam com ele) e o que aconteceria se você prefixasse DB_PASSWORD com VITE_ por engano.
Uma lista com pelo menos duas informações que jamais podem ganhar o prefixo VITE_ no seu projeto autoral (ou no UniEventos).
Pistas
Abra a aba Sources do DevTools e procure pelos arquivos .js gerados pelo Vite (ficam sob um caminho como src/ ou assets/).
Procure diretamente por apiKey ou pelo nome de uma das suas variáveis de ambiente no código listado.
No Vite, qualquer variável sem o prefixo VITE_ simplesmente não é substituída no build — ela fica undefined no navegador. É essa a proteção, e é por isso que ela só funciona se você a respeitar.
Um colega registrou duas rotas parecidas sem perceber a colisão: GET /api/eventos/:id e, mais abaixo no arquivo, GET /api/eventos/destaque (pensada para devolver o evento em destaque do momento). Toda chamada para /api/eventos/destaque cai no handler de :id, porque "destaque" é interpretado como se fosse um id. Corrija o problema e proteja seu projeto autoral do mesmo bug.
Critérios de pronto
GET /api/eventos/destaque devolve o evento marcado como destaque (ou uma mensagem clara se nenhum evento tiver esse status), sem passar pelo handler de :id.
Um comentário no código explica, em português, por que a ordem de registro das rotas no Express importa aqui.
No seu projeto autoral, você identifica — ou descarta, com justificativa por escrito — uma rota fixa que corre o mesmo risco de colisão com uma rota de parâmetro dinâmico.
Pistas
O Express testa as rotas na ordem em que foram registradas — a primeira que casar "ganha", mesmo que outra rota mais abaixo fosse a intenção.
Rotas fixas (sem :parametro) precisam ser registradas antes de rotas com parâmetro dinâmico no mesmo prefixo.
Para simular "evento em destaque", adicione um campo booleano (destaque: true) a um dos eventos de teste em memória.
A seção 2 desta aula prometeu que o Node atende milhares de conexões com uma única thread — desde que o trabalho seja I/O, não cálculo. Prove isso (e o limite disso) na prática: meça quanto uma rota "pesada" de CPU (sem nenhum await) atrasa todas as outras requisições simultâneas do seu servidor, mesmo as mais simples.
Critérios de pronto
Uma rota GET /api/pesado que faz um cálculo síncrono de pelo menos 2 segundos (ex.: um laço somando até alguns bilhões), sem await em nada.
Uma sequência de chamadas (dois terminais, ou um script) que dispara /api/pesado e, quase ao mesmo tempo, GET /api/eventos, medindo o tempo de resposta de cada uma com curl -w '%{time_total}'.
Uma tabela no README comparando o tempo de /api/eventos sozinha versus rodando ao mesmo tempo que /api/pesado.
Um parágrafo explicando, com suas palavras, por que esse atraso acontece mesmo o Node sendo "não bloqueante" — e em que isso difere do que acontece com await pool.query(...) ou await getDocs(...).
Pistas
Um for simples somando um contador local até um número grande, sem nenhuma chamada assíncrona no meio, bloqueia a thread principal inteira.
Abra dois terminais: um dispara /api/pesado, o outro dispara /api/eventos alguns milissegundos depois — repare que a segunda chamada espera a primeira terminar.
curl -w '%{time_total}\n' -o /dev/null -s <url> imprime só o tempo total, sem poluir a saída com o corpo da resposta.
Lembre da analogia do garçom (seção 2): um cálculo síncrono é como o garçom parar tudo para cozinhar ele mesmo, em vez de delegar à cozinha e continuar atendendo outras mesas.
No seu projeto autoral, replique o que foi feito hoje:
Crie o repositório <seu-projeto>-api, com package.json configurado em ESM, scripts dev/start, .env/.env.example/.gitignore.
Monte um arquivo src/dados/<entidade-principal>.js com 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.js com 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 arquivo EVIDENCIAS.md no 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.
Referência de path-to-regexp v8, usado internamente pelo Express 5 para casar rotas — útil para entender a sintaxe de curingas e segmentos opcionais em profundidade.
Plano da disciplina em que esta trilha nasceu — 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 chega ao Marco 2 do projeto. Deixe a unieventos-api rodando — ela cresce a partir daqui, aula após aula, até virar a API do seu projeto autoral final.
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 em express.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, devolvendo 422 com mensagens em português;
entender por que throw dentro de um handler async do Express 5 cai automaticamente no tratador de erros;
organizar testes manuais num arquivo requests.http cobrindo todos os endpoints.
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ê chega ao Marco 2 do seu projeto autoral.
[ ] unieventos-api da Aula 07 rodando, com GET /api/eventos e GET /api/eventos/:id funcionando em memória.
[ ] Front-end unieventos-web apontando para essa API via baseURL do Axios.
[ ] Entendimento de async/await e por que erros em handlers async do 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 do Marco 2. Leia a seção "🎓 Marco do projeto — Unidade 2" logo no início do período de aula, para planejar seu tempo.
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/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.
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.
Texto
✅ 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:
Texto
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/eventosnão é idempotente: cada chamada cria um evento novo, mesmo enviando o corpo idêntico.
📌 Vale gravar
Se a pergunta pedir para classificar um verbo HTTP como idempotente ou não, lembre: GET, PUT, DELETE são idempotentes; POST não é. PATCH depende de como é implementado, mas normalmente também não é.
🧠 Você sabia?
O termo REST foi cunhado em 2000, na tese de doutorado de Roy Fielding — um dos autores da própria especificação do protocolo HTTP. Ele não descrevia uma tecnologia nova, mas um estilo arquitetural que já explicava por que a web funcionava tão bem em escala: cada recurso com endereço próprio, operações padronizadas (os verbos HTTP) e respostas que já dizem por si mesmas o que aconteceu (os status codes). Praticamente toda API que você consome hoje — de rede social, de banco, de pagamento — segue essas convenções, ainda que quase nenhuma implemente a especificação de Fielding à risca.
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.
JavaScript
// dentro do handler de criação, depois de gerar o novo evento com id 7res.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 é sempre res.status(201).json(objeto), com o status vindo antes, encadeado.
APIs públicas costumam prefixar as rotas com um número de versão — /api/v1/eventos — para poder evoluir sem quebrar quem já consome a versão antiga: quando uma mudança incompatível é necessária, ela nasce em /api/v2 e o /v1 continua no ar até que todos os clientes migrem.
JavaScript
// exemplo de API versionada — NÃO é o que usamos aquiapp.use('/api/v1/eventos',eventosRoutesV1)app.use('/api/v2/eventos',eventosRoutesV2)
🧠 Você sabia?
Versionar por caminho (/api/v1) é a forma mais comum, mas não a única: há APIs que versionam por cabeçalho (Accept: application/vnd.unieventos.v2+json) ou por parâmetro de query (?versao=2). O trade-off é sempre o mesmo — caminho é explícito e fácil de testar no navegador; cabeçalho mantém a URL do recurso estável, que é o argumento "REST puro". Nesta trilha usamos /api sem versão, porque o unieventos-api tem um único cliente (o nosso front) e nenhuma versão antiga para preservar; carregar um v1 que nunca vira v2 só adiciona ruído. Quando você publicar uma API com clientes de terceiros, aí sim escolha e documente uma estratégia de versionamento.
O prefixo da unieventos-api, portanto, continua sendo o mesmo da Aula 07:
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.
JSON
{"dados":{"id":1,"titulo":"Semana Acadêmica de Computação"},"paginacao":{"pagina":1,"porPagina":10,"total":42}}
JSON
{"erro":{"mensagem":"Evento não encontrado","codigo":"EVENTO_NAO_ENCONTRADO"}}
dados carrega o conteúdo (objeto único ou array); paginacao carrega os 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.
GET /api/eventos?pagina=2&porPagina=10
GET /api/eventos?categoria=palestra
GET /api/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.
// src/dados/eventos.jsexportconsteventos=[{id:1,titulo:'Semana Acadêmica de Computação',descricao:'Palestras e minicursos sobre o mercado de tecnologia.',categoria:'palestra',dataHora:'2030-10-15T19:00:00',local:'Auditório Central',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:'2030-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:'2030-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 issoexportfunctionproximoId(){constmaiorId=eventos.reduce((max,e)=>Math.max(max,e.id),0)returnmaiorId+1}
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.
JavaScript
// src/erros/ErroHttp.jsexportclassErroHttpextendsError{constructor(status,mensagem,codigo='ERRO'){super(mensagem)this.name='ErroHttp'this.status=statusthis.codigo=codigo}}// atalhos comuns, para não repetir "new ErroHttp(404, ...)" em todo lugarexportfunctionerroNaoEncontrado(mensagem='Recurso não encontrado'){returnnewErroHttp(404,mensagem,'NAO_ENCONTRADO')}exportfunctionerroValidacao(mensagem='Dados inválidos'){returnnewErroHttp(422,mensagem,'VALIDACAO')}
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/eventos. É só na hora de montar, em servidor.js, que o prefixo é definido:
JavaScript
app.use('/api/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.
JavaScript
// src/routes/eventos.routes.jsimport{Router}from'express'import{eventos,proximoId}from'../dados/eventos.js'import{erroNaoEncontrado,erroValidacao}from'../erros/ErroHttp.js'constrouter=Router()// função auxiliar: encontra o índice do evento pelo id, ou -1functionindiceDoEvento(id){returneventos.findIndex((e)=>e.id===id)}// GET /api/eventos — lista com filtro, ordenação e paginaçãorouter.get('/',(req,res)=>{letresultado=[...eventos]// filtro por categoriaif(req.query.categoria){resultado=resultado.filter((e)=>e.categoria===req.query.categoria)}// ordenaçãoconstordenarPor=req.query.ordenarPor||'id'constdirecao=req.query.direcao==='desc'?-1:1resultado.sort((a,b)=>{if(a[ordenarPor]<b[ordenarPor])return-1*direcaoif(a[ordenarPor]>b[ordenarPor])return1*direcaoreturn0})// paginaçãoconstpagina=Number(req.query.pagina)||1constporPagina=Number(req.query.porPagina)||10constinicio=(pagina-1)*porPaginaconstpaginaDeResultados=resultado.slice(inicio,inicio+porPagina)res.json({dados:paginaDeResultados,paginacao:{pagina,porPagina,total:resultado.length},})})// GET /api/eventos/:id — busca um evento específicorouter.get('/:id',(req,res)=>{constid=Number(req.params.id)constevento=eventos.find((e)=>e.id===id)if(!evento){throwerroNaoEncontrado('Evento não encontrado')}res.json({dados:evento})})// POST /api/eventos — cria um evento novorouter.post('/',(req,res)=>{constcorpo=req.bodyif(!corpo||!corpo.titulo||!corpo.categoria){throwerroValidacao('Campos "titulo" e "categoria" são obrigatórios')}constnovoEvento={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/eventos/${novoEvento.id}`).json({dados:novoEvento})})// PUT /api/eventos/:id — substitui o evento inteirorouter.put('/:id',(req,res)=>{constid=Number(req.params.id)constindice=indiceDoEvento(id)if(indice===-1){throwerroNaoEncontrado('Evento não encontrado')}constcorpo=req.bodyif(!corpo||!corpo.titulo||!corpo.categoria){throwerroValidacao('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/eventos/:id — atualiza campos específicosrouter.patch('/:id',(req,res)=>{constid=Number(req.params.id)constindice=indiceDoEvento(id)if(indice===-1){throwerroNaoEncontrado('Evento não encontrado')}// mescla só os campos enviados — PATCH é parcial, diferente de PUTeventos[indice]={...eventos[indice],...req.body}res.json({dados:eventos[indice]})})// DELETE /api/eventos/:id — remove o eventorouter.delete('/:id',(req,res)=>{constid=Number(req.params.id)constindice=indiceDoEvento(id)if(indice===-1){throwerroNaoEncontrado('Evento não encontrado')}eventos.splice(indice,1)res.status(204).send()})exportdefaultrouter
⚠️ Atenção
Note router.delete(...), não router.del(...). app.del/router.del foram removidos no Express 5 (Aula 07, §5).
// src/servidor.jsimportexpressfrom'express'importcorsfrom'cors'importeventosRoutesfrom'./routes/eventos.routes.js'import{middlewareNaoEncontrado,tratadorDeErros}from'./middlewares/erros.js'import{logger}from'./middlewares/logger.js'import{medidorDeTempo}from'./middlewares/medidorDeTempo.js'constapp=express()app.use(cors())app.use(express.json())app.use(logger)app.use(medidorDeTempo)// monta o router em /api/eventos — dentro do router, as rotas usam caminhos relativosapp.use('/api/eventos',eventosRoutes)// a partir daqui, nenhuma rota casou: 404app.use(middlewareNaoEncontrado)// tratador de erros SEMPRE por últimoapp.use(tratadorDeErros)constporta=process.env.PORTA||3000app.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.
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, um throw dentro de um handler async já 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.
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.
JavaScript
app.use(cors())// 1º: libera CORSapp.use(express.json())// 2º: faz o parse do corpoapp.use(logger)// 3º: registra a requisição no consoleapp.use(medidorDeTempo)// 4º: começa a medir o tempo de respostaapp.use('/api/eventos',eventosRoutes)// 5º: tenta casar com alguma rota de eventoapp.use(middlewareNaoEncontrado)// 6º: só roda se nada casou acimaapp.use(tratadorDeErros)// 7º: só roda se algo lançou erro em qualquer ponto anterior
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:
JavaScript
// middleware de rota: só roda para POST /api/eventosrouter.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).
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.
Texto
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.
🔬 Investigue
Adicione um console.log('middleware X rodou') no início de cada middleware registrado em servidor.js (cors, express.json, logger, medidorDeTempo) e reinicie o servidor. Faça uma única requisição GET /api/eventos pelo navegador e observe, no terminal, a ordem exata em que as mensagens aparecem. Depois, mova app.use(logger) para depois de app.use('/api/eventos', eventosRoutes) e repita a requisição — o que muda na ordem impressa, e por quê?
// src/middlewares/logger.js// registra método, caminho e horário de cada requisição recebidaexportfunctionlogger(req,res,next){constagora=newDate().toISOString()console.log(`[${agora}] ${req.method}${req.originalUrl}`)next()}
JavaScript
// src/middlewares/medidorDeTempo.js// mede quanto tempo o servidor levou para responder, em milissegundosexportfunctionmedidorDeTempo(req,res,next){constinicio=process.hrtime.bigint()// 'finish' dispara quando a resposta terminou de ser enviadares.on('finish',()=>{constfim=process.hrtime.bigint()constduracaoMs=Number(fim-inicio)/1_000_000console.log(` ↳ ${res.statusCode} em ${duracaoMs.toFixed(1)}ms`)})next()}
JavaScript
// src/middlewares/erros.jsimport{ErroHttp}from'../erros/ErroHttp.js'// roda quando nenhuma rota casou com a requisição — precisa vir depois de todas as rotasexportfunctionmiddlewareNaoEncontrado(req,res,next){next(newErroHttp(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 reconheceexportfunctiontratadorDeErros(err,req,res,next){// erros conhecidos (ErroHttp) já sabem seu status; erros inesperados viram 500conststatus=errinstanceofErroHttp?err.status:500constcodigo=errinstanceofErroHttp?err.codigo:'ERRO_INTERNO'constmensagem=errinstanceofErroHttp?err.message:'Erro interno do servidor'if(status===500){// erro inesperado: registre o stack completo no servidor, mas não exponha ao clienteconsole.error(err)}res.status(status).json({erro:{mensagem,codigo}})}
JavaScript
// src/middlewares/validador.jsexportfunctionvalidar(schema){// retorna um middleware de rota configurado para o schema recebido — isto é Strategyreturn(req,res,next)=>{constresultado=schema.safeParse(req.body)if(!resultado.success){constmensagens=resultado.error.issues.map((problema)=>problema.message)returnres.status(422).json({erro:{mensagem:'Dados inválidos',codigo:'VALIDACAO',detalhes:mensagens},})}// substitui req.body pelos dados já validados e tipados pelo Zodreq.body=resultado.datanext()}}
// src/servidor.js (trecho adicional)importmorganfrom'morgan'importhelmetfrom'helmet'importrateLimitfrom'express-rate-limit'importcompressionfrom'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 clienteapp.use(morgan('dev'))// log de requisições formatado — mais completo que nosso loggerconstlimitador=rateLimit({windowMs:15*60*1000,// janela de 15 minutoslimit:100,// no máximo 100 requisições por IP nessa janela (era `max` até a v6)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
💡 Dicamorgan('dev') e nosso logger/medidorDeTempo fazem 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.
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:
JavaScript
// Express 5: qualquer throw, síncrono ou dentro de um await, é capturadorouter.get('/:id',async(req,res)=>{constevento=awaitbuscarEventoPorIdNoBanco(req.params.id)// função hipotética assíncronaif(!evento){throwerroNaoEncontrado('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:
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).
📌 Vale gravar
Se perguntarem por que express-async-handler nã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 handler async, encaminhando para o middleware de erro — antes isso exigia embrulhar manualmente.
🧩 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 com next(), 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 zod ilustram o Strategy (comportamental): a função validar(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 middleware validar. Isso é Strategy: o algoritmo (validação) é injetado, intercambiável, sem alterar quem o usa.
💻 Mão na massa — validação com Zod e testes organizados¶
// src/schemas/evento.schema.jsimport{z}from'zod'exportconstschemaEvento=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 opcionaisexportconstschemaEventoParcial=schemaEvento.partial()
// 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'constrouter=Router()// GET /api/eventos — lista com filtro, ordenação e paginação (sem alteração desde a seção 2)router.get('/',(req,res)=>{letresultado=[...eventos]// filtro por categoriaif(req.query.categoria){resultado=resultado.filter((e)=>e.categoria===req.query.categoria)}// ordenaçãoconstordenarPor=req.query.ordenarPor||'id'constdirecao=req.query.direcao==='desc'?-1:1resultado.sort((a,b)=>{if(a[ordenarPor]<b[ordenarPor])return-1*direcaoif(a[ordenarPor]>b[ordenarPor])return1*direcaoreturn0})// paginaçãoconstpagina=Number(req.query.pagina)||1constporPagina=Number(req.query.porPagina)||10constinicio=(pagina-1)*porPaginaconstpaginaDeResultados=resultado.slice(inicio,inicio+porPagina)res.json({dados:paginaDeResultados,paginacao:{pagina,porPagina,total:resultado.length},})})// GET /api/eventos/:id — busca um evento específico (sem alteração desde a seção 2)router.get('/:id',(req,res)=>{constid=Number(req.params.id)constevento=eventos.find((e)=>e.id===id)if(!evento){throwerroNaoEncontrado('Evento não encontrado')}res.json({dados:evento})})router.post('/',validar(schemaEvento),(req,res)=>{// req.body já chega validado e com os tipos corretos (vagas já é number, por exemplo)constnovoEvento={id:proximoId(),...req.body}eventos.push(novoEvento)res.status(201).location(`/api/eventos/${novoEvento.id}`).json({dados:novoEvento})})router.put('/:id',validar(schemaEvento),(req,res)=>{constid=Number(req.params.id)constindice=eventos.findIndex((e)=>e.id===id)if(indice===-1)throwerroNaoEncontrado('Evento não encontrado')eventos[indice]={id,...req.body}res.json({dados:eventos[indice]})})router.patch('/:id',validar(schemaEventoParcial),(req,res)=>{constid=Number(req.params.id)constindice=eventos.findIndex((e)=>e.id===id)if(indice===-1)throwerroNaoEncontrado('Evento não encontrado')eventos[indice]={...eventos[indice],...req.body}res.json({dados:eventos[indice]})})exportdefaultrouter
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.
💡 Dica
A variável @baseUrl no topo do arquivo evita repetir http://localhost:3000/api em toda linha — troque só ali quando mudar de ambiente (local, homologação, produção).
Passo 4 — adaptar o front ao envelope de resposta¶
A API mudou de contrato: onde antes ela devolvia um array solto ([{...}, {...}]), agora devolve { dados, paginacao }. O unieventos-web da Aula 06 não sabe disso — o eventosService.listar() faz return resposta.data, e a HomeView chama .filter() no resultado. Se você subir os dois lado a lado agora, o console mostra eventos.value.filter is not a function. Toda mudança de contrato na API cobra um passo do lado do cliente — e este é o passo.
Há dois lugares possíveis para desembrulhar o envelope. O primeiro é o próprio eventosService, explícito, endpoint a endpoint:
JavaScript
// src/services/eventosService.js (unieventos-web) — versão adaptada ao envelopeimporthttpfrom'./http'exportdefault{asynclistar(filtros={}){constresposta=awaithttp.get('/eventos',{params:filtros})// a API devolve { dados, paginacao }; quem chama continua recebendo só o arrayreturnresposta.data.dados},// a mesma ideia vale para listagens paginadas, quando a tela precisa do totalasynclistarComPaginacao(filtros={}){constresposta=awaithttp.get('/eventos',{params:filtros})return{eventos:resposta.data.dados,paginacao:resposta.data.paginacao}},asyncbuscarPorId(id){constresposta=awaithttp.get(`/eventos/${id}`)returnresposta.data.dados},asynccriar(evento){constresposta=awaithttp.post('/eventos',evento)returnresposta.data.dados},asyncatualizar(id,evento){constresposta=awaithttp.put(`/eventos/${id}`,evento)returnresposta.data.dados},asyncremover(id){awaithttp.delete(`/eventos/${id}`)},}
O segundo é o interceptor de resposta do http.js (Aula 06), que resolve de uma vez para todos os services — inclusive os que você ainda vai escrever:
JavaScript
// src/services/http.js (trecho — dentro do interceptor de response já existente)http.interceptors.response.use((response)=>{// desembrulha o envelope: quem chamou recebe direto o conteúdo de `dados`if(response.data&&typeofresponse.data==='object'&&'dados'inresponse.data){response.paginacao=response.data.paginacao// preserva a paginação para quem precisarresponse.data=response.data.dados}returnresponse},(error)=>{if(error.response?.status===401){localStorage.removeItem('uniEventosToken')}// o envelope de erro também é único: { erro: { mensagem, codigo } }error.mensagemAmigavel=error.response?.data?.erro?.mensagem??'Falha de comunicação com o servidor'returnPromise.reject(error)})
⚠️ Atenção
Escolha um dos dois — se você desembrulhar no interceptor e no service, resposta.data.dados vira undefined e a lista some sem erro nenhum no console. Neste material seguimos com a versão do interceptor, porque o envelope é uma decisão da API inteira, não de um endpoint. Registre a escolha em uma linha no README do front.
Suba os dois projetos ao mesmo tempo — unieventos-api em http://localhost:3000 e unieventos-web com npm run dev — e percorra este roteiro de ponta a ponta:
Terminal
# 1) a API sozinha, pelo terminal
curl-s"http://localhost:3000/api/eventos?pagina=1&porPagina=2"|jq
Resultado esperado:
JSON
{"dados":[{"id":1,"titulo":"Semana Acadêmica de Computação","categoria":"palestra","dataHora":"2030-09-10T19:00:00.000Z","local":"Auditório Central","vagas":120}],"paginacao":{"pagina":1,"porPagina":2,"total":3}}
Terminal
# 2) o envelope de erro, com um POST inválido
curl-s-XPOSThttp://localhost:3000/api/eventos\-H"Content-Type: application/json"-d'{"titulo":"AB"}'|jq
Resultado esperado: status 422 e corpo { "erro": { "mensagem": "...", "codigo": "VALIDACAO", "detalhes": [...] } }.
No navegador, abra o unieventos-web: a lista da HomeView carrega normalmente, o formulário administrativo cria um evento e a exclusão remove da tabela. Na aba Network, a resposta de GET /api/eventos mostra o objeto { dados, paginacao }; no Console, nenhum filter is not a function.
Pare a API (Ctrl+C) e recarregue o front: a mensagem de erro que aparece na tela vem de error.mensagemAmigavel, não de um undefined.
Se os quatro passos passam, o contrato novo está fechado dos dois lados — e é exatamente esse o critério do Marco 2.
Resultado esperado: (a) 404 — o evento 999 não existe; (b) 422 — "titulo":"AB" tem menos de 3 caracteres e "categoria":"show" não está no enum, ambos rejeitados pelo Zod; (c) 200 com o evento atualizado — vagas: 40 é um inteiro positivo válido no schema parcial.
A2. Complete a linha que falta para a rota de estatísticas (Laboratório B1, a seguir) não ser capturada pelo handler de :id:
JavaScript
constrouter=Router()// linha que falta aquirouter.get('/:id',(req,res)=>{/* busca o evento pelo id */})
Resultado esperado: router.get('/estatisticas/por-categoria', (req, res) => { ... }) — registrada antes de router.get('/:id', ...), senão o Express interpreta estatisticas como o valor do parâmetro :id.
A3. Em uma frase: por que um middleware de erro precisa ter exatamente quatro parâmetros — (err, req, res, next) — mesmo quando next não é usado dentro dele?
Resultado esperado: porque o Express identifica um middleware de erro pela contagem de parâmetros da função (a aridade); com menos de quatro, ele é tratado como middleware normal e nunca é chamado no caminho de erro.
A4. Ache o erro nas linhas abaixo (a ordem de registro quebra o tratamento de erros) e diga a correção:
Resultado esperado: tratadorDeErros está registrado antes das rotas — ele nunca vai capturar erro nenhum. A ordem correta é cors(), express.json(), as rotas, middlewareNaoEncontrado e, só por último, tratadorDeErros.
A5. Verdadeiro ou falso, com justificativa de uma linha: "PATCH é sempre idempotente, assim como PUT e DELETE."
Resultado esperado: falso — PATCH costuma não ser idempotente (ex.: um corpo que decrementa vagas em 1 produz um resultado diferente a cada chamada); PUT é idempotente porque substitui o recurso inteiro pelo mesmo valor em todas as chamadas.
B1. Endpoint de contagem por categoria. Crie GET /api/eventos/estatisticas/por-categoria que devolve { "dados": { "palestra": 1, "minicurso": 1, "workshop": 1 } }, contando quantos eventos existem em cada categoria.
Resultado esperado: a contagem bate exatamente com os três eventos de exemplo desta aula, e a rota responde corretamente mesmo com estatisticas no caminho, sem cair no handler de :id.
Dica
Cuidado com a ordem: registre essa rota antes de router.get('/:id', ...), senão o Express interpreta estatisticas como um valor de :id.
B2. 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.
Resultado esperado: no terminal, uma requisição GET /api/eventos não gera nenhuma linha de log; uma POST /api/eventos gera uma linha, no mesmo formato de antes.
Dica
Um if (req.method !== 'GET') { ... } dentro do middleware, antes de chamar next().
B3. Erro de validação com múltiplos campos. Envie, pelo requests.http, um POST /api/eventos com titulo vazio ecategoria inválida ao mesmo tempo. Confirme que a resposta 422 lista as duas mensagens de erro no array detalhes.
Resultado esperado: a resposta 422 traz detalhes com pelo menos duas mensagens, uma sobre o titulo e outra sobre a categoria, na mesma requisição.
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.
B4. Rate limit em ação. Reduza temporariamente o limit 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.
Resultado esperado: as primeiras 5 chamadas respondem 200; a partir da sexta, a resposta muda para 429, até a janela de 1 minuto expirar.
B5. PATCH que tenta mudar o id. Envie PATCH /api/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).
Resultado esperado: antes da correção, o registro em memória passa a ter id: 999 (inconsistente com a URL usada para acessá-lo); depois da correção, o id da URL sempre prevalece, mesmo enviando outro id no corpo.
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.
C1. Middleware de erro específico para JSON malformado. Envie, via curl, um POST /api/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.
Resultado esperado: sem a correção, o erro de parsing cai no tratadorDeErros genérico e devolve 500 com "Erro interno do servidor"; depois de adicionar a verificação de err.type === 'entity.parse.failed', a mesma requisição passa a devolver 400 com { "erro": { "mensagem": "JSON inválido no corpo da requisição", "codigo": "JSON_INVALIDO" } }.
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' } }) }.
Teste no seu unieventos-api: GET /api/eventos/abc (um id que não é número). O que a rota devolve? Compare com o que a tabela de status codes desta aula promete para "recurso inexistente". Investigue por que Number('abc') não gera o erro que você esperava, e corrija a rota para tratar esse caso de forma explícita.
Critérios de pronto
GET /api/eventos/abc responde 400 (requisição malformada) em vez de tratar "abc" como um id válido.
Um comentário no código explica o que Number('abc') retorna e por que isso passava despercebido antes.
O mesmo tratamento é aplicado a toda rota que recebe :id como parâmetro numérico.
Pistas
Number('abc') não lança erro — ele devolve NaN, um valor "não é um número" que ainda passa por comparações sem quebrar o programa.
Number.isNaN(id) detecta o caso; combine com um throw de erro 400 antes de continuar a lógica normal do handler.
Considere um pequeno middleware de validação de parâmetro reutilizável, para não repetir a checagem em cada rota com :id.
O medidorDeTempo desta aula usa process.hrtime.bigint() e o evento 'finish' do objeto res. Implemente uma segunda versão que, além de logar o tempo no console, guarda em um array em memória as últimas 100 durações de resposta e exponha isso em GET /api/metricas (tempo médio, mínimo e máximo). Meça se registrar essas métricas atrasa perceptivelmente as respostas.
Critérios de pronto
GET /api/metricas devolve { "dados": { "media": N, "minimo": N, "maximo": N, "amostras": N } }, calculado a partir das últimas 100 requisições reais.
O array de amostras nunca cresce além de 100 itens (as mais antigas são descartadas).
Uma medição no README compara o tempo de resposta de GET /api/eventos com e sem o middleware de métricas ativado (usando curl -w '%{time_total}').
Uma frase conclui se a diferença medida é ou não perceptível para este projeto.
Pistas
Um array declarado fora de qualquer função guarda o estado entre requisições — cuidado, isso não escalaria para múltiplas instâncias do servidor (mesma limitação do array em memória das Aulas 07 e 08).
array.push(duracao); if (array.length > 100) array.shift() mantém o tamanho fixo.
Para média, mínimo e máximo, um reduce simples resolve; não precisa de biblioteca externa.
O envelope { "erro": { "mensagem", "codigo" } } desta aula não chega pronto ao usuário final — alguém no front precisa transformá-lo em algo visível. Implemente, no front-end do seu projeto autoral, um interceptor de resposta do Axios que trate todos os códigos de erro conhecidos (VALIDACAO, NAO_ENCONTRADO, ROTA_NAO_ENCONTRADA, RATE_LIMIT, ERRO_INTERNO, JSON_INVALIDO) com uma mensagem amigável específica, e prove com prints que cada código produz uma notificação diferente na tela.
Critérios de pronto
Uma função traduzirErro(codigo) no front-end mapeia cada código conhecido para uma frase em português voltada ao usuário final (não a mensagem técnica crua).
Um snackbar ou notificação visível aparece para pelo menos 4 códigos de erro diferentes, provocados de propósito (evento inexistente, campo inválido, rota errada, limite de requisições).
Um código não mapeado (ex.: um código que você inventa de propósito, só para o teste) cai num texto padrão ("Algo deu errado, tente novamente"), sem quebrar a interface.
Prints (ou um vídeo curto) mostrando as quatro notificações diferentes na tela.
Pistas
O interceptor de resposta do Axios (Aula 06) recebe o erro em error.response.data.erro.codigo — é esse valor que entra no mapeamento de tradução.
Um objeto { VALIDACAO: '...', NAO_ENCONTRADO: '...' } com um valor padrão (objeto[codigo] ?? 'Algo deu errado...') cobre o caso "código desconhecido" sem precisar de uma cadeia longa de if/else.
Para provocar o RATE_LIMIT de propósito, reduza temporariamente o limit do rate limiter (Laboratório B4) e dispare várias requisições seguidas pelo front.
Adicionar ao seu requests.http autoral 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 429 aparece.
Revisar seu tratador de erros: force um erro inesperado (ex.: acesse uma propriedade de undefined de propósito dentro de uma rota) e confirme que a resposta chega como 500 com 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, e validação com Zod retornando 422 com mensagens claras.
O Marco 2 fecha com 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 um (não o UniEventos, que é a aplicação de referência desta trilha).
Vuetify para toda a interface (nenhum CSS puro estrutural fora do Vuetify, exceto ajustes pontuais) — Aula 04.
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.) — Aula 04/05.
No mínimo 6 componentes próprios (.vue autorais, além dos componentes do Vuetify) — componentes de card, formulário, lista, filtro, layout, etc. — Aula 05.
Uma store Pinia com estado assíncrono: ações que chamam a API, estados de carregando e erro, getters quando fizer sentido — Aula 06.
Axios com instância dedicada (axios.create) e ao menos um interceptor — Aula 06.
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 — Aula 06.
Formulário com validação (Vuetify rules ou biblioteca de validação) para criar ou editar um registro do domínio — Aula 05.
Layout responsivo — funcional em tela de celular e de desktop, usando o sistema de grid do Vuetify — Aula 04.
CRUD completo na API (unieventos-api do projeto autoral), com middlewares próprios e validação com Zod retornando 422 — Aulas 07/08.
Abra o requests.http (ou o Insomnia/Postman) e rode todos os endpoints da sua API, incluindo os casos de erro (404, 422) — todos devem responder como esperado.
No navegador, force um erro (derrube a API com Ctrl+C e recarregue o front): a tela deve mostrar uma mensagem amigável, não um undefined ou uma tela em branco.
Redimensione a janela do navegador (ou use o modo responsivo do DevTools) navegando pelas rotas: nenhuma tela quebra.
Rode npm install && npm run dev em uma cópia limpa do repositório e confirme que tudo sobe sem passos extras não documentados no README.
RFC 9110 (semântica HTTP) — referência formal de métodos e status codes, para quem quiser a fonte primária.
Plano da disciplina em que esta trilha nasceu — 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.
Na Aula 08 você chegou ao Marco 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.
Guarde desde já uma decisão que vai valer para toda a Unidade 3: as colunas do banco são snake_case (data_hora, imagem_url), mas o JSON que a API troca com o front continua camelCase (dataHora, imagemUrl), como desde a Aula 06. Quem faz a tradução entre os dois vocabulários é o repositório, e só ele — nem o service, nem o controller, nem o Vue precisam saber que existe um data_hora do outro lado.
[ ] unieventos-api da Aula 08, com CRUD completo em memória, middlewares e validação Zod funcionando.
[ ] requests.http cobrindo todos os endpoints (Aula 08).
[ ] Marco 2 alcançado.
[ ] Modelagem relacional revisada: entidade, atributo, chave primária e estrangeira (conteúdo de cursos anteriores de banco de dados ou estudo prévio equivalente — 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.
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.
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. Esta trilha usa a versão 8, com o driver mysql2 (Node) na versão 3.x, 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.
🧠 Você sabia?
O nome "MySQL" não é uma sigla técnica: "My" é o nome da filha de um dos criadores originais, Michael Widenius, que trabalhava na empresa sueca MySQL AB nos anos 1990. O banco passou por várias mãos corporativas desde então — foi comprado pela Sun Microsystems em 2008, e a Sun foi comprada pela Oracle em 2010, dona atual do MySQL. Apesar das trocas de dono, o MySQL continua open source e é, até hoje, um dos bancos relacionais mais usados do mundo — inclusive por empresas que competem diretamente com produtos da própria Oracle.
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çãoDATETIME no 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 com new 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 trilha, 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.
-- sql/schema.sql-- Script de criação do banco de dados do UniEventos.-- Execute com: mysql -u root -p < sql/schema.sqlCREATEDATABASEIFNOTEXISTSunieventosCHARACTERSETutf8mb4COLLATEutf8mb4_unicode_ci;USEunieventos;-- tabela de usuários — uid do Firebase Auth vem na Aula 10, já deixamos o campo prontoCREATETABLEusuarios(idINTAUTO_INCREMENTPRIMARYKEY,firebase_uidVARCHAR(128)UNIQUE,nomeVARCHAR(120)NOTNULL,emailVARCHAR(160)NOTNULLUNIQUE,criado_emDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMP);-- tabela de eventosCREATETABLEeventos(idINTAUTO_INCREMENTPRIMARYKEY,tituloVARCHAR(160)NOTNULL,descricaoTEXT,categoriaENUM('palestra','minicurso','workshop')NOTNULL,data_horaDATETIMENOTNULL,localVARCHAR(160)NOTNULL,vagasINTNOTNULLDEFAULT0,imagem_urlVARCHAR(400),criado_emDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMP,INDEXidx_eventos_categoria(categoria),INDEXidx_eventos_data_hora(data_hora));-- tabela de inscrições — relação N:N entre usuarios e eventosCREATETABLEinscricoes(idINTAUTO_INCREMENTPRIMARYKEY,evento_idINTNOTNULL,usuario_idINTNOTNULL,criado_emDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMP,CONSTRAINTfk_inscricoes_eventoFOREIGNKEY(evento_id)REFERENCESeventos(id)ONDELETECASCADE,CONSTRAINTfk_inscricoes_usuarioFOREIGNKEY(usuario_id)REFERENCESusuarios(id)ONDELETECASCADE,-- um mesmo usuário não pode se inscrever duas vezes no mesmo eventoUNIQUEKEYuk_inscricao_unica(evento_id,usuario_id));-- dados de exemploINSERTINTOusuarios(firebase_uid,nome,email)VALUES('uid-exemplo-001','Ana Souza','ana.souza@exemplo.com'),('uid-exemplo-002','Bruno Lima','bruno.lima@exemplo.com');INSERTINTOeventos(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','2030-10-15 19:00:00','Auditório Central',80,'https://picsum.photos/seed/semana-computacao/400/240'),('Minicurso de Vue 3','Introdução prática ao framework Vue com Composition API.','minicurso','2030-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','2030-10-28 19:30:00','Laboratório 1',25,'https://picsum.photos/seed/workshop-firebase/400/240');INSERTINTOinscricoes(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.
💡 Dicautf8mb4 (em vez do antigo utf8 do 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.
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.
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:
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:
Terminal
dockerstopmysql-unieventos# para o contêiner
dockerstartmysql-unieventos# 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.yml ao repositório unieventos-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 único docker compose up -d, sem precisar copiar o comando docker run manualmente.
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:
Terminal
mysql-uroot-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.
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.
JavaScript
// src/bancoDeDados.jsimportmysqlfrom'mysql2/promise'exportconstpool=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 falharconnectionLimit:10,// no máximo 10 conexões simultâneas no poolnamedPlaceholders: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: envia a consulta e os valores juntos, o driver monta e executaconst[linhas]=awaitpool.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 repetidasconst[linhas2]=awaitpool.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 trilha 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:
JavaScript
// NUNCA FAÇA ISSO — vulnerável a SQL injectionconstcategoria=req.query.categoriaconstsql=`SELECT * FROM eventos WHERE categoria = '${categoria}'`const[linhas]=awaitpool.query(sql)
Se alguém enviar categoria como ' OR 1=1 --, a string final montada fica:
SQL
SELECT*FROMeventosWHEREcategoria=''OR1=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:
JavaScript
// CORRETO — consulta parametrizadaconstcategoria=req.query.categoriaconst[linhas]=awaitpool.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.
// SELECT: o resultado é um array de linhas (mesmo com 0 ou 1 resultado)const[linhas]=awaitpool.execute('SELECT * FROM eventos WHERE id = ?',[1])constevento=linhas[0]// undefined se não encontrou// INSERT: o resultado é um objeto com metadados da inserçãoconst[resultado]=awaitpool.execute('INSERT INTO eventos (titulo, categoria, data_hora, local, vagas) VALUES (?, ?, ?, ?, ?)',['Palestra de teste','palestra','2030-11-05 19:00:00','Auditório Central',60],)console.log(resultado.insertId)// id gerado pelo AUTO_INCREMENTconsole.log(resultado.affectedRows)// quantas linhas foram afetadas (1, aqui)// UPDATE / DELETE: também devolvem affectedRowsconst[resultadoUpdate]=awaitpool.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.
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.
JavaScript
// 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árioconst[inscricoesDoEvento]=awaitpool.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.
JavaScript
// src/repositories/inscricoesRepository.jsimport{pool}from'../bancoDeDados.js'import{erroNaoEncontrado,erroValidacao}from'../erros/ErroHttp.js'exportasyncfunctioninscreverUsuarioNoEvento(eventoId,usuarioId){// pool.getConnection() empresta UMA conexão específica do pool, exclusiva para esta transaçãoconstconexao=awaitpool.getConnection()try{awaitconexao.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 inscreverconst[eventos]=awaitconexao.execute('SELECT vagas FROM eventos WHERE id = ? FOR UPDATE',[eventoId],)if(eventos.length===0){throwerroNaoEncontrado('Evento não encontrado')}if(eventos[0].vagas<=0){throwerroValidacao('Não há vagas disponíveis para este evento')}awaitconexao.execute('INSERT INTO inscricoes (evento_id, usuario_id) VALUES (?, ?)',[eventoId,usuarioId],)awaitconexao.execute('UPDATE eventos SET vagas = vagas - 1 WHERE id = ?',[eventoId],)// só grava tudo em definitivo se as três operações acima passaram sem erroawaitconexao.commit()}catch(erro){// desfaz TUDO que essa transação tentou fazer — o banco volta ao estado anteriorawaitconexao.rollback()throwerro}finally{// devolve a conexão ao pool, sempre — sucesso ou falhaconexao.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çãoconexao.release() no finally é obrigatório. Se você esquecer de liberar uma conexão emprestada do pool, ela fica presa — e depois de connectionLimit conexõ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:
Texto
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().
🔬 Investigue
Abra duas conexões simultâneas ao MySQL (duas abas do MySQL Workbench/DBeaver, ou dois terminais com mysql -u root -p). Na primeira, rode START TRANSACTION; seguido de SELECT vagas FROM eventos WHERE id = 1 FOR UPDATE;, e não dê COMMIT ainda. Na segunda aba, tente rodar a mesma consulta (SELECT ... FOR UPDATE) para o mesmo id. O que acontece? Volte à primeira aba e rode COMMIT; — o que muda imediatamente na segunda?
# .env.example (versionado, sem valores sigilosos)PORTA=3000DB_HOST=localhost
DB_USER=DB_PASSWORD=DB_NAME=unieventos
Terminal
node--watch--env-file=.envsrc/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.
🧩 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 com new 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.
💻 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:
Texto
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.
// src/repositories/eventosRepository.jsimport{pool}from'../bancoDeDados.js'// As colunas do MySQL são snake_case (data_hora, imagem_url); o contrato HTTP da// unieventos-api é camelCase desde a Aula 06 (dataHora, imagemUrl). O repositório é// o único lugar da aplicação que conhece os dois vocabulários — é ele que traduz.functionlinhaParaEvento(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,}}exportasyncfunctionlistarEventos({categoria,ordenarPor,direcao,porPagina,offset}){constcolunasPermitidas=['id','titulo','data_hora','vagas']constcoluna=colunasPermitidas.includes(ordenarPor)?ordenarPor:'id'constsentidoOrdenacao=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 stringletsql='SELECT * FROM eventos'constparametros=[]if(categoria){sql+=' WHERE categoria = ?'parametros.push(categoria)}sql+=` ORDER BY ${coluna}${sentidoOrdenacao} LIMIT ? OFFSET ?`parametros.push(porPagina,offset)// ATENÇÃO: aqui é pool.query, não pool.execute. O mysql2 envia os parâmetros de um// statement preparado como string, e o MySQL recusa `LIMIT '10'` com// "Incorrect arguments to mysqld_stmt_execute". Como porPagina e offset já foram// validados como inteiros no service, pool.query resolve sem abrir brecha de injeção.const[linhas]=awaitpool.query(sql,parametros)returnlinhas.map(linhaParaEvento)}exportasyncfunctioncontarEventos(categoria){letsql='SELECT COUNT(*) AS total FROM eventos'constparametros=[]if(categoria){sql+=' WHERE categoria = ?'parametros.push(categoria)}const[linhas]=awaitpool.execute(sql,parametros)returnlinhas[0].total}exportasyncfunctionbuscarEventoPorId(id){const[linhas]=awaitpool.execute('SELECT * FROM eventos WHERE id = ?',[id])returnlinhas[0]?linhaParaEvento(linhas[0]):null}exportasyncfunctioninserirEvento(evento){const[resultado]=awaitpool.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,],)returnbuscarEventoPorId(resultado.insertId)}exportasyncfunctionsubstituirEvento(id,evento){const[resultado]=awaitpool.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)returnnullreturnbuscarEventoPorId(id)}exportasyncfunctionatualizarEventoParcial(id,campos){constcolunasPermitidas={titulo:'titulo',descricao:'descricao',categoria:'categoria',dataHora:'data_hora',local:'local',vagas:'vagas',imagemUrl:'imagem_url',}constatribuicoes=[]constparametros=[]for(const[chave,valor]ofObject.entries(campos)){if(colunasPermitidas[chave]){atribuicoes.push(`${colunasPermitidas[chave]} = ?`)parametros.push(valor)}}if(atribuicoes.length===0)returnbuscarEventoPorId(id)parametros.push(id)const[resultado]=awaitpool.execute(`UPDATE eventos SET ${atribuicoes.join(', ')} WHERE id = ?`,parametros,)if(resultado.affectedRows===0)returnnullreturnbuscarEventoPorId(id)}exportasyncfunctionremoverEvento(id){const[resultado]=awaitpool.execute('DELETE FROM eventos WHERE id = ?',[id])returnresultado.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 que listarEventos valida ordenarPor contra 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.
// src/services/eventosService.jsimport*aseventosRepositoryfrom'../repositories/eventosRepository.js'import{erroNaoEncontrado,erroValidacao}from'../erros/ErroHttp.js'exportasyncfunctionobterListaDeEventos({categoria,ordenarPor,direcao,pagina,porPagina}){constpaginaSegura=Math.max(1,Number(pagina)||1)constporPaginaSegura=Math.min(100,Math.max(1,Number(porPagina)||10))constoffset=(paginaSegura-1)*porPaginaSeguraconst[eventos,total]=awaitPromise.all([eventosRepository.listarEventos({categoria,ordenarPor,direcao,porPagina:porPaginaSegura,offset}),eventosRepository.contarEventos(categoria),])return{eventos,paginacao:{pagina:paginaSegura,porPagina:porPaginaSegura,total},}}exportasyncfunctionobterEventoPorId(id){constevento=awaiteventosRepository.buscarEventoPorId(id)if(!evento){throwerroNaoEncontrado('Evento não encontrado')}returnevento}exportasyncfunctioncriarEvento(dadosEvento){if(dadosEvento.vagas<0){throwerroValidacao('Vagas não pode ser negativo')}returneventosRepository.inserirEvento(dadosEvento)}exportasyncfunctionatualizarEventoCompleto(id,dadosEvento){consteventoAtualizado=awaiteventosRepository.substituirEvento(id,dadosEvento)if(!eventoAtualizado){throwerroNaoEncontrado('Evento não encontrado')}returneventoAtualizado}exportasyncfunctionatualizarEventoParcial(id,campos){consteventoAtualizado=awaiteventosRepository.atualizarEventoParcial(id,campos)if(!eventoAtualizado){throwerroNaoEncontrado('Evento não encontrado')}returneventoAtualizado}exportasyncfunctionexcluirEvento(id){constremoveu=awaiteventosRepository.removerEvento(id)if(!removeu){throwerroNaoEncontrado('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.
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.
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.
// src/servidor.jsimportexpressfrom'express'importcorsfrom'cors'importhelmetfrom'helmet'importcompressionfrom'compression'importmorganfrom'morgan'importrateLimitfrom'express-rate-limit'importeventosRoutesfrom'./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 servidorconstapp=express()app.use(cors())app.use(helmet())app.use(compression())app.use(express.json())app.use(morgan('dev'))app.use(logger)app.use(medidorDeTempo)constlimitador=rateLimit({windowMs:15*60*1000,limit:100})app.use('/api/',limitador)app.use('/api/eventos',eventosRoutes)app.use(middlewareNaoEncontrado)app.use(tratadorDeErros)constporta=process.env.PORTA||3000app.listen(porta,()=>{console.log(`unieventos-api rodando em http://localhost:${porta}`)})
Com o MySQL rodando e o schema.sql aplicado, suba a API (node --watch --env-file=.env src/servidor.js) e reabra o mesmorequests.http da Aula 08 — nenhuma linha dele precisa mudar.
{"dados":[{"id":1,"titulo":"Semana Acadêmica de Computação","categoria":"palestra","dataHora":"2030-09-10T19:00:00.000Z","local":"Auditório Central","vagas":120,"imagemUrl":null}],"paginacao":{"pagina":1,"porPagina":2,"total":3}}
Confira, item por item: (1) as chaves do objeto vêm em camelCase (dataHora, imagemUrl), e não com o nome das colunas (data_hora, imagem_url) — é o linhaParaEvento do repositório fazendo a tradução; (2) o envelope continua { dados, paginacao }; (3) POST inválido devolve 422 com o mesmo { erro: { mensagem, codigo } } de antes; (4) GET /api/eventos/999 devolve 404. Se algum teste que passava na Aula 08 agora falha, o problema está na camada MySQL nova, não no contrato da API — que permaneceu idêntico.
A1. Preveja, sem rodar, o valor de linhas e de evento no trecho abaixo, considerando que a tabela eventos desta aula só tem os ids 1, 2 e 3:
JavaScript
const[linhas]=awaitpool.execute('SELECT * FROM eventos WHERE id = ?',[999])constevento=linhas[0]
Resultado esperado: linhas é um array vazio ([]) — nenhuma linha bate com id = 999 — e evento é undefined, porque acessar a posição 0 de um array vazio devolve undefined.
A2. Complete a linha que falta para este INSERT inserir corretamente os três valores esperados pela query:
JavaScript
const[resultado]=awaitpool.execute('INSERT INTO usuarios (firebase_uid, nome, email) VALUES (?, ?, ?)',// linha que falta aqui)
Resultado esperado: [uid, nome, email] — um array com exatamente três valores, na mesma ordem das três ? do SQL; a ordem importa tanto quanto a quantidade.
A3. Em uma frase: por que pool.execute é preferível a pool.query para uma consulta que roda com muita frequência (ex.: buscarEventoPorId, chamada em quase todo endpoint)?
Resultado esperado: porque execute usa prepared statements — o SQL é compilado uma vez pelo servidor MySQL e reaproveitado nas chamadas seguintes, evitando recompilar a mesma consulta repetidamente.
A4. Ache o erro nas linhas abaixo (a conexão emprestada do pool não é devolvida em caso de erro) e diga a correção:
JavaScript
exportasyncfunctioncontarInscricoes(eventoId){constconexao=awaitpool.getConnection()const[linhas]=awaitconexao.execute('SELECT COUNT(*) AS total FROM inscricoes WHERE evento_id = ?',[eventoId])conexao.release()returnlinhas[0].total}
Resultado esperado: se conexao.execute lançar uma exceção, o conexao.release() da linha seguinte nunca roda, e a conexão fica presa no pool para sempre. A correção é envolver o execute num try/finally, com conexao.release() dentro do finally.
A5. Verdadeiro ou falso, com justificativa de uma linha: "DELETE FROM eventos WHERE id = ? com um id que não existe na tabela lança uma exceção no mysql2."
Resultado esperado: falso — o DELETE roda normalmente e devolve affectedRows: 0; é responsabilidade do código verificar esse valor e decidir se isso significa "não encontrado" (como faz removerEvento desta aula).
B1. Repositório de usuários. Escreva src/repositories/usuariosRepository.js com listarUsuarios(), buscarUsuarioPorId(id) e inserirUsuario({ nome, email }). Use consultas parametrizadas em todas.
Resultado esperado: listarUsuarios() devolve um array com os usuários de exemplo (Ana Souza, Bruno Lima); buscarUsuarioPorId(1) devolve só o registro de Ana; inserirUsuario({ nome: 'Carla Dias', email: 'carla@exemplo.com' }) grava um novo registro, e uma consulta seguinte confirma três usuários na tabela.
Dica
Siga exatamente o padrão de eventosRepository.js: pool.execute(sql, parametros), desestruturando [linhas] do retorno.
B2. Endpoint de inscrição. Crie POST /api/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).
Resultado esperado: com vagas disponíveis, a resposta é 201 com a inscrição criada; depois de zerar as vagas do evento no banco, a mesma chamada responde 422 com a mensagem "Não há vagas disponíveis para este evento".
Dica
O erro de vagas esgotadas já vem como erroValidacao(...) (um ErroHttp de status 422 e código VALIDACAO) de dentro da transação — seu controller só precisa dar await e deixar o Express capturar automaticamente.
B3. 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.
Resultado esperado: com a versão concatenada, a query maliciosa (1 OR 1=1) devolve todos os eventos da tabela em vez de nenhum; com a versão parametrizada, a mesma entrada é tratada como valor literal e não devolve nenhum evento (porque nenhum id se chama isso).
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ó.
B4. Í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).
Resultado esperado: com o índice, EXPLAIN mostra type: ref e um valor baixo em rows; sem o índice, type: ALL (varredura completa da tabela) e rows igual ao total de linhas da tabela eventos.
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.
B5. 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.
Resultado esperado: depois do throw proposital, a tabela inscricoes não ganha nenhuma linha nova — o rollback desfez o INSERT que já tinha rodado, confirmando que a transação é tudo-ou-nada.
Dica
Consulte a tabela inscricoes direto pelo Workbench/DBeaver antes e depois de rodar o teste, para confirmar visualmente que nada foi persistido.
C1. Endpoint de listagem com JOIN. Crie GET /api/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.
Resultado esperado: GET /api/eventos/:id/inscricoes devolve { "dados": [...] } com nome e e-mail de cada inscrito, em ordem de inscrição; para um evento inexistente, a resposta é 404, sem que o controller precise checar isso manualmente (o service já lança o erro).
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.
Toda tabela do schema.sql desta aula tem pelo menos um índice — mas nem toda consulta que você vai escrever no seu projeto autoral necessariamente usa esses índices do jeito que você espera. Rode EXPLAIN numa consulta do seu próprio domínio e descubra se ela realmente está usando o índice que você criou, ou se está fazendo uma varredura completa da tabela sem que ninguém tenha percebido.
Critérios de pronto
O EXPLAIN de pelo menos uma consulta do seu repositório autoral está colado no README, com os campos type e rows destacados.
Uma frase explica se o resultado é bom (usa índice) ou ruim (varredura completa) e por quê.
Se for ruim, uma segunda versão do EXPLAIN, depois de criar o índice que faltava, mostra a melhora.
Pistas
EXPLAIN SELECT ... na frente de qualquer consulta mostra como o MySQL planeja executá-la, sem rodar de verdade.
type: ALL sempre é suspeito numa tabela grande; type: ref ou type: const geralmente indicam uso de índice.
Um índice só ajuda se a cláusula WHERE (ou o JOIN) filtrar exatamente pela coluna indexada — um índice em titulo não ajuda um WHERE categoria = ?.
A transação desta aula usa FOR UPDATE para travar a linha do evento — mas o que acontece se você remover essa trava de propósito e disparar duas inscrições simultâneas para um evento com exatamente 1 vaga? Reproduza a condição de corrida (race condition) que o FOR UPDATE existe para evitar, meça o dano, e depois prove que a versão correta resolve.
Critérios de pronto
Uma cópia temporária de inscreverUsuarioNoEvento sem o FOR UPDATE (troque por um SELECT vagas FROM eventos WHERE id = ? simples).
Um script que dispara duas chamadas quase simultâneas (Promise.all com duas chamadas da função) contra um evento com vagas = 1.
Uma consulta ao banco depois do teste mostrando quantas inscrições foram criadas (o bug aparece quando o número é 2, não 1).
A mesma bateria de testes rodada contra a versão com FOR UPDATE, confirmando que só 1 inscrição é criada.
Pistas
Sem FOR UPDATE, as duas transações conseguem ler vagas: 1 ao mesmo tempo, antes de qualquer uma delas fazer o UPDATE — as duas "acham" que podem inscrever.
Promise.all([inscreverUsuarioNoEvento(id, 1), inscreverUsuarioNoEvento(id, 2)]) dispara as duas chamadas de forma concorrente o suficiente para expor a corrida na maioria das vezes (não é garantido a cada execução — rode algumas vezes).
SELECT COUNT(*) FROM inscricoes WHERE evento_id = ? depois do teste revela o número real de inscrições criadas.
A promessa central desta aula é que migrar de memória para MySQL não deveria quebrar nenhum contrato de API. Prove isso formalmente no seu projeto autoral: grave as respostas de todo o requests.http rodando contra a versão em memória (Aula 08), migre para MySQL, rode de novo, e compare as duas rodadas (ignorando só os campos que legitimamente mudam, como datas de criação).
Critérios de pronto
Um script (bash, Node, o que preferir) que roda cada requisição do requests.http duas vezes — antes e depois da migração — salvando as respostas em arquivos JSON separados (respostas-memoria/ e respostas-mysql/).
Uma comparação (diff, ou script próprio) apontando quais campos mudaram entre as duas rodadas.
Uma lista, no README, dos campos que mudaram legitimamente (ex.: id pode mudar se o AUTO_INCREMENT começar de outro número) e uma confirmação de que o formato (as chaves do JSON, os status codes) é idêntico.
Se algum contrato realmente quebrou (chave que sumiu, status diferente), uma correção no service ou controller até a comparação bater.
Pistas
curl -s <url> | python3 -m json.tool (ou jq) formata a resposta para comparação legível.
diff <(cat respostas-memoria/get-eventos.json) <(cat respostas-mysql/get-eventos.json) mostra exatamente o que mudou entre os dois arquivos.
Ignore id e qualquer campo de data/hora automática ao comparar — eles mudam legitimamente entre execuções; o que importa é a estrutura e as regras de negócio (status codes, mensagens de erro, formato do envelope).
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
Incorrect arguments to mysqld_stmt_execute na listagem paginada
pool.execute envia os parâmetros de LIMIT ? OFFSET ? como string, e o MySQL só aceita inteiro ali
troque por pool.query nessa consulta (com porPagina/offset já validados como inteiro no service), como no repositório desta aula
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
Modele as tabelas do seu domínio em sql/schema.sql, com ao menos duas tabelas relacionadas por chave estrangeira (equivalente a eventos/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.http da 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.
MySQL 8 Reference Manual — capítulo The InnoDB Storage Engine — para entender transações, FOR UPDATE e 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 da disciplina em que esta trilha nasceu — 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.
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.
Checklist antes de começar:
[ ] unieventos-web rodando com Vue Router e Pinia configurados (Aulas 04–06).
[ ] unieventos-api rodando com Express 5, endpoints de eventos e persistência MySQL (Aulas 07–09).
[ ] Projeto Firebase criado (Aula 07) — anote o projectId.
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 bcrypt fazem 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, nesta trilha (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.
🧠 Você sabia?
"Hash" para senha é técnica dos anos 1970, mas o erro mais comum em vazamentos reais não é a falta de hash — é usar um hash rápido demais. Em 2012, o vazamento do LinkedIn expôs mais de 100 milhões de senhas com hash SHA-1 sem salt; a maioria foi quebrada em poucos dias, porque hardware moderno calcula bilhões de SHA-1 por segundo. bcrypt e Argon2 são desenhados de propósito para serem lentos (milhares de cálculos por segundo, não bilhões) — a lentidão em si é a defesa contra força bruta.
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:
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:
JSON
{"alg":"RS256","kid":"abc123"}
Payload — as "claims" (afirmações) sobre o usuário. É aqui que vive a informação:
iat (issued at) e exp (expiration) são timestamps Unix. admin é um exemplo de custom claim — vamos usar exatamente isso na seção 8.4 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": false para "admin": true — a assinatura deixa de bater, e quem valida o token (no nosso caso, o firebase-admin no back-end) rejeita o token inteiro.
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.
📌 Vale gravar
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.
🔬 Investigue
Faça login no UniEventos, abra o console do navegador e rode:
js
const token = await auth.currentUser.getIdToken()
console.log(token)
console.log(JSON.parse(atob(token.split('.')[1])))
Compare o resultado com o que aparece ao colar o mesmo token em jwt.io. Calcule exp - iat em segundos — deve dar exatamente 3600 (a validade de 1 hora do ID token). Agora rode JSON.parse(atob(token.split('.')[0])): o que aparece é o header, não o payload — qual das duas partes diz qual algoritmo assinou o token?
3. Habilitando autenticação no console do Firebase¶
O pacote já está instalado desde a Aula 07 (firebase@12). Se o seu projeto ainda não tem, instale:
Terminal
npminstallfirebase@12
JavaScript
// src/services/firebase.jsimport{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.constfirebaseConfig={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,}constapp=initializeApp(firebaseConfig)exportconstauth=getAuth(app)exportconstdb=getFirestore(app)
Terminal
# .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=123456789VITE_FIREBASE_APP_ID=1:123456789:web:abcdef
⚠️ Atençãofirebase.auth() com namespace não existe mais. A única forma correta no SDK 12 é a API modular: importar funções soltas (getAuth, signInWithEmailAndPassword, onAuthStateChanged etc.) de 'firebase/auth' e passar a instância auth como primeiro argumento. Se você encontrar tutorial usando firebase.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:
JavaScript
// src/services/authService.jsimport{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".constMENSAGENS_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.',}functiontraduzirErro(erro){constmensagem=MENSAGENS_ERRO[erro.code]returnmensagem??'Não foi possível concluir a operação. Tente novamente.'}exportasyncfunctioncadastrar(nome,email,senha){try{constcredencial=awaitcreateUserWithEmailAndPassword(auth,email,senha)// O Firebase não pede nome no cadastro por e-mail/senha — setamos depois.awaitupdateProfile(credencial.user,{displayName:nome})returncredencial.user}catch(erro){thrownewError(traduzirErro(erro))}}exportasyncfunctionentrar(email,senha){try{constcredencial=awaitsignInWithEmailAndPassword(auth,email,senha)returncredencial.user}catch(erro){thrownewError(traduzirErro(erro))}}exportasyncfunctionentrarComGoogle(){try{constprovedor=newGoogleAuthProvider()constcredencial=awaitsignInWithPopup(auth,provedor)returncredencial.user}catch(erro){thrownewError(traduzirErro(erro))}}exportasyncfunctionsair(){awaitsignOut(auth)}exportasyncfunctionsolicitarRedefinicaoSenha(email){try{awaitsendPasswordResetEmail(auth,email)}catch(erro){thrownewError(traduzirErro(erro))}}// Registra um observador do estado de login. Retorna a função de// cancelamento — quem chamar deve guardá-la e invocar ao desmontar.exportfunctionobservarAutenticacao(callback){returnonAuthStateChanged(auth,callback)}
💡 DicaonAuthStateChanged dispara 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.
JavaScript
// src/stores/authStore.jsimport{defineStore}from'pinia'import{ref,computed}from'vue'import{observarAutenticacao}from'@/services/authService'exportconstuseAuthStore=defineStore('auth',()=>{constusuario=ref(null)constcarregando=ref(false)constinicializado=ref(false)// Promise única, compartilhada por todos que chamarem inicializar().// Evita registrar o observador do Firebase mais de uma vez.letpromessaInicializacao=nullfunctioninicializar(){if(promessaInicializacao)returnpromessaInicializacaopromessaInicializacao=newPromise((resolve)=>{observarAutenticacao((usuarioFirebase)=>{usuario.value=usuarioFirebaseif(!inicializado.value){inicializado.value=trueresolve()// só resolve no PRIMEIRO disparo do observador}})})returnpromessaInicializacao}constestaLogado=computed(()=>usuario.value!==null)// Custom claim "admin" só aparece depois de setCustomUserClaims (seção 8.4)// e de o usuário obter um novo ID token — ver observação logo abaixo.// custom claims NÃO vêm no objeto `User`: é preciso decodificar o ID token.// Guardamos o resultado em um ref próprio, atualizado a cada mudança de sessão.constehAdmin=ref(false)asyncfunctionatualizarClaims(){if(!usuario.value){ehAdmin.value=falsereturn}// `true` força a busca de um token novo — necessário logo depois de// marcar o usuário como admin no back-end (seção 8.4)constresultado=awaitusuario.value.getIdTokenResult(true)ehAdmin.value=resultado.claims.admin===true}return{usuario,carregando,inicializado,inicializar,estaLogado,ehAdmin,atualizarClaims}})
🔎 Por baixo do capôonAuthStateChanged dispara 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 leem usuario e estaLogado, que são refs/computed normais e continuam atualizando sozinhos.
Repare no cuidado com ehAdmin: a tentação é escrever usuario.value?.customClaims?.admin, mas o objeto User do Firebase não expõe customClaims — essa propriedade simplesmente não existe ali, e o getter devolveria undefined para todo mundo, sem erro nenhum no console. Claims custom vivem dentro do ID token e só aparecem depois de decodificá-lo com getIdTokenResult(). Por isso ehAdmin é um ref alimentado por atualizarClaims(), chamado no onAuthStateChanged e de novo depois que alguém vira admin no back-end (seção 8.4).
// src/router/index.jsimport{createRouter,createWebHistory}from'vue-router'import{useAuthStore}from'@/stores/authStore'constrouter=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)=>{constauthStore=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.awaitauthStore.inicializar()if(to.meta.requerAuth&&!authStore.estaLogado){return{name:'login',query:{redirect:to.fullPath},}}if(to.meta.requerAdmin&&!authStore.ehAdmin){return{name:'home'}}returntrue})exportdefaultrouter
Depois do login, a tela de login redireciona de volta para onde o usuário queria ir:
⚠️ Atenção
Um beforeEach no Router impede que a interface mostre a tela protegida — mas qualquer pessoa pode desligar o JavaScript, chamar a API diretamente com curl ou 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 8.
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:
JavaScript
// src/services/http.js — o MESMO arquivo da Aula 06, agora com o token do Firebaseimportaxiosfrom'axios'import{auth}from'./firebase'importrouterfrom'@/router'consthttp=axios.create({baseURL:import.meta.env.VITE_API_URL??'http://localhost:3000/api',})http.interceptors.request.use(async(config)=>{constusuarioAtual=auth.currentUserif(usuarioAtual){// getIdToken() usa o cache do SDK; só bate na rede do Firebase// quando o token está perto de expirar (renovação automática).consttoken=awaitusuarioAtual.getIdToken()config.headers.Authorization=`Bearer ${token}`}returnconfig})http.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}})}returnPromise.reject(erro)},)exportdefaulthttp
💡 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 chamar getIdToken() 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.
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.
⚠️ AtençãoserviceAccountKey.json nunca 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.
Terminal
# instalação, versão travada conforme especificação desta trilha
npminstallfirebase-admin@14.2.0
JavaScript
// unieventos-api/src/config/firebaseAdmin.jsimport{initializeApp,cert,getApps}from'firebase-admin/app'import{getAuth}from'firebase-admin/auth'importfsfrom'node:fs'functioncarregarCredencial(){// Em produção: variável de ambiente com o JSON em base64.if(process.env.FIREBASE_SERVICE_ACCOUNT_BASE64){constjson=Buffer.from(process.env.FIREBASE_SERVICE_ACCOUNT_BASE64,'base64').toString('utf-8')returnJSON.parse(json)}// Em desenvolvimento: arquivo local, fora do Git.constconteudo=fs.readFileSync(newURL('../../serviceAccountKey.json',import.meta.url),'utf-8')returnJSON.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())})}exportconstauthAdmin=getAuth()
// unieventos-api/src/middlewares/autenticar.jsimport{authAdmin}from'../config/firebaseAdmin.js'// Envelope de erro: o mesmo { erro: { mensagem, codigo } } da Aula 08.// O front (store da Aula 11) lê `erro.mensagem` — devolver uma string solta aqui// faria a mensagem sumir da tela sem erro nenhum no console.exportasyncfunctionautenticar(req,res,next){constcabecalho=req.headers.authorizationif(!cabecalho?.startsWith('Bearer ')){returnres.status(401).json({erro:{mensagem:'Token de autenticação ausente.',codigo:'NAO_AUTENTICADO'},})}consttoken=cabecalho.replace('Bearer ','')try{consttokenDecodificado=awaitauthAdmin.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.returnres.status(401).json({erro:{mensagem:'Token inválido ou expirado.',codigo:'TOKEN_INVALIDO'},})}}
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.
// unieventos-api/src/middlewares/autorizar.jsexportfunctionautorizar(papeis=[]){return(req,res,next)=>{if(!req.usuario){// autenticar() deve sempre rodar antes de autorizar() na cadeiareturnres.status(401).json({erro:{mensagem:'Token de autenticação ausente.',codigo:'NAO_AUTENTICADO'},})}consttemPermissao=papeis.includes('admin')?req.usuario.admin:trueif(!temPermissao){returnres.status(403).json({erro:{mensagem:'Você não tem permissão para esta ação.',codigo:'NAO_AUTORIZADO'},})}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:
⚠️ 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), com true pedindo renovação forçada) para o token trazer admin: 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):
JavaScript
// src/stores/authStore.js — ajuste da action inicializar()functioninicializar(){if(promessaInicializacao)returnpromessaInicializacaopromessaInicializacao=newPromise((resolve)=>{observarAutenticacao(async(usuarioFirebase)=>{if(usuarioFirebase){constresultado=awaitusuarioFirebase.getIdTokenResult()usuario.value=usuarioFirebaseehAdminClaim.value=resultado.claims.admin===true}else{usuario.value=nullehAdminClaim.value=false}if(!inicializado.value){inicializado.value=trueresolve()}})})returnpromessaInicializacao}
JavaScript
// e trocar o computed ehAdmin por uma ref simples atualizada acimaconstehAdminClaim=ref(false)constehAdmin=computed(()=>ehAdminClaim.value)
É o mesmosrc/routes/eventos.routes.js da Aula 09 — mesmos caminhos, mesmos controllers, mesma validação Zod. A única mudança é a cadeia de middlewares que passa a preceder os handlers de escrita:
JavaScript
// unieventos-api/src/routes/eventos.routes.jsimport{Router}from'express'import*aseventosControllerfrom'../controllers/eventosController.js'import{validar}from'../middlewares/validador.js'import{schemaEvento,schemaEventoParcial}from'../schemas/evento.schema.js'import{autenticar}from'../middlewares/autenticar.js'import{autorizar}from'../middlewares/autorizar.js'constrouter=Router()// Leitura pública — qualquer visitante, sem token, vê os eventosrouter.get('/',eventosController.listar)router.get('/:id',eventosController.buscarPorId)// Escrita exige apenas estar autenticado (o `validar` da Aula 09 continua no lugar)router.post('/',autenticar,validar(schemaEvento),eventosController.criar)router.put('/:id',autenticar,validar(schemaEvento),eventosController.substituir)router.patch('/:id',autenticar,validar(schemaEventoParcial),eventosController.atualizarParcial)// Exclusão exige estar autenticado E ser adminrouter.delete('/:id',autenticar,autorizar(['admin']),eventosController.excluir)exportdefaultrouter
💡 Dica
A ordem importa: autenticar vem antes de validar. Não faz sentido gastar validação de corpo em quem nem provou quem é — e o 401 sai mais barato que o 422.
🧩 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 8.2: 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.
// src/main.jsimport{createApp}from'vue'import{createPinia}from'pinia'importAppfrom'./App.vue'importrouterfrom'./router'import{useAuthStore}from'@/stores/authStore'import'@mdi/font/css/materialdesignicons.css'import'vuetify/styles'import{createVuetify}from'vuetify'constvuetify=createVuetify({theme:{defaultTheme:'light'}})constapp=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')
Com a API e o front rodando ao mesmo tempo, confira os seis pontos abaixo, nesta ordem:
Cadastro — crie uma conta pelo formulário do Passo 1. Resultado esperado: redirecionamento para a home já logado, e o usuário novo visível em Firebase Console → Authentication → Users.
F5 com sessão — recarregue a página logada. Resultado esperado: o menu não pisca "Entrar/Cadastrar" antes de mostrar o nome — é o inicializado da store fazendo efeito.
Guard — deslogue e digite /admin na barra de endereços. Resultado esperado: redirecionamento para /login?redirect=/admin; ao entrar, você cai direto em /admin.
Token na requisição — logado, abra DevTools → Network e provoque um POST. Resultado esperado: o cabeçalho Authorization: Bearer eyJ... sai junto, colocado pelo interceptor do http.js.
Back-end — rode os três curl acima. Resultado esperado: 401 sem token, 401 com token forjado, 201 com token válido.
Autorização — com um usuário comum (sem a custom claim), tente o DELETE. Resultado esperado: 403 com { "erro": { "mensagem": "...", "codigo": "..." } }; marque o usuário como admin (Seção 8.4), chame atualizarClaims() e repita — agora sai 204.
A1. Verdadeiro ou falso, com justificativa de uma linha: "Um JWT é criptografado — por isso ninguém além do Firebase consegue ler o que tem dentro do payload."
Resultado esperado: falso. JWT é assinado, não criptografado; qualquer pessoa decodifica o payload em Base64URL (com atob() ou em jwt.io). A assinatura só impede alterar o conteúdo sem invalidar o token — ela não esconde nada.
A2. Complete a linha que falta no trecho abaixo para que o guard de rota não redirecione um usuário já logado para /login logo depois de um F5:
A3. Em uma frase: por que o middleware autorizar(['admin']) precisa sempre vir depois de autenticar na cadeia de uma rota, nunca antes ou sozinho?
Resultado esperado: porque autorizar só lê req.usuario.admin, e é autenticar quem popula req.usuario a partir do token — sem autenticar antes, req.usuario é undefined e o acesso a .admin quebra.
A4. Ache o erro nas linhas abaixo (a rota deveria exigir login e papel de admin para excluir, mas está com a cadeia de middlewares na ordem errada):
Resultado esperado: autorizar está antes de autenticar — a ordem correta é autenticar, autorizar(['admin']), eventosController.remover.
A5. Preveja a saída: um usuário está logado há duas horas, com a aba aberta o tempo todo, sem nunca ter recarregado a página, e faz uma requisição autenticada agora. O ID token que o interceptor enviaria, se nada tivesse mudado, já expirou (dura só 1h). A requisição falha com 401?
Resultado esperado: não. getIdToken() renova o token automaticamente (usando o refresh token) sempre que ele está a menos de 5 minutos de expirar — o interceptor sempre envia um token válido, mesmo em uma aba aberta há horas.
B1. 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.
Resultado esperado: a conta aparece no console do Firebase (Authentication → Users); depois do logout, a sessão salva pelo Firebase desaparece; um novo login recria a mesma sessão.
Dica
Abra o DevTools → Application → verifique se há chaves salvas pelo Firebase no IndexedDB/LocalStorage após o login.
B2. Login com Google. Habilite o provedor Google no console e teste entrarComGoogle().
Resultado esperado: o popup do Google fecha sozinho, o usuário aparece logado, e displayName/photoURL vêm preenchidos automaticamente pela conta Google.
Dica
Se o popup fechar sozinho sem erro visível, confira o console — geralmente é domínio não autorizado em Authentication → Settings → Authorized domains.
B3. 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.
Resultado esperado: deslogado, a rota redireciona para /login preservando o destino em redirect; logado (inclusive logo após um F5 na própria rota protegida), o conteúdo aparece sem nenhum redirecionamento.
Dica
Teste apertando F5 na rota protegida já logado — não pode redirecionar para login.
B4. 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.
Resultado esperado: sem token → 401; com token inválido/forjado → 401; com token válido → o status de sucesso do endpoint (200/201/204, conforme o método).
Dica
Um token expira em 1h — se testar depois de muito tempo, gere outro logando de novo no front.
B5. 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.
Resultado esperado: depois de deslogar e logar de novo, authStore.ehAdmin vira true e o item de menu de administração passa a aparecer na barra de navegação.
Dica
Se continuar false, o token em cache é o antigo — force getIdTokenResult(true) ou deslogue mesmo.
C1. Sessão comprometida, ponta a ponta. Hoje, se um usuário desconfiar que seu token vazou (ex.: perdeu o notebook destravado), trocar a senha não invalida tokens já emitidos: o ID token continua válido até expirar (até 1h) e o refresh token, que renova automaticamente, também segue válido. Implemente um endpoint POST /api/usuarios/revogar-sessoes (autenticado) que chama authAdmin.revokeRefreshTokens(uid), e prove com curl que o efeito é real de imediato — não só depois de o token expirar sozinho.
Resultado esperado: antes da revogação, uma chamada a um endpoint protegido com um token guardado retorna sucesso normalmente; depois de POST /api/usuarios/revogar-sessoes, a mesma chamada com o mesmo token (ainda dentro da validade de 1h) passa a retornar 401 com uma mensagem clara ({"erro": {"mensagem": "Sessão revogada, faça login novamente.", "codigo": "SESSAO_REVOGADA"}}).
Dica
verifyIdToken(token, true) — o segundo argumento true faz o SDK checar revogação; sem ele, revokeRefreshTokens não tem efeito nenhum sobre um ID token ainda dentro da validade.
authAdmin.revokeRefreshTokens(uid) grava um timestamp no usuário; qualquer ID token emitido antes desse timestamp passa a ser considerado revogado quando a checagem está ligada.
Ajuste o middleware autenticar (Seção 8.2) para authAdmin.verifyIdToken(token, true) e trate o erro específico (erro.code === 'auth/id-token-revoked') com uma mensagem diferente da de "token inválido comum".
Para provar com curl: pegue um token, chame um endpoint protegido (sucesso), chame o novo endpoint de revogação, e chame o mesmo endpoint protegido de novo com o mesmo token (401).
Você já viu que dar F5 não desloga o usuário — mas onde exatamente o SDK guarda essa informação para sobreviver ao recarregamento da página? Abra o DevTools no UniEventos já logado e investigue, sem ler a documentação antes.
Critérios de pronto
Um comentário (ou nota no README do seu projeto) diz em qual mecanismo de armazenamento do navegador (Local Storage, IndexedDB ou cookie) o Firebase Auth guarda a sessão, com o nome exato da chave/banco encontrado.
Uma frase explica por que essa chave não some quando você fecha e reabre a aba, mas some quando você limpa os dados do site.
Um teste documentado: apague manualmente essa entrada pelo DevTools e recarregue a página — confirme que o usuário é deslogado, provando que aquele é de fato o mecanismo responsável.
Uma comparação de uma linha com onde o ID token em si (não a sessão persistente) fica durante a execução da página.
Pistas
No Chrome DevTools, olhe Application → IndexedDB, procurando um banco com nome parecido com "firebaseLocalStorageDb" — e também Application → Local Storage, para comparar.
Depois de apagar a entrada certa, recarregue com F5 e observe authStore.usuario no Vue DevTools.
Para o ID token durante a execução, pense em onde auth.currentUser vive — em disco ou só em memória do processo do navegador?
Ao dar F5 numa página do UniEventos com o usuário já logado, por uma fração de segundo aparecem os botões "Entrar"/"Cadastrar" antes de trocarem para o menu de usuário logado. É rápido demais para notar em conexão boa — mas ative o throttling "Slow 3G" na aba Network do DevTools e o "pisca" fica bem visível e feio. A store já resolve esse mesmo problema para o guard de rota (aguardando inicializar()), mas o componente BarraNavegacao (Seção 6) não faz o mesmo. Corrija o flicker sem duplicar a lógica de aguardar a Promise dentro do template.
Critérios de pronto
Com throttling "Slow 3G" ativo, um F5 numa página logada não mostra mais os botões de "Entrar"/"Cadastrar", nem que seja por um instante.
A solução não usa setTimeout nem "esconder com CSS" — o componente só decide o que renderizar depois que authStore.inicializado é true.
Enquanto inicializado ainda é false, um indicador de carregamento simples aparece no lugar do menu (v-progress-linear ou um spinner pequeno).
Um comentário de uma linha explica por que esse problema não existe na primeira visita (sem sessão salva) — só aparece em F5 com sessão já existente.
Pistas
authStore.inicializado já existe (Seção 5) — falta alguém no template ler essa ref antes de decidir o que mostrar.
v-if="authStore.inicializado" envolvendo o <v-app-bar> inteiro (ou só a parte que depende do login) resolve sem duplicar a Promise do guard de rota.
Ative "Slow 3G" em DevTools → Network → Throttling para conseguir ver o flicker devagar o bastante para testar com calma.
Hoje, qualquer conta criada por e-mail/senha pode criar um evento imediatamente — mesmo com um e-mail inventado (fulano@empresa-que-nao-existe.com) que a pessoa nem é dona de verdade. Ainda não confirmamos que o e-mail é real. Implemente a confirmação de e-mail de ponta a ponta: o cadastro dispara a verificação, o front bloqueia a criação de eventos até o e-mail estar confirmado, e o back-end confirma isso de novo — porque, como vimos no fim da Seção 6, guard de rota é UX, não segurança.
Critérios de pronto
cadastrar() dispara sendEmailVerification(credencial.user) logo após criar a conta.
Uma tela avisa "confirme seu e-mail" e não deixa o formulário de criação de evento habilitado enquanto usuario.emailVerified for false (a store precisa recarregar esse dado com user.reload() depois de o usuário clicar em "já confirmei").
No back-end, o middleware autenticar (ou um novo exigirEmailConfirmado) rejeita POST /api/eventos com 403 e uma mensagem clara se tokenDecodificado.email_verified for false, mesmo que alguém tenha contornado a tela do front.
Um teste com curl, usando um token de conta não confirmada, prova que o back-end bloqueia mesmo sem passar pelo front.
Um parágrafo no README explica por que essa checagem não pode viver só no front.
Pistas
sendEmailVerification vem do mesmo firebase/auth que os outros métodos do authService.js — importe e chame logo depois de createUserWithEmailAndPassword.
usuarioFirebase.emailVerified fica desatualizado até você chamar usuarioFirebase.reload() e ler de novo — o SDK não observa essa mudança automaticamente como faz com login/logout.
O tokenDecodificado que verifyIdToken devolve já traz email_verified (com underscore — é assim que o Firebase nomeia essa claim).
Crie o middleware como uma função separada (exigirEmailConfirmado) para poder aplicá-la só nas rotas de escrita em que fizer sentido, sem misturar com autenticar.
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: commit + push.
Critério de pronto: os quatro passos do vídeo aparecem, e o commit com a implementação está no repositório.
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.
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.
Checklist antes de começar:
[ ] unieventos-api com autenticação Firebase funcionando (Aula 10) e CRUD básico de eventos no MySQL (Aula 09).
[ ] unieventos-web com Pinia, Vue Router, Vuetify e authStore funcionando (Aulas 05–10).
[ ] MySQL rodando localmente com a tabela eventos criada.
[ ] unieventos-api e unieventos-web rodando em portas diferentes (ex.: 3000 e 5173) — vamos revisitar CORS.
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
PATCH
/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.
{"dados":[{"id":1,"titulo":"Semana da Computação","descricao":"Palestras e minicursos de tecnologia","categoria":"palestra","dataHora":"2030-12-01T19:00:00.000Z","local":"Auditório Central","vagas":80,"vagasDisponiveis":62,"imagemUrl":"https://storage.unieventos.dev/eventos/semana-computacao.jpg"}],"paginacao":{"pagina":1,"porPagina":10,"total":34,"totalPaginas":4}}
Status: 200 OK.
⚠️ Atenção — camelCase no JSON, snake_case no banco
As colunas do MySQL são data_hora e imagem_url (Aula 09), mas o JSON que a API troca com o front é dataHora e imagemUrl, camelCase, desde a Aula 06. Quem traduz é o repositório, com uma função linhaParaEvento — e só ele. Se você deixar o SELECT * vazar os nomes de coluna para a resposta, o formulário do Passo 3 grava campo errado e a tela mostra "Invalid Date" sem erro nenhum no console.
GET /api/eventos/:id — um evento. Status 200 OK ou 404 Not Found com { "erro": { "mensagem": "Evento não encontrado.", "codigo": "NAO_ENCONTRADO" } }.
POST /api/eventos — corpo:
JSON
{"titulo":"Semana da Computação","descricao":"Palestras e minicursos de tecnologia","categoria":"palestra","dataHora":"2030-12-01T19:00:00","local":"Auditório Central","vagas":80,"imagemUrl":"https://storage.unieventos.dev/eventos/semana-computacao.jpg"}
Resposta: o evento criado, com id, status 201 Created. Erros de validação: 422 Unprocessable Entity com { "erro": { "mensagem": "...", "codigo": "VALIDACAO", "detalhes": [...] } } — o mesmo envelope de erro da Aula 08. Sem token: 401.
PUT /api/eventos/:id — o corpo completo, exatamente o mesmo esquema do POST (é o que "substituir o recurso inteiro" significa; para atualização parcial existe o PATCH, da Aula 09). 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 — o desafio ⭐⭐ desta aula fecha essa brecha). Evento inexistente: 404.
PATCH /api/eventos/:id — herdado da Aula 09: corpo com apenas os campos que mudam, validado pelo esquemaEventoParcial. Resposta: o evento atualizado, 200 OK. Mesmos códigos de erro do PUT.
DELETE /api/eventos/:id — sem corpo. Resposta: 204 No Content. Sem token: 401. Sem ser admin: 403. Evento com inscritos: 409 Conflict com { "erro": { "mensagem": "Não é possível excluir evento com inscritos.", "codigo": "CONFLITO" } }.
Todo erro, em qualquer endpoint, sai no envelope único { "erro": { "mensagem", "codigo" } } fixado na Aula 08 — com um detalhes extra quando a falha é de validação. O front nunca precisa adivinhar a forma de uma resposta de erro.
💡 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.
🧠 Você sabia?
A sigla CRUD não nasceu na web. Ela foi popularizada por James Martin em 1983, no livro Managing the Data-base Environment, para descrever as quatro operações básicas sobre um registro. Quarenta anos depois, virou o mapa dos verbos HTTP — POST, GET, PUT/PATCH, DELETE — e o esqueleto de praticamente toda API REST, inclusive a que você está fechando hoje.
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¶
Duas mudanças em relação à Aula 09, ambas declaradas de propósito antes do código.
1. Os nomes das funções encurtam. Como o arquivo já se chama eventosRepository.js, repetir "Evento" em cada função é redundante: listarEventos vira listar, buscarEventoPorId vira buscarPorId, inserirEvento vira criar, substituirEvento vira atualizar, excluirEvento vira remover. É uma renomeação mecânica — troque os nomes no repositório e no service, e o resto da aplicação nem percebe. O caminho do pool não muda: continua src/bancoDeDados.js, como na Aula 09.
2. vagas passa a ser capacidade total, não vagas restantes. Na Aula 09, inscrever alguém fazia UPDATE eventos SET vagas = vagas - 1: o contador era mantido à mão, e qualquer inscrição perdida ou revertida deixava o número errado para sempre. A partir de agora, eventos.vagas guarda a capacidade do evento (um número que só muda quando o organizador edita), e a disponibilidade é derivada por consulta: vagas - COUNT(inscricoes). Dado derivado nunca "desincroniza" — é sempre calculado a partir da fonte da verdade, que são as linhas de inscricoes.
A migração do banco da Aula 09 para esse modelo, para rodar uma única vez:
SQL
-- 1) devolve a `vagas` o valor de capacidade (vagas restantes + inscritos já feitos)UPDATEeventoseSETe.vagas=e.vagas+(SELECTCOUNT(*)FROMinscricoesiWHEREi.evento_id=e.id);-- 2) a inscrição passa a ser identificada pelo uid do Firebase (Aula 10),-- não mais por um id inteiro da tabela `usuarios`ALTERTABLEinscricoesDROPFOREIGNKEYfk_inscricoes_usuario;ALTERTABLEinscricoesCHANGECOLUMNusuario_idusuario_uidVARCHAR(128)NOTNULL;ALTERTABLEinscricoesADDCONSTRAINTuq_inscricaoUNIQUE(evento_id,usuario_uid);
⚠️ Atenção
Rode o passo 1 antes de qualquer coisa e só uma vez: rodar duas vezes soma os inscritos de novo e infla a capacidade. Se o seu banco de desenvolvimento estiver vazio de inscrições, os dois números coincidem e nada muda — o que é o caso mais provável logo após subir o ambiente.
JavaScript
// unieventos-api/src/repositories/eventosRepository.jsimport{pool}from'../bancoDeDados.js'import{ErroHttp,erroNaoEncontrado}from'../erros/ErroHttp.js'// tradução única entre o vocabulário do banco (snake_case) e o da API (camelCase)functionlinhaParaEvento(linha){return{id:linha.id,titulo:linha.titulo,descricao:linha.descricao,categoria:linha.categoria,dataHora:linha.data_hora,local:linha.local,vagas:linha.vagas,vagasDisponiveis:linha.vagas_disponiveis,imagemUrl:linha.imagem_url,}}exportasyncfunctionlistar({pagina,porPagina,busca,categoria}){constoffset=(pagina-1)*porPaginaconstcondicoes=[]constparametros=[]if(busca){condicoes.push('(titulo LIKE ? OR descricao LIKE ?)')parametros.push(`%${busca}%`,`%${busca}%`)}if(categoria){condicoes.push('categoria = ?')parametros.push(categoria)}constclausulaWhere=condicoes.length>0?`WHERE ${condicoes.join(' AND ')}`:''const[linhas]=awaitpool.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,porPagina,offset],)const[[{total}]]=awaitpool.query(`SELECT COUNT(*) AS total FROM eventos e ${clausulaWhere}`,parametros,)return{linhas:linhas.map(linhaParaEvento),total}}exportasyncfunctionbuscarPorId(id){const[linhas]=awaitpool.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],)returnlinhas[0]?linhaParaEvento(linhas[0]):null}exportasyncfunctioncontarInscritos(id,conexao=pool){const[[{total}]]=awaitconexao.query('SELECT COUNT(*) AS total FROM inscricoes WHERE evento_id = ?',[id],)returntotal}exportasyncfunctioncriar(evento){const[resultado]=awaitpool.query(`INSERT INTO eventos (titulo, descricao, categoria, data_hora, local, vagas, imagem_url) VALUES (?, ?, ?, ?, ?, ?, ?)`,[evento.titulo,evento.descricao,evento.categoria,evento.dataHora,evento.local,evento.vagas,evento.imagemUrl??null,],)returnbuscarPorId(resultado.insertId)}exportasyncfunctionatualizar(id,evento){awaitpool.query(`UPDATE eventos SET titulo = ?, descricao = ?, categoria = ?, data_hora = ?, local = ?, vagas = ?, imagem_url = ? WHERE id = ?`,[evento.titulo,evento.descricao,evento.categoria,evento.dataHora,evento.local,evento.vagas,evento.imagemUrl??null,id,],)returnbuscarPorId(id)}exportasyncfunctionremover(id){awaitpool.query('DELETE FROM eventos WHERE id = ?',[id])}// ATENÇÃO: esta função está DELIBERADAMENTE incompleta — ela abre a transação,// trava a linha e confere as vagas, mas ainda não insere a inscrição. Completá-la// (e expor o endpoint) é o Laboratório C1 desta aula.exportasyncfunctionverificarVagaEInscrever(id,usuarioUid){// 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).constconexao=awaitpool.getConnection()try{awaitconexao.beginTransaction()const[linhas]=awaitconexao.query('SELECT vagas, (SELECT COUNT(*) FROM inscricoes WHERE evento_id = ?) AS inscritos FROM eventos WHERE id = ? FOR UPDATE',[id,id],)constevento=linhas[0]if(!evento)throwerroNaoEncontrado('Evento não encontrado.')if(evento.inscritos>=evento.vagas){thrownewErroHttp(409,'Não há vagas disponíveis para este evento.','SEM_VAGAS')}// (o Laboratório C1 insere aqui a linha em `inscricoes`, usando esta mesma conexão)awaitconexao.commit()returntrue}catch(erro){// um único rollback, no caminho de erro — os `throw` acima caem todos aquiawaitconexao.rollback()throwerro}finally{conexao.release()}}
🔎 Por baixo do capôFOR UPDATE trava 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.
// unieventos-api/src/services/eventosService.jsimport{z}from'zod'import*aseventosRepositoryfrom'../repositories/eventosRepository.js'import{ErroHttp,erroNaoEncontrado}from'../erros/ErroHttp.js'exportconstesquemaEvento=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',}),dataHora:z.string().datetime({offset:true,message:'Data e hora em formato ISO inválido'}).or(z.string().min(1))// aceita também "2030-12-01T19:00:00" sem offset.refine((valor)=>!Number.isNaN(Date.parse(valor)),'Data e hora inválidas').refine((valor)=>newDate(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'),imagemUrl:z.url('URL de imagem inválida').optional().or(z.literal('')),})exportconstesquemaEventoParcial=esquemaEvento.partial()exportasyncfunctionlistar({pagina=1,porPagina=10,busca,categoria}){constpaginaSegura=Math.max(1,Number(pagina))constporPaginaSegura=Math.min(50,Math.max(1,Number(porPagina)))const{linhas,total}=awaiteventosRepository.listar({pagina:paginaSegura,porPagina:porPaginaSegura,busca,categoria,})return{dados:linhas,paginacao:{pagina:paginaSegura,porPagina:porPaginaSegura,total,totalPaginas:Math.ceil(total/porPaginaSegura),},}}exportasyncfunctionbuscarPorId(id){constevento=awaiteventosRepository.buscarPorId(id)if(!evento)throwerroNaoEncontrado('Evento não encontrado.')returnevento}exportasyncfunctioncriar(dadosBrutos){constdados=esquemaEvento.parse(dadosBrutos)returneventosRepository.criar(dados)}exportasyncfunctionatualizar(id,dadosBrutos){awaitbuscarPorId(id)// garante 404 antes de tentar validar/atualizar// MESMO esquema do POST: PUT substitui o recurso inteiro (ver contrato da §1)constdados=esquemaEvento.parse(dadosBrutos)returneventosRepository.atualizar(id,dados)}exportasyncfunctionatualizarParcial(id,dadosBrutos){constatual=awaitbuscarPorId(id)constdados=esquemaEventoParcial.parse(dadosBrutos)returneventosRepository.atualizar(id,{...atual,...dados})}exportasyncfunctionremover(id){awaitbuscarPorId(id)constinscritos=awaiteventosRepository.contarInscritos(id)if(inscritos>0){thrownewErroHttp(409,'Não é possível excluir evento com inscritos.','CONFLITO')}awaiteventosRepository.remover(id)}
2.3 Controller — só orquestra requisição/resposta¶
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. Ele não muda de envelope aqui — continua sendo { erro: { mensagem, codigo } }, com 422 para validação. A única adição é reconhecer o ZodError que escapa do esquemaEvento.parse() dentro do service:
JavaScript
// unieventos-api/src/middlewares/tratadorDeErros.js — o da Aula 08, com um caso a maisimport{ZodError}from'zod'import{ErroHttp}from'../erros/ErroHttp.js'exportfunctiontratadorDeErros(err,req,res,next){if(errinstanceofZodError){returnres.status(422).json({erro:{mensagem:'Dados inválidos.',codigo:'VALIDACAO',detalhes:err.issues.map((i)=>({campo:i.path.join('.'),mensagem:i.message})),},})}conststatus=errinstanceofErroHttp?err.status:500constcodigo=errinstanceofErroHttp?err.codigo:'ERRO_INTERNO'constmensagem=errinstanceofErroHttp?err.message:'Erro interno do servidor'if(status===500)console.error(err)res.status(status).json({erro:{mensagem,codigo}})}
⚠️ Atenção
Nada de inventar um terceiro formato de erro. ErroHttp e o envelope { erro: { mensagem, codigo } } vêm da Aula 08 e valem até o fim da trilha — inclusive na documentação Swagger da Aula 14. Um front que aprendeu a ler erro.mensagem uma vez lê para sempre.
Reaproveitando o padrão de validação da Aula 08, com o mesmo esquema completo nos dois pontos em que o PUT passa (middleware e service):
JavaScript
// unieventos-api/src/middlewares/validar.jsexportfunctionvalidar(esquema){return(req,res,next)=>{req.body=esquema.parse(req.body)// lança ZodError, capturado pelo tratadorErrosnext()}}
JavaScript
// unieventos-api/src/routes/eventos.routes.jsimport{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*aseventosControllerfrom'../controllers/eventosController.js'constrouter=Router()router.get('/',eventosController.listar)router.get('/:id',eventosController.buscarPorId)router.post('/',autenticar,validar(esquemaEvento),eventosController.criar)// PUT usa o esquema COMPLETO, o mesmo do POST — é o que o service revalida adiante.// O esquema parcial é do PATCH (Aula 09), não do PUT.router.put('/:id',autenticar,validar(esquemaEvento),eventosController.atualizar)router.patch('/:id',autenticar,validar(esquemaEventoParcial),eventosController.atualizarParcial)router.delete('/:id',autenticar,autorizar(['admin']),eventosController.remover)exportdefaultrouter
💡 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.
4. Store Pinia: estado da lista, item atual e paginação¶
JavaScript
// src/stores/eventosStore.jsimport{defineStore}from'pinia'import{ref}from'vue'import*aseventosServicefrom'@/services/eventosService'exportconstuseEventosStore=defineStore('eventos',()=>{constlista=ref([])constitemAtual=ref(null)constcarregando=ref(false)consterro=ref(null)constpaginacao=ref({pagina:1,porPagina:10,total:0,totalPaginas:0})asyncfunctioncarregar({pagina=1,porPagina=10}={}){carregando.value=trueerro.value=nulltry{constresultado=awaiteventosService.listarEventos({pagina,porPagina})lista.value=resultado.dadospaginacao.value=resultado.paginacao}catch(e){erro.value=e.response?.data?.erro?.mensagem??'Não foi possível carregar os eventos.'}finally{carregando.value=false}}asyncfunctionbuscar({termo='',categoria='',pagina=1}={}){carregando.value=trueerro.value=nulltry{constresultado=awaiteventosService.listarEventos({pagina,porPagina:paginacao.value.porPagina,busca:termo,categoria,})lista.value=resultado.dadospaginacao.value=resultado.paginacao}catch(e){erro.value=e.response?.data?.erro?.mensagem??'Não foi possível buscar os eventos.'}finally{carregando.value=false}}asyncfunctioncarregarUm(id){carregando.value=trueerro.value=nulltry{itemAtual.value=awaiteventosService.buscarEvento(id)}catch(e){erro.value=e.response?.data?.erro?.mensagem??'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.asyncfunctioncriar(evento){carregando.value=trueerro.value=nulltry{constnovoEvento=awaiteventosService.criarEvento(evento)lista.value=[novoEvento,...lista.value]returnnovoEvento}catch(e){erro.value=e.response?.data?.erro?.mensagem??'Não foi possível criar o evento.'throwe}finally{carregando.value=false}}asyncfunctionatualizar(id,evento){carregando.value=trueerro.value=nulltry{consteventoAtualizado=awaiteventosService.atualizarEvento(id,evento)constindice=lista.value.findIndex((e)=>e.id===Number(id))if(indice!==-1)lista.value[indice]=eventoAtualizadoreturneventoAtualizado}catch(e){erro.value=e.response?.data?.erro?.mensagem??'Não foi possível atualizar o evento.'throwe}finally{carregando.value=false}}asyncfunctionremover(id){carregando.value=trueerro.value=nulltry{awaiteventosService.removerEvento(id)lista.value=lista.value.filter((e)=>e.id!==Number(id))}catch(e){erro.value=e.response?.data?.erro?.mensagem??'Não foi possível excluir o evento.'throwe}finally{carregando.value=false}}return{lista,itemAtual,carregando,erro,paginacao,carregar,buscar,carregarUm,criar,atualizar,remover}})
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.
📌 Vale gravar
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 trilha, sempre pessimista.
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.
JavaScript
// src/services/storageService.jsimport{getStorage,refasstorageRef,uploadBytes,getDownloadURL}from'firebase/storage'import{auth}from'./firebase'conststorage=getStorage()exportasyncfunctionenviarImagemEvento(arquivo){if(!auth.currentUser){thrownewError('É preciso estar autenticado para enviar imagens.')}constnomeUnico=`${Date.now()}-${arquivo.name}`constcaminho=`eventos/${nomeUnico}`constreferencia=storageRef(storage,caminho)awaituploadBytes(referencia,arquivo)returngetDownloadURL(referencia)}
O trecho abaixo entra no EventoFormView.vue que você constrói no Passo 3 do Mão na massa — guarde-o para lá:
Vue SFC
<!-- trecho a adicionar em EventoFormView.vue: campo de upload -->
<script setup>
// os mesmos imports do Passo 3, mais o serviço de storage
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useEventosStore } from '@/stores/eventosStore'
import { enviarImagemEvento } from '@/services/storageService'
const enviandoImagem = ref(false)
async function aoSelecionarImagem(arquivos) {
// sem `multiple`, o v-file-input emite um File solto; com `multiple`, um array.
// Normalizar aqui evita o clássico "arquivo é undefined" em um dos dois casos.
const arquivo = Array.isArray(arquivos) ? arquivos[0] : arquivos
if (!arquivo) return
enviandoImagem.value = true
try {
form.value.imagemUrl = 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.imagemUrl" :src="form.imagemUrl" max-height="200" class="mb-4" cover />
</template>
💡 Dica
A alternativa é usar multer no 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 do POST/PUT.
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:
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-api está rodando mostra qualquer console.error do tratadorErros e, se usar morgan ou similar, cada requisição recebida — confirme que ela chegou, com o método e caminho certos.
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á usando cors(), ou está usando com origin que 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çalho Authorization.
Requisição aparece como OPTIONS seguida de falha → é o preflight automático do navegador para métodos como PUT/DELETE ou cabeçalhos customizados; se o servidor não responde 200/204 a esse OPTIONS, o navegador cancela a requisição real.
🔬 Investigue
Com front e API rodando, abra a aba Network, filtre por eventos e edite um evento. Você verá duas requisições para a mesma URL: um OPTIONS e o PUT. Clique no OPTIONS: qual foi o status e quais cabeçalhos Access-Control-Allow-* vieram na resposta? Agora saia da conta e recarregue a lista (um GET sem Authorization): apareceu algum OPTIONS antes dele? A diferença é a definição de "requisição simples" do CORS — GET sem cabeçalhos fora da lista segura dispensa o preflight; PUT com Authorization e Content-Type: application/json não.
⚠️ Atençãoorigin: '*' (liberar qualquer origem) parece resolver tudo rápido, mas não funciona junto com credentials: true — o navegador rejeita essa combinação por especificação. Como o UniEventos usa Authorization (não cookies), credentials: true nem é estritamente necessário aqui, mas vale registrar: se um dia usar cookies de sessão, origin precisa ser um domínio explícito, nunca *.
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 http.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.
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.
JavaScript
// src/router/index.js — trecho das rotas de evento (adicionar ao array de routes){path:'/eventos',name:'eventos-lista',component:()=>import('@/views/EventosListaView.vue'),},{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,},
⚠️ AtençãodataHora?.slice(0, 16) funciona porque o back-end devolve um ISO 8601 completo (2030-12-01T19:00:00.000Z) e o input datetime-local espera AAAA-MM-DDTHH:mm. É um detalhe de formato pequeno, mas quebra silenciosamente se esquecido — o campo simplesmente aparece vazio.
{"dados":[{"id":1,"titulo":"Semana da Computação","categoria":"palestra","dataHora":"2030-12-01T19:00:00.000Z","local":"Auditório Central","vagas":80,"vagasDisponiveis":62,"imagemUrl":null}],"paginacao":{"pagina":1,"porPagina":2,"total":8,"totalPaginas":4}}
Repare em três coisas: as chaves em camelCase, o vagasDisponiveisderivado pelo LEFT JOIN (não guardado em coluna) e o envelope { dados, paginacao }.
Depois o CRUD pela tela, na ordem:
Listar — /eventos mostra a tabela paginada; digitar na busca espera 400 ms e recarrega; ir para a página 2 dispara nova requisição (confira na aba Network que ?pagina=2 sai de verdade — paginação é no servidor).
Criar — "Novo evento" abre /eventos/novo em modo criação; salvar um evento válido volta para a lista com o evento novo no topo.
Editar — o lápis abre /eventos/:id/editarjá preenchido (se abrir vazio, o nome da rota está errado: evento-form-editar, não evento-form); alterar o título e salvar reflete na tabela.
Excluir — a lixeira (visível só para admin) pede confirmação e remove a linha; em um evento com inscritos, o snackbar mostra a mensagem de 409 vinda de erro.mensagem.
Erro de validação — envie um evento com vagas: 0: a resposta é 422, e a mensagem que aparece na tela é a do campo, não um "Erro interno".
Sem token — deslogue e tente criar pelo curl: 401, e o front redireciona para /login.
Se os seis passam, front e back estão falando exatamente o contrato da seção 1.
A1. Preveja o status e o corpo de cada chamada abaixo sem rodar, usando só o contrato da seção 1 e as rotas da seção 2.4. Depois rode as três e confira.
Terminal
# (a) sem cabeçalho Authorization
curl-i-XDELETEhttp://localhost:3000/api/eventos/7
# (b) com token válido de um usuário comum (não admin)
curl-i-XDELETEhttp://localhost:3000/api/eventos/7-H"Authorization: Bearer $TOKEN"# (c) com token de admin, mas o evento 7 tem 3 inscritos
curl-i-XDELETEhttp://localhost:3000/api/eventos/7-H"Authorization: Bearer $TOKEN_ADMIN"
Resultado esperado: (a) 401, barrado pelo autenticar antes de qualquer regra; (b) 403, barrado pelo autorizar(['admin']); (c) 409 com { "erro": { "mensagem": "Não é possível excluir evento com inscritos.", "codigo": "CONFLITO" } }, vindo do service.
A2. O front chama GET /api/eventos?pagina=0&porPagina=500. Que valores de paginacao.pagina e paginacao.porPagina voltam na resposta? Aponte a linha de eventosService.listar (back-end) que decide cada um.
Resultado esperado: pagina: 1 (o Math.max(1, …) corrige o zero) e porPagina: 50 (o Math.min(50, …) corta o excesso) — nenhum erro é devolvido, os valores são apenas normalizados.
A3. Verdadeiro ou falso, com justificativa de uma linha: "No Express 5, o controller criar precisa de try/catch para que um ZodError lançado dentro de eventosService.criar chegue ao tratadorDeErros."
Resultado esperado: falso — o Express 5 encaminha automaticamente a rejeição de um handler async para o middleware de erro; o try/catch seria redundante (e, se engolisse o erro sem next(err), faria a requisição travar sem resposta).
A4. Em duas linhas: por que EventoFormView.vue faz evento.dataHora?.slice(0, 16) antes de preencher o formulário? O que aparece no campo se você remover o .slice?
Resultado esperado: o back-end devolve ISO completo (2030-12-01T19:00:00.000Z) e o datetime-local só aceita AAAA-MM-DDTHH:mm; sem o .slice, o navegador descarta o valor inteiro e o campo aparece vazio, sem aviso no console.
A5. Na store, remover(id) termina com lista.value.filter((e) => e.id !== Number(id)). Se alguém trocar por e.id !== id e o id chegar como a string '3' vinda de route.params, o que o usuário vê na tela logo depois de excluir? E depois de apertar F5? Explique a diferença.
Resultado esperado: com !== estrito entre número e string a comparação nunca é falsa, então a linha continua na tabela mesmo depois do 204 do servidor; após o F5 ela some, porque a lista é recarregada do banco — o clássico "some quando recarrego", sinal de estado local dessincronizado do servidor.
B1. Contrato documentado. Escreva a tabela de contrato (seção 1) para uma entidade do seu projeto autoral.
Resultado esperado: cinco linhas (método, caminho, autenticação) e, abaixo da tabela, um bloco JSON de corpo e um de resposta para o POST e para o GET de lista.
Dica
Cinco linhas — uma por endpoint — método, caminho, autenticação. Corpo e resposta podem ir em blocos JSON abaixo da tabela.
B2. 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).
Resultado esperado: os 5 endpoints respondem no curl com os status do contrato; um POST com campo inválido devolve 422 com detalhes apontando o campo; ?pagina=2&porPagina=5 muda a fatia devolvida.
Dica
Reaproveite a estrutura de eventosService.js — troque só os campos do z.object.
B3. 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.
Resultado esperado: cada ação só altera lista depois do await; provocar um 422 deixa erro preenchido e carregando de volta em false.
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.
B4. 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.
Resultado esperado: buscar, paginar, criar, editar e excluir funcionam sem F5; o formulário de edição abre preenchido, inclusive o campo de data.
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.
B5. Depuração guiada. Provoque de propósito um erro 422 (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.
Resultado esperado: dois prints (um 422 com o detalhes visível na aba Response; um bloqueio de CORS com a mensagem do console) e duas linhas dizendo qual lado — front ou back — causou cada um.
Dica
Depois do teste de CORS, não esqueça de voltar o origin correto — é fácil esquecer e passar a aula seguinte "quebrada".
C1. Inscrição atômica, ponta a ponta. A função verificarVagaEInscrever do repositório (seção 2.1) abre a transação, trava a linha com FOR UPDATE e confere as vagas — mas ainda não insere a inscrição. Complete-a e exponha o recurso: POST /api/eventos/:id/inscricoes (autenticado) responde 201 com a inscrição, 404 se o evento não existe e 409 se não há vagas; no front, um botão "Inscrever-se" que some quando vagasDisponiveis chega a zero. Para fechar, prove a atomicidade: com um evento de 5 vagas, dispare 20 requisições simultâneas e confira no banco quantas inscrições existem.
Resultado esperado: SELECT COUNT(*) FROM inscricoes WHERE evento_id = ? devolve exatamente 5, e as outras 15 respostas foram 409.
Dica
O INSERT INTO inscricoes (evento_id, usuario_uid) precisa acontecer entre o SELECT ... FOR UPDATE e o commit(), usando a mesma conexao. Os erros já saem no formato certo: erroNaoEncontrado() vira 404 e o ErroHttp(409, …, 'SEM_VAGAS') vira 409 no tratadorDeErros. Para as 20 requisições simultâneas, um script Node com Promise.all(Array.from({ length: 20 }, () => fetch(url, opcoes))) basta — use tokens de usuários diferentes, ou a UNIQUE (evento_id, usuario_uid) vai barrar antes da regra de vagas.
Um colega "melhorou" a EventosListaView.vue e agora acontece algo estranho: navegue até a página 3 da tabela e digite "docker" na busca — a tabela mostra "Nenhum evento encontrado", mas o rodapé insiste que existem 2 resultados. O "Minicurso de Docker" está lá no banco. Este é o trecho alterado:
JavaScript
// src/views/EventosListaView.vue — trecho com o bug plantadowatch(termoBusca,()=>{clearTimeout(temporizadorBusca)temporizadorBusca=setTimeout(()=>{carregarPagina()},400)})
Antes de olhar o código, abra a aba Network: qual query string está indo para a API, e o que a resposta traz em dados e em paginacao? A resposta do servidor está errada — ou está certa demais?
Critérios de pronto
Um comentário no topo do arquivo registra a URL exata da requisição que reproduz o bug e o JSON de paginacao devolvido.
Buscar a partir de qualquer página mostra os resultados corretos, e o rodapé da tabela bate com o que está na tela.
Limpar a busca (botão clearable) também volta a um estado coerente, sem requisição duplicada na aba Network.
Uma frase no comentário explica por que o back-end não tem culpa nenhuma.
Pistas
Na aba Network, clique na requisição de eventos e compare os parâmetros pagina e busca com paginacao.totalPaginas da resposta.
Compare o watch acima com a versão do Passo 1 do "Mão na massa" — uma linha sumiu.
Se, ao restaurar a linha, a busca disparar duas requisições, investigue o watch(() => opcoesTabela.value.page, ...): mudar a página também chama carregarPagina.
A store desta aula é pessimista de propósito (seção 5). Mas abra o DevTools, ative o throttling "Slow 3G" na aba Network e exclua um evento: o botão gira por três segundos antes de a linha sumir. Gmail e Trello removem o item na hora — e o devolvem à lista se o servidor recusar. Implemente a exclusão otimista em eventosStore.remover e responda, com medições, se ela vale a complexidade extra neste projeto.
Critérios de pronto
A linha some da tabela imediatamente ao confirmar a exclusão, antes de a API responder.
Se a API responder 409 (evento com inscritos), a linha volta na mesma posição em que estava, sem duplicar, e um snackbar explica o motivo.
carregando, erro e paginacao.total continuam coerentes nos dois caminhos (sucesso e falha).
Um comentário no topo da função registra o tempo entre o clique e o sumiço da linha, com e sem otimismo, medido com throttling "Slow 3G".
Um parágrafo no README do projeto autoral diz para quais operações você adotaria a estratégia otimista e por quê.
Pistas
Guarde o índice e uma cópia do item antes de removê-lo da lista: const indice = lista.value.findIndex(...) e const copia = lista.value[indice].
No catch, lista.value.splice(indice, 0, copia) devolve o item ao lugar original.
Para forçar o 409 sem ter o endpoint de inscrições, insira uma linha direto no MySQL: INSERT INTO inscricoes (evento_id, usuario_uid) VALUES (7, 'teste').
Meça com performance.now() antes do filter e dentro do finally; o throttling fica no menu de velocidade da aba Network.
O botão de editar só aparece para quem está logado, e o de excluir só para admin. Mas botão escondido não é permissão: qualquer pessoa com um token válido consegue montar um PUT /api/eventos/3 no curl e editar o evento que outra pessoa criou, porque a seção 1 deixou essa decisão em aberto ("qualquer autenticado pode editar"). Feche a brecha: um evento passa a ter dono, e só o dono ou um admin pode editá-lo ou excluí-lo.
Critérios de pronto
A tabela eventos ganha a coluna criado_por (uid do Firebase), preenchida no POST a partir do token — nunca a partir do corpo da requisição.
PUT e DELETE feitos por quem não é dono nem admin respondem 403 com { "erro": { "mensagem": "...", "codigo": "..." } }; a regra mora no service, não na rota.
O contrato da seção 1 e a store são atualizados: a resposta de GET inclui criadoPor (o linhaParaEvento ganha mais uma linha), e a tela só mostra os botões para o dono ou para admin.
Um script docs/teste-permissoes.sh com quatro chamadas curl (dono edita, outro usuário tenta editar, admin exclui, anônimo tenta excluir) e o status esperado em comentário ao lado de cada uma.
Pistas
O middleware autenticar da Aula 10 já deixa req.usuario.uid disponível; passe esse uid do controller para o service junto com req.body.
ALTER TABLE eventos ADD COLUMN criado_por VARCHAR(128) NULL e um UPDATE para preencher os eventos antigos com o uid do admin.
No service, atualizar(id, dados, solicitante): busque o evento, compare evento.criado_por com solicitante.uid e verifique se o solicitante é admin (a mesma informação que o middleware autorizar usa) antes de tocar no repositório.
Para obter dois tokens diferentes: faça login com contas diferentes em duas janelas anônimas e rode await getAuth().currentUser.getIdToken() no console de cada uma.
LIMIT 10 OFFSET 99990 obriga o MySQL a ler e descartar 99.990 linhas antes de devolver 10 — a última página é sempre a mais lenta. Gere 100 mil eventos falsos, meça o tempo das primeiras e das últimas páginas e depois implemente a alternativa que Twitter e Slack usam: paginação por cursor, em que o cliente pede "os 10 próximos depois deste ponto" em vez de "a página N".
Critérios de pronto
Um script scripts/semear.js insere 100.000 eventos em lotes (INSERT ... VALUES (...), (...)) em menos de um minuto.
Uma tabela no README compara o tempo de resposta (aba Network ou curl -w '%{time_total}') de ?pagina=1, ?pagina=5000 e ?pagina=10000, antes e depois.
GET /api/eventos?depois=<cursor>&porPagina=10 devolve os próximos 10 eventos em ordem de dataHora, id e um campo proximoCursor (null na última página).
O EXPLAIN das duas consultas está colado no README, com uma frase apontando a diferença nas colunas rows e type.
A tela de listagem continua funcionando no modo antigo (pagina) — o novo modo é adicional, e a store escolhe um deles.
Pistas
Procure por "keyset pagination": a condição é WHERE (data_hora, id) > (?, ?) ORDER BY data_hora, id LIMIT ?, e um índice composto (data_hora, id) é o que faz a diferença.
O cursor pode ser simplesmente data_hora e id do último item, codificados em base64 para o cliente não precisar entender o formato: Buffer.from(JSON.stringify([data, id])).toString('base64').
Para gerar dados, Array.from({ length: 1000 }) por lote e datas espalhadas com new Date(Date.now() + i * 60000) evitam empates no cursor.
v-data-table-server não sabe o que é cursor; para o modo novo, um botão "Carregar mais" que concatena em lista.value é mais honesto do que forçar a tabela.
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.
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.
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.
Checklist antes de começar:
[ ] unieventos-web funcionando 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.
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.
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.
📌 Vale gravar
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.
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-1 se 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 com eyJ... (é 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 chave anon só consegue fazer o que as policies permitirem. Já a service_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. Use service_role só em ambiente de servidor (scripts administrativos, back-end próprio), nunca em código que roda no navegador.
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).
-- Tabela de eventos. uuid como PK (padrão do Supabase/Postgres,-- gerado automaticamente, sem depender de auto-incremento sequencial).createtableeventos(iduuidprimarykeydefaultgen_random_uuid(),titulotextnotnull,descricaotextnotnull,categoriatextnotnullcheck(categoriain('palestra','minicurso','workshop')),data_horatimestamptznotnull,localtextnotnull,vagasintegernotnullcheck(vagas>0),imagem_urltext,usuario_iduuidnotnullreferencesauth.users(id),criado_emtimestamptznotnulldefaultnow());-- Tabela de inscrições, referenciando eventos e o usuário autenticado.createtableinscricoes(iduuidprimarykeydefaultgen_random_uuid(),evento_iduuidnotnullreferenceseventos(id)ondeletecascade,usuario_iduuidnotnullreferencesauth.users(id),criado_emtimestamptznotnulldefaultnow(),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 um timestamp sem fuso, que é ambíguo. auth.users é uma tabela que o próprio Supabase Auth já cria e mantém — é para lá que signUp/signInWithPassword gravam. 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.
Toda tabela criada pelo SQL Editor (o caminho que usamos na §3) nasce 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 é:
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:
JSON
{"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.
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.
🧠 Você sabia?
A "API REST autogerada" do Supabase não é código deles: é o PostgREST, projeto de código aberto criado por Joe Nelson em 2014 que transforma qualquer schema Postgres em uma API HTTP. Cada supabase.from('eventos').select('*').eq('categoria', 'palestra') vira, na prática, um GET /rest/v1/eventos?select=*&categoria=eq.palestra — e é o PostgREST que repassa o seu JWT ao Postgres para que auth.uid() funcione dentro das policies. O RLS, por sua vez, existe no Postgres desde a versão 9.5, lançada em 2016 — bem antes de o Supabase existir.
Policies: leitura pública, inserção autenticada, edição/exclusão só do dono¶
SQL
-- LEITURA: qualquer pessoa (mesmo não autenticada) pode ver eventos.createpolicy"eventos_leitura_publica"oneventosforselectusing(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).createpolicy"eventos_insercao_autenticada"oneventosforinserttoauthenticatedwithcheck(auth.uid()=usuario_id);-- EDIÇÃO: só o dono do evento pode editar.createpolicy"eventos_edicao_dono"oneventosforupdatetoauthenticatedusing(auth.uid()=usuario_id)withcheck(auth.uid()=usuario_id);-- EXCLUSÃO: só o dono pode excluir.createpolicy"eventos_exclusao_dono"oneventosfordeletetoauthenticatedusing(auth.uid()=usuario_id);
SQL
-- 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).createpolicy"inscricoes_leitura_publica"oninscricoesforselectusing(true);createpolicy"inscricoes_insercao_propria"oninscricoesforinserttoauthenticatedwithcheck(auth.uid()=usuario_id);createpolicy"inscricoes_exclusao_propria"oninscricoesfordeletetoauthenticatedusing(auth.uid()=usuario_id);
As duas cláusulas parecem sinônimos, mas checam momentos diferentes:
USING filtra quais linhas existentes a operação pode enxergar/afetar. Vale para SELECT, UPDATE e DELETE — é a condição "essa linha, que já está no banco, pode ser vista/alterada/apagada por você?".
WITH CHECK valida os dados da linha depois da operação (ou os dados que vão ser inseridos). Vale para INSERT e UPDATE — é 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).
📌 Vale gravarUSING = 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?). INSERT só tem WITH CHECK (não existe linha "antes"). SELECT/DELETE só têm USING. UPDATE tem os dois.
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¶
// consultas de exemplo — cole no console do navegador ou num componente de teste// Selecionar colunas específicasconst{data,error}=awaitsupabase.from('eventos').select('id, titulo, categoria, data_hora, vagas')// Filtros: eq, neq, gt, lt, like, ilike, inconst{data:palestras}=awaitsupabase.from('eventos').select('*').eq('categoria','palestra')const{data:buscaPorTitulo}=awaitsupabase.from('eventos').select('*').ilike('titulo','%semana%')// ilike = LIKE case-insensitiveconst{data:futuros}=awaitsupabase.from('eventos').select('*').gt('data_hora',newDate().toISOString())const{data:algumasCategorias}=awaitsupabase.from('eventos').select('*').in('categoria',['palestra','workshop'])// Ordenaçãoconst{data:ordenados}=awaitsupabase.from('eventos').select('*').order('data_hora',{ascending:true})// Paginação: range(inicio, fim), ambos inclusive, base 0constpagina=1constporPagina=10constinicio=(pagina-1)*porPaginaconstfim=inicio+porPagina-1const{data:pagina1,count}=awaitsupabase.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}=awaitsupabase.from('eventos').select('*').eq('id','algum-uuid-aqui').single()
⚠️ Atençãosingle()estoura em erro se a consulta não retornar exatamente uma linha — nem zero, nem duas ou mais. Se o id pode não existir (ex.: usuário editou a URL na mão), trate o error em vez de assumir que data sempre vem preenchido. Para o caso "pode não existir, e tudo bem", use .maybeSingle() no lugar de .single() — ele devolve data: null sem erro quando não encontra.
🔬 Investigue
Rode a consulta paginada acima (com count: 'exact') com a aba Network aberta e filtre por rest/v1. Abra a requisição: repare na URL (/rest/v1/eventos?select=*&order=data_hora.asc), nos cabeçalhos apikey (a chave anon) e Authorization: Bearer ... (a mesma chave anon quando você está deslogado; o token de sessão do usuário quando está logado), no cabeçalho de requisição Range: 0-9 e no de resposta Content-Range: 0-9/34. Agora peça .range(500, 509) numa tabela com poucas linhas e anote o status e o Content-Range que voltam — eles explicam por que uma página "além do fim" merece tratamento na sua store.
{ data, error }: por que try/catch sozinho não basta¶
O supabase-jsnã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:
JavaScript
// ERRADO — o try/catch aqui nunca vê o erro de RLS/validaçãotry{const{data}=awaitsupabase.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 explicitamenteconst{data,error}=awaitsupabase.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 null em vez de lançar. try/catch continua útil para erros de rede (sem internet, timeout), mas a lógica de negócio do Supabase sempre passa pelo error do objeto retornado. Esqueça isso e você vai debugar "por que meu insert não fez nada" sem nunca ver a mensagem real.
// INSERT — .select() no final devolve a linha criada (senão, data vem null)const{data:novoEvento,error:erroInsert}=awaitsupabase.from('eventos').insert({titulo:'Minicurso de Docker',descricao:'Introdução prática a containers',categoria:'minicurso',data_hora:'2030-12-10T14:00:00-04:00',local:'Laboratório 3',vagas:30,usuario_id:(awaitsupabase.auth.getUser()).data.user.id,}).select().single()// UPDATE — sempre com .eq() para não atualizar a tabela inteiraconst{data:eventoAtualizado,error:erroUpdate}=awaitsupabase.from('eventos').update({vagas:40}).eq('id',novoEvento.id).select().single()// DELETEconst{error:erroDelete}=awaitsupabase.from('eventos').delete().eq('id',novoEvento.id)
⚠️ Atenção
Um update() ou delete()sem .eq(...) (ou outro filtro) tenta afetar a tabela inteira. O RLS te protege de estragos globais (a policy usuario_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.
O Supabase entende as foreign keys que você declarou e permite buscar dados relacionados dentro do mesmo select, sem escrever JOIN manualmente:
JavaScript
// Buscar eventos já trazendo as inscrições relacionadasconst{data:eventosComInscritos,error}=awaitsupabase.from('eventos').select('*, inscricoes(*)')// eventosComInscritos[0].inscricoes é um array com as inscrições daquele evento// Contagem de relacionados sem trazer todas as linhasconst{data:eventosComContagem}=awaitsupabase.from('eventos').select('*, inscricoes(count)')
// src/services/supabaseAuthService.jsimport{supabase}from'./supabase'exportasyncfunctioncadastrar(email,senha){const{data,error}=awaitsupabase.auth.signUp({email,password:senha})if(error)thrownewError(error.message)returndata.user}exportasyncfunctionentrar(email,senha){const{data,error}=awaitsupabase.auth.signInWithPassword({email,password:senha})if(error)thrownewError(error.message)returndata.user}exportasyncfunctionsair(){const{error}=awaitsupabase.auth.signOut()if(error)thrownewError(error.message)}exportasyncfunctionobterSessaoAtual(){const{data}=awaitsupabase.auth.getSession()returndata.session}// Observa login/logout/renovação de token, igual ao onAuthStateChanged// do Firebase que vimos na Aula 10.exportfunctionobservarAutenticacao(callback){const{data:assinatura}=supabase.auth.onAuthStateChange((_evento,sessao)=>{callback(sessao)})// devolvemos uma função que chama o método NO objeto — passar// `assinatura.subscription.unsubscribe` solto perderia o `this`return()=>assinatura.subscription.unsubscribe()}
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.
JavaScript
// src/stores/authStoreSupabase.jsimport{defineStore}from'pinia'import{ref,computed}from'vue'import{obterSessaoAtual,observarAutenticacao}from'@/services/supabaseAuthService'exportconstuseAuthStore=defineStore('auth',()=>{constusuario=ref(null)constcarregando=ref(false)constinicializado=ref(false)letpromessaInicializacao=nullfunctioninicializar(){if(promessaInicializacao)returnpromessaInicializacaopromessaInicializacao=newPromise((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??nullif(!inicializado.value){inicializado.value=trueresolve()}})})returnpromessaInicializacao}constestaLogado=computed(()=>usuario.value!==null)return{usuario,carregando,inicializado,inicializar,estaLogado}})
JavaScript
// 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)=>{constauthStore=useAuthStore()awaitauthStore.inicializar()if(to.meta.requerAuth&&!authStore.estaLogado){return{name:'login',query:{redirect:to.fullPath}}}returntrue})
💡 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.
Antes do código, um passo no painel: o Realtime só emite eventos de tabelas incluídas na publicação de replicação lógica, e nenhuma tabela entra nela por padrão. Vá em Database → Replication, abra a publicação supabase_realtime e marque a tabela eventos. Sem isso, o subscribe() conecta, não dá erro nenhum, e simplesmente nada acontece — é o motivo nº 1 de "meu Realtime não funciona".
JavaScript
// trecho de EventosListaView.vue (variante Supabase)import{onMounted,onUnmounted}from'vue'import{supabase}from'@/services/supabase'letcanal=nullonMounted(()=>{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 tipo de momento que mais impressiona quando demonstrado ao vivo — vale ver isso funcionando 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 o supabase-js manté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:
JavaScript
// cole no console do navegador, na página do seu app rodando com Viteconst{supabase}=awaitimport('/src/services/supabase.js')const{data,error}=awaitsupabase.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.
JavaScript
// src/stores/eventosStoreSupabase.jsimport{defineStore}from'pinia'import{ref}from'vue'import{supabase}from'@/services/supabase'exportconstuseEventosStore=defineStore('eventos',()=>{constlista=ref([])constitemAtual=ref(null)constcarregando=ref(false)consterro=ref(null)constpaginacao=ref({pagina:1,porPagina:10,total:0,totalPaginas:0})asyncfunctioncarregar({pagina=1,porPagina=10}={}){carregando.value=trueerro.value=nullconstinicio=(pagina-1)*porPaginaconstfim=inicio+porPagina-1const{data,error,count}=awaitsupabase.from('eventos').select('*',{count:'exact'}).order('data_hora',{ascending:true}).range(inicio,fim)if(error){erro.value=error.message}else{lista.value=datapaginacao.value={pagina,porPagina,total:count,totalPaginas:Math.ceil(count/porPagina)}}carregando.value=false}asyncfunctioncarregarUm(id){carregando.value=trueerro.value=nullconst{data,error}=awaitsupabase.from('eventos').select('*').eq('id',id).maybeSingle()if(error){erro.value=error.message}elseif(!data){erro.value='Evento não encontrado.'}else{itemAtual.value=data}carregando.value=false}asyncfunctioncriar(evento){carregando.value=trueerro.value=nullconst{data:sessao}=awaitsupabase.auth.getUser()const{data,error}=awaitsupabase.from('eventos').insert({...evento,usuario_id:sessao.user.id}).select().single()carregando.value=falseif(error){erro.value=error.messagethrownewError(error.message)}lista.value=[data,...lista.value]returndata}asyncfunctionatualizar(id,evento){carregando.value=trueerro.value=nullconst{data,error}=awaitsupabase.from('eventos').update(evento).eq('id',id).select().single()carregando.value=falseif(error){erro.value=error.messagethrownewError(error.message)}constindice=lista.value.findIndex((e)=>e.id===id)if(indice!==-1)lista.value[indice]=datareturndata}asyncfunctionremover(id){carregando.value=trueerro.value=nullconst{error}=awaitsupabase.from('eventos').delete().eq('id',id)carregando.value=falseif(error){erro.value=error.messagethrownewError(error.message)}lista.value=lista.value.filter((e)=>e.id!==id)}return{lista,itemAtual,carregando,erro,paginacao,carregar,carregarUm,criar,atualizar,remover}})
⚠️ AtençãoauthStore.usuario?.id === evento.usuario_id no template controla só a exibição do botão — é UX, igual ao guard de rota da Aula 10. A garantia de verdade é a policy eventos_edicao_dono (seção 4): mesmo que alguém forje uma requisição de update para um evento alheio direto contra a API do Supabase, o banco recusa porque auth.uid() não bate com usuario_id.
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.
Repare numa diferença que a tela do Passo 2 deixou passar de propósito: falando direto com o Supabase, o front recebe as colunas cruas do Postgres (evento.data_hora, evento.imagem_url) e o template se adaptou a elas. Do lado da API Express, o mesmo evento chega em camelCase (dataHora, imagemUrl), porque o repositório do back-end traduz. Duas implementações da "mesma" interface devolvendo nomes de campo diferentes não é um Adapter — é um vazamento. No Passo 4, a conversão passa a ser responsabilidade explícita do adaptador Supabase, e a tela volta a falar um vocabulário só.
Passo 3 — Interface comum e implementação para a API Express¶
Passo 4 — Mesma interface, implementação Supabase¶
JavaScript
// src/repositories/eventosRepoSupabase.jsimport{supabase}from'@/services/supabase'// mesma tradução snake_case → camelCase que o repositório MySQL faz na Aula 11functionlinhaParaEvento(linha){if(!linha)returnnullreturn{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,usuarioId:linha.usuario_id,}}// e o caminho inverso, para insert/updatefunctioneventoParaLinha(evento){return{titulo:evento.titulo,descricao:evento.descricao,categoria:evento.categoria,data_hora:evento.dataHora,local:evento.local,vagas:evento.vagas,imagem_url:evento.imagemUrl||null,}}exportconsteventosRepoSupabase={asynclistar({pagina=1,porPagina=10}={}){constinicio=(pagina-1)*porPaginaconstfim=inicio+porPagina-1const{data,error,count}=awaitsupabase.from('eventos').select('*',{count:'exact'}).order('data_hora',{ascending:true}).range(inicio,fim)if(error)thrownewError(error.message)// Formato devolvido igual ao da API Express — é isso que faz o// Adapter funcionar: a FORMA da resposta precisa ser a mesma.// Inclusive o NOME DOS CAMPOS: as colunas do Postgres são snake_case// (data_hora, imagem_url), mas o contrato do front é camelCase desde a// Aula 06. Do lado Express quem traduz é o repositório do back-end; aqui,// como não existe back-end nosso no meio, a tradução é obrigação do Adapter.return{dados:data.map(linhaParaEvento),paginacao:{pagina,porPagina,total:count,totalPaginas:Math.ceil(count/porPagina)},}},asyncbuscarPorId(id){const{data,error}=awaitsupabase.from('eventos').select('*').eq('id',id).single()if(error)thrownewError(error.message)returnlinhaParaEvento(data)},asynccriar(evento){const{data:sessao}=awaitsupabase.auth.getUser()const{data,error}=awaitsupabase.from('eventos').insert({...eventoParaLinha(evento),usuario_id:sessao.user.id}).select().single()if(error)thrownewError(error.message)returnlinhaParaEvento(data)},asyncatualizar(id,evento){const{data,error}=awaitsupabase.from('eventos').update(eventoParaLinha(evento)).eq('id',id).select().single()if(error)thrownewError(error.message)returnlinhaParaEvento(data)},asyncremover(id){const{error}=awaitsupabase.from('eventos').delete().eq('id',id)if(error)thrownewError(error.message)},}
Passo 5 — Trocando a implementação por variável de ambiente¶
JavaScript
// src/repositories/eventosRepo.jsimport{eventosRepoExpress}from'./eventosRepoExpress'import{eventosRepoSupabase}from'./eventosRepoSupabase'// VITE_BACKEND=express ou VITE_BACKEND=supabase no .envconstbackendEscolhido=import.meta.env.VITE_BACKEND??'express'exportconsteventosRepo=backendEscolhido==='supabase'?eventosRepoSupabase:eventosRepoExpress
JavaScript
// src/services/eventosService.js — reescrito para usar o Adapterimport{eventosRepo}from'@/repositories/eventosRepo'exportfunctionlistarEventos(params){returneventosRepo.listar(params)}exportfunctionbuscarEvento(id){returneventosRepo.buscarPorId(id)}exportfunctioncriarEvento(evento){returneventosRepo.criar(evento)}exportfunctionatualizarEvento(id,evento){returneventosRepo.atualizar(id,evento)}exportfunctionremoverEvento(id){returneventosRepo.remover(id)}
Terminal
# .env — uma linha decide qual back-end o front usaVITE_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.
📌 Vale gravar
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.
O teste do Adapter é o mesmo roteiro executado duas vezes, com uma linha de .env de diferença. Faça assim:
Com VITE_BACKEND=express no .env (e a unieventos-api + MySQL rodando), abra o UniEventos: liste, crie, edite e exclua um evento. Anote o que aparece na aba Network: requisições para http://localhost:3000/api/eventos.
Pare o npm run dev, troque para VITE_BACKEND=supabase, suba de novo e repita exatamente os mesmos quatro passos. Agora as requisições saem para https://<seu-projeto>.supabase.co/rest/v1/eventos.
Resultado esperado: as duas rodadas se comportam igual na tela — mesma lista, mesmo formulário preenchido na edição, mesma data formatada (sinal de que a tradução data_hora → dataHora do Passo 4 está funcionando), mesmo comportamento do botão de excluir. Nenhum arquivo dentro de stores/ ou views/ foi tocado entre uma rodada e outra: confirme com git status.
Dois testes negativos fecham a verificação:
RLS de verdade — deslogado, tente criar um evento pelo console do navegador: await supabase.from('eventos').insert({ titulo: 'teste' }). Resultado esperado: error de violação de policy, data: null — a tela nem precisa impedir, o banco impede.
Realtime — com a replicação habilitada, abra o UniEventos em duas abas e crie um evento numa delas. Resultado esperado: a lista da outra aba se atualiza sozinha, sem F5.
A1. A tabela eventos está com RLS habilitado e tem só a policy eventos_leitura_publica. Um usuário autenticado roda supabase.from('eventos').insert({ ... }).select(). Preveja o que vem em { data, error } — e compare com o que vem num select quando não existe policy nenhuma. Por que os dois casos se comportam de forma diferente?
Resultado esperado: o insert devolve data: null e um error de violação de policy (código 42501), porque não existe policy de insert; o select sem policy nenhuma devolve data: [] e error: null — silêncio, não erro. RLS nega por padrão, e negar uma leitura é simplesmente não devolver linhas.
A2. Complete as lacunas da policy que permite a um usuário alterar apenas as próprias inscrições, sem poder transferi-las para outra pessoa. Depois diga qual das duas cláusulas impede o "roubo" de uma inscrição.
Resultado esperado: using (auth.uid() = usuario_id) e with check (auth.uid() = usuario_id). É o with check que impede o roubo: ele valida a linha depois da alteração, barrando um update que tente trocar o usuario_id para outra pessoa.
A3. Verdadeiro ou falso, com justificativa: "Um try/catch ao redor de await supabase.from('eventos').delete().eq('id', id) captura a violação de policy quando o usuário tenta apagar um evento alheio."
Resultado esperado: falso — o supabase-js não lança exceção nesses casos; ele resolve a Promise com { data, error }. Um delete que não casa com a policy nem chega a ser erro: afeta zero linhas e volta error: null. Só olhando o retorno (e o count) é que você descobre o que aconteceu.
A4. Uma tabela tem 15 eventos. Preveja data.length e count para .select('*', { count: 'exact' }).range(10, 19). E para .range(0, 4)? Que combinação de pagina/porPagina da store produz cada chamada?
Resultado esperado: .range(10, 19) devolve data.length === 5 (só existem 15 linhas) e count === 15 — count é sempre o total da consulta, não o da fatia; corresponde a pagina: 2, porPagina: 10. .range(0, 4) devolve data.length === 5 e count === 15, correspondendo a pagina: 1, porPagina: 5.
A5. Em duas linhas: a chave anon vai para o bundle público do front e isso é seguro por design; a service_role não pode ir. O que exatamente cada uma "respeita" ou "ignora" dentro do Postgres?
Resultado esperado: a anon entra como o papel anon/authenticated e respeita todas as policies de RLS — por isso pode ser pública. A service_roleignora o RLS por completo (é BYPASSRLS): com ela, qualquer pessoa lê e escreve qualquer linha de qualquer tabela. Ela só existe para código de servidor.
A6.carregarUm(id) da store usa .maybeSingle(); buscarPorId do eventosRepoSupabase usa .single(). Para um id inexistente, preveja o { data, error } de cada um e diga qual dos dois comportamentos o eventosRepoExpress (que devolve 404) espelha melhor.
Resultado esperado: .maybeSingle() devolve data: null, error: null; .single() devolve data: null e um error (PGRST116 — 0 linhas onde se esperava exatamente 1). O .single() espelha melhor o Express, porque também transforma "não encontrei" em erro, que é o que a store precisa para exibir "Evento não encontrado".
B1. 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.
Resultado esperado: as tabelas aparecem no Table Editor e um select feito do front devolve data: [] sem erro — o sinal de que o RLS está ligado e ainda não há policy.
Dica
Habilite RLS na mesma migração/script SQL em que cria a tabela — não deixe para depois, é fácil esquecer.
B2. Policies completas. Escreva as quatro policies (leitura pública, inserção autenticada, edição e exclusão só do dono) para sua tabela principal.
Resultado esperado: deslogado, select funciona e insert devolve error de policy; logado como A, editar uma linha de B afeta zero linhas.
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.
B3. CRUD com supabase-js. Implemente select, insert, update, delete da sua entidade, sempre desestruturando { data, error } e tratando o erro.
Resultado esperado: cada operação passa por um if (error); um insert que viola uma check constraint mostra a mensagem real do Postgres na tela, não um null silencioso.
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.
B4. Realtime funcionando. Assine mudanças na sua tabela principal e demonstre, em duas abas, uma lista atualizando sozinha.
Resultado esperado: criar um registro numa aba faz a lista da outra atualizar em menos de um segundo, sem F5, e sair da tela remove o canal (removeChannel).
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.
C1. 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. Atenção a dois detalhes que costumam quebrar. (1) Os id são INT no MySQL e uuid no Supabase — a store e as rotas precisam funcionar com os dois. (2) A tabela do Supabase tem uma coluna de dono (usuario_id, exigida pelas policies de RLS) que a tabela MySQL das Aulas 09/11 não tem: decida se o Adapter devolve esse campo só num dos lados (e a tela lida com undefined) ou se você acrescenta criado_por também no MySQL — e escreva a decisão em uma linha no README.
Resultado esperado: trocar VITE_BACKEND no .env e reiniciar o npm run dev mantém a tela funcionando sem tocar em nenhuma linha de store ou view, inclusive o formulário de edição.
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. Se a edição quebrar só num dos back-ends, procure um Number(id) que não deveria estar na store.
Um colega criou a tabela de comentários de eventos com o SQL abaixo e reclamou de dois "bugs do Supabase": visitantes deslogados não veem comentário nenhum (a página fica vazia, sem erro), e um usuário conseguiu "roubar" o comentário de outro. Os dois problemas estão nas policies — o Supabase está fazendo exatamente o que foi mandado.
Você reproduz os dois problemas antes de corrigir: um select deslogado devolvendo data: [] e um update({ usuario_id: '<uuid de outro usuário>' }) bem-sucedido feito pelo dono original.
As policies corrigidas: leitura pública de verdade e edição que não permite trocar o dono.
Um teste manual documentado em docs/policies-comentarios.md: quatro chamadas (select deslogado, insert deslogado, update do próprio texto, update tentando mudar usuario_id) com o { data, error } observado em cada uma.
A policy de delete para o dono, que estava faltando.
Pistas
Releia "USING × WITH CHECK" na seção 4: uma das policies só tem metade do que precisa.
to authenticated exclui explicitamente o papel anon — e qual é o papel de quem nunca fez login?
Para testar como outro usuário sem trocar de conta, procure "Testing policies" na documentação de RLS do Supabase: a ideia é trocar o papel (set role authenticated) e injetar o sub do JWT antes da consulta no SQL Editor.
Rode npm run build e procure eyJ dentro de dist/assets/*.js: a sua chave anon está lá, legível para qualquer pessoa que abrir o site. Isso é esperado — mas o que exatamente alguém consegue fazer com ela e um terminal? Descubra usando só curl contra a API REST do seu projeto, sem supabase-js, e mostre que a única coisa entre a chave pública e os seus dados são as policies.
Critérios de pronto
Um script docs/anon-vs-rls.sh com pelo menos quatro chamadas curl a https://<projeto>.supabase.co/rest/v1/eventos: GET sem token, POST sem token, POST com o token de sessão de um usuário logado e DELETE de um evento de outro usuário com esse mesmo token.
Cada chamada tem, em comentário, o status HTTP e o corpo observados — e a policy (ou a falta dela) que explica o resultado.
O token de sessão é obtido pela própria API de auth (/auth/v1/token?grant_type=password), não copiado do DevTools.
Um parágrafo final responde: se você desabilitasse o RLS de eventos por um minuto, o que o segundo curl passaria a fazer?
Pistas
A API REST espera dois cabeçalhos: apikey: <anon> e Authorization: Bearer <anon ou token de sessão>. Sem Authorization, a resposta já é reveladora.
Para o login por curl: POST /auth/v1/token?grant_type=password com Content-Type: application/json e { "email": "...", "password": "..." } — o access_token vem na resposta.
Um DELETE barrado por policy não devolve erro: devolve 204 e não apaga nada. Confira com um GET em seguida — ou peça Prefer: return=representation para ver o que foi afetado.
Nunca cole a service_role nesse script — o objetivo é provar o que a chave pública consegue.
⭐⭐
⭐⭐ Vagas ao vivo: inscrições com join, contagem e Realtime¶
supabasecrudvuebanco-de-dados
O card de evento mostra vagas, mas não quantas já foram ocupadas — e o botão "Inscrever-se" nem existe na versão Supabase. Construa o fluxo completo usando só o que a aula ensinou: contagem de relacionados no select, insert/delete em inscricoes respeitando as policies, e Realtime para que "12/40 vagas" mude na tela de todo mundo quando alguém se inscreve.
Critérios de pronto
O card mostra ocupadas/vagas vindo de um único select com inscricoes(count) — sem uma segunda consulta por evento.
"Inscrever-se" vira "Cancelar inscrição" quando o usuário logado já tem inscrição naquele evento; deslogado, o botão leva ao login.
Tentar se inscrever duas vezes mostra a mensagem da constraint unique (evento_id, usuario_id) traduzida para o usuário, não o texto cru do Postgres.
Com duas abas abertas, inscrever-se em uma faz o contador da outra mudar sem F5, assinando a tabela inscricoes (não eventos).
Um comentário no código explica por que o contador pode, por alguns instantes, estar errado numa aba que perdeu a conexão WebSocket.
Pistas
select('*, inscricoes(count)') devolve inscricoes: [{ count: 12 }] — um array com um objeto, não um número.
Para saber se "eu" estou inscrito sem uma consulta por card, busque as inscrições do usuário logado uma vez (.eq('usuario_id', id)) e guarde os evento_id num Set na store.
O código de violação de unique no Postgres é 23505; error.code chega intacto no supabase-js.
O Realtime precisa estar habilitado para inscricoes em Database → Replication; o .on('postgres_changes', { table: 'inscricoes' }, ...) pode simplesmente recarregar a lista.
⭐⭐⭐
⭐⭐⭐ A última vaga, sem condição de corrida — dentro do banco¶
supabasebanco-de-dadossegurancaapi
Na Aula 11, a última vaga foi protegida com uma transação e FOR UPDATE dentro do Express. Com Supabase não existe "seu servidor" para colocar essa lógica — e um select de contagem seguido de insert no front deixa a porta aberta: vinte pessoas clicando ao mesmo tempo num evento com cinco vagas podem gerar vinte inscrições. Resolva onde o Supabase espera que você resolva: numa função Postgres chamada por supabase.rpc(), que confere e insere de forma atômica.
Critérios de pronto
Existe a função inscrever(p_evento_id uuid) no schema public, que lê o evento com trava de linha, conta as inscrições, recusa com uma exceção clara quando não há vaga e insere a inscrição para auth.uid() — tudo na mesma transação.
O front chama supabase.rpc('inscrever', { p_evento_id }) e mostra "Evento lotado" quando error.message indicar isso.
Um script no console dispara 20 chamadas simultâneas com Promise.all (com 20 usuários de teste, ou temporariamente sem a unique) contra um evento de 5 vagas; o count final é 5.
Um ADR curto (docs/adr/000X-inscricao-por-rpc.md, no formato que a Aula 14 apresenta) registra por que a regra foi para o banco e o que se perde com isso (testes unitários em JS, portabilidade).
A função não pode ser usada para inscrever outra pessoa: usuario_id vem de auth.uid(), nunca de parâmetro.
Pistas
Leia "Database Functions" e a parte sobre security definer na documentação de RLS do Supabase; decida se a função roda como security invoker (respeita RLS) ou security definer (ignora RLS e, por isso, precisa checar auth.uid() sozinha).
O esqueleto é create or replace function inscrever(p_evento_id uuid) returns uuid language plpgsql as $$ declare ... begin ... end $$; — dentro, select vagas into v_vagas from eventos where id = p_evento_id for update; é a trava.
raise exception 'SEM_VAGAS' aborta a transação inteira; o texto chega em error.message no front.
Para o teste de concorrência, Promise.all(Array.from({ length: 20 }, () => supabase.rpc('inscrever', { p_evento_id }))) no console — e conte com select count(*) from inscricoes where evento_id = '...' no SQL Editor.
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. Guarde este texto: é exatamente o tipo de comparação técnica que volta na retrospectiva de padrões da Aula 15.
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.
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.
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 zod em src/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) usando vitest.
Criar e executar migrations de banco de dados com scripts numerados e uma tabela de controle, sem depender de schema.sql manual.
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.
API unieventos-api funcionando com Express 5, persistência em MySQL (Aula 09) e autenticação Firebase (Aula 10), com CRUD completo (Aula 11).
Estrutura de pastas src/routes, src/controllers, src/services, src/repositories já existente desde a Aula 09 — hoje ela é formalizada e completada, não criada do zero.
Node.js 22 LTS e MySQL rodando localmente (ou acessível via DATABASE_URL).
Checklist antes de começar:
[ ] npm run dev sobe 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.
As Aulas 09 e 11 já entregaram o esqueleto certo: rota → controller → service → repository, com validação por middleware Zod e um tratador de erros central. O que falta não é a divisão em camadas — é formalizar e completar o que começamos. Hoje as dependências deixam de ser importadas e passam a ser injetadas (o que torna o service testável sem banco), a configuração passa por uma porta única e validada, os erros ganham uma hierarquia de domínio em cima do ErroHttp da Aula 08, e o schema do banco deixa de ser um schema.sql manual para virar migrations versionadas.
A solução é formalizar cinco responsabilidades separadas:
Texto
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:
Texto
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
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 (autenticar), 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.
⚠️ Atenção
Camada de fora pode conhecer e importar a de dentro. Camada de dentro nunca pode importar a de fora.
Na prática:
routes pode importar controllers. controllersnão pode importar routes.
controllers pode importar services. servicesnão pode importar controllers nem req/res do Express.
services pode importar repositories. repositoriesnão pode importar services.
repositories pode importar db. db não sabe que repositories existe.
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.
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:
JavaScript
// 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'exportasyncfunctionlistarEventos(){const[linhas]=awaitpool.query('SELECT * FROM eventos ORDER BY data_hora')returnlinhas}exportasyncfunctioncriarEvento(dados){if(dados.vagas<0){thrownewError('vagas não pode ser negativo')}const[resultado]=awaitpool.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):
JavaScript
// 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'../erros/index.js'exportfunctioncriarServicoDeEventos({eventosRepository}){return{asynclistarEventos({categoria,busca,pagina=1,porPagina=20}={}){constpaginaSegura=Math.max(1,Number(pagina)||1)constporPaginaSegura=Math.min(50,Math.max(1,Number(porPagina)||20))const[dados,total]=awaitPromise.all([eventosRepository.listar({categoria,busca,pagina:paginaSegura,porPagina:porPaginaSegura}),eventosRepository.contar({categoria,busca}),])// o MESMO envelope das Aulas 08–11: { dados, paginacao }.// Devolver o array puro aqui quebraria o v-data-table-server do front,// que lê `paginacao.total` para saber quantas páginas existem.return{dados,paginacao:{pagina:paginaSegura,porPagina:porPaginaSegura,total,totalPaginas:Math.ceil(total/porPaginaSegura),},}},asyncbuscarEventoPorId(id){constevento=awaiteventosRepository.buscarPorId(id)if(!evento){thrownewErroNaoEncontrado(`Evento ${id} não encontrado`)}returnevento},asynccriarEvento(dados){if(dados.vagas<0){thrownewErroDeValidacao('vagas não pode ser negativo')}returneventosRepository.criar(dados)},asyncatualizarEvento(id,dados){awaitthis.buscarEventoPorId(id)// reaproveita a validação de existênciareturneventosRepository.atualizar(id,dados)},asyncremoverEvento(id){awaitthis.buscarEventoPorId(id)returneventosRepository.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.
JavaScript
// src/app.js (montagem — quem decide as dependências concretas)import{criarRepositorioDeEventosMySQL}from'./repositories/eventosRepository.mysql.js'import{criarServicoDeEventos}from'./services/eventosService.js'consteventosRepository=criarRepositorioDeEventosMySQL()consteventosService=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.
🧠 Você sabia?
O nome "injeção de dependência" foi cunhado por Martin Fowler em 2004, no artigo Inversion of Control Containers and the Dependency Injection pattern, justamente para separar a técnica (receber dependências de fora) dos containers pesados que a implementavam em Java na época. Vinte anos depois, o exemplo mais simples do artigo continua igual ao que você acabou de escrever: uma função que recebe o que precisa por parâmetro.
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.
JavaScript
// src/config/index.jsimport{z}from'zod'import'dotenv/config'constesquemaDeAmbiente=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.constresultado=esquemaDeAmbiente.safeParse(process.env)if(!resultado.success){console.error('❌ Configuração de ambiente inválida:')for(constproblemaofresultado.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)}exportconstconfig=Object.freeze(resultado.data)
Terminal
# .env.example — copie para .env e preencha com valores reaisNODE_ENV=development
PORT=3000DB_HOST=localhost
DB_PORT=3306DB_USER=root
DB_PASSWORD=troque-esta-senha
DB_NAME=unieventos
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:
JavaScript
// src/db/pool.js — uso de config em vez de process.env espalhadoimportmysqlfrom'mysql2/promise'import{config}from'../config/index.js'// a configuração vive no MESMO arquivo do pool — um arquivo só, sem// `configuracaoDoPool.js` separado para importar de dois lugaresconstconfiguracaoDoPool={host:config.DB_HOST,port:config.DB_PORT,user:config.DB_USER,password:config.DB_PASSWORD,database:config.DB_NAME,waitForConnections:true,connectionLimit:10,}
⚠️ Atenção
Se você esquecer DB_PASSWORD no .env, o processo não sobe — imprime exatamente qual variável falta e sai com process.exit(1). Isso é intencional: é infinitamente melhor descobrir isso agora, no npm run dev, do que na madrugada anterior ao deploy do Marco 3, tentando descobrir por que a aplicação não sobe em produção.
A classe base já existe desde a Aula 08: ErroHttp, em src/erros/ErroHttp.js, com status, message e codigo. Não vamos criar uma segunda família de erros — vamos estender essa, dando nome de domínio a cada caso e acrescentando a marca operacional, que separa "erro esperado" de "bug".
JavaScript
// src/erros/index.js — reexporta o ErroHttp da Aula 08 e acrescenta os erros de domínioimport{ErroHttp}from'./ErroHttp.js'export{ErroHttp}// Erro de domínio = ErroHttp + a marca `operacional`, que o tratador usa// para decidir entre logar um aviso (esperado) ou um erro com stack (bug).exportclassErroDeAplicacaoextendsErroHttp{constructor(mensagem,status=500,codigo='ERRO_INTERNO'){super(status,mensagem,codigo)this.name=this.constructor.namethis.operacional=true}}exportclassErroDeValidacaoextendsErroDeAplicacao{constructor(mensagem,detalhes=[]){// 422, o mesmo status de validação fixado na Aula 08 — não 400super(mensagem,422,'VALIDACAO')this.detalhes=detalhes}}exportclassErroNaoEncontradoextendsErroDeAplicacao{constructor(mensagem='Recurso não encontrado'){super(mensagem,404,'NAO_ENCONTRADO')}}exportclassErroDeAutorizacaoextendsErroDeAplicacao{constructor(mensagem='Você não tem permissão para executar esta ação'){super(mensagem,403,'NAO_AUTORIZADO')}}exportclassErroDeConflitoextendsErroDeAplicacao{constructor(mensagem='Conflito com o estado atual do recurso'){super(mensagem,409,'CONFLITO')}}
⚠️ AtençãoerroNaoEncontrado() e erroValidacao() (os atalhos da Aula 08) continuam valendo — new ErroNaoEncontrado(...) é a mesma coisa com nome de classe. O que não muda em hipótese alguma é o envelope que sai na resposta: { erro: { mensagem, codigo } }, com detalhes quando for validação. A store da Aula 11 lê exatamente erro.mensagem; inventar um formato novo aqui quebraria o front sem aviso.
Usar essa hierarquia no service fica direto:
JavaScript
// trecho de src/services/inscricoesService.jsimport{ErroDeConflito,ErroDeAutorizacao}from'../erros/index.js'asyncfunctioninscrever({eventoId,usuarioUid}){constjaInscrito=awaitinscricoesRepository.existeInscricao(eventoId,usuarioUid)if(jaInscrito){thrownewErroDeConflito('Você já está inscrito neste evento')}returninscricoesRepository.criar({eventoId,usuarioUid})}asyncfunctioncancelarInscricao({inscricaoId,usuarioUidSolicitante}){constinscricao=awaitinscricoesRepository.buscarPorId(inscricaoId)if(inscricao.usuario_uid!==usuarioUidSolicitante){thrownewErroDeAutorizacao('Só é possível cancelar a própria inscrição')}returninscricoesRepository.remover(inscricaoId)}
// src/utils/logger.jsimportpinofrom'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.exportconstlogger=pino({level:config.NODE_ENV==='production'?'info':'debug',transport:config.NODE_ENV==='production'?undefined:{target:'pino-pretty',options:{colorize:true}},})
// src/middlewares/tratadorDeErros.jsimport{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.exportfunctiontratadorDeErros(erro,req,res,next){conststatus=erro.status??500constehErroOperacional=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')}// Envelope ÚNICO da trilha, fixado na Aula 08 e lido pela store da Aula 11.constcorpoDaResposta={erro:{mensagem:ehErroOperacional?erro.message:'Erro interno do servidor',codigo:erro.codigo??'ERRO_INTERNO',},}if(erro.detalhes){corpoDaResposta.erro.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.erro.stack=erro.stack}res.status(status).json(corpoDaResposta)}
JavaScript
// src/server.js — captura de falhas que escapam do Expressimport{app}from'./app.js'import{config}from'./config/index.js'import{logger}from'./utils/logger.js'constservidor=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)})
📌 Vale gravarunhandledRejection captura Promises rejeitadas que ninguém tratou; uncaughtException captura exceções síncronas que escaparam de qualquer try/catch. Nenhum dos dois substitui tratamento de erro local — são uma rede de segurança final, não a primeira linha de defesa.
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.
Terminal
npminstallhelmetexpress-rate-limitcors
JavaScript
// src/middlewares/seguranca.jsimporthelmetfrom'helmet'importrateLimitfrom'express-rate-limit'importcorsfrom'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.).exportconstcabecalhosDeSeguranca=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.exportconstlimitadorDeTaxa=rateLimit({windowMs:15*60*1000,// 15 minutoslimit:100,// 100 requisições por IP nessa janelastandardHeaders:true,legacyHeaders:false,message:{erro:{mensagem:'Muitas requisições. Tente novamente mais tarde.',codigo:'RATE_LIMIT'}},})// 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.exportconstcorsConfigurado=cors({origin:config.CORS_ORIGEM_PERMITIDA,methods:['GET','POST','PUT','DELETE'],allowedHeaders:['Content-Type','Authorization'],})
JavaScript
// trecho de src/app.js — ordem importa: segurança primeiro, depois parsing, depois rotasimportexpressfrom'express'import{cabecalhosDeSeguranca,limitadorDeTaxa,corsConfigurado}from'./middlewares/seguranca.js'exportfunctioncriarApp({eventosRepository}={}){constapp=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'}))// As rotas de negócio e o tratador de erros entram aqui — Passo 13 do "Mão na massa"returnapp}
⚠️ Atençãoexpress.json({ limit: '10kb' }) rejeita automaticamente corpos maiores com 413 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).
🔬 Investigue
Com a API rodando, execute curl -i http://localhost:3000/health e conte os cabeçalhos da resposta. Comente a linha app.use(cabecalhosDeSeguranca), reinicie e rode de novo: quantos sumiram? Procure X-Powered-By: Express (o helmet o remove — é uma pista de graça para quem ataca), X-Content-Type-Options: nosniff e Content-Security-Policy. Depois descomente e teste o limitador: for i in $(seq 1 101); do curl -s -o /dev/null -w "%{http_code} " http://localhost:3000/health; done — o último número deve ser 429, e um curl -i na sequência mostra RateLimit-Remaining: 0.
5.1 Checklist OWASP Top 10 aplicado a esta trilha¶
Categoria OWASP
O que fazemos no UniEventos
A01 — Quebra de controle de acesso
Middlewares autenticar/autorizar (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.
▲
╱ ╲ 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.
// test/eventos.rota.test.jsimport{describe,it,expect}from'vitest'importrequestfrom'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.functionmontarAppDeTeste(){consteventosRepository=criarRepositorioDeEventosEmMemoria([{id:1,titulo:'Semana da Computação',categoria:'palestra',vagas:40},])returncriarApp({eventosRepository})}describe('rotas de /api/eventos',()=>{it('GET /api/eventos retorna 200 e o envelope { dados, paginacao }',async()=>{constapp=montarAppDeTeste()constresposta=awaitrequest(app).get('/api/eventos')expect(resposta.status).toBe(200)expect(Array.isArray(resposta.body.dados)).toBe(true)expect(resposta.body.dados).toHaveLength(1)expect(resposta.body.paginacao.total).toBe(1)})it('GET /api/eventos/:id inexistente retorna 404 no envelope de erro',async()=>{constapp=montarAppDeTeste()constresposta=awaitrequest(app).get('/api/eventos/999')expect(resposta.status).toBe(404)expect(resposta.body.erro.mensagem).toMatch(/não encontrado/i)expect(resposta.body.erro.codigo).toBe('NAO_ENCONTRADO')})it('POST /api/eventos sem título retorna 422 (validação zod)',async()=>{constapp=montarAppDeTeste()constresposta=awaitrequest(app).post('/api/eventos').send({categoria:'palestra',vagas:10})expect(resposta.status).toBe(422)expect(resposta.body.erro.detalhes).toBeDefined()})it('POST /api/eventos válido retorna 201 e o evento criado',async()=>{constapp=montarAppDeTeste()constresposta=awaitrequest(app).post('/api/eventos').send({titulo:'Hackathon de Tecnologia',categoria:'workshop',vagas:60})expect(resposta.status).toBe(201)expect(resposta.body.titulo).toBe('Hackathon de Tecnologia')expect(resposta.body.id).toBeDefined()})})
6.4 Teste unitário (service, com repositório falso)¶
JavaScript
// test/eventos.service.test.jsimport{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.functioncriarRepositorioFalso(eventosIniciais=[]){consteventos=[...eventosIniciais]return{asynclistar(){returneventos},asynccontar(){returneventos.length},asyncbuscarPorId(id){returneventos.find((evento)=>evento.id===id)??null},asynccriar(dados){constnovoEvento={id:eventos.length+1,...dados}eventos.push(novoEvento)returnnovoEvento},}}describe('eventosService (unitário)',()=>{it('listarEventos devolve o envelope { dados, paginacao }',async()=>{constservice=criarServicoDeEventos({eventosRepository:criarRepositorioFalso([{id:1,titulo:'Evento A'}]),})constresultado=awaitservice.listarEventos()expect(resultado.dados).toHaveLength(1)expect(resultado.dados[0].titulo).toBe('Evento A')expect(resultado.paginacao).toEqual({pagina:1,porPagina:20,total:1,totalPaginas:1})})it('buscarEventoPorId lança ErroNaoEncontrado quando o id não existe',async()=>{constservice=criarServicoDeEventos({eventosRepository:criarRepositorioFalso([])})awaitexpect(service.buscarEventoPorId(42)).rejects.toThrow('não encontrado')})it('criarEvento lança ErroDeValidacao quando vagas é negativo',async()=>{constservice=criarServicoDeEventos({eventosRepository:criarRepositorioFalso([])})awaitexpect(service.criarEvento({titulo:'Evento inválido',categoria:'palestra',vagas:-5}),).rejects.toThrow('vagas não pode ser negativo')})})
Rodando os testes:
Terminal
npmtest
Texto
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.
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¶
As migrations não inventam um schema novo: elas reconstroem exatamente o sql/schema.sql da Aula 09 (mesmas tabelas, mesmos tamanhos de coluna, mesmas chaves) e, a partir daí, registram como um passo versionado a mudança que a Aula 11 fez à mão no banco. É essa continuidade que permite jogar o schema.sql fora sem perder nada.
-- migrations/0003_criar_tabela_inscricoes.sql-- versão da Aula 09: a inscrição aponta para a tabela `usuarios`CREATETABLEIFNOTEXISTSinscricoes(idINTAUTO_INCREMENTPRIMARYKEY,evento_idINTNOTNULL,usuario_idINTNOTNULL,criado_emDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMP,CONSTRAINTfk_inscricoes_eventoFOREIGNKEY(evento_id)REFERENCESeventos(id)ONDELETECASCADE,CONSTRAINTfk_inscricoes_usuarioFOREIGNKEY(usuario_id)REFERENCESusuarios(id)ONDELETECASCADE,UNIQUEKEYuk_inscricao_unica(evento_id,usuario_id))ENGINE=InnoDBDEFAULTCHARSET=utf8mb4;
SQL
-- migrations/0004_inscricao_por_uid_do_firebase.sql-- A MUDANÇA da Aula 11, agora versionada: com o Firebase como fonte de identidade-- (Aula 10), a inscrição passa a guardar o uid direto, sem depender da tabela local.ALTERTABLEinscricoesDROPFOREIGNKEYfk_inscricoes_usuario;ALTERTABLEinscricoesDROPINDEXuk_inscricao_unica;ALTERTABLEinscricoesCHANGECOLUMNusuario_idusuario_uidVARCHAR(128)NOTNULL;ALTERTABLEinscricoesADDUNIQUEKEYuk_evento_usuario(evento_id,usuario_uid);
⚠️ Atenção
A migration 0004 é destrutiva se rodada num banco com dados reais: os usuario_id inteiros viram texto e perdem a ligação com usuarios. Em produção, isso viraria três migrations (adicionar a coluna nova, copiar usuarios.firebase_uid para ela, só então remover a antiga). Aqui, com banco de desenvolvimento, a versão curta serve — mas saiba que a versão curta é a exceção, não a regra.
JavaScript
// 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{fileURLToPath}from'node:url'importmysqlfrom'mysql2/promise'import{config}from'../src/config/index.js'constPASTA_DE_MIGRATIONS=newURL('../migrations',import.meta.url)asyncfunctiongarantirTabelaDeControle(conexao){awaitconexao.query(` CREATE TABLE IF NOT EXISTS migrations_executadas ( nome_arquivo VARCHAR(255) PRIMARY KEY, executado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB `)}asyncfunctionlistarMigrationsJaExecutadas(conexao){const[linhas]=awaitconexao.query('SELECT nome_arquivo FROM migrations_executadas')returnnewSet(linhas.map((linha)=>linha.nome_arquivo))}asyncfunctionexecutarMigracoes(){constconexao=awaitmysql.createConnection({host:config.DB_HOST,port:config.DB_PORT,user:config.DB_USER,password:config.DB_PASSWORD,database:config.DB_NAME,multipleStatements:true,})try{awaitgarantirTabelaDeControle(conexao)constjaExecutadas=awaitlistarMigrationsJaExecutadas(conexao)constarquivos=(awaitreaddir(PASTA_DE_MIGRATIONS)).filter((arquivo)=>arquivo.endsWith('.sql')).sort()// nomes numerados (0001_..., 0002_...) garantem ordem corretaletquantidadeAplicada=0for(constarquivoofarquivos){if(jaExecutadas.has(arquivo)){console.log(`↷ ${arquivo} já aplicada, pulando`)continue}// fileURLToPath, não `.pathname`: no Windows, `new URL(...).pathname` devolve// "/C:/Users/..." — com a barra sobrando — e o readFile falhaconstcaminhoCompleto=fileURLToPath(newURL(`../migrations/${arquivo}`,import.meta.url))constsql=awaitreadFile(caminhoCompleto,'utf-8')console.log(`▶ aplicando ${arquivo}...`)awaitconexao.query(sql)awaitconexao.query('INSERT INTO migrations_executadas (nome_arquivo) VALUES (?)',[arquivo])quantidadeAplicada+=1console.log(`✔ ${arquivo} aplicada`)}console.log(`\nConcluído: ${quantidadeAplicada} migration(s) nova(s) aplicada(s).`)}finally{awaitconexao.end()}}executarMigracoes().catch((erro)=>{console.error('❌ falha ao rodar migrations:',erro.message)process.exit(1)})
JSON
// package.json — trecho de "scripts"{"scripts":{"migrar":"node scripts/migrar.js"}}
🔎 Por baixo do capô
Ferramentas prontas como node-pg-migrate (Postgres) ou umzug (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.
🧩 Padrão 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 2: 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:
JavaScript
// src/db/pool.js (o mesmo arquivo da Seção 3, visto agora pelo ângulo do padrão)letinstanciaDoPool// módulo ES: só existe uma vez por processo Node — Singleton naturalexportfunctionobterPool(){if(!instanciaDoPool){instanciaDoPool=mysql.createPool(configuracaoDoPool)}returninstanciaDoPool}
Qualquer repositório que chame obterPool() 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 de criarEvento, 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.js encapsula toda a SQL; o resto da aplicação nunca escreve SELECT/INSERT fora dessa camada.
Strategy — a escolha de qual repositório usar em tempo de execução (ver src/repositories/index.js na 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 QueryBuilder no Passo 7 do Mão na massa.
💻 Mão na massa — refatorando o unieventos-api para arquitetura em camadas¶
// src/validators/eventoSchema.jsimport{z}from'zod'exportconsteventoSchema=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.url().optional(),})exportconsteventoAtualizacaoSchema=eventoSchema.partial()
JavaScript
// src/middlewares/validar.jsimport{ErroDeValidacao}from'../erros/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.exportfunctionvalidar(schema){return(req,res,next)=>{constresultado=schema.safeParse(req.body)if(!resultado.success){constdetalhes=resultado.error.issues.map((problema)=>({campo:problema.path.join('.'),mensagem:problema.message,}))thrownewErroDeValidacao('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/erros/index.js da Seção 4.1.
Passo 7 — o repositório MySQL (com Builder de query):
JavaScript
// src/repositories/queryBuilderDeListagem.js// Builder: monta incrementalmente a query SQL de listagem, adicionando cláusulas// WHERE só para os filtros que realmente vieram preenchidos.exportclassQueryBuilderDeListagem{constructor(tabela){this.tabela=tabelathis.condicoes=[]this.parametros=[]this.limiteValor=20this.deslocamentoValor=0}comCategoria(categoria){if(categoria){this.condicoes.push('categoria = ?')this.parametros.push(categoria)}returnthis// encadeamento fluente — marca registrada do Builder}comBuscaDeTexto(termo){if(termo){this.condicoes.push('titulo LIKE ?')this.parametros.push(`%${termo}%`)}returnthis}comPaginacao(pagina=1,porPagina=20){this.limiteValor=porPaginathis.deslocamentoValor=(pagina-1)*porPaginareturnthis}construir(){constclausulaWhere=this.condicoes.length>0?`WHERE ${this.condicoes.join(' AND ')}`:''constsql=` SELECT * FROM ${this.tabela}${clausulaWhere} ORDER BY data_hora ASC LIMIT ? OFFSET ? `return{sql,parametros:[...this.parametros,this.limiteValor,this.deslocamentoValor],}}// mesma cláusula WHERE, sem ORDER BY nem paginação — para o `total` do envelopeconstruirContagem(){constclausulaWhere=this.condicoes.length>0?`WHERE ${this.condicoes.join(' AND ')}`:''return{sql:`SELECT COUNT(*) AS total FROM ${this.tabela}${clausulaWhere}`,parametros:[...this.parametros],}}}
JavaScript
// src/repositories/eventosRepository.mysql.jsimport{obterPool}from'../db/pool.js'import{QueryBuilderDeListagem}from'./queryBuilderDeListagem.js'functionlinhaParaEvento(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,}}exportfunctioncriarRepositorioDeEventosMySQL(){constpool=obterPool()return{asynclistar({categoria,busca,pagina,porPagina}={}){const{sql,parametros}=newQueryBuilderDeListagem('eventos').comCategoria(categoria).comBuscaDeTexto(busca).comPaginacao(pagina,porPagina).construir()const[linhas]=awaitpool.query(sql,parametros)returnlinhas.map(linhaParaEvento)},asynccontar({categoria,busca}={}){const{sql,parametros}=newQueryBuilderDeListagem('eventos').comCategoria(categoria).comBuscaDeTexto(busca).construirContagem()const[[{total}]]=awaitpool.query(sql,parametros)returntotal},asyncbuscarPorId(id){const[linhas]=awaitpool.query('SELECT * FROM eventos WHERE id = ?',[id])returnlinhas[0]?linhaParaEvento(linhas[0]):null},asynccriar(dados){const[resultado]=awaitpool.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}},asyncatualizar(id,dados){awaitpool.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],)returnthis.buscarPorId(id)},asyncremover(id){awaitpool.query('DELETE FROM eventos WHERE id = ?',[id])},}}
Passo 8 — o repositório em memória (para testes):
JavaScript
// 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.exportfunctioncriarRepositorioDeEventosEmMemoria(eventosIniciais=[]){leteventos=[...eventosIniciais]letproximoId=eventos.length+1return{asynclistar({categoria}={}){if(!categoria)returneventosreturneventos.filter((evento)=>evento.categoria===categoria)},asynccontar({categoria}={}){return(awaitthis.listar({categoria})).length},asyncbuscarPorId(id){returneventos.find((evento)=>evento.id===Number(id))??null},asynccriar(dados){constnovoEvento={id:proximoId++,...dados}eventos.push(novoEvento)returnnovoEvento},asyncatualizar(id,dados){eventos=eventos.map((evento)=>(evento.id===Number(id)?{...evento,...dados}:evento))returnthis.buscarPorId(id)},asyncremover(id){eventos=eventos.filter((evento)=>evento.id!==Number(id))},}}
Passo 9 — Strategy: escolha do repositório por ambiente:
JavaScript
// 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'exportfunctionobterRepositorioDeEventos(){if(config.NODE_ENV==='test'){returncriarRepositorioDeEventosEmMemoria()}returncriarRepositorioDeEventosMySQL()}
Passo 10 — o service (mostrado completo na Seção 2):
Use src/services/eventosService.js da Seção 2, já com a hierarquia de erros da Seção 4.1.
Passo 11 — o controller:
JavaScript
// src/controllers/eventosController.jsexportfunctioncriarControllerDeEventos({eventosService}){return{asynclistar(req,res){const{categoria,busca,pagina,porPagina}=req.query// já vem no envelope { dados, paginacao } montado pelo serviceconstresultado=awaiteventosService.listarEventos({categoria,busca,pagina,porPagina})res.status(200).json(resultado)},asyncbuscarPorId(req,res){constevento=awaiteventosService.buscarEventoPorId(Number(req.params.id))res.status(200).json(evento)},asynccriar(req,res){constevento=awaiteventosService.criarEvento(req.body)res.status(201).json(evento)},asyncatualizar(req,res){constevento=awaiteventosService.atualizarEvento(Number(req.params.id),req.body)res.status(200).json(evento)},asyncremover(req,res){awaiteventosService.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 handler async é capturado automaticamente e encaminhado ao middleware de erro — não precisamos mais de .catch(next) como no Express 4.
Passo 12 — as rotas:
JavaScript
// src/routes/eventos.routes.jsimport{Router}from'express'import{validar}from'../middlewares/validar.js'import{eventoSchema,eventoAtualizacaoSchema}from'../validators/eventoSchema.js'import{autenticar}from'../middlewares/autenticar.js'import{autorizar}from'../middlewares/autorizar.js'exportfunctioncriarRotasDeEventos({eventosController}){constrouter=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('/',autenticar,validar(eventoSchema),eventosController.criar)router.put('/:id',autenticar,validar(eventoAtualizacaoSchema),eventosController.atualizar)// exclusão continua exigindo admin, como nas Aulas 10 e 11router.delete('/:id',autenticar,autorizar(['admin']),eventosController.remover)returnrouter}
Passo 13 — montando a aplicação:
JavaScript
// src/app.jsimportexpressfrom'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'exportfunctioncriarApp({eventosRepository=obterRepositorioDeEventos()}={}){constapp=express()app.use(cabecalhosDeSeguranca)app.use(corsConfigurado)app.use(limitadorDeTaxa)app.use(express.json({limit:'10kb'}))consteventosService=criarServicoDeEventos({eventosRepository})consteventosController=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)returnapp}
Passo 14 — o server.js (mostrado completo na Seção 4.3). Rode e confira:
A refatoração só terminou quando a API continua respondendo exatamente o mesmo contrato de antes. Verifique nesta ordem:
Terminal
# 1) migrations aplicadas do zero, em um banco vazio
npmrunmigrar
Resultado esperado: as quatro migrations são aplicadas em ordem e a tabela migrations_executadas lista as quatro; rodar npm run migrar de novo imprime "já aplicada, pulando" e não altera nada.
Terminal
# 2) o contrato de listagem, com o envelope de sempre
curl-s"http://localhost:3000/api/eventos?pagina=1&porPagina=2"|jq
Resultado esperado: { "dados": [ … ], "paginacao": { "pagina": 1, "porPagina": 2, "total": 3, "totalPaginas": 2 } }, com os campos em camelCase (dataHora, imagemUrl).
Terminal
# 3) o envelope de erro, idêntico ao da Aula 08
curl-shttp://localhost:3000/api/eventos/999|jq
curl-s-XPOSThttp://localhost:3000/api/eventos-H"Content-Type: application/json"-d'{"titulo":"Ab"}'|jq
Resultado esperado: 404 com { "erro": { "mensagem": "Evento 999 não encontrado", "codigo": "NAO_ENCONTRADO" } } e 422 com codigo: "VALIDACAO" mais o array detalhes.
Configuração — remova DB_PASSWORD do .env e rode npm run dev. Resultado esperado: o processo não sobe, e a mensagem diz qual variável falta.
Front intacto — suba o unieventos-web da Aula 11 contra esta API refatorada e repita o CRUD pela tela. Resultado esperado: tudo funciona sem uma linha alterada no front. Esse é o teste real da refatoração: por fora, nada mudou.
Testes — npm test passa com o MySQL desligado, porque a suíte usa o repositório em memória.
A1. Preveja a resposta (status e corpo) de POST /api/eventos com o corpo { "titulo": "Ab", "categoria": "show", "vagas": "10" }, passando pelo validar(eventoSchema) do Passo 5. Quais campos aparecem em detalhes? Por que vagasnão aparece, mesmo tendo chegado como string?
A2. Complete a lacuna e diga o status HTTP e o nível de log (warn ou error) que o tratadorDeErros vai produzir:
JavaScript
constjaInscrito=awaitinscricoesRepository.existeInscricao(eventoId,usuarioUid)if(jaInscrito){thrownew________('Você já está inscrito neste evento')}
A3. Verdadeiro ou falso, com justificativa: "Em produção (NODE_ENV=production), o tratadorDeErros nunca devolve erro.message ao cliente."
A4. Em duas linhas: por que test/eventos.rota.test.js sobe a aplicação Express inteira e ainda assim não precisa de MySQL rodando? Aponte o parâmetro que torna isso possível.
A5. Classifique cada trecho na camada certa (routes, controllers, services, repositories, middlewares) e justifique em uma linha: (a) if (inscritos > 0) throw new ErroDeConflito(...); (b) res.status(204).end(); (c) LIMIT ? OFFSET ?; (d) req.body = resultado.data; (e) router.put('/:id', autenticar, ...).
A6. Você rodou npm run migrar e as três migrations foram aplicadas. Depois editou 0002_criar_tabela_inscricoes.sql para acrescentar uma coluna e rodou npm run migrar de novo. O que o script imprime, e o que acontece com a coluna? Qual é o jeito certo de fazer essa mudança?
B1. Refatore seu projeto autoral para a arquitetura em camadas — crie as pastas config/, db/, erros/, 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).
B2. 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).
B3. 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 { "erro": { "mensagem": "...", "codigo": "NAO_ENCONTRADO" } }, 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.
B4. 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.
B5. 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).
C1. Service de inscrições com injeção de dependência e testes sem banco. Escreva criarServicoDeInscricoes({ inscricoesRepository, eventosRepository }) com quatro regras: evento inexistente (ErroNaoEncontrado), evento lotado (ErroDeConflito), já inscrito (ErroDeConflito) e cancelamento por quem não é dono (ErroDeAutorizacao). Cubra cada regra com um teste unitário usando repositórios falsos e escreva um teste de rota para POST /api/inscricoes — que hoje é impossível sem Firebase, porque autenticar é importado direto dentro de criarRotasDeInscricoes. Resolva isso sem tocar no Firebase.
Resultado esperado: npm test mostra 5 testes novos passando (4 unitários + 1 de rota) com MySQL e Firebase desligados; o teste de rota confirma 201 com token "válido" e 401 sem token.
Dica
A mesma técnica do repositório vale para o middleware: criarRotasDeInscricoes({ inscricoesController, autenticar = autenticarReal }). No teste, injete (req, res, next) => { req.usuario = { uid: 'uid-teste' }; next() } para o caso 201, e um que responde res.status(401).json({ erro: { mensagem: 'Token ausente', codigo: 'NAO_AUTENTICADO' } }) para o outro. criarApp precisa repassar esse parâmetro até as rotas.
Um colega reorganizou o app.js "para ficar mais legível" e agora os testes de rota falham de um jeito curioso: POST /api/eventos válido devolve 422 dizendo que todos os campos são obrigatórios, e GET /api/eventos/999 devolve uma página HTML em vez de { "erro": { "mensagem": "..." } }. Este é o arquivo:
JavaScript
// src/app.js — versão com os bugs plantadosexportfunctioncriarApp({eventosRepository=obterRepositorioDeEventos()}={}){constapp=express()app.use(cabecalhosDeSeguranca)app.use(corsConfigurado)app.use(tratadorDeErros)consteventosService=criarServicoDeEventos({eventosRepository})consteventosController=criarControllerDeEventos({eventosService})app.use('/api/eventos',criarRotasDeEventos({eventosController}))app.use(limitadorDeTaxa)app.use(express.json({limit:'10kb'}))returnapp}
Rode npm test antes de mexer em qualquer coisa: os testes já contam a história inteira.
Critérios de pronto
Os 7 testes da aula voltam a passar sem alterar nenhum teste.
Um comentário acima de cada app.use explica por que ele está naquela posição (o que ele precisa que já tenha acontecido, e quem depende dele).
Você descobre e anota o valor de req.body que chegava ao validar() na versão bugada — e por que o Express 5 se comporta assim.
Uma frase liga o problema ao padrão Chain of Responsibility da seção "Padrão de projeto em uso".
Pistas
No Express 5, req.body é undefined enquanto nenhum parser rodou — e safeParse(undefined) reclama de tudo.
Um middleware de erro só captura erros de quem foi registrado antes dele na cadeia.
O limitadorDeTaxa depois das rotas nunca é alcançado por uma requisição que já foi respondida — confira com curl -i que o cabeçalho RateLimit-Limit sumiu.
Em uma rede compartilhada — um laboratório, um escritório, ou até um provedor com NAT/CGNAT — várias pessoas saem para a internet pelo mesmo IP. Com limit: 100 por IP a cada 15 minutos, bastam quatro pessoas testando a mesma API publicada para a quinta receber 429 sem ter feito nada. Meça o problema e depois redesenhe o limitador para punir quem abusa — não quem compartilha a rede.
Critérios de pronto
Um script scripts/estressar.sh faz 101 requisições a GET /health em sequência e mostra, com curl -i, os cabeçalhos RateLimit-Limit/RateLimit-Remaining caindo até o 429.
Leituras públicas (GET) têm um limite folgado; escritas (POST/PUT/DELETE) têm um limite apertado e separado.
Em rotas autenticadas, a chave do limitador é o uid do usuário, não o IP — dois usuários no mesmo IP têm cotas independentes.
Um teste com supertest prova que a 21ª escrita seguida do mesmo usuário recebe 429 com { erro: { mensagem, codigo } } em JSON, e o limitador é injetável (o resto da suíte não pode passar a falhar por causa dele).
O README explica o que muda quando a API está atrás de um proxy (Render, Nginx) e o que app.set('trust proxy', ...) tem a ver com isso.
Pistas
express-rate-limit aceita várias instâncias com configurações diferentes; aplique cada uma com router.use ou por método, não só com app.use global.
A opção keyGenerator: (req) => req.usuario?.uid ?? req.ip resolve a chave — mas só funciona se o limitador rodar depois de autenticar.
Para os testes, deixe criarApp aceitar { limitadores } e injete instâncias com limit baixo (e windowMs curto) só no teste que verifica o 429.
Atrás de um proxy, req.ip é o IP do proxy até você configurar trust proxy; a documentação do express-rate-limit tem uma seção inteira sobre isso.
⭐⭐
⭐⭐ Supabase como repositório — do lado certo da chave¶
supabasepadroes-de-projetonoderefatoracao
Na Aula 12 a service_role era proibida porque o código rodava no navegador. Aqui é diferente: o back-end é um ambiente de servidor, e a chave pode ficar no .env. Implemente criarRepositorioDeEventosSupabase() com a mesma interface dos repositórios MySQL e memória, e escolha entre os três por configuração — sem que service, controller ou testes percebam a troca. Depois responda: se o RLS não se aplica à service_role, quem passa a garantir "só o dono edita"?
Critérios de pronto
config/index.js valida DB_PROVIDER (mysql ou supabase) e, quando for supabase, exige SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY.
repositories/index.js (Strategy) devolve a implementação certa; os 7 testes da aula continuam passando sem alteração.
listar com filtros (categoria, busca, paginação) funciona nos dois provedores e devolve objetos com o mesmo formato (dataHora, imagemUrl — o mapeamento de nomes de coluna é responsabilidade do repositório).
Um ADR curto registra a decisão: regra de negócio no service (Express) versus policies no banco (Supabase), e por que a chave service_role no servidor não repete o erro da Aula 12.
.env.example atualizado, e a chave nunca aparece em log nem em resposta de erro.
Pistas
createClient(url, serviceRoleKey, { auth: { persistSession: false } }) — no servidor não há sessão de usuário para persistir.
A busca de texto vira .ilike('titulo', '%' + termo + '%'); a paginação, .range(inicio, fim); o total vem com { count: 'exact' }.
Trate { data, error } dentro do repositório e lance os erros de domínio da seção 4.1 — o service não pode saber que existe Supabase por trás.
Os testes já injetam o repositório em memória, então não dependem de DB_PROVIDER; se algum passou a falhar, algo vazou de config para o service.
Sete testes dão confiança — mas confiança em quê, exatamente? Meça: instale @vitest/coverage-v8, rode npx vitest run --coverage e olhe o relatório linha a linha. Você vai descobrir que remover, atualizar, o 413 do limite de payload, o 403 e o 409 nunca foram exercitados. Leve services/ e controllers/ a pelo menos 90% de cobertura — e faça isso sem transformar a suíte em algo que precisa de MySQL, Firebase ou de um relógio de 15 minutos.
Critérios de pronto
npm run test:cobertura gera o relatório e falha se services/ ou controllers/ ficarem abaixo de 90% de linhas.
Testes novos cobrem: PUT/DELETE felizes e com 404; 409 de inscrição duplicada; 403 de cancelamento alheio; 413 para corpo maior que 10kb; 400 para JSON malformado.
Rotas autenticadas são testadas com um autenticar injetado (Nível C do laboratório), nunca com token real do Firebase.
A suíte inteira roda em menos de 5 segundos e não depende de variável de ambiente além de NODE_ENV=test.
Um trecho no README explica, em três frases, por que 90% não significa "sem bugs" — com um exemplo real de linha coberta que ainda poderia estar errada.
Pistas
coverage.thresholds no vitest.config.js (procure "coverage thresholds" na documentação do Vitest) faz o comando falhar abaixo da meta.
Para o 413, request(app).post('/api/eventos').set('Content-Type', 'application/json').send('x'.repeat(11 * 1024)) basta — o Express responde antes de chegar ao controller.
JSON malformado é .send('{ "titulo": ') com o mesmo Content-Type; observe qual status e qual mensagem o seu tratadorDeErros devolve hoje (o erro do parser não é "operacional") e decida se é o certo.
O limitadorDeTaxa em memória compartilha estado entre testes do mesmo processo — injete um limitador com limit alto (ou desative em NODE_ENV=test) para que a cobertura não passe a falhar por 429.
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 o RateLimit-Limit no cabeçalho de resposta.
Rode npm test e 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.
Martin, Robert C. — Clean Architecture (capítulos sobre a regra de dependência), referência complementar da bibliografia do plano de curso.
Na Aula 14 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.
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.
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 /health responde 200 na 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).
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-jsdoc resolve — a documentação vive ao lado do código, no mesmo arquivo, na mesma revisão de código.
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 trilha é 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.
🧠 Você sabia?
Até a versão 2.0, a especificação se chamava literalmente "Swagger Specification". Em 2015, a empresa por trás dela doou o formato para a Linux Foundation, que criou a OpenAPI Initiative — um consórcio com Google, Microsoft, IBM, PayPal e dezenas de outras empresas — para governar a especificação de forma neutra, sem depender de uma única companhia. Foi nesse momento que o nome do formato virou "OpenAPI" e o nome "Swagger" ficou só com o conjunto de ferramentas (que a mesma empresa continuou mantendo, hoje sob a SmartBear).
Um documento OpenAPI é um único objeto JSON (ou YAML) com estas chaves de topo:
YAML
openapi:3.0.0# versão da especificação usadainfo:# metadados da APItitle:UniEventos APIversion:1.0.0description:API de eventos acadêmicos do UniEventosservers:# onde a API está hospedada (pode ter vários)-url:http://localhost:3000description:Ambiente localtags:# agrupamento visual dos endpoints no Swagger UI-name:Eventos-name:Inscrições-name:Autenticaçãopaths:# cada endpoint documentado/api/eventos:get:{...}post:{...}components:# peças reutilizáveis entre pathsschemas:{...}# formatos de objeto (Evento, EventoInput, Erro...)securitySchemes:{...}# como a API autentica (ex.: bearerAuth)
Explicando cada bloco com o UniEventos:
openapi — string fixa 3.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), com parameters, requestBody e responses.
components.schemas — formatos de objeto reutilizáveis (o formato de um Evento, de um EventoInput, de um Erro padrão), referenciados de dentro de paths com $ref em 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 de Evento em 5 endpoints diferentes, cada um aponta para #/components/schemas/Evento. Mude uma vez, atualiza em todo lugar.
🔬 Investigue
Abra https://petstore.swagger.io — a "API de exemplo" oficial do ecossistema Swagger, publicada há anos exatamente para esse tipo de teste. Expanda um endpoint, clique em "Try it out" e "Execute"; depois abra a aba Network do DevTools e confira a URL exata que foi chamada — ela deve bater com o que está declarado em servers. Agora abra direto no navegador https://petstore.swagger.io/v2/swagger.json: é o documento OpenAPI cru, em JSON, o mesmo que alimenta a interface bonita que você acabou de usar. Ache, nesse JSON, a chave paths e conte quantos métodos HTTP diferentes o endpoint /pet/{petId} responde.
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. É a abordagem que implementamos, passo a passo, na seção "💻 Mão na massa" logo adiante.
A alternativa é escrever o documento OpenAPI inteiro em um arquivo .yaml, sem anotação nenhuma no código, e servir esse arquivo estático:
YAML
# openapi.yaml (resumo — não é o que vamos usar hoje, é só para você conhecer a alternativa)openapi:3.0.0info:title:UniEventos APIversion:1.0.0paths:/api/eventos:get:tags:[Eventos]summary:Lista eventosresponses:'200':description:Lista de eventos
JavaScript
// server.js — servindo o YAML escrito à mão, em vez de gerado por anotaçãoimport{readFileSync}from'node:fs'importyamlfrom'yaml'// npm install yamlimportswaggerUifrom'swagger-ui-express'constdocumentoOpenApi=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 trilha, a abordagem oficial é a (a) — anotações junto ao código, sempre atualizadas na mesma revisão.
4. Além do Swagger: documentação completa do projeto¶
<!-- README.md -->
# UniEventos API



API REST da plataforma **UniEventos** — divulgação e inscrição em eventos acadêmicos.
Projeto desenvolvido na disciplina em que esta trilha nasceu.
## Requisitos-Node.js 22 LTS
-MySQL 8 (local ou gerenciado)
-Conta de serviço do Firebase (arquivo de credenciais)
## Instalação```bash
gitclonehttps://github.com/seu-usuario/unieventos-api.git
cdunieventos-api
npminstall
cp.env.example.env# preencha com suas credenciais
npmrunmigrar
npmrundev
```## 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.
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.
<!-- CONTRIBUTING.md -->
# Como contribuir1. 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ê.
// 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> * }} */exportfunctionuseEventos(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.
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:
Markdown
# 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:
Markdown
<!-- 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.
📌 Vale gravar
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.
🧩 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 @openapi fazem 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 do router.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.
É 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.
💻 Mão na massa — documentando a unieventos-api com Swagger¶
Chega de teoria: agora você instala as duas bibliotecas, configura a spec, serve a interface, documenta os schemas e todos os endpoints do UniEventos, e liga a segurança bearerAuth — na ordem em que você faria isso de verdade num projeto novo.
// src/docs/swaggerSpec.jsimportswaggerJsdocfrom'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.constopcoes={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 em que esta trilha nasceu.',contact:{name:'Equipe UniEventos',email:'contato@unieventos.exemplo',},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'],}exportconstswaggerSpec=swaggerJsdoc(opcoes)
⚠️ Atenção
Repare na chave definition dentro de opcoes. Em versões antigas do swagger-jsdoc (2.x/3.x) essa chave se chamava swaggerDefinition. Nesta trilha usamos swagger-jsdoc@6.3.0, que exige definition. Se você copiar um tutorial antigo da internet com swaggerDefinition, a spec gerada fica com paths: {} vazio e nenhum erro é lançado — o bug é silencioso.
Passo 2 — Servir a documentação com swagger-ui-express¶
JavaScript
// src/app.js — trecho adicionado à montagem da aplicação (depois das rotas de negócio)importswaggerUifrom'swagger-ui-express'import{swaggerSpec}from'./docs/swaggerSpec.js'// Opções de customização visual do Swagger UI.constopcoesDoSwaggerUi={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)})
Terminal
npmrundev
# abra no navegador:# http://localhost:3000/api-docs → interface interativa# http://localhost:3000/api-docs.json → JSON cru da especificação
💡 DicaswaggerUi.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 mesmo app.use.
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.
🔎 Por baixo do capô
Esses arquivos *.schema.js não exportam nada útil em termos de código JavaScript — servem só para o swagger-jsdoc encontrar o comentário (por isso estão incluídos em apis: [...] na configuração do Passo 1). É uma convenção comum para não poluir arquivos de rota reais com blocos de schema grandes.
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:
JavaScript
// src/routes/autenticacao.routes.jsimport{Router}from'express'import{autenticar}from'../middlewares/autenticar.js'exportfunctioncriarRotasDeAutenticacao(){constrouter=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' */// o `autenticar` NÃO é decorativo: é ele que valida o token e preenche// `req.usuario`. Sem ele, a anotação promete 401 e o handler estoura 500.router.get('/verificar',autenticar,(req,res)=>{res.status(200).json({uid:req.usuario.uid,email:req.usuario.email})})returnrouter}
Passo 5 — Segurança com bearerAuth e o botão "Authorize"¶
O esquema bearerAuth já foi declarado em components.securitySchemes (Passo 1):
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çalho Authorization).
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.
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 (Passo 5), depois expanda a operação, edite o JSON de exemplo no campo de corpo, e execute.
Resultado esperado: as chamadas respondem com o status e o corpo documentados — GET /api/eventos devolve o envelope { dados, paginacao } exatamente como o schema ListaDeEventos promete; POST /api/eventos sem autorizar devolve 401 no envelope { erro: { mensagem, codigo } }; depois do "Authorize", devolve 201 com o evento criado. Se a resposta real e o exemplo documentado divergirem em qualquer campo, a documentação está mentindo — conserte o schema, não o print.
⚠️ Atenção — CORS e servers
O Swagger UI faz a requisição do navegador, então as mesmas regras de CORS da Aula 13 se aplicam: se servers apontar 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 que CORS_ORIGEM_PERMITIDA inclua 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).
A1. Verdadeiro ou falso, com justificativa de uma linha: "swagger-jsdoc gera a documentação automaticamente a partir dos tipos declarados nas funções JavaScript, sem precisar de comentário nenhum."
Resultado esperado: falso — o swagger-jsdoc só lê comentários @openapi com bloco YAML dentro; ele não infere nada a partir da assinatura de funções ou do corpo do código.
A2. Complete a linha que falta para que as opções abaixo gerem a spec corretamente na versão 6.x do swagger-jsdoc:
Resultado esperado: definition (nunca swaggerDefinition, que é a chave das versões antigas 2.x/3.x).
A3. Em uma frase: qual a diferença entre OpenAPI e Swagger?
Resultado esperado: OpenAPI é a especificação — o formato que descreve a API; Swagger é o conjunto de ferramentas (swagger-jsdoc, swagger-ui-express) construído em torno dessa especificação.
A4. Ache o erro nas linhas abaixo (o Swagger UI mostra "not found" ao tentar exibir o exemplo do corpo de resposta):
Resultado esperado: falta o "s" em "schemas" — o caminho correto é #/components/schemas/Evento.
A5. Preveja a saída: dois endpoints diferentes referenciam $ref: '#/components/schemas/Erro'. Você muda um campo desse schema. Quantos lugares no Swagger UI mostram a mudança?
Resultado esperado: todos os endpoints que referenciam esse schema por $ref mudam juntos, imediatamente — é justamente a vantagem de não repetir a definição em cada endpoint.
B1. Configure swagger-jsdoc e swagger-ui-express no seu projeto autoral, com definition (não swaggerDefinition), info, pelo menos uma tag e o securitySchemebearerAuth.
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 do Passo 1 e troque só o title, description e as tags para o domínio do seu projeto.
B2. 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).
B3. Teste um endpoint protegido pelo "Authorize" — obtenha um token do Firebase (Passo 5) 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.
B4. 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 4.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.
C1. Crie os schemas reutilizáveis da entidade principal do seu domínio (equivalente a Evento/EventoInput/Erro/Paginacao) e referencie com $ref nos 3 endpoints do exercício B2 — nenhum campo pode ser redigitado à mão dentro de uma anotação de rota.
Resultado esperado: mudar um campo no schema reflete automaticamente em todos os endpoints que o referenciam, sem editar nenhuma rota; o Swagger UI mostra o mesmo exemplo de corpo em todas elas.
Dica
Coloque os schemas em src/docs/schemas/*.schema.js e inclua o caminho no array apis da configuração do swagger-jsdoc (Passo 1) — sem isso, o arquivo do schema é ignorado silenciosamente.
Um colega documentou um novo endpoint PATCH /api/eventos/{id}/destaque (marca um evento como destaque na home) com uma anotação @openapi completa — mas o Swagger UI insiste em mostrar essa operação isolada, fora dos grupos "Eventos"/"Inscrições"/"Autenticação", num grupo solto chamado "default" no fim da página. Antes de olhar o código, abra /api-docs do seu projeto e investigue: o que diferencia visualmente uma operação agrupada de uma "solta"?
Critérios de pronto
Um comentário registra qual chave do bloco @openapida operação (não da configuração global) estava faltando.
Depois de corrigida, a operação aparece dentro do grupo correto no Swagger UI.
Uma frase explica a diferença entre a lista tags da configuração global (definition.tags, Passo 1) e a lista tags: [...] escrita dentro de cada anotação de rota — os dois têm o mesmo nome, mas papéis diferentes.
Você documenta pelo menos um outro endpoint do seu projeto autoral usando o mesmo padrão de agrupamento, para confirmar que entendeu a diferença.
Pistas
Compare a anotação da operação "solta" com uma que aparece agrupada corretamente — falta exatamente uma chave dentro do bloco @openapi da operação.
tags na configuração global só declara o nome e a descrição do grupo — quem coloca de fato uma operação dentro dele é o tags: [...] escrito na anotação de cada rota.
Depois de corrigir, reinicie a API (ou deixe npm run dev reiniciar sozinho, já que ele roda com --watch) e recarregue /api-docs — a spec só é montada de novo quando o módulo swaggerSpec.js é reimportado.
Reproduza no seu repositório uma divergência que acontece o tempo todo em projeto real. Renomeie, só no linhaParaEvento do repositório, o campo imagemUrl para urlDaImagem, e ajuste a anotação Swagger de GET /api/eventos/{id} — e apenas ela — para o nome novo. Suba a API: agora GET /api/eventos (a listagem) documenta imagemUrl, GET /api/eventos/{id} documenta urlDaImagem, e as duas devolvem a mesma coisa. Quem está integrando o front pelo Swagger UI vai escrever código para um campo que não existe na metade das respostas. Encontre a causa estrutural dessa divergência e conserte — sem editar o mesmo nome de campo em três lugares diferentes.
Critérios de pronto
Ao final, o campo volta a se chamar imagemUrl (o nome do contrato da trilha) e aparece igual em todas as operações que retornam um evento.
A correção acontece em um único lugar (o schema Evento em components.schemas), referenciado por $ref em todos os endpoints — não copiado em cada anotação de rota.
Um teste manual (curl num endpoint real) confirma que o nome do campo na resposta bate exatamente com o que a documentação promete.
Um comentário de uma linha explica por que documentar o mesmo campo em vários lugares (em vez de usar $ref) foi o que permitiu essa divergência passar despercebida.
Pistas
Procure todas as ocorrências do nome antigo do campo dentro de src/docs/schemas/ e src/routes/ — um grep -r no terminal encontra rápido.
Se um endpoint declarar o exemplo do corpo "na mão", em vez de usar $ref: '#/components/schemas/Evento', ele fica exposto a esse tipo de esquecimento — troque para $ref sempre que possível.
Depois de corrigir o schema, confirme visualmente no Swagger UI que o exemplo de todas as operações relacionadas ao evento mudou junto.
Na Seção 1 você leu que "documentação vira uma fonte de verdade verificável" quando existe uma ferramenta de teste de contrato comparando a resposta real com o que foi documentado. Hoje isso ainda é só teoria no UniEventos: nada garante que a resposta real de GET /api/eventos continua batendo com o schema Evento documentado depois de um refactor. Construa esse teste de contrato mínimo, sem depender de biblioteca externa pesada.
Critérios de pronto
Um script scripts/testar-contrato.js busca /api-docs.json, extrai o schema Evento de components.schemas, faz uma chamada real a GET /api/eventos, e confere que cada campo obrigatório do schema existe na resposta real e bate com o type declarado (string, integer etc.).
O script termina com código de saída diferente de zero e uma mensagem clara se algum campo estiver faltando ou com tipo errado.
Um teste proposital: remova temporariamente um campo do controller que monta a resposta de /api/eventos e confirme que o script acusa a divergência.
O script está incluído como um passo do workflow de CI (ou de um script npm), rodando antes ou depois da suíte de testes de unidade.
Pistas
fetch('http://localhost:3000/api-docs.json') devolve o JSON completo da spec — o schema fica em components.schemas.Evento.properties.
Para cada chave de properties, confira typeof valorReal contra o type declarado ("integer"/"number" → typeof === 'number'; "string" → typeof === 'string').
Registre o script como um script npm (ex.: "testar:contrato": "node scripts/testar-contrato.js").
Não tente validar formatos complexos (date-time, uri) de início — comece só conferindo presença do campo e o tipo primitivo.
Documente todos os endpoints do seu projeto autoral com anotações @openapi (não só os 3 do laboratório).
Garanta que os schemas Erro e de paginação (se aplicável) estão presentes e referenciados.
Revise o README.md seguindo a estrutura da Seção 4.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.
Bibliografia do plano da disciplina em que esta trilha nasceu — capítulos sobre documentação de APIs REST e contratos de serviço.
Na Aula 15 fechamos esta trilha com deploy real (front e back), CI/CD com GitHub Actions, retrospectiva de todos os padrões de projeto usados, perguntas para testar seu domínio e os requisitos completos do Marco 3. Traga a API documentada e pronta para publicar.
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 PORT dinâmico, health check e variáveis de ambiente seguras.
Escrever um Dockerfile simples para a API e um docker-compose.yml com 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 de um tempo definido, explicando as próprias decisões técnicas.
Alcançar o Marco 3 do projeto autoral, conferindo cada requisito da unidade de ponta a ponta.
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.
unieventos-api (ou projeto autoral) com arquitetura em camadas (Aula 13) e documentação Swagger (Aula 14) completas.
Front-end (unieventos-web ou equivalente) com build funcionando localmente (npm run build sem 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 build do front gera a pasta dist/ sem erro.
[ ] npm test do back passa localmente.
[ ] Você tem acesso de administrador aos dois repositórios (front e back) no GitHub.
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/:
Texto
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. O index.html, em contrapartida, nunca deve ser cacheado agressivamente — ele é o que aponta para os hashes corretos a cada novo deploy.
🔬 Investigue
Abra a aba Network do DevTools em qualquer site grande que você usa no dia a dia (não precisa ser o UniEventos) e clique num arquivo .js ou .css com um hash estranho no nome. Olhe o cabeçalho de resposta Cache-Control — em serviços bem configurados, ele costuma trazer algo como max-age=31536000, immutable (um ano). Agora clique no documento principal da página (o HTML) e compare: o Cache-Control dele é bem mais curto, ou no-cache. Por que faz sentido cachear "para sempre" um arquivo, mas nunca o HTML que aponta para ele?
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.
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).
Terminal
# .env.production — lido automaticamente quando NODE_ENV=production (no build)VITE_API_URL=https://unieventos-api.onrender.com/api
VITE_FIREBASE_API_KEY=AIzaSy...
VITE_FIREBASE_AUTH_DOMAIN=unieventos.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=unieventos
Terminal
# .env.development — lido em npm run devVITE_API_URL=http://localhost:3000/api
VITE_FIREBASE_API_KEY=AIzaSy...
VITE_FIREBASE_AUTH_DOMAIN=unieventos.firebaseapp.com
VITE_FIREBASE_PROJECT_ID=unieventos
JavaScript
// src/services/http.js — o cliente da Aula 06, agora lendo a variável de ambienteimportaxiosfrom'axios'consthttp=axios.create({baseURL:import.meta.env.VITE_API_URL,// troca sozinho entre dev e produção})exportdefaulthttp
⚠️ AtençãoVITE_API_URL inclui o sufixo /api — é o prefixo em que a unieventos-api monta suas rotas desde a Aula 07. Os services chamam http.get('/eventos'), e a URL final vira .../api/eventos. Esquecer o /api aqui é o erro pós-deploy mais comum: tudo compila, tudo publica, e toda requisição volta 404 porque foi para .../eventos.
⚠️ Atençãoimport.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 de VITE_API_URL entre no bundle. Serviços como Vercel fazem isso automaticamente a cada push, rodando npm run build de novo.
Se o site for publicado em um subcaminho (comum no GitHub Pages, ex.: usuario.github.io/unieventos-web/), configure base:
JavaScript
// vite.config.js — trecho relevante para deploy em subcaminhoexportdefaultdefineConfig({base:'/unieventos-web/',// necessário só se NÃO estiver na raiz do domínioplugins:[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.
JSON
// vercel.json — rewrite de SPA na Vercel{"rewrites":[{"source":"/(.*)","destination":"/index.html"}]}
Texto
# _redirects — Netlify (arquivo dentro de public/, copiado para dist/ no build)
/* /index.html 200
JSON
// firebase.json — trecho relevante do Firebase Hosting{"hosting":{"public":"dist","rewrites":[{"source":"**","destination":"/index.html"}]}}
📌 Vale gravar
"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.html em qualquer caminho desconhecido?"
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.production local.
Clique Deploy. Em cerca de 1 minuto, a Vercel devolve uma URL pública (https://unieventos-web.vercel.app).
A cada git push na branch main, 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 trilha.
npminstall-gfirebase-tools
firebaselogin
firebaseinithosting
# 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)
npmrunbuild
firebasedeploy--onlyhosting
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.
// 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:
Terminal
npmrundeploy
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.1 O essencial, independente do serviço escolhido¶
JavaScript
// src/server.js — PORT precisa vir do ambiente, nunca fixoimport{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.constservidor=app.listen(config.PORT,()=>{console.log(`API rodando na porta ${config.PORT}`)})
JSON
// 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"}}
JavaScript
// 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:
[ ] PORT vem de process.env.PORT (via config, Aula 13), nunca hardcoded.
[ ] Script start existe em package.json e sobe a API com node puro (sem --watch, que é só para desenvolvimento).
[ ] GET /health responde 200 sem 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 .env configuradas como secrets no painel do serviço, nunca commitadas no Git.
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.
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.
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
# DockerfileFROMnode:22-alpineWORKDIR/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.COPYpackage.jsonpackage-lock.json./
RUNnpminstall--omit=dev
COPYsrc./src
COPYmigrations./migrations
COPYscripts./scripts
EXPOSE3000CMD["npm","start"]
🧠 Você sabia?
Contêineres não são uma invenção da Docker. As primitivas do kernel Linux que tornam a isolação possível — cgroups (limitar quanto de CPU e memória um processo pode usar) e namespaces (isolar o que um processo enxerga do sistema) — existem desde 2007/2008, quase seis anos antes do lançamento da Docker em 2013. O que a Docker inventou não foi a isolação em si: foi a experiência — empacotar essas primitivas complexas atrás de um Dockerfile legível e de dois comandos (docker build, docker run) que qualquer pessoa consegue usar sem entender kernel Linux por dentro.
dockercomposeup--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 por localhost — por isso DB_HOST: mysql e não DB_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 via npm 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:
Terminal
# .env de produção da APICORS_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 /health primeiro depois de um deploy. Se ele responde 200, o processo subiu — o problema está em uma camada específica (banco, CORS, variável de ambiente), não na infraestrutura toda.
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.
YAML
# .github/workflows/ci.ymlname:CIon:push:branches:['**']pull_request:branches:[main]jobs:lint-e-testes:runs-on:ubuntu-lateststeps:-name:Baixar o código do repositóriouses:actions/checkout@v4-name:Configurar Node.js 22uses:actions/setup-node@v4with:node-version:'22'cache:'npm'-name:Instalar dependênciasrun:npm ci-name:Rodar lintrun:npm run lint --if-present-name:Rodar testesrun:npm testdeploy:needs:lint-e-testesif:github.ref == 'refs/heads/main' && github.event_name == 'push'runs-on:ubuntu-lateststeps:-name:Baixar o código do repositóriouses:actions/checkout@v4-name:Disparar deploy na Render via deploy hookrun: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 job deploy só roda depois que lint-e-testes termina 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 branch main, 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çãonpm ci (não npm install) dentro de workflows de CI: ele instala exatamente as versões travadas em package-lock.json, de forma determinística, e falha se o lockfile estiver dessincronizado do package.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.
🧩 Todos os padrões de projeto usados nesta trilha¶
Esta trilha cobre 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
📌 Vale gravar
Dominar de verdade os padrões de projeto desta trilha exige duas coisas: saber a definição de cada padrão e conseguir apontar um exemplo concreto de onde ele apareceu — não basta decorar o nome, é preciso reconhecer o padrão dentro de um trecho de código real.
7. Teste seu domínio: padrões de projeto e arquitetura de toda a trilha¶
Esta seção é uma autoavaliação, cobrindo as três unidades desta trilha. O objetivo não é "rodar código" — é entender o porquê de cada decisão técnica, não só a sintaxe. Use as perguntas para descobrir sozinho o que você já domina e o que vale revisar antes de fechar o projeto (ou de explicar seu código na apresentação).
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 usar axios global? (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/promise com 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-jsdoc 6.x é definition, e o que acontece se usar swaggerDefinition? (Aula 14)
O que é uma migration de banco de dados e por que ela substitui um schema.sql aplicado 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?
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 trilha, a chave correta dentro das opções para declarar openapi, info e components é:
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 2.
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 este material sempre declara explicitamente defaultTheme: 'light' na criação da instância, para manter a interface consistente com as capturas de tela e exemplos do material. Ver especificação da Aula 04/05.
Apresente seu projeto autoral individualmente, em 8 minutos, cobrindo estes pontos:
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.
Use esta tabela para se autoavaliar — ou peça a um colega que assista e aponte o que falta:
Critério
O que observar
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 — apresentar rodando em localhost não mostra o que você realmente construiu. Se o deploy falhar na hora, tenha um vídeo curto de backup gravado com antecedência mostrando o fluxo funcionando.
Se você está seguindo esta trilha em uma turma com professor, apresente na ordem e no dia combinados por ele, dentro do tempo do encontro. Se está estudando por conta própria, grave a apresentação e revise-a no dia seguinte, ou apresente-a para outra pessoa — um colega de estudo, alguém da família, ou uma chamada rápida com alguém da comunidade. O que importa é o exercício em si: explicar o próprio projeto em voz alta, dentro de um tempo limitado.
O que foi construído nesta trilha é 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 desta trilha¶
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.
Muitos dos temas tocados de leve nesta trilha — arquitetura de software, segurança de aplicações web, engenharia de dados, IA aplicada a desenvolvimento — são linhas de pesquisa ativas em diversos grupos de pesquisa em Computação. Se você está cursando esta trilha em uma instituição de ensino e algum tópico despertou curiosidade além do conteúdo de uma aula, procure o professor da disciplina para conversar sobre projetos de iniciação científica ou extensão relacionados. Se você chegou até aqui por conta própria, esses mesmos temas são bons pontos de partida para aprofundar — grupos de pesquisa, comunidades on-line e cursos avançados costumam orbitar exatamente esses assuntos.
🧩 Padrão de projeto em uso — Configuração externa (Twelve-Factor) e Adapter¶
Duas decisões de arquitetura tomadas ao longo desta trilha ficam evidentes só agora, no momento de publicar de verdade.
Configuração externa por variáveis de ambiente. Desde a Aula 13, o unieventos-api lê PORT, DB_HOST, CORS_ORIGEM_PERMITIDA etc. de process.env, nunca de um valor fixo no código (Seção 3.1). Isso não é só "boa prática" abstrata: é o que permite o mesmo código-fonte, sem alterar uma linha, rodar em três ambientes diferentes — seu notebook (.env local), o CI (variáveis do GitHub Actions) e a nuvem (secrets da Render) — só trocando o que fica fora do código. O manifesto The Twelve-Factor App formalizou esse princípio (fator III, "Config") como um dos doze fatores de aplicações que escalam bem em nuvem; é o mesmo raciocínio por trás de VITE_API_URL no front (Seção 1.2).
JavaScript
// PORT vem de fora — o mesmo código roda em dev, CI e produção sem mudarconstservidor=app.listen(config.PORT,()=>{console.log(`API rodando na porta ${config.PORT}`)})
Adapter — trocar o banco sem tocar no service. Na Aula 12, MySQL virou Supabase mantendo a mesma interface de repositório (listar, buscarPorId, criar, atualizar, remover) — o service nunca soube qual banco estava por trás. Hoje, ao decidir onde hospedar o banco de produção (MySQL gerenciado na Render/Railway, ou Supabase), essa escolha de infraestrutura continua isolada na camada de repositório: o Adapter já construído é exatamente o que torna essa decisão, tomada agora no deploy, indiferente para o resto da aplicação.
📌 Vale gravar
Configuração externa e Adapter resolvem problemas diferentes, mas se reforçam: uma isola onde a aplicação roda, a outra isola em que banco ela persiste — juntas, permitem que o mesmo código passe de localhost para produção sem reescrever uma linha de lógica de negócio.
Passo 1 — configure as variáveis de ambiente de produção do front:
Terminal
# 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:
Terminal
npmrunbuild
npmrunpreview
# 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:
Terminal
mkdir-p.github/workflows
# cole o conteúdo de .github/workflows/ci.yml da Seção 5
gitadd.github/workflows/ci.yml
gitcommit-m"adiciona pipeline de CI com lint e testes"
gitpush
Confira na aba Actions do GitHub que o workflow rodou e passou.
O teste do deploy é feito de fora, numa aba anônima — exatamente como qualquer pessoa acessando de fora vai ver. Nada de "funciona na minha máquina".
Terminal
# 1) a API publicada responde
curl-ihttps://<sua-api>.onrender.com/health
curl-shttps://<sua-api>.onrender.com/api/eventos|jq'.paginacao'
Resultado esperado: 200 {"status":"ok"} no health check e o objeto paginacao do envelope de listagem. Se a segunda chamada devolver 404, o /api foi esquecido em algum lugar.
Front publicado — abra a URL da Vercel numa janela anônima. A lista de eventos carrega (dado real, vindo da API publicada). Resultado esperado: nenhum erro de CORS no console, e nenhuma requisição para localhost.
Rota interna com F5 — navegue até /eventos/1 e recarregue a página. Resultado esperado: a página carrega normalmente; um 404 aqui significa que falta o rewrite de SPA (Seção 1.4).
Login e escrita — faça login pelo Firebase e crie um evento. Resultado esperado: 201, o evento aparece na lista, e outra pessoa abrindo a URL num outro computador vê o mesmo evento.
Variáveis — confira no painel da Vercel que VITE_API_URL termina em /api, e no painel da Render que CORS_ORIGEM_PERMITIDA é exatamente a URL do front (sem barra no fim).
CI — faça um commit qualquer e confirme na aba Actions que lint e testes rodaram no push.
Só depois que os seis passam é que o Marco 3 está de fato completo.
A1. Verdadeiro ou falso, com justificativa de uma linha: "trocar VITE_API_URL no painel do serviço de hospedagem, depois que o front já está publicado, atualiza a URL usada pelo bundle sem precisar gerar um novo build."
Resultado esperado: falso. import.meta.env.VITE_* é substituído por um valor literal em tempo de build — trocar a variável depois, sem rebuildar, não tem efeito nenhum no JavaScript já gerado.
A2. Complete a linha que falta para que o rewrite de SPA funcione na Vercel (F5 numa rota interna não pode dar 404):
A3. Em uma frase: por que app.listen(config.PORT, ...) precisa ler PORT de process.env em vez de usar 3000 fixo no código?
Resultado esperado: porque a maioria dos serviços de nuvem injeta a própria porta via variável de ambiente — escutar numa porta fixa quebra o deploy nesses ambientes.
A4. Ache o erro nas linhas abaixo (a API sobe normalmente no docker compose up, mas todo endpoint que usa banco falha com erro de conexão):
YAML
environment:DB_HOST:localhostDB_PORT:3306
Resultado esperado: DB_HOST deveria ser o nome do serviço no docker-compose.yml (mysql), não localhost — dentro da rede criada pelo Compose, cada serviço enxerga os outros pelo nome do serviço, não por localhost.
A5. Preveja a saída: o workflow de CI tem on.push.branches: ['**'] e o job deploy com if: github.ref == 'refs/heads/main' && github.event_name == 'push'. Você faz git push numa branch feature/relatorio. O job deploy roda?
Resultado esperado: não. lint-e-testes roda (o push bateu em alguma branch, e o padrão '**' cobre qualquer uma), mas deploy não roda, porque a condição if exige que a branch seja main.
Rode as migrations manualmente pelo shell do serviço antes de testar qualquer rota que dependa de tabela do banco.
B4. 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.
C1. Crie o workflow de CI/CD completo no seu repositório de back-end: um job lint-e-testes que roda em qualquer push, e um job deploy que só roda depois do primeiro passar, restrito à branch main, disparando o deploy de verdade (deploy hook do seu serviço de hospedagem).
Resultado esperado: um push numa branch de feature só dispara lint-e-testes; um push (ou merge) em main dispara os dois jobs, e a aba Actions do GitHub mostra ambos passando em verde, nessa ordem.
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. Para o deploy hook, gere a URL no painel do seu serviço de hospedagem e guarde como secret do repositório — nunca em texto puro no workflow.
Planos gratuitos de hospedagem "dormem" o processo depois de um tempo sem tráfego (Seção 3.2) — mas quanto tempo, exatamente, uma requisição demora para acordar um serviço adormecido, comparado com uma requisição normal? Meça e documente, em vez de só repetir o que a Seção 3.2 avisa.
Critérios de pronto
Duas medições com curl -w '%{time_total}\n' -o /dev/null -s https://sua-api.onrender.com/health: uma logo depois de um período sem tráfego (cold start) e outra imediatamente em seguida (processo já acordado).
Uma tabela de duas linhas no README compara os dois tempos.
Uma frase explica por que /health é a rota certa para esse teste (não depende de banco nem de autenticação — Seção 3.1).
Uma sugestão registrada (não precisa implementar) de como reduzir esse impacto para quem for apresentar o projeto ao vivo (ex.: "acordar" o serviço minutos antes da apresentação).
Pistas
-w '%{time_total}\n' imprime só o tempo total da requisição, em segundos; -o /dev/null descarta o corpo da resposta, que não importa aqui.
Espere alguns minutos sem fazer nenhuma requisição ao serviço antes da primeira medição, para garantir que ele realmente "dormiu".
Compare também o que aparece na aba Network do DevTools ao abrir o front publicado logo depois do cold start — a primeira chamada à API "trava" visivelmente mais que as seguintes.
Um colega jura que testou tudo: npm run preview local funciona perfeitamente, F5 em qualquer rota interna funciona. Mas depois de publicado, o mesmo F5 em /eventos/3 devolve uma página de erro genérica do provedor de hospedagem, 404 Not Found. "Funciona na minha máquina" de novo. Investigue a diferença entre o ambiente local (vite preview) e o provedor de hospedagem escolhido.
Critérios de pronto
Um comentário identifica exatamente qual arquivo de configuração está ausente ou mal escrito no repositório publicado (vercel.json, _redirects ou firebase.json, conforme o serviço).
Depois de corrigido, um F5 em pelo menos duas rotas internas diferentes, na aplicação publicada, devolve a página certa, sem 404.
Uma frase explica por que vite previewnunca reproduz esse bug sozinho — ele já simula o rewrite de SPA por padrão, então o problema só aparece quando o arquivo de configuração do provedor real está ausente.
Um teste com curl -I na URL publicada de uma rota interna confirma o status 200 (não 404) depois da correção.
Pistas
Confira se o arquivo de rewrite (vercel.json, _redirects ou o trecho de firebase.json) está versionado no Git e não só criado localmente e esquecido no .gitignore.
Para o Netlify/_redirects, lembre que o arquivo precisa estar dentro de public/ para o Vite copiá-lo para dist/ no build — fora dali, ele nunca chega à hospedagem.
curl -I https://seu-front.vercel.app/eventos/3 mostra só os cabeçalhos e o status — mais rápido que abrir o navegador para conferir o resultado repetidas vezes.
Hoje, se um deploy quebrar (uma variável de ambiente errada, uma migration esquecida), você só descobre quando alguém tenta usar a aplicação e encontra um erro. Implemente um smoke test pós-deploy: um passo automático no workflow de CI/CD que, depois de publicar, confirma que a aplicação está realmente funcionando — e falha o workflow (avisando você) se não estiver.
Critérios de pronto
Um script scripts/smoke-test.sh (ou .js) que roda depois do job deploy: chama /health da API publicada e confirma 200; chama um endpoint de leitura pública (ex.: GET /api/eventos) e confirma que a resposta é uma lista válida; tenta uma escrita sem token e confirma que a resposta é 401 (nunca 500).
Se qualquer uma dessas três checagens falhar, o script termina com código de saída diferente de zero, e o job do GitHub Actions aparece em vermelho.
O script está incluído como o último passo do job deploy no .github/workflows/ci.yml.
Um teste proposital: aponte o script para uma URL errada (ou pare o serviço) e confirme que o workflow realmente falha — não é suficiente que o script "pareça correto" sem nunca ter sido visto falhando.
Pistas
Um script simples com curl -f (a flag -f faz o curl retornar código de erro se o status HTTP não for de sucesso) já cobre boa parte da checagem, sem precisar de biblioteca extra.
Para o teste de escrita sem token, curl -s -o /dev/null -w '%{http_code}' imprime só o código de status, fácil de comparar num if do shell.
Espere alguns segundos depois do deploy hook antes de rodar o smoke test — o serviço pode levar um instante para religar o processo com o novo código.
Rode o script manualmente contra sua API já publicada antes de colocá-lo no workflow — mais fácil depurar localmente do que lendo logs do GitHub Actions.
🔥
🔥 Boss — Seu projeto autoral, no ar e à prova de F5¶
deployci-cdprojetotestes
Chegamos ao fim das três unidades. Este é o desafio que fecha a trilha: seu projeto autoral publicado, de ponta a ponta, com todas as camadas construídas ao longo das aulas funcionando juntas em produção — e não só "funcionando", mas resistindo aos testes que costumam derrubar um projeto assim que alguém de fora tenta usá-lo.
Critérios de pronto
Front-end e back-end publicados com URL pública, sem depender de localhost em lugar nenhum — nem em texto do README, nem em variável de ambiente esquecida.
CRUD completo de pelo menos 2 entidades relacionadas, autenticação protegendo as rotas de escrita, e um papel diferenciado (ex.: admin) funcionando de verdade em produção — não só localmente.
/api-docs acessível publicamente, com todos os endpoints documentados e o botão "Authorize" funcionando com um token real, obtido do seu Firebase de produção.
Um script scripts/smoke-test.sh (do desafio ⭐⭐⭐, ou um novo) que roda depois do deploy, incluído como último passo do workflow de CI/CD.
F5 em pelo menos três rotas internas diferentes, na aplicação publicada, não produz 404 em nenhuma delas.
Um parágrafo no README relaciona, para cada uma das três unidades desta trilha, um padrão de projeto (da tabela consolidada da Seção 6) que sobrevive intacto na versão publicada — com o nome do arquivo e a linha onde ele aparece.
Pistas
Comece pelo smoke test — ele é o que prova, de fora para dentro, que "está no ar" significa mais do que a página inicial carregar.
Para testar F5 em produção sem abrir o navegador manualmente três vezes, um script com curl -I em cada rota (a resposta, graças ao rewrite de SPA, deve vir com 200, nunca 404) automatiza a checagem.
O parágrafo de padrões não precisa ser longo — uma linha por unidade já cumpre o critério, desde que aponte um trecho de código real, não só o nome do padrão.
Se o smoke test falhar depois de um deploy automático, registre no README como um ADR curto (Aula 14): o que quebrou e por quê — é exatamente esse tipo de decisão que outra pessoa (ou você mesmo, em três meses) vai querer entender.
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-docs estejam visíveis.
Critério de pronto: aplicação publicada e acessível publicamente; vídeo de backup gravado; roteiro da apresentação pronto.
O Marco 3 fecha esta trilha: uma aplicação full stack completa, construída sobre o projeto autoral definido na Aula 01 e evoluído ao longo de todas as aulas.
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) — Aulas 07/13.
Banco de dados persistente (MySQL ou Supabase/Postgres), com schema versionado (migrations ou scripts SQL organizados) — Aulas 09/12.
Autenticação funcional (Firebase Auth ou autenticação nativa do Supabase), protegendo pelo menos as rotas de escrita (criação/edição/remoção) — Aula 10.
CRUD completo de pelo menos 2 entidades relacionadas (ex.: "Evento" e "Inscrição"), com relacionamento real no banco (chave estrangeira ou equivalente) — Aula 11.
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 — Aula 14.
Deploy funcionando com URL pública — tanto do front quanto do back (ou só do front, se usando Supabase como back completo) — Aula 15.
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.
O que separa um projeto pronto de um feito às pressas na véspera:
[ ] CRUD completo de 2+ entidades relacionadas funcionando de ponta a ponta (criar, listar, editar, remover), com relacionamento real entre elas.
[ ] Banco de dados corretamente modelado: schema versionado, queries corretas, sem duplicação nem inconsistência de dados.
[ ] Rotas de escrita exigindo usuário autenticado, com a identidade usada corretamente (ex.: só o dono edita/remove seu próprio recurso).
[ ] Documentação Swagger/OpenAPI completa: todos os endpoints documentados, schemas reutilizáveis, segurança declarada, /api-docs acessível.
[ ] Front e back publicados e acessíveis externamente, sem depender de localhost em nenhum lugar (nem no README, nem em variável de ambiente esquecida).
[ ] README completo conforme a estrutura da Aula 14; arquitetura em camadas aplicada; ao menos alguns testes automatizados presentes.
Abra a aplicação publicada em uma aba anônima do navegador (sem cache, sem sessão salva) e percorra o fluxo completo: listar, ver detalhe, autenticar, criar, editar, remover.
Teste com curl (ou uma aba anônima) que uma rota de escrita sem token retorna 401/403, não 200.
Abra /api-docs publicamente e use o botão "Authorize" com um token real — todos os endpoints devem responder como documentado.
Aperte F5 em pelo menos três rotas internas da aplicação publicada: nenhuma deve retornar 404.
Rode npm install && npm run dev (ou docker compose up) em uma cópia limpa dos dois repositórios e confirme que tudo sobe sem passo não documentado no README.
Explique em voz alta, para um colega ou para você mesmo, uma decisão técnica do próprio código — se travar, é sinal de que vale revisar aquele trecho antes da apresentação.
Bibliografia do plano da disciplina em que esta trilha nasceu — capítulos sobre implantação, integração contínua e ciclo de vida de aplicações web.
Fim da trilha. 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. Se você quer testar o quanto absorveu, revise as questões da Seção 7 com calma, não na véspera da apresentação. Bom estudo, e bom portfólio.