APIs públicas: exemplos práticos

[107] APIs públicas: exemplos práticos

Seis APIs públicas, nenhuma exigindo cadastro ou chave: ViaCEP para endereço, IBGE para estados e municípios, Open-Meteo para previsão, PokeAPI, JSONPlaceholder e DiceBear para avatares. Cada uma vira uma mini-aplicação, e no fim as três primeiras se juntam num painel só.
Javascript

28 min de leitura

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}` +
    `&current=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

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.

Comentários

Mais em Javascript

Introdução ao TypeScript
Introdução ao TypeScript

Uma variável que é número agora e string depois dá flexibilidade e gera uma…

Condicionais: if, else e switch
Condicionais: if, else e switch

O JavaScript acha que 0 é igual a false e que "5" é igual a 5 — duas…

Projeto: API REST com autenticação JWT
Projeto: API REST com autenticação JWT

O Módulo 4 fecha juntando Node, Express, MongoDB e Mongoose numa API de…