kodingup.camp

Beranda/Node.js Backend: Dasar, Express & REST API

Memanggil API Lain & CORS

Modul 10 dari 105 menit bacaCalling APIs and CORS

Isi modul

    Kenapa ini penting

    Backend jarang berdiri sendiri. Ia memanggil payment gateway, layanan kirim email, API ongkos kirim, API cuaca, atau layanan internal lain. Di sisi lain, backend juga dipanggil oleh frontend React yang berjalan di domain atau port berbeda, dan di sinilah hampir semua pemula pertama kali bertemu error merah di console browser: CORS.

    Modul ini menutup lingkaran: server memanggil API lain dengan aman (dengan timeout dan penanganan error), lalu frontend React yang sudah kamu kuasai disambungkan ke Tugas API yang baru saja kamu buat.

    Konsep inti

    fetch di server

    Node sudah punya fetch global yang sama dengan di browser (stabil sejak Node 21, ada sejak 18). Tidak perlu axios atau node-fetch untuk kebutuhan dasar. Ada tiga hal yang harus diperhatikan di server:

    1. fetch tidak melempar error untuk status 4xx/5xx. Promise hanya reject kalau jaringan gagal (DNS, koneksi ditolak). Kamu harus memeriksa res.ok sendiri.
    2. Selalu pasang timeout. Tanpa timeout, kalau API luar menggantung, request penggunamu ikut menggantung, dan koneksi menumpuk. Gunakan signal: AbortSignal.timeout(ms). Saat waktu habis, fetch reject dengan error bernama TimeoutError.
    3. Terjemahkan error API luar ke status yang tepat untuk client-mu. API luar membalas 404 atau 500? Itu bukan salah client-mu; balas 502 Bad Gateway. API luar terlalu lama? Balas 504 Gateway Timeout.

    Analoginya: kamu memesan bahan ke pemasok. Kamu tetap harus memeriksa isi kiriman (status), dan kalau pemasok tidak datang dalam 30 menit, kamu tidak menunggu sampai warung tutup; kamu bilang ke pelanggan bahwa menunya sedang tidak tersedia.

    API key untuk layanan pihak ketiga disimpan di environment variable dan hanya dipakai di server. Inilah salah satu alasan utama punya backend: frontend tidak pernah melihat rahasianya.

    Apa itu CORS

    Browser menerapkan Same-Origin Policy: JavaScript di halaman http://localhost:5173 (Vite) tidak boleh membaca response dari origin lain seperti http://localhost:3000, kecuali server tujuan mengizinkan. Origin adalah gabungan protokol + domain + port; beda port saja sudah dianggap beda origin.

    CORS (Cross-Origin Resource Sharing) adalah cara server memberi izin lewat header response, terutama Access-Control-Allow-Origin. Untuk request "tidak sederhana" (misalnya method PATCH/DELETE atau header Content-Type: application/json), browser lebih dulu mengirim request preflight dengan method OPTIONS untuk bertanya "boleh tidak?". Server harus menjawab dengan header Access-Control-Allow-Methods dan Access-Control-Allow-Headers yang sesuai.

    Hal penting yang sering disalahpahami: CORS bukan pengaman API. CORS hanya mengatur apakah browser boleh memberikan response ke JavaScript halaman lain. curl, Postman, atau server lain tetap bisa memanggil API-mu dan membaca hasilnya. Perlindungan sesungguhnya adalah autentikasi dan otorisasi (bagian 2).

    Paket cors

    Paket cors (2.8.6) adalah middleware yang mengurus header dan preflight:

    import cors from "cors";
    app.use(cors({ origin: ["http://localhost:5173", "https://tugasku.id"] }));
    

    Opsi origin bisa berupa string, array, regex, atau fungsi. Dengan array, header Access-Control-Allow-Origin hanya dikirim kalau origin request ada di daftar. Hindari origin: "*" untuk API yang memakai cookie/kredensial; kombinasi itu memang ditolak browser.

    Alternatif saat development: proxy Vite

    Saat development, kamu juga bisa menghindari CORS dengan proxy dari dev server Vite (server.proxy di vite.config.js), sehingga browser mengira API berada di origin yang sama. Tapi di produksi, kalau frontend dan API ada di domain berbeda, pengaturan CORS di server tetap dibutuhkan.

    Contoh kode

    Memanggil API luar dengan timeout dan penerjemahan error:

    // fetch-demo.js, jalankan: node fetch-demo.js
    import express from "express";
    
    const app = express();
    
    // Endpoint "lambat" untuk simulasi API pihak ketiga yang lemot
    app.get("/lambat", async (req, res) => {
      await new Promise((r) => setTimeout(r, 3000));
      res.json({ pesan: "akhirnya..." });
    });
    
    async function ambilJSON(url, { timeout = 2000 } = {}) {
      const res = await fetch(url, {
        headers: { Accept: "application/json", "User-Agent": "kodingup-camp-demo" },
        signal: AbortSignal.timeout(timeout), // batalkan kalau lewat batas waktu
      });
      if (!res.ok) {
        // fetch TIDAK melempar untuk 4xx/5xx, kita yang harus memeriksa
        const err = new Error(`API luar membalas ${res.status}`);
        err.status = 502; // Bad Gateway: masalah ada di layanan hulu
        throw err;
      }
      return res.json();
    }
    
    app.get("/api/pengguna/:id", async (req, res) => {
      const user = await ambilJSON(`https://jsonplaceholder.typicode.com/users/${req.params.id}`);
      // kirim hanya field yang dibutuhkan frontend
      res.json({ id: user.id, nama: user.name, kota: user.address.city });
    });
    
    app.get("/api/coba-lambat", async (req, res) => {
      const data = await ambilJSON(`http://localhost:${PORT}/lambat`, { timeout: 1000 });
      res.json(data);
    });
    
    app.use((err, req, res, next) => {
      if (err.name === "TimeoutError") {
        return res.status(504).json({ error: "API luar terlalu lama merespons" });
      }
      console.error(err.message);
      res.status(err.status ?? 500).json({ error: err.message });
    });
    
    const PORT = process.env.PORT ?? 3000;
    app.listen(PORT, () => console.log(`Jalan di http://localhost:${PORT}`));
    
    curl -s -w ' [%{http_code}]' localhost:3000/api/pengguna/1
    # → {"id":1,"nama":"Leanne Graham","kota":"Gwenborough"} [200]
    
    curl -s -w ' [%{http_code}]' localhost:3000/api/pengguna/999
    # → {"error":"API luar membalas 404"} [502]
    
    time curl -s -w ' [%{http_code}]' localhost:3000/api/coba-lambat
    # → {"error":"API luar terlalu lama merespons"} [504]
    # → ... 1.011 total   ← berhenti di 1 detik, tidak menunggu 3 detik
    

    Kalau server tujuan mati sama sekali, fetch reject dengan TypeError: fetch failed dan detailnya ada di err.cause.

    CORS dengan daftar origin, diuji dengan curl yang meniru header browser:

    // cors-demo.js
    import express from "express";
    import cors from "cors";
    
    const app = express();
    app.use(cors({ origin: ["http://localhost:5173", "https://tugasku.id"] }));
    app.get("/api/x", (req, res) => res.json({ ok: true }));
    app.listen(3000);
    
    curl -s -i localhost:3000/api/x -H "Origin: http://localhost:5173" | grep -iE "^(HTTP|access-control|vary)"
    # → HTTP/1.1 200 OK
    # → Access-Control-Allow-Origin: http://localhost:5173
    # → Vary: Origin
    
    curl -s -i localhost:3000/api/x -H "Origin: http://evil.example" | grep -iE "^(HTTP|access-control|vary)"
    # → HTTP/1.1 200 OK        ← server tetap menjawab!
    # → Vary: Origin           ← tapi tanpa Allow-Origin, browser menolak memberikan hasilnya ke JS
    
    # Preflight yang dikirim browser sebelum DELETE dengan JSON
    curl -s -i -X OPTIONS localhost:3000/api/x \
      -H "Origin: https://tugasku.id" \
      -H "Access-Control-Request-Method: DELETE" \
      -H "Access-Control-Request-Headers: content-type" | grep -iE "^(HTTP|access-control)"
    # → HTTP/1.1 204 No Content
    # → Access-Control-Allow-Origin: https://tugasku.id
    # → Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE
    # → Access-Control-Allow-Headers: content-type
    

    Contoh kedua membuktikan poin penting: origin yang tidak diizinkan tetap mendapat 200 OK dan datanya. CORS hanya membuat browser menahan response itu dari JavaScript halaman lain.

    Menghubungkan frontend React (proyek Vite di port 5173) ke Tugas API. Jalankan API dengan CORS_ORIGIN=http://localhost:5173 npm run dev (port 3000).

    // src/api.js (proyek React)
    const BASE = import.meta.env.VITE_API_URL ?? "http://localhost:3000/api/v1";
    
    async function request(path, options = {}) {
      const res = await fetch(`${BASE}${path}`, {
        ...options,
        headers: { "Content-Type": "application/json", ...options.headers },
      });
      if (res.status === 204) return null;
      const body = await res.json();
      if (!res.ok) throw Object.assign(new Error(body.error.message), body.error); // bawa code & details
      return body;
    }
    
    export const api = {
      daftar: (q = "") => request(`/tugas?limit=50&q=${encodeURIComponent(q)}`),
      tambah: (judul) => request("/tugas", { method: "POST", body: JSON.stringify({ judul }) }),
      toggle: (t) => request(`/tugas/${t.id}`, { method: "PATCH", body: JSON.stringify({ selesai: !t.selesai }) }),
      hapus: (id) => request(`/tugas/${id}`, { method: "DELETE" }),
    };
    
    // src/DaftarTugas.jsx
    import { useEffect, useState } from "react";
    import { api } from "./api.js";
    
    export default function DaftarTugas() {
      const [tugas, setTugas] = useState([]);
      const [judul, setJudul] = useState("");
      const [error, setError] = useState(null);
    
      async function muat() {
        const { data } = await api.daftar();
        setTugas(data);
      }
    
      useEffect(() => {
        muat().catch((e) => setError(e.message));
      }, []);
    
      async function kirim(e) {
        e.preventDefault();
        try {
          await api.tambah(judul);
          setJudul("");
          setError(null);
          await muat();
        } catch (e) {
          // pesan per field dari server, misalnya "Judul minimal 3 karakter"
          setError(e.details?.fieldErrors?.judul?.[0] ?? e.message);
        }
      }
    
      return (
        <main>
          <form onSubmit={kirim}>
            <input value={judul} onChange={(e) => setJudul(e.target.value)} placeholder="Tugas baru" />
            <button>Tambah</button>
          </form>
          {error && <p role="alert">{error}</p>}
          <ul>
            {tugas.map((t) => (
              <li key={t.id}>
                <label>
                  <input type="checkbox" checked={t.selesai} onChange={() => api.toggle(t).then(muat)} />
                  {t.judul} ({t.prioritas})
                </label>
                <button onClick={() => api.hapus(t.id).then(muat)}>Hapus</button>
              </li>
            ))}
          </ul>
        </main>
      );
    }
    

    Kalau di console browser muncul pesan seperti "blocked by CORS policy: No 'Access-Control-Allow-Origin' header", periksa: apakah CORS_ORIGIN sama persis dengan origin frontend (termasuk port, tanpa garis miring di akhir), dan apakah middleware cors dipasang sebelum router.

    Kesalahan umum

    1. Mengira fetch melempar error untuk 404/500. Tidak. Periksa res.ok atau res.status sendiri.
    2. Memanggil API luar tanpa timeout. Satu layanan lambat bisa membuat seluruh server-mu ikut lambat. Selalu pakai AbortSignal.timeout.
    3. Menaruh API key pihak ketiga di frontend (misalnya di VITE_...). Semua variabel VITE_ ikut ke bundle dan bisa dibaca siapa saja. Panggil API itu dari server.
    4. Memakai cors() tanpa opsi di produksi. Default-nya mengizinkan semua origin (*). Tentukan daftar origin yang jelas.
    5. Menganggap CORS sebagai keamanan. CORS hanya berlaku di browser; curl dan server lain tidak terpengaruh. Lindungi data dengan autentikasi.

    Latihan

    1. Mudah, Tambahkan endpoint GET /api/todo-acak yang mengambil https://jsonplaceholder.typicode.com/todos/<angka acak 1-200> dengan timeout 2 detik, lalu mengembalikan hanya { judul, selesai }.
    2. Sedang, Buat fungsi ambilDenganRetry(url, { percobaan = 3, timeout = 1000 }) yang mencoba ulang kalau terjadi TimeoutError atau status 5xx, dengan jeda 200 ms, 400 ms, 800 ms (exponential backoff). Error 4xx tidak di-retry.
    3. Menantang, Tambahkan cache sederhana di memori untuk GET /api/pengguna/:id: hasil disimpan 60 detik, dan response menyertakan header X-Cache: HIT atau X-Cache: MISS. Uji dua kali dengan curl -i.
    Lihat solusi

    1.

    app.get("/api/todo-acak", async (req, res) => {
      const id = Math.floor(Math.random() * 200) + 1;
      const todo = await ambilJSON(`https://jsonplaceholder.typicode.com/todos/${id}`, { timeout: 2000 });
      res.json({ judul: todo.title, selesai: todo.completed });
    });
    

    2.

    const tidur = (ms) => new Promise((r) => setTimeout(r, ms));
    
    export async function ambilDenganRetry(url, { percobaan = 3, timeout = 1000 } = {}) {
      for (let ke = 1; ; ke++) {
        try {
          const res = await fetch(url, { signal: AbortSignal.timeout(timeout) });
          if (res.status >= 500) throw Object.assign(new Error(`Server hulu ${res.status}`), { bisaUlang: true });
          if (!res.ok) throw new Error(`Request ditolak ${res.status}`); // 4xx: jangan diulang
          return await res.json();
        } catch (err) {
          const bisaUlang = err.name === "TimeoutError" || err.bisaUlang;
          if (!bisaUlang || ke >= percobaan) throw err;
          await tidur(200 * 2 ** (ke - 1)); // 200, 400, 800 ms
        }
      }
    }
    

    3.

    const cache = new Map(); // id → { data, kedaluwarsa }
    
    app.get("/api/pengguna/:id", async (req, res) => {
      const simpanan = cache.get(req.params.id);
      if (simpanan && simpanan.kedaluwarsa > Date.now()) {
        res.set("X-Cache", "HIT");
        return res.json(simpanan.data);
      }
      const user = await ambilJSON(`https://jsonplaceholder.typicode.com/users/${req.params.id}`);
      const data = { id: user.id, nama: user.name, kota: user.address.city };
      cache.set(req.params.id, { data, kedaluwarsa: Date.now() + 60_000 });
      res.set("X-Cache", "MISS").json(data);
    });
    
    // curl -si localhost:3000/api/pengguna/1 | grep -i x-cache   → X-Cache: MISS
    // curl -si localhost:3000/api/pengguna/1 | grep -i x-cache   → X-Cache: HIT
    

    Ringkasan

    Video pendamping

    Kalau penjelasan di atas belum masuk, tonton ini. Video dari kanal YouTube pihak lain.

    Learn CORS In 6 MinutesWeb Dev Simplified · Bahasa InggrisBuka di YouTube

    Kuis pemahaman

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