Teoria sem prática é esquecida rápido. Neste artigo vamos consumir APIs públicas reais — sem cadastro, sem chave, sem custo — e construir mini-aplicações funcionais com cada uma.
O objetivo é duplo: solidificar tudo que aprendemos sobre Fetch, async/await e tratamento de erros, e ao mesmo tempo mostrar o quão poderoso o JavaScript se torna quando conectado ao mundo real.
As APIs que vamos usar
API URL Base Chave?
─────────────────────────────────────────────────────────────────
JSONPlaceholder https://jsonplaceholder.typicode.com Não
ViaCEP https://viacep.com.br Não
PokeAPI https://pokeapi.co/api/v2 Não
Open-Meteo https://api.open-meteo.com/v1 Não
IBGE https://servicodados.ibge.gov.br Não
DiceBear (Avatares) https://api.dicebear.com/7.x Não
Todas gratuitas, públicas e sem autenticação. Perfeito para aprender.
API 1 — ViaCEP: buscando endereços brasileiros
A ViaCEP é uma API brasileira que retorna dados de endereço a partir de um CEP. Extremamente útil em formulários de e-commerce e cadastro.
async function buscarCEP(cep) {
// Remove formatação — aceita "01310-100" ou "01310100"
const cepLimpo = cep.replace(/\D/g, "");
if (cepLimpo.length !== 8) {
throw new Error("CEP deve ter 8 dígitos.");
}
const response = await fetch(`https://viacep.com.br/ws/${cepLimpo}/json/`);
if (!response.ok) {
throw new Error(`Erro ${response.status} ao consultar CEP.`);
}
const dados = await response.json();
// ViaCEP retorna { erro: true } para CEPs inválidos
if (dados.erro) {
throw new Error(`CEP ${cep} não encontrado.`);
}
return {
cep: dados.cep,
logradouro: dados.logradouro,
complemento: dados.complemento,
bairro: dados.bairro,
cidade: dados.localidade,
estado: dados.uf,
ibge: dados.ibge,
ddd: dados.ddd,
};
}
// Preenchimento automático de formulário
const inputCEP = document.querySelector("#cep");
const campos = {
logradouro: document.querySelector("#logradouro"),
bairro: document.querySelector("#bairro"),
cidade: document.querySelector("#cidade"),
estado: document.querySelector("#estado"),
};
function debounce(fn, delay) {
let timer;
return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); };
}
const preencherEndereco = debounce(async (cep) => {
if (cep.replace(/\D/g, "").length !== 8) return;
inputCEP.style.borderColor = "";
try {
const endereco = await buscarCEP(cep);
campos.logradouro.value = endereco.logradouro;
campos.bairro.value = endereco.bairro;
campos.cidade.value = endereco.cidade;
campos.estado.value = endereco.estado;
// Foca no próximo campo relevante
document.querySelector("#numero")?.focus();
console.log(`✅ Endereço encontrado: ${endereco.logradouro}, ${endereco.cidade}/${endereco.estado}`);
} catch (erro) {
inputCEP.style.borderColor = "red";
console.error(erro.message);
}
}, 600);
inputCEP.addEventListener("input", (e) => {
preencherEndereco(e.target.value);
});
API 2 — IBGE: dados geográficos do Brasil
O IBGE disponibiliza uma API completa com estados, municípios e muito mais:
const IBGE_BASE = "https://servicodados.ibge.gov.br/api/v1";
async function buscarEstados() {
const response = await fetch(`${IBGE_BASE}/localidades/estados?orderBy=nome`);
const estados = await response.json();
return estados.map(e => ({
id: e.id,
sigla: e.sigla,
nome: e.nome,
regiao: e.regiao.nome,
}));
}
async function buscarMunicipios(siglaEstado) {
const response = await fetch(
`${IBGE_BASE}/localidades/estados/${siglaEstado}/municipios?orderBy=nome`
);
const municipios = await response.json();
return municipios.map(m => ({
id: m.id,
nome: m.nome,
}));
}
// Populando selects de estado/cidade encadeados
async function inicializarSelects() {
const selectEstado = document.querySelector("#estado");
const selectCidade = document.querySelector("#cidade");
// Carrega estados
const estados = await buscarEstados();
estados.forEach(estado => {
const option = document.createElement("option");
option.value = estado.sigla;
option.textContent = `${estado.nome} (${estado.sigla})`;
selectEstado.appendChild(option);
});
// Ao trocar estado, carrega cidades
selectEstado.addEventListener("change", async () => {
const sigla = selectEstado.value;
selectCidade.innerHTML = "<option>Carregando...</option>";
selectCidade.disabled = true;
try {
const municipios = await buscarMunicipios(sigla);
selectCidade.innerHTML = '<option value="">Selecione a cidade</option>';
municipios.forEach(m => {
const option = document.createElement("option");
option.value = m.id;
option.textContent = m.nome;
selectCidade.appendChild(option);
});
selectCidade.disabled = false;
} catch (erro) {
selectCidade.innerHTML = "<option>Erro ao carregar cidades</option>";
}
});
}
inicializarSelects();
API 3 — Open-Meteo: previsão do tempo
Open-Meteo é uma API meteorológica gratuita e de código aberto — sem cadastro, sem chave:
async function buscarPrevisao(latitude, longitude, dias = 7) {
const params = new URLSearchParams({
latitude,
longitude,
daily: [
"temperature_2m_max",
"temperature_2m_min",
"precipitation_sum",
"weathercode",
"windspeed_10m_max",
].join(","),
current_weather: true,
timezone: "America/Sao_Paulo",
forecast_days: dias,
});
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?${params}`
);
if (!response.ok) throw new Error("Erro ao buscar previsão do tempo.");
const dados = await response.json();
// Interpretando o weathercode
const descricaoTempo = (codigo) => {
const codigos = {
0: { desc: "Céu limpo", icone: "☀️" },
1: { desc: "Predominantemente limpo", icone: "🌤️" },
2: { desc: "Parcialmente nublado", icone: "⛅" },
3: { desc: "Nublado", icone: "☁️" },
45: { desc: "Neblina", icone: "🌫️" },
51: { desc: "Garoa leve", icone: "🌦️" },
61: { desc: "Chuva leve", icone: "🌧️" },
71: { desc: "Neve leve", icone: "🌨️" },
80: { desc: "Pancadas de chuva", icone: "⛈️" },
95: { desc: "Tempestade", icone: "🌩️" },
};
return codigos[codigo] || { desc: "Variável", icone: "🌈" };
};
const { daily, current_weather } = dados;
return {
agora: {
temperatura: current_weather.temperature,
vento: current_weather.windspeed,
...descricaoTempo(current_weather.weathercode),
},
previsao: daily.time.map((data, i) => ({
data: new Date(data).toLocaleDateString("pt-BR", { weekday: "short", day: "2-digit", month: "2-digit" }),
maxima: daily.temperature_2m_max[i],
minima: daily.temperature_2m_min[i],
chuva: daily.precipitation_sum[i],
vento: daily.windspeed_10m_max[i],
...descricaoTempo(daily.weathercode[i]),
})),
};
}
// Buscando o tempo em algumas cidades brasileiras
async function tempoNoBrasil() {
const cidades = [
{ nome: "São Paulo", lat: -23.55, lon: -46.63 },
{ nome: "Rio de Janeiro", lat: -22.91, lon: -43.17 },
{ nome: "Curitiba", lat: -25.43, lon: -49.27 },
{ nome: "Recife", lat: -8.05, lon: -34.88 },
{ nome: "Manaus", lat: -3.10, lon: -60.02 },
];
const resultados = await Promise.all(
cidades.map(async (cidade) => {
const previsao = await buscarPrevisao(cidade.lat, cidade.lon, 1);
return { cidade: cidade.nome, ...previsao.agora };
})
);
console.log("\n🌡️ TEMPO AGORA NO BRASIL\n");
resultados.forEach(r => {
console.log(`${r.icone} ${r.cidade}: ${r.temperatura}°C — ${r.desc}`);
});
}
tempoNoBrasil();
API 4 — PokeAPI: construindo um Pokédex
const POKE_BASE = "https://pokeapi.co/api/v2";
// Cache em memória para evitar requisições repetidas
const cachePokemons = new Map();
async function buscarPokemon(nomeOuId) {
const chave = String(nomeOuId).toLowerCase();
if (cachePokemons.has(chave)) {
return cachePokemons.get(chave);
}
const response = await fetch(`${POKE_BASE}/pokemon/${chave}`);
if (response.status === 404) {
throw new Error(`Pokémon "${nomeOuId}" não encontrado.`);
}
if (!response.ok) throw new Error(`Erro ${response.status}`);
const dados = await response.json();
const pokemon = {
id: dados.id,
nome: dados.name,
nomeFormatado: dados.name.charAt(0).toUpperCase() + dados.name.slice(1),
tipos: dados.types.map(t => t.type.name),
altura: dados.height / 10, // decímetros → metros
peso: dados.weight / 10, // hectogramas → kg
habilidades: dados.abilities.map(a => a.ability.name),
stats: dados.stats.reduce((acc, s) => {
acc[s.stat.name] = s.base_stat;
return acc;
}, {}),
sprites: {
frente: dados.sprites.front_default,
costas: dados.sprites.back_default,
artwork: dados.sprites.other?.["official-artwork"]?.front_default,
},
experienciaBase: dados.base_experience,
};
cachePokemons.set(chave, pokemon);
return pokemon;
}
async function buscarEvolucoes(nomeOuId) {
// Busca a espécie primeiro
const especieResp = await fetch(`${POKE_BASE}/pokemon-species/${nomeOuId}`);
const especie = await especieResp.json();
// Busca a cadeia de evolução
const evolucaoResp = await fetch(especie.evolution_chain.url);
const evolucao = await evolucaoResp.json();
// Extrai a cadeia recursivamente
function extrairCadeia(chain) {
const resultado = [chain.species.name];
if (chain.evolves_to.length > 0) {
resultado.push(...extrairCadeia(chain.evolves_to[0]));
}
return resultado;
}
return extrairCadeia(evolucao.chain);
}
async function comparar(nome1, nome2) {
const [p1, p2] = await Promise.all([
buscarPokemon(nome1),
buscarPokemon(nome2),
]);
const stats = ["hp", "attack", "defense", "speed", "special-attack", "special-defense"];
console.log(`
⚔️ BATALHA: ${p1.nomeFormatado} vs ${p2.nomeFormatado}
`);
// padEnd(10, nome) PREENCHE com o nome repetido: "Pikachu" vira
// "PikachuPik". O segundo argumento é o texto de preenchimento,
// não o conteúdo. Para alinhar um nome, é ele que recebe o padEnd.
console.log(`${"STAT".padEnd(20)} ${p1.nomeFormatado.padEnd(15)} ${p2.nomeFormatado}`);
console.log("─".repeat(50));
let vitorias1 = 0, vitorias2 = 0;
stats.forEach(stat => {
const v1 = p1.stats[stat] || 0;
const v2 = p2.stats[stat] || 0;
const vencedor = v1 > v2 ? "←" : v1 < v2 ? "→" : "=";
if (v1 > v2) vitorias1++;
if (v2 > v1) vitorias2++;
console.log(`${stat.padEnd(20)} ${String(v1).padEnd(15)} ${v2} ${vencedor}`);
});
console.log("─".repeat(50));
const vencedor = vitorias1 > vitorias2
? p1.nomeFormatado
: vitorias2 > vitorias1
? p2.nomeFormatado
: "Empate";
console.log(`
🏆 Vencedor nos stats: ${vencedor}`);
}
// Exemplos
const pikachu = await buscarPokemon("pikachu");
console.log(`${pikachu.nomeFormatado} — Tipos: ${pikachu.tipos.join(", ")}`);
console.log(`Altura: ${pikachu.altura}m | Peso: ${pikachu.peso}kg`);
const evolucoes = await buscarEvolucoes("charmander");
console.log(`Cadeia de evolução: ${evolucoes.join(" → ")}`);
// charmander → charmeleon → charizard
await comparar("pikachu", "bulbasaur");
API 5 — DiceBear: gerando avatares dinâmicos
Uma API para gerar avatares SVG únicos — ótimo para perfis de usuário:
function gerarAvatarURL(seed, estilo = "adventurer", opcoes = {}) {
const {
tamanho = 128,
backgroundColor = "b6e3f4",
} = opcoes;
const params = new URLSearchParams({
seed,
size: tamanho,
backgroundColor,
});
return `https://api.dicebear.com/7.x/${estilo}/svg?${params}`;
}
// Estilos disponíveis
const estilos = [
"adventurer", "avataaars", "big-smile",
"bottts", "croodles", "fun-emoji",
"icons", "identicon", "initials",
"lorelei", "micah", "miniavs",
"notionists", "open-peeps", "personas",
"pixel-art", "rings", "shapes",
"thumbs",
];
// Gerar avatar baseado no nome do usuário
function avatarParaUsuario(nome, estilo = "adventurer") {
return gerarAvatarURL(nome, estilo);
}
// Galeria de avatares
async function criarGaleriaAvatares(usuarios) {
const container = document.querySelector("#galeria");
usuarios.forEach(usuario => {
const div = document.createElement("div");
div.className = "avatar-card";
const img = document.createElement("img");
img.src = avatarParaUsuario(usuario.nome);
img.alt = usuario.nome;
img.width = 80;
img.height = 80;
img.style.borderRadius = "50%";
const nome = document.createElement("p");
nome.textContent = usuario.nome;
div.append(img, nome);
container.appendChild(div);
});
}
// Uso
criarGaleriaAvatares([
{ nome: "Ana Paula" },
{ nome: "Carlos Silva" },
{ nome: "Beatriz Costa" },
]);
Aplicação completa — Dashboard multi-API
Vamos combinar ViaCEP, IBGE e Open-Meteo em uma única aplicação:
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<title>Dashboard Brasil</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #232637;
--border: #2e3248;
--accent: #7c6ff7;
--text: #e4e6f0;
--muted: #8b8fa8;
}
body {
font-family: 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 2rem 1rem;
}
.container { max-width: 900px; margin: 0 auto; }
h1 {
text-align: center;
font-size: 1.5rem;
margin-bottom: 2rem;
color: var(--text);
}
.busca-cep {
display: flex;
gap: .75rem;
margin-bottom: 2rem;
}
.busca-cep input {
flex: 1;
padding: .75rem 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 1rem;
}
.busca-cep input:focus {
outline: none;
border-color: var(--accent);
}
.busca-cep button {
padding: .75rem 1.5rem;
background: var(--accent);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 700;
font-size: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
animation: aparecer .3s ease;
}
@keyframes aparecer {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.card-titulo {
font-size: .8rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .1em;
margin-bottom: 1rem;
}
.card-valor {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: .25rem;
}
.card-sub { font-size: .9rem; color: var(--muted); }
.endereco-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: .5rem;
}
.campo { font-size: .9rem; }
.campo span { display: block; color: var(--muted); font-size: .75rem; }
.previsao {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: .5rem;
}
.dia {
background: var(--surface2);
border-radius: 8px;
padding: .75rem .5rem;
text-align: center;
font-size: .85rem;
}
.dia .icone { font-size: 1.5rem; margin: .25rem 0; }
.dia .maxima { font-weight: 700; }
.dia .minima { color: var(--muted); }
.skeleton {
background: linear-gradient(90deg, var(--surface2) 25%, var(--border) 50%, var(--surface2) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
height: 1rem;
margin: .25rem 0;
}
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
.erro-card {
background: rgba(248, 113, 113, .1);
border: 1px solid rgba(248, 113, 113, .3);
border-radius: 12px;
padding: 1rem 1.5rem;
color: #f87171;
margin-bottom: 1rem;
display: none;
}
.erro-card.visivel { display: block; }
</style>
</head>
<body>
<div class="container">
<h1>🇧🇷 Dashboard Brasil</h1>
<div class="busca-cep">
<input type="text" id="input-cep"
placeholder="Digite um CEP (ex: 01310-100)"
maxlength="9">
<button id="btn-buscar">Buscar</button>
</div>
<div class="erro-card" id="erro"></div>
<div id="resultado"></div>
</div>
<script>
const inputCEP = document.querySelector("#input-cep");
const btnBuscar = document.querySelector("#btn-buscar");
const erroEl = document.querySelector("#erro");
const resultado = document.querySelector("#resultado");
// ── CEP com máscara ──────────────────────────────
inputCEP.addEventListener("input", (e) => {
let v = e.target.value.replace(/\D/g, "");
if (v.length > 5) v = v.slice(0, 5) + "-" + v.slice(5, 8);
e.target.value = v;
});
// ── APIs ─────────────────────────────────────────
async function buscarCEP(cep) {
const limpo = cep.replace(/\D/g, "");
if (limpo.length !== 8) throw new Error("CEP deve ter 8 dígitos.");
const r = await fetch(`https://viacep.com.br/ws/${limpo}/json/`);
const d = await r.json();
if (d.erro) throw new Error(`CEP "${cep}" não encontrado.`);
return d;
}
async function buscarTempo(uf) {
// Coordenadas aproximadas das capitais por UF
const coords = {
SP: [-23.55, -46.63], RJ: [-22.91, -43.17], MG: [-19.92, -43.94],
RS: [-30.03, -51.22], PR: [-25.43, -49.27], SC: [-27.60, -48.55],
BA: [-12.97, -38.50], PE: [-8.05, -34.88], CE: [-3.73, -38.52],
PA: [-1.46, -48.50], AM: [-3.10, -60.02], GO: [-16.69, -49.25],
MT: [-15.60, -56.10], MS: [-20.44, -54.65], ES: [-20.32, -40.34],
MA: [-2.53, -44.30], RN: [-5.79, -35.21], PB: [-7.12, -34.86],
AL: [-9.67, -35.74], SE: [-10.91, -37.07], PI: [-5.09, -42.80],
TO: [-10.25, -48.33], RO: [-8.76, -63.90], AC: [-9.97, -67.81],
AP: [0.03, -51.07], RR: [2.82, -60.67], DF: [-15.78, -47.93],
};
const [lat, lon] = coords[uf] || [-15.78, -47.93];
const params = new URLSearchParams({
latitude: lat, longitude: lon,
current_weather: true,
daily: "temperature_2m_max,temperature_2m_min,weathercode",
timezone: "America/Sao_Paulo",
forecast_days: 5,
});
const r = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`);
return await r.json();
}
async function buscarMunicipios(uf) {
const r = await fetch(
`https://servicodados.ibge.gov.br/api/v1/localidades/estados/${uf}/municipios`
);
const dados = await r.json();
return dados.length;
}
// ── Renderização ─────────────────────────────────
function codigoTempo(c) {
const m = {
0: "☀️", 1: "🌤️", 2: "⛅", 3: "☁️",
45: "🌫️", 51: "🌦️", 61: "🌧️", 71: "🌨️",
80: "⛈️", 95: "🌩️",
};
return m[c] || "🌈";
}
function renderizarSkeleton() {
resultado.innerHTML = `
<div class="grid">
<div class="card">
<div class="card-titulo">Carregando endereço...</div>
<div class="skeleton" style="width:70%"></div>
<div class="skeleton" style="width:50%"></div>
<div class="skeleton" style="width:60%"></div>
</div>
<div class="card">
<div class="card-titulo">Carregando clima...</div>
<div class="skeleton" style="width:40%; height: 2rem"></div>
<div class="skeleton" style="width:60%"></div>
</div>
</div>`;
}
function renderizarDados(cep, tempo, totalMunicipios) {
const { current_weather, daily } = tempo;
const iconeAgora = codigoTempo(current_weather.weathercode);
const diasHTML = daily.time.slice(0, 5).map((data, i) => `
<div class="dia">
<div>${new Date(data + "T12:00:00").toLocaleDateString("pt-BR", { weekday: "short" })}</div>
<div class="icone">${codigoTempo(daily.weathercode[i])}</div>
<div class="maxima">${Math.round(daily.temperature_2m_max[i])}°</div>
<div class="minima">${Math.round(daily.temperature_2m_min[i])}°</div>
</div>
`).join("");
resultado.innerHTML = `
<div class="grid">
<div class="card">
<div class="card-titulo">📍 Endereço</div>
<div class="endereco-grid">
<div class="campo">
<span>Logradouro</span>
${cep.logradouro || "—"}
</div>
<div class="campo">
<span>Bairro</span>
${cep.bairro || "—"}
</div>
<div class="campo">
<span>Cidade</span>
${cep.localidade}
</div>
<div class="campo">
<span>Estado</span>
${cep.uf}
</div>
<div class="campo">
<span>DDD</span>
(${cep.ddd})
</div>
<div class="campo">
<span>Código IBGE</span>
${cep.ibge}
</div>
</div>
</div>
<div class="card">
<div class="card-titulo">🌡️ Clima Agora (capital)</div>
<div class="card-valor">${iconeAgora} ${current_weather.temperature}°C</div>
<div class="card-sub">Vento: ${current_weather.windspeed} km/h</div>
</div>
<div class="card">
<div class="card-titulo">🏙️ Estado em números</div>
<div class="card-valor">${totalMunicipios.toLocaleString("pt-BR")}</div>
<div class="card-sub">Municípios em ${cep.uf}</div>
</div>
</div>
<div class="card">
<div class="card-titulo">📅 Previsão para os próximos 5 dias</div>
<div class="previsao" style="margin-top:.75rem">${diasHTML}</div>
</div>
`;
}
// ── Busca principal ───────────────────────────────
async function buscar() {
const cep = inputCEP.value.trim();
if (!cep) return;
erroEl.classList.remove("visivel");
renderizarSkeleton();
btnBuscar.disabled = true;
btnBuscar.textContent = "Buscando...";
try {
// Busca CEP primeiro (necessário para saber o estado)
const dadosCEP = await buscarCEP(cep);
// Busca clima e municípios em paralelo
const [tempo, totalMunicipios] = await Promise.all([
buscarTempo(dadosCEP.uf),
buscarMunicipios(dadosCEP.uf),
]);
renderizarDados(dadosCEP, tempo, totalMunicipios);
} catch (erro) {
resultado.innerHTML = "";
erroEl.textContent = `⚠️ ${erro.message}`;
erroEl.classList.add("visivel");
} finally {
btnBuscar.disabled = false;
btnBuscar.textContent = "Buscar";
}
}
btnBuscar.addEventListener("click", buscar);
inputCEP.addEventListener("keydown", (e) => {
if (e.key === "Enter") buscar();
});
// Carrega um CEP de exemplo
inputCEP.value = "01310-100";
buscar();
</script>
</body>
</html>
Boas práticas ao consumir APIs públicas
// ✅ 1. Respeite os rate limits — não faça spam de requisições
// Use debounce em campos de busca
// ✅ 2. Faça cache de respostas que não mudam frequentemente
const cache = new Map();
async function comCache(url, ttl = 60000) {
if (cache.has(url)) {
const { dados, timestamp } = cache.get(url);
if (Date.now() - timestamp < ttl) return dados;
}
const dados = await fetch(url).then(r => r.json());
cache.set(url, { dados, timestamp: Date.now() });
return dados;
}
// ✅ 3. Sempre mostre loading e trate erros visualmente
// ✅ 4. Use Promise.all para requisições independentes
// ✅ 5. Documente quais APIs você usa e seus termos de uso
// ✅ 6. Prepare um fallback para quando a API estiver fora
async function comFallback(fn, fallback) {
try {
return await fn();
} catch {
return fallback;
}
}
Tarefa para você
Usando as APIs deste artigo, construa um cartão de perfil de desenvolvedor:
Dados a exibir:
1. Avatar gerado pelo DiceBear com o nome do usuário
2. Campo de CEP com preenchimento automático via ViaCEP
3. Temperatura atual da capital do estado detectado via Open-Meteo
4. Um campo de username do GitHub que busca os repositórios do usuário
5. Exibir os 3 repos com mais estrelas
Extras:
- Skeleton loading em todos os campos
- Tratamento de erro em cada seção independentemente
(se o GitHub falhar, o resto ainda funciona)
- Botão "Gerar PDF" que exporta o cartão como CSV
com nome, cidade, estado e repos
Ver solução — o cartão de perfil, com cada seção falhando por conta própria
// ---- index.html
// <form id="perfil">
// <input id="nome" placeholder="Seu nome" required>
// <input id="cep" placeholder="CEP" inputmode="numeric" maxlength="9">
// <input id="github" placeholder="usuário do GitHub">
// <button type="submit">Montar cartão</button>
// <button type="button" id="btn-csv">Exportar CSV</button>
// </form>
//
// <article class="cartao">
// <div id="secao-avatar" class="secao"></div>
// <div id="secao-endereco" class="secao"></div>
// <div id="secao-clima" class="secao"></div>
// <div id="secao-repos" class="secao"></div>
// </article>
//
// <style>
// .esqueleto { background: linear-gradient(90deg,#eee 25%,#f5f5f5 37%,#eee 63%);
// background-size: 400% 100%; animation: brilho 1.4s infinite;
// border-radius: 6px; height: 1rem; margin: .4rem 0; }
// @keyframes brilho { from { background-position: 100% 50% }
// to { background-position: 0 50% } }
// .erro-secao { color: #c62828; font-size: .9rem; }
// </style>
// ---- cartao.js
// O enunciado pede um botão "Gerar PDF" que exporta em CSV. São formatos
// diferentes, e os campos pedidos (nome, cidade, estado, repos) são
// tabulares — então o que faz sentido é o CSV, e o botão passa a se
// chamar pelo que faz. Para PDF de verdade seria preciso uma biblioteca
// (jsPDF) ou o próprio window.print() com uma folha de estilo de
// impressão; nenhum dos dois é "exportar CSV".
const CAPITAIS = {
AC: [-9.97, -67.81], AL: [-9.67, -35.74], AM: [-3.12, -60.02],
AP: [0.03, -51.07], BA: [-12.97, -38.50], CE: [-3.72, -38.54],
DF: [-15.78, -47.93], ES: [-20.32, -40.34], GO: [-16.69, -49.26],
MA: [-2.53, -44.30], MG: [-19.92, -43.94], MS: [-20.44, -54.65],
MT: [-15.60, -56.10], PA: [-1.46, -48.50], PB: [-7.12, -34.88],
PE: [-8.05, -34.88], PI: [-5.09, -42.80], PR: [-25.43, -49.27],
RJ: [-22.91, -43.17], RN: [-5.79, -35.21], RO: [-8.76, -63.90],
RR: [2.82, -60.67], RS: [-30.03, -51.23], SC: [-27.59, -48.55],
SE: [-10.95, -37.07], SP: [-23.55, -46.63], TO: [-10.18, -48.33],
};
const cartao = { nome: "", cidade: "", estado: "", temperatura: null, repos: [] };
async function buscarJSON(url) {
const resposta = await fetch(url);
if (!resposta.ok) throw new Error(`HTTP ${resposta.status}`);
return resposta.json();
}
// ---------------------------------------------------------------
// Esqueleto de carregamento
// ---------------------------------------------------------------
function esqueleto(secao, linhas = 3) {
secao.innerHTML = "";
for (let i = 0; i < linhas; i++) {
const barra = document.createElement("div");
barra.classList.add("esqueleto");
barra.style.width = `${60 + Math.random() * 40}%`;
secao.appendChild(barra);
}
}
function erroNaSecao(secao, mensagem) {
secao.innerHTML = "";
const aviso = document.createElement("p");
aviso.classList.add("erro-secao");
aviso.textContent = `⚠️ ${mensagem}`;
secao.appendChild(aviso);
}
// ---------------------------------------------------------------
// 1 — avatar (DiceBear não faz requisição: a URL já é a imagem)
// ---------------------------------------------------------------
function montarAvatar(nome) {
const secao = document.querySelector("#secao-avatar");
secao.innerHTML = "";
const imagem = document.createElement("img");
// encodeURIComponent porque o nome tem espaço e pode ter acento —
// sem isso a URL quebra em "Ana Paula".
imagem.src = `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(nome)}`;
imagem.alt = `Avatar de ${nome}`;
imagem.width = 96;
const titulo = document.createElement("h2");
titulo.textContent = nome;
secao.append(imagem, titulo);
}
// ---------------------------------------------------------------
// 2 — ViaCEP
// ---------------------------------------------------------------
async function buscarEndereco(cep) {
const limpo = String(cep).replace(/\D/g, "");
if (limpo.length !== 8) throw new Error("CEP precisa ter 8 dígitos.");
const dados = await buscarJSON(`https://viacep.com.br/ws/${limpo}/json/`);
// O ViaCEP responde 200 com { erro: true } para CEP inexistente —
// response.ok não pega isso. Cada API tem seu jeito de dizer "não
// achei"; ler a documentação é parte do trabalho.
if (dados.erro) throw new Error("CEP não encontrado.");
return { cidade: dados.localidade, estado: dados.uf, bairro: dados.bairro };
}
// ---------------------------------------------------------------
// 3 — Open-Meteo, na capital do estado
// ---------------------------------------------------------------
async function buscarTemperatura(uf) {
const coordenadas = CAPITAIS[uf];
if (!coordenadas) throw new Error(`Sem capital mapeada para ${uf}.`);
const [lat, lon] = coordenadas;
const dados = await buscarJSON(
`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` +
`¤t=temperature_2m&timezone=America%2FSao_Paulo`
);
return dados.current.temperature_2m;
}
// ---------------------------------------------------------------
// 4 e 5 — GitHub, os 3 repositórios com mais estrelas
// ---------------------------------------------------------------
async function buscarRepos(usuario, quantidade = 3) {
// per_page=100 numa tacada: pedir página a página multiplicaria as
// chamadas, e a API sem token permite só 60 por hora por IP.
const repos = await buscarJSON(
`https://api.github.com/users/${encodeURIComponent(usuario)}/repos?per_page=100&sort=updated`
);
return repos
.filter((repo) => !repo.fork) // fork não é trabalho seu
.sort((a, b) => b.stargazers_count - a.stargazers_count)
.slice(0, quantidade)
.map((repo) => ({
nome: repo.name,
estrelas: repo.stargazers_count,
descricao: repo.description ?? "",
url: repo.html_url,
}));
}
// ---------------------------------------------------------------
// A orquestração — cada seção falha sozinha
// ---------------------------------------------------------------
async function montarCartao({ nome, cep, github }) {
cartao.nome = nome;
montarAvatar(nome);
const secaoEndereco = document.querySelector("#secao-endereco");
const secaoClima = document.querySelector("#secao-clima");
const secaoRepos = document.querySelector("#secao-repos");
[secaoEndereco, secaoClima, secaoRepos].forEach((s) => esqueleto(s));
// allSettled, não all: com `all`, o GitHub fora do ar levaria junto
// o endereço e o clima que já tinham chegado. É exatamente o que o
// enunciado pede para evitar.
const [endereco, repos] = await Promise.allSettled([
buscarEndereco(cep),
buscarRepos(github),
]);
if (endereco.status === "fulfilled") {
const { cidade, estado, bairro } = endereco.value;
Object.assign(cartao, { cidade, estado });
secaoEndereco.innerHTML = "";
const linha = document.createElement("p");
linha.textContent = `${bairro ? bairro + " · " : ""}${cidade}/${estado}`;
secaoEndereco.appendChild(linha);
// O clima depende do estado, então só começa agora.
try {
const graus = await buscarTemperatura(estado);
cartao.temperatura = graus;
secaoClima.innerHTML = "";
const texto = document.createElement("p");
texto.textContent = `🌡️ ${graus} °C na capital de ${estado}`;
secaoClima.appendChild(texto);
} catch (erro) {
erroNaSecao(secaoClima, `Clima indisponível (${erro.message})`);
}
} else {
erroNaSecao(secaoEndereco, endereco.reason.message);
erroNaSecao(secaoClima, "Sem estado, sem clima.");
}
if (repos.status === "fulfilled") {
cartao.repos = repos.value;
secaoRepos.innerHTML = "";
const lista = document.createElement("ul");
for (const repo of repos.value) {
const item = document.createElement("li");
const link = document.createElement("a");
link.href = repo.url;
link.target = "_blank";
link.rel = "noopener";
link.textContent = `${repo.nome} ⭐ ${repo.estrelas}`;
item.append(link);
if (repo.descricao) item.append(` — ${repo.descricao}`);
lista.appendChild(item);
}
secaoRepos.appendChild(
repos.value.length ? lista : document.createTextNode("Nenhum repositório próprio.")
);
} else {
erroNaSecao(secaoRepos, `GitHub indisponível (${repos.reason.message})`);
}
}
// ---------------------------------------------------------------
// Exportação em CSV
// ---------------------------------------------------------------
function exportarCSV() {
const escapar = (v) =>
/[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g, '""')}"` : String(v);
const colunas = ["nome", "cidade", "estado", "temperatura", "repositorio", "estrelas"];
// Uma linha por repositório: um CSV com "repos" numa célula só não
// serve para nada em planilha.
const linhas = (cartao.repos.length ? cartao.repos : [{ nome: "", estrelas: "" }]).map(
(repo) => [
cartao.nome, cartao.cidade, cartao.estado,
cartao.temperatura ?? "", repo.nome, repo.estrelas,
]
);
const csv = [colunas.join(","), ...linhas.map((l) => l.map(escapar).join(","))].join("\n");
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `cartao-${cartao.nome.toLowerCase().replace(/\s+/g, "-")}.csv`;
link.click();
URL.revokeObjectURL(link.href); // sem isto o blob fica na memória até recarregar
}
document.querySelector("#perfil").addEventListener("submit", (evento) => {
evento.preventDefault();
montarCartao({
nome: document.querySelector("#nome").value.trim(),
cep: document.querySelector("#cep").value,
github: document.querySelector("#github").value.trim(),
});
});
document.querySelector("#btn-csv").addEventListener("click", exportarCSV);
// ---------------------------------------------------------------
// O detalhe que morde: 200 não quer dizer "deu certo"
// ---------------------------------------------------------------
// O ViaCEP responde 200 com { erro: true }. A API do GitHub responde
// 403 com uma mensagem sobre limite de requisições que não parece
// erro de autenticação. Cada serviço inventa o seu jeito de dizer
// "não". `response.ok` é o piso da verificação, nunca o teto.
Promise.allSettled é o que torna possível o "se o GitHub falhar, o resto ainda funciona": com Promise.all, uma API fora do ar apaga o cartão inteiro, inclusive os dados que já tinham chegado. E lembre que response.ok não pega tudo — há API que devolve 200 com { erro: true } no corpo.
Seis serviços, nenhum exigindo cadastro: ViaCEP, IBGE, Open-Meteo, PokeAPI, JSONPlaceholder e DiceBear cobrem endereço, geografia, clima, dados estruturados, teste e imagem — o suficiente para montar quase qualquer protótipo sem depender de chave de API. E as integrações acabam reforçando sempre o mesmo trio: cache para não repetir chamada, debounce para não disparar a cada tecla, e a suposição de que a resposta pode simplesmente não vir.
Fontes e Referências
- ViaCEP — Documentação: https://viacep.com.br
- IBGE API — Documentação: https://servicodados.ibge.gov.br/api/docs
- Open-Meteo — Documentação: https://open-meteo.com/en/docs
- PokeAPI — Documentação: https://pokeapi.co/docs/v2
- DiceBear — Documentação: https://www.dicebear.com/introduction
- JSONPlaceholder — Documentação: https://jsonplaceholder.typicode.com
- MDN Web Docs — Using Fetch: https://developer.mozilla.org/pt-BR/docs/Web/API/Fetch_API/Using_Fetch
- Public APIs — catálogo de APIs abertas: https://github.com/public-apis/public-apis
Exercícios
Exercício 1
Esta era a limpeza do CEP antes da correção. O usuário digita 01310-100, um CEP perfeitamente válido. O que acontece?
const cepLimpo = cep.replace(/D/g, "");
if (cepLimpo.length !== 8) {
throw new Error("CEP deve ter 8 dígitos.");
}
Ver resposta
✓ Resposta: Lança o erro — e lançaria para qualquer CEP com hífen. A intenção era /\D/g, "todo caractere que não é dígito", mas a barra invertida se perdeu e sobrou /D/g, que casa apenas com a letra maiúscula D. Como não há nenhum D num CEP, o replace não remove nada, a string continua com nove caracteres por causa do hífen, e a validação reprova. O detalhe cruel é que o código parece funcionar em teste: digitando 01310100, sem hífen, o comprimento já é 8, a validação passa e a consulta acontece normalmente — o defeito só aparece com a formatação que a máscara do próprio artigo produz. Vale reter a diferença: \d é dígito e \D é o complemento, tudo que não é dígito; o mesmo par existe em \w/\W e \s/\S. E a lição geral: expressão regular que "não faz nada" costuma ser escape perdido, não lógica errada.
Exercício 2
A ViaCEP responde para um CEP inexistente. O que ela devolve, e por que a checagem de response.ok não basta aqui?
const response = await fetch("https://viacep.com.br/ws/99999999/json/");
console.log(response.status); // A
const dados = await response.json();
console.log(dados); // B
Ver resposta
✓ Resposta: A é 200 e B é { erro: true } — ou, em versões mais recentes, { erro: "true" }, com o valor em texto. A ViaCEP considera que a requisição foi atendida com sucesso: ela procurou e não achou, e comunica isso no corpo, não no status. É o "erro de negócio" que o artigo anterior classificou: response.ok é true, e um cliente que só verifique o status segue adiante achando que tem um endereço, para depois preencher o formulário com undefined em cada campo. Por isso o buscarCEP do artigo faz as duas checagens, o ok e o dados.erro. Duas observações práticas: como o valor pode vir booleano ou string, o teste seguro é if (dados.erro), que é verdadeiro nos dois casos, e nunca dados.erro === true; e um CEP com formato inválido, como 123, aí sim devolve 400 — ou seja, a mesma API usa os dois mecanismos, cada um para um tipo de problema.
Exercício 3
O dashboard busca o CEP e, com o estado em mãos, busca clima e municípios. Por que a primeira chamada não pode entrar no Promise.all?
const dadosCEP = await buscarCEP(cep);
const [tempo, totalMunicipios] = await Promise.all([
buscarTempo(dadosCEP.uf),
buscarMunicipios(dadosCEP.uf),
]);
Ver resposta
✓ Resposta: Porque as duas chamadas de dentro do Promise.all dependem do resultado da primeira: ambas recebem dadosCEP.uf, que só existe depois que a ViaCEP responde. Paralelizar exige independência, e aqui há uma dependência real de dados — não é uma escolha de estilo. O desenho está correto: uma etapa em série, seguida de duas em paralelo, o que custa o tempo da ViaCEP mais o tempo da mais lenta entre clima e municípios, em vez da soma das três. Vale reparar num efeito colateral do Promise.all nesse contexto: se a API do IBGE estiver fora, a rejeição derruba o conjunto e o painel não mostra nada, nem o clima que chegou bem. Quando as seções são independentes na tela, Promise.allSettled costuma ser a escolha melhor — cada cartão renderiza o que conseguiu, e só o que falhou mostra o aviso de indisponível. Aliás, a própria tarefa proposta no fim do artigo pede exatamente isso.
Exercício 4
A comparação de Pokémon imprimia o cabeçalho assim. Com p1.nomeFormatado valendo "Pikachu", o que aparece na coluna?
console.log(`${"STAT".padEnd(20)} ${"".padEnd(10, p1.nomeFormatado.slice(0, 10))}`);
Ver resposta
✓ Resposta: Aparece PikachuPik. O segundo argumento do padEnd não é o conteúdo — é o texto de preenchimento, repetido quantas vezes for preciso até a string alcançar o comprimento pedido. Como a string de partida é vazia, os dez caracteres do resultado são inteiramente preenchimento: "Pikachu" repetido e cortado em 10, o que produz o nome seguido das três primeiras letras dele de novo. O padrão normal é o inverso — p1.nomeFormatado.padEnd(10) —, em que o nome é o conteúdo e o preenchimento é o espaço, que é o valor padrão. O erro passa despercebido porque o resultado parece um nome e tem o alinhamento certo; só a leitura atenta revela as letras extras. E há um limite adicional a conhecer: padEnd conta unidades de código UTF-16, então nomes com emoji ou acentos compostos desalinham a tabela mesmo quando o código está correto.
Exercício 5
O campo de CEP tem debounce de 600 ms e a busca dispara a cada tecla. O usuário digita os oito dígitos e depois corrige o último. Quantas requisições saem — e o que ainda falta neste código?
const preencherEndereco = debounce(async (cep) => {
if (cep.replace(/\D/g, "").length !== 8) return;
const endereco = await buscarCEP(cep);
campos.cidade.value = endereco.cidade;
}, 600);
inputCEP.addEventListener("input", (e) => preencherEndereco(e.target.value));
Ver resposta
✓ Resposta: Saem duas requisições: uma 600 ms depois do oitavo dígito e outra 600 ms depois da correção. As teclas anteriores não geram nada — em parte pelo debounce, que cancela e reagenda, e em parte pela guarda de comprimento, que descarta CEPs incompletos antes de chegar à rede. É um bom desenho, e o que falta nele é o cancelamento. Se a primeira consulta demorar mais que a segunda, a resposta antiga chega por último e sobrescreve o endereço correto — a mesma corrida vista no artigo do fetch, que o debounce reduz mas não elimina. A correção é um AbortController guardado entre as chamadas, abortando a anterior antes de iniciar a próxima. Falta ainda um detalhe de interface: entre a tecla e a resposta há um intervalo em que a tela não diz nada, e um CEP consultado numa conexão lenta parece simplesmente ignorado — daí a recomendação, repetida em todo o módulo, de acender um indicador de carregamento junto com a requisição, e não depois dela.