kodingup.camp

Beranda/Advanced JavaScript: Interaksi Browser

Integrasi Web API

Modul 4 dari 58 menit bacaWeb API Integration

Isi modul

    Kenapa ini penting

    Hampir tidak ada aplikasi modern yang datanya ditulis langsung di HTML. Daftar produk Tokopedia, cuaca hari ini, kurs Rupiah, postingan media sosial, semuanya diambil dari server saat halaman dibuka. Halaman web hanyalah "etalase", sedangkan barangnya ada di gudang. Jalur penghubung etalase dan gudang itu disebut API (Application Programming Interface). Dalam konteks web, API biasanya berupa alamat URL yang membalas data dalam format JSON.

    Analoginya seperti memesan makanan di restoran. Kamu (browser) tidak masuk ke dapur (database). Kamu memberi pesanan ke pelayan (API) dengan format yang disepakati, yaitu menu. Pelayan pergi ke dapur lalu kembali membawa makanan (response). Kadang pelayan kembali dengan kabar "maaf, menu ini habis" (error 404) atau "dapur sedang kacau" (error 500), dan kamu harus siap menghadapi kabar itu dengan sopan, bukan diam saja.

    Di materi async kamu sudah belajar Promise dan async/await. Modul ini memakainya untuk hal yang paling sering dikerjakan front-end developer: mengambil data, menangani loading dan error, lalu menampilkannya ke DOM. Kita akan memakai API publik gratis tanpa API key:

    Kedua API ini mengirim header CORS yang mengizinkan diakses dari halaman mana pun, jadi bisa langsung dipanggil dari browser.

    Konsep inti

    Anatomi request dan response HTTP

    Setiap komunikasi dengan API terdiri dari request (permintaan) dan response (balasan).

    Request berisi:

    Response berisi:

    Status Arti Contoh situasi
    200 OK Berhasil GET produk sukses
    201 Created Data baru berhasil dibuat POST komentar sukses
    204 No Content Berhasil, tanpa isi DELETE sukses
    400 Bad Request Request-mu salah format field wajib kosong
    401 / 403 Belum login / tidak punya izin token kedaluwarsa
    404 Not Found Sumber daya tidak ada /posts/99999
    429 Too Many Requests Terlalu sering memanggil kena rate limit
    500 / 503 Server sedang bermasalah bukan salahmu, tapi tetap harus ditangani

    Hafalkan pola umumnya: 2xx sukses, 4xx kesalahan di sisi peminta, 5xx kesalahan di sisi server.

    JSON

    JSON (JavaScript Object Notation) adalah format teks untuk bertukar data. Bentuknya mirip object JavaScript, tapi lebih ketat: key wajib memakai tanda kutip ganda, tidak boleh ada koma di akhir, tidak boleh ada function, undefined, atau komentar.

    fetch dan jebakan terbesarnya

    fetch(url, opsi) mengembalikan Promise berisi object Response. Ada satu hal yang sering membuat pemula bingung: fetch tidak menganggap 404 atau 500 sebagai error. Promise dari fetch hanya reject jika jaringan gagal total, misalnya offline, DNS tidak ditemukan, atau diblokir CORS. Jika server membalas 404, fetch tetap sukses dan memberikan response dengan response.ok === false.

    Karena itu, setiap fetch harus memeriksa response.ok (bernilai true untuk status 200-299) dan melempar error sendiri kalau perlu. Pola yang aman:

    const res = await fetch(url);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const data = await res.json();
    

    Loading, error, dan empty state

    Pengguna tidak bisa melihat jaringan. Kalau layar kosong selama dua detik, mereka mengira aplikasinya rusak. Setiap tampilan yang memuat data perlu menangani empat keadaan:

    1. Loading: data sedang diambil. Tampilkan spinner atau skeleton, lalu nonaktifkan tombol supaya tidak diklik dua kali.
    2. Success: data datang, lalu tampilkan.
    3. Empty: data datang tapi kosong (misalnya hasil pencarian nihil). Tampilkan pesan yang jelas, bukan layar putih.
    4. Error: gagal. Tampilkan pesan manusiawi dan, kalau masuk akal, tombol "Coba lagi".

    Gunakan try / catch / finally. Blok finally selalu jalan, baik sukses maupun gagal, jadi cocok untuk mematikan indikator loading.

    Timeout dan pembatalan dengan AbortController

    Secara default, fetch bisa menunggu sangat lama kalau server lambat. AbortSignal.timeout(5000) membuat sinyal yang otomatis membatalkan request setelah 5 detik. AbortController juga berguna untuk membatalkan request lama ketika pengguna sudah mengetik kata kunci baru, sehingga hasil lama tidak menimpa hasil baru. Request yang dibatalkan akan reject dengan error bernama AbortError (atau TimeoutError untuk timeout).

    CORS: kenapa kadang fetch ditolak

    Browser menerapkan aturan keamanan bernama Same-Origin Policy. JavaScript di https://tokoku.com secara default tidak boleh membaca response dari origin lain seperti https://api.lain.com. Origin adalah gabungan protokol, domain, dan port. Tanpa aturan ini, situs jahat yang kamu buka bisa diam-diam memanggil API bank-mu memakai cookie login-mu dan membaca saldomu.

    CORS (Cross-Origin Resource Sharing) adalah cara server memberi izin. Server API menambahkan header seperti Access-Control-Allow-Origin: * (semua boleh) atau Access-Control-Allow-Origin: https://tokoku.com (hanya origin itu). Browser membaca header ini lalu memutuskan apakah JavaScript-mu boleh melihat response-nya.

    Beberapa hal penting tentang CORS:

    Di DevTools, pesan errornya biasanya berbunyi "...has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present...". Kalau melihat pesan itu, kamu sekarang tahu masalahnya ada di izin server.

    Keamanan API key

    API publik seperti JSONPlaceholder tidak butuh key. Banyak API lain butuh. Ingat satu hal: semua yang ada di kode front-end bisa dilihat siapa pun lewat View Source atau tab Network. Jangan pernah menaruh API key rahasia (misalnya key payment gateway atau AI berbayar) di JavaScript browser. Simpan di backend.

    Contoh kode

    GET dasar dan pengecekan status

    async function ambilPost(id) {
      const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
      console.log(res.status, res.ok); // → 200 true
    
      if (!res.ok) throw new Error(`Gagal memuat post ${id}: HTTP ${res.status}`);
      return res.json(); // Promise berisi object
    }
    
    const post = await ambilPost(1); // await di level atas bisa di Console atau <script type="module">
    console.log(post.title); // → 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit'
    console.log(Object.keys(post)); // → [ 'userId', 'id', 'title', 'body' ]
    
    // Membuktikan fetch TIDAK reject saat 404
    const res404 = await fetch("https://jsonplaceholder.typicode.com/posts/99999");
    console.log(res404.status, res404.ok); // → 404 false  (tidak masuk catch!)
    
    try {
      await ambilPost(99999);
    } catch (err) {
      console.log(err.message); // → 'Gagal memuat post 99999: HTTP 404'
    }
    

    Helper fetchJSON yang bisa dipakai ulang

    // Satu tempat untuk: cek status, parse JSON, timeout, pesan error rapi
    async function fetchJSON(url, { timeout = 8000, ...opsi } = {}) {
      let res;
      try {
        res = await fetch(url, { ...opsi, signal: opsi.signal ?? AbortSignal.timeout(timeout) });
      } catch (err) {
        if (err.name === "TimeoutError") throw new Error("Server terlalu lama merespons. Coba lagi.");
        if (err.name === "AbortError") throw err; // dibatalkan sengaja, teruskan apa adanya
        throw new Error("Tidak bisa terhubung. Periksa koneksi internet kamu.");
      }
    
      if (!res.ok) {
        const pesan = {
          404: "Data tidak ditemukan.",
          429: "Terlalu banyak permintaan. Tunggu sebentar.",
        }[res.status] ?? (res.status >= 500 ? "Server sedang bermasalah." : `Permintaan gagal (HTTP ${res.status}).`);
        throw new Error(pesan);
      }
    
      return res.status === 204 ? null : res.json();
    }
    
    // Query string dengan URLSearchParams
    const params = new URLSearchParams({ userId: 1, _limit: 3 });
    const posts = await fetchJSON(`https://jsonplaceholder.typicode.com/posts?${params}`);
    console.log(posts.length);   // → 3
    console.log(posts[0].userId); // → 1
    

    POST: mengirim data

    async function kirimPost(judul, isi) {
      return fetchJSON("https://jsonplaceholder.typicode.com/posts", {
        method: "POST",
        headers: { "Content-Type": "application/json" }, // wajib agar server tahu body-nya JSON
        body: JSON.stringify({ title: judul, body: isi, userId: 1 }), // body harus string
      });
    }
    
    const baru = await kirimPost("Belajar fetch", "POST itu ternyata gampang");
    console.log(baru);
    // → { title: 'Belajar fetch', body: 'POST itu ternyata gampang', userId: 1, id: 101 }
    // Status response-nya 201 Created. id 101 dari server simulasi; datanya tidak benar-benar tersimpan.
    
    // PATCH dan DELETE polanya sama
    const diubah = await fetchJSON("https://jsonplaceholder.typicode.com/posts/1", {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "Judul baru" }),
    });
    console.log(diubah.title); // → 'Judul baru'
    

    Render ke DOM lengkap dengan loading, empty, error

    <section>
      <label>Pilih user:
        <select id="pilih-user"><option value="">Memuat...</option></select>
      </label>
      <div id="status" role="status" aria-live="polite"></div>
      <ul id="hasil"></ul>
    </section>
    
    <style>
      .skeleton { height: 1.2em; margin: .5em 0; border-radius: 4px;
        background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
        background-size: 200% 100%; animation: kilau 1.2s infinite; }
      @keyframes kilau { to { background-position: -200% 0; } }
      .error { color: #c0392b; }
    </style>
    
    const API = "https://jsonplaceholder.typicode.com";
    const pilih = document.querySelector("#pilih-user");
    const statusEl = document.querySelector("#status");
    const hasil = document.querySelector("#hasil");
    
    function tampilLoading() {
      statusEl.textContent = "Memuat tugas...";
      hasil.innerHTML = '<li class="skeleton"></li>'.repeat(5); // HTML statis buatan sendiri, aman
    }
    
    function tampilError(pesan, ulangi) {
      hasil.innerHTML = "";
      statusEl.textContent = "";
      const p = document.createElement("p");
      p.className = "error";
      p.textContent = pesan;
      const tombol = document.createElement("button");
      tombol.textContent = "Coba lagi";
      tombol.addEventListener("click", ulangi, { once: true });
      statusEl.append(p, tombol);
    }
    
    function tampilTodos(todos) {
      hasil.innerHTML = "";
      if (todos.length === 0) {
        statusEl.textContent = "User ini belum punya tugas.";
        return;
      }
      const selesai = todos.filter((t) => t.completed).length;
      statusEl.textContent = `${selesai} dari ${todos.length} tugas selesai`;
    
      const frag = document.createDocumentFragment();
      for (const t of todos) {
        const li = document.createElement("li");
        li.textContent = `${t.completed ? "✅" : "⬜"} ${t.title}`; // data API → textContent
        frag.append(li);
      }
      hasil.append(frag);
    }
    
    let pengontrol; // untuk membatalkan request lama
    
    async function muatTodos(userId) {
      pengontrol?.abort(); // batalkan request sebelumnya kalau user cepat ganti pilihan
      pengontrol = new AbortController();
    
      tampilLoading();
      pilih.disabled = true;
      try {
        const todos = await fetchJSON(`${API}/todos?userId=${userId}`, { signal: pengontrol.signal });
        tampilTodos(todos);
      } catch (err) {
        if (err.name === "AbortError") return; // diganti request baru, bukan error
        tampilError(err.message, () => muatTodos(userId));
      } finally {
        pilih.disabled = false; // selalu jalan: sukses maupun gagal
      }
    }
    
    async function init() {
      try {
        const users = await fetchJSON(`${API}/users`);
        pilih.innerHTML = '<option value="">-- pilih --</option>';
        for (const u of users) {
          pilih.append(new Option(u.name, u.id)); // new Option(teks, value) aman dari XSS
        }
      } catch (err) {
        tampilError(err.message, init);
      }
    }
    
    pilih.addEventListener("change", () => {
      if (pilih.value) muatTodos(pilih.value);
    });
    
    init();
    // Pilih "Leanne Graham" → status: '11 dari 20 tugas selesai', lalu 20 <li> tampil
    

    Mengambil beberapa sumber sekaligus

    // Promise.all: jalan paralel, gagal semua kalau satu gagal
    const [user, postsUser] = await Promise.all([
      fetchJSON("https://jsonplaceholder.typicode.com/users/1"),
      fetchJSON("https://jsonplaceholder.typicode.com/posts?userId=1"),
    ]);
    console.log(`${user.name} menulis ${postsUser.length} post`); // → 'Leanne Graham menulis 10 post'
    
    // Promise.allSettled: tetap tampilkan yang berhasil walau ada yang gagal
    const hasilSemua = await Promise.allSettled([
      fetchJSON("https://jsonplaceholder.typicode.com/posts/1"),
      fetchJSON("https://jsonplaceholder.typicode.com/posts/99999"),
    ]);
    console.log(hasilSemua.map((h) => h.status)); // → [ 'fulfilled', 'rejected' ]
    

    Pencarian dengan debounce

    // Debounce: tunggu pengguna berhenti mengetik 400 ms baru panggil API
    function debounce(fn, jeda = 400) {
      let timer;
      return (...args) => {
        clearTimeout(timer);
        timer = setTimeout(() => fn(...args), jeda);
      };
    }
    
    const inputCari = document.querySelector("#cari-produk");
    inputCari?.addEventListener(
      "input",
      debounce(async () => {
        const q = encodeURIComponent(inputCari.value.trim());
        if (!q) return;
        const data = await fetchJSON(`https://dummyjson.com/products/search?q=${q}&limit=5`);
        console.log(data.products.map((p) => p.title)); // → daftar judul produk yang cocok
      })
    );
    // Mengetik "phone" huruf demi huruf → hanya 1 request, bukan 5
    

    Tanpa debounce, mengetik "phone" mengirim lima request (p, ph, pho, ...). Itu membebani server dan bisa memicu error 429.

    Kesalahan umum

    1. Menganggap catch menangkap 404/500. fetch hanya reject untuk kegagalan jaringan. Selalu cek res.ok dan throw sendiri.
    2. Lupa await pada res.json(). const data = res.json(); data.title bernilai undefined karena data masih Promise. Tulis const data = await res.json();.
    3. POST tanpa JSON.stringify atau tanpa header. Mengirim body: { title: "x" } akan terkirim sebagai "[object Object]". Body wajib string, dan sertakan "Content-Type": "application/json".
    4. "Memperbaiki" CORS dari front-end. Menambah header Access-Control-Allow-Origin di request atau memakai mode: "no-cors" tidak menyelesaikan apa pun. Izin CORS diberikan server; kalau server tidak mengizinkan, gunakan backend/proxy sendiri.
    5. Tidak ada loading dan error state. Layar kosong saat memuat atau gagal membuat pengguna mengira aplikasimu rusak. Tangani empat keadaan: loading, success, empty, error.

    Latihan

    1. Mudah, Ambil data https://jsonplaceholder.typicode.com/users/3, lalu tampilkan nama, email, dan nama kota user tersebut di console dalam satu kalimat.
    2. Sedang, Buat halaman yang menampilkan 10 post pertama sebagai kartu (judul + isi). Saat tombol "Lihat komentar" di sebuah kartu diklik, ambil /posts/{id}/comments dan tampilkan jumlah serta daftar email pengomentar di bawah kartu itu. Pakai event delegation dan tampilkan teks "Memuat..." selama request berjalan.
    3. Menantang, Buat form "Tulis post" (judul, isi) yang mengirim POST ke JSONPlaceholder. Validasi judul minimal 5 karakter, nonaktifkan tombol saat mengirim, lalu tambahkan post baru di paling atas daftar dari soal 2 saat berhasil. Tampilkan pesan error kalau gagal.
    Lihat solusi
    // 1. Mudah
    const u = await fetchJSON("https://jsonplaceholder.typicode.com/users/3");
    console.log(`${u.name} (${u.email}) tinggal di ${u.address.city}`);
    // → 'Clementine Bauch (Nathan@yesenia.net) tinggal di McKenziehaven'
    
    <!-- 2 & 3 -->
    <form id="form-post">
      <input name="title" placeholder="Judul" required>
      <textarea name="body" placeholder="Isi" required></textarea>
      <button>Kirim</button>
      <p id="pesan-post" role="status"></p>
    </form>
    <div id="daftar-post"></div>
    
    const API = "https://jsonplaceholder.typicode.com";
    const wadah = document.querySelector("#daftar-post");
    
    function buatKartu(post) {
      const artikel = document.createElement("article");
      artikel.className = "kartu";
      artikel.dataset.id = post.id;
    
      const h3 = document.createElement("h3");
      h3.textContent = post.title;
      const p = document.createElement("p");
      p.textContent = post.body;
      const tombol = document.createElement("button");
      tombol.textContent = "Lihat komentar";
      tombol.dataset.aksi = "komentar";
      const area = document.createElement("div");
      area.className = "komentar";
    
      artikel.append(h3, p, tombol, area);
      return artikel;
    }
    
    // 2. Sedang
    async function muatPosts() {
      wadah.textContent = "Memuat post...";
      try {
        const posts = await fetchJSON(`${API}/posts?_limit=10`);
        wadah.textContent = "";
        wadah.append(...posts.map(buatKartu));
      } catch (err) {
        wadah.textContent = err.message;
      }
    }
    
    wadah.addEventListener("click", async (e) => {
      const tombol = e.target.closest('[data-aksi="komentar"]');
      if (!tombol) return;
      const kartu = tombol.closest(".kartu");
      const area = kartu.querySelector(".komentar");
    
      tombol.disabled = true;
      area.textContent = "Memuat...";
      try {
        const komentar = await fetchJSON(`${API}/posts/${kartu.dataset.id}/comments`);
        area.textContent = `${komentar.length} komentar dari: `;
        const ul = document.createElement("ul");
        for (const k of komentar) {
          const li = document.createElement("li");
          li.textContent = k.email;
          ul.append(li);
        }
        area.append(ul);
      } catch (err) {
        area.textContent = err.message;
        tombol.disabled = false; // izinkan coba lagi
      }
    });
    
    muatPosts();
    
    // 3. Menantang
    const formPost = document.querySelector("#form-post");
    const pesan = document.querySelector("#pesan-post");
    
    formPost.addEventListener("submit", async (e) => {
      e.preventDefault();
      const data = Object.fromEntries(new FormData(formPost));
      if (data.title.trim().length < 5) {
        pesan.textContent = "Judul minimal 5 karakter.";
        return;
      }
    
      const tombol = formPost.querySelector("button");
      tombol.disabled = true;
      tombol.textContent = "Mengirim...";
      try {
        const baru = await fetchJSON(`${API}/posts`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ ...data, userId: 1 }),
        });
        wadah.prepend(buatKartu(baru)); // paling atas
        pesan.textContent = `Post terkirim (id ${baru.id}).`; // → 'Post terkirim (id 101).'
        formPost.reset();
      } catch (err) {
        pesan.textContent = err.message;
      } finally {
        tombol.disabled = false;
        tombol.textContent = "Kirim";
      }
    });
    

    Satu catatan: post baru dari JSONPlaceholder selalu ber-id 101 dan tidak benar-benar tersimpan, jadi tombol "Lihat komentar" di kartu itu akan mengembalikan daftar kosong. Di API sungguhan, data akan tersimpan.

    Ringkasan

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.