Desain REST API yang Tidak Menyesal Diubah Enam Bulan Kemudian

Desain REST API yang Tidak Menyesal Diubah Enam Bulan Kemudian

API internal biasanya dimulai dengan satu endpoint dan tidak pernah dirancang. Enam bulan kemudian ada /getBlogList, /blog/all, dan /api/blogs yang ketiganya hidup, dua di antaranya dipakai aplikasi mobile yang tidak bisa dirilis ulang minggu ini, dan penambahan satu field memecahkan klien Android.

Bagian yang mahal dari desain API bukan menulisnya; bagian yang mahal adalah mengubahnya setelah ada yang memakainya. Enam keputusan berikut yang paling menentukan seberapa mahal perubahan itu nanti.

Penamaan resource jamak dan hindari kata kerja di URL

URL menamai benda; metode HTTP yang menyatakan tindakan. Kata kerja di URL berarti kamu membuat bahasa sendiri yang harus dihafal setiap klien.

BURUK                          BAIK
GET  /getBlogs                 GET    /blogs
POST /createBlog               POST   /blogs
POST /updateBlog?id=5          PATCH  /blogs/5
POST /deleteBlog?id=5          DELETE /blogs/5
GET  /blogListByCategory       GET    /blogs?category=seo

Empat aturan yang membuat penamaan bisa diprediksi tanpa membaca dokumentasi:

Selalu jamak. /blogs/5, bukan /blog/5. Konsistensi mengalahkan kebenaran gramatikal — klien tidak perlu menghafal mana yang tunggal.

Kaitan sebagai sub-resource, satu tingkat saja. /blogs/5/comments bagus. /users/3/blogs/5/comments/9/replies adalah jalan yang tidak bisa diubah nanti; setelah tingkat kedua, jadikan sumber daya tingkat atas dengan filter: /replies?comment=9.

Filter, urutan, dan paginasi lewat query string, bukan lewat path. /blogs?status=published&sort=-published_at&limit=20. Awalan - untuk urutan menurun adalah konvensi yang dipahami luas dan menghemat satu parameter.

Tindakan yang bukan CRUD memang ada — dan untuk itu, sub-resource lebih baik daripada kata kerja. Alih-alih POST /blogs/5/publish, pertimbangkan PUT /blogs/5/status dengan body {"status":"published"}. Yang kedua bisa melayani semua transisi status tanpa menambah endpoint setiap kali ada status baru.

Dan satu hal kecil yang menyelamatkan banyak jam: pilih satu gaya penamaan field dan pertahankan. snake_case cocok kalau datanya datang dari SQL, camelCase kalau klien utamamu JavaScript. Yang tidak bisa dimaafkan adalah campuran — published_at bersama readingTime di objek yang sama akan terus menghasilkan bug salah tulis selama API itu hidup.

Status code yang benar: 201, 204, 409, 422 dan artinya

Mengembalikan 200 untuk segalanya memaksa setiap klien mem-parsing body untuk tahu apakah berhasil. Enam kode ini menutupi hampir semua kebutuhan API internal:

200 OK          — berhasil, ada body
201 Created     — resource baru dibuat; WAJIB sertakan header Location
204 No Content  — berhasil, tidak ada body (DELETE, atau PUT tanpa balasan)
400 Bad Request — JSON rusak, tipe parameter salah
401 Unauthorized— belum terautentikasi (belum login / token hilang)
403 Forbidden   — sudah terautentikasi, tapi tidak berhak
404 Not Found   — tidak ada, atau tidak boleh dilihat oleh pemanggil ini
409 Conflict    — bentrok keadaan: slug sudah dipakai, sudah terbit
422 Unprocessable — bentuknya valid, isinya tidak lolos validasi bisnis
429 Too Many Requests — kena rate limit; sertakan Retry-After

Pembedaan yang paling sering salah, dan paling berguna kalau benar:

401 vs 403. "Kamu siapa?" versus "Aku tahu kamu siapa, dan kamu tidak boleh." Klien memperlakukan keduanya berbeda: 401 memicu alur login ulang, 403 memicu pesan "tidak punya akses". Menggabungkannya membuat aplikasi memaksa login ulang untuk hal yang tidak akan pernah berhasil.

400 vs 422. 400 berarti request-nya tidak bisa dipahami (JSON rusak, page=abc). 422 berarti dipahami tapi ditolak ("judul wajib diisi", "tanggal terbit di masa lalu"). Klien menampilkan 422 sebagai galat per-field di formulir; 400 sebagai bug.

404 untuk yang tidak berhak dilihat. Untuk resource yang keberadaannya sendiri rahasia — draf artikel orang lain — 404 lebih baik daripada 403, karena 403 memberi tahu bahwa id itu ada.

Contoh 201 yang lengkap:

const [result] = await db.query('INSERT INTO blogs (...) VALUES (...)', [...]);
res.status(201)
   .location(`/api/blogs/${result.insertId}`)
   .json({ data: { id: result.insertId, slug: row.slug } });

Header Location membuat klien tidak perlu menebak URL resource yang baru dibuat, dan mengembalikan slug final penting karena server mungkin mengubahnya (misalnya menambah sufiks -2 saat bentrok).

Format error konsisten: code, message, details

Galat adalah bagian API yang paling sering dipakai klien dan paling jarang dirancang. Kalau bentuknya berubah-ubah, setiap klien menulis penanganan galat sendiri dan semuanya rapuh.

Satu bentuk, untuk semua galat:

{
  "error": {
    "code": "validation_failed",
    "message": "Artikel belum bisa disimpan.",
    "details": [
      { "field": "title",  "code": "required", "message": "Judul wajib diisi." },
      { "field": "slug",   "code": "taken",    "message": "Slug ini sudah dipakai." }
    ],
    "request_id": "r-9f2a71c4"
  }
}

Empat bagian, masing-masing punya pembaca yang berbeda:

  • code — string stabil untuk kode klien. Klien bercabang pada ini, bukan pada message. Begitu sebuah code dipublikasikan, ia tidak boleh berubah artinya.
  • message — untuk manusia, boleh berubah, boleh diterjemahkan.
  • details — per-field, sehingga formulir bisa menyorot input yang salah tanpa menebak dari teks.
  • request_id — dicatat juga di log server. Ini yang mengubah laporan "API-nya error" menjadi satu baris log yang bisa ditemukan dalam sepuluh detik.

Satu penangan galat, bukan format yang ditulis ulang di setiap route:

app.use((err, req, res, next) => {
  const status = err.status || 500;
  const body = {
    error: {
      code: err.code || (status === 500 ? 'internal_error' : 'request_failed'),
      message: status === 500 ? 'Terjadi kesalahan di server kami.' : err.message,
      details: err.details || undefined,
      request_id: req.id,
    },
  };
  // Detail internal masuk log, tidak masuk respons.
  if (status >= 500) console.error('[api] %s %s %s', req.id, req.originalUrl, err.stack);
  res.status(status).json(body);
});

Aturan yang tidak boleh dilanggar: jangan pernah mengirim stack trace atau pesan galat database ke klien. ER_DUP_ENTRY for key 'uniq_blogs_slug' membocorkan nama tabel dan indeksmu; yang dibutuhkan klien adalah {"code":"slug_taken"}.

Versioning lewat prefix path vs header, plus biaya masing-masing

Pertanyaan yang benar bukan "path atau header", tapi "perubahan apa yang sebenarnya butuh versi baru".

Perubahan yang aman tanpa versi baru (klien lama tetap jalan):

  • menambah field pada respons — asalkan klien tidak memvalidasi skema secara ketat;
  • menambah parameter query yang opsional;
  • menambah endpoint baru;
  • menambah nilai baru pada enum kalau klien sudah punya jalur default.

Perubahan yang memecahkan klien (butuh versi baru):

  • menghapus atau mengganti nama field;
  • mengubah tipe ("5" menjadi 5, atau string tanggal menjadi objek);
  • mengubah arti field yang sudah ada — yang paling berbahaya, karena tidak ada yang meledak, hasilnya hanya jadi salah;
  • membuat field yang opsional menjadi wajib.

Lalu pilih mekanismenya:

Prefix path (/api/v1/blogs) — terlihat di log dan di riwayat browser, mudah dirutekan di Nginx, mudah dijelaskan. Biayanya: dua versi berarti dua set route yang harus dijaga, dan biasanya duplikasi kode kalau tidak hati-hati memisahkan lapisan presentasi dari lapisan data.

app.use('/api/v1', require('./routes/api/v1'));
app.use('/api/v2', require('./routes/api/v2'));   // v2 memakai service yang sama

Header (Accept: application/vnd.zalvice.v2+json) — URL tetap bersih dan satu resource punya satu alamat selamanya. Biayanya nyata: tidak terlihat di log akses, tidak bisa dibuka di browser, dan sulit di-debug lewat tautan yang dikirim di chat.

Untuk API internal dan API untuk beberapa klien yang kamu kenal, prefix path hampir selalu pilihan yang benar — kemudahan debugging lebih berharga daripada kemurnian URL.

Dan yang paling penting, apa pun mekanismenya: tulis kebijakan penghentian sebelum versi kedua dibuat. "v1 didukung 12 bulan setelah v2 rilis, dengan header Deprecation dan Sunset pada setiap respons v1." Tanpa itu, v1 hidup selamanya karena tidak ada yang tahu siapa yang masih memakainya — dan cara mengetahuinya adalah mencatat pemakaian per versi per klien sejak hari pertama.

Pagination cursor vs offset untuk daftar blog dan works

Paginasi adalah keputusan yang paling sulit diubah setelah klien memakainya, karena bentuk responsnya berubah.

Offset (?page=2&limit=20) — sederhana, memungkinkan "lompat ke halaman 7", dan cocok untuk admin yang butuh nomor halaman. Dua kelemahannya nyata: LIMIT 20 OFFSET 10000 memaksa MySQL membaca 10.020 baris untuk membuang 10.000, dan item bisa terlewat atau muncul dua kali kalau ada penambahan data di antara permintaan halaman.

Cursor (?after=eyJpZCI6NDJ9&limit=20) — stabil terhadap penulisan baru dan cepat pada kedalaman berapa pun, karena jadi kondisi WHERE yang memakai indeks:

SELECT id, title, published_at FROM blogs
 WHERE status = 'published'
   AND (published_at, id) < (?, ?)        -- cursor: nilai baris terakhir sebelumnya
 ORDER BY published_at DESC, id DESC
 LIMIT 21;                                -- +1 untuk mengetahui ada halaman berikutnya

Perhatikan perbandingan tupel (published_at, id). Kolom pengurutan utama saja tidak cukup — dua artikel bisa punya published_at identik, dan tanpa pemecah imbang satu di antaranya akan terlewat di batas halaman.

Bentuk responsnya:

{
  "data": [ { "id": 42, "title": "..." } ],
  "page": { "limit": 20, "next": "eyJwIjoiMjAyNi0wOS0xNCIsImkiOjQyfQ", "has_more": true }
}

Perhatikan bahwa tidak ada total. Ini keputusan sadar: COUNT(*) pada tabel besar dengan filter adalah kueri termahal di halaman daftar, dan hampir tidak ada klien yang benar-benar membutuhkan angka pastinya. Kalau memang perlu, sediakan sebagai endpoint terpisah atau nilai perkiraan.

Panduan memilih: cursor untuk feed publik (blog, works, katalog — daftar yang panjang dan terus bertambah di atas), offset untuk tabel admin (di mana nomor halaman dan "lompat ke halaman terakhir" benar-benar dipakai, dan jumlah barisnya terbatas).

Dan bungkus daftar dalam objek ({"data": [...]}), bukan array di akar. Array di akar tidak punya tempat untuk menaruh metadata paginasi nanti — dan menambahkannya nanti adalah perubahan yang memecahkan semua klien.

Langkah berikutnya

Kalau kamu punya API yang sudah berjalan, tiga jam kerja berikut memberi hasil terbesar: (1) daftar semua endpoint dan tandai yang memakai kata kerja di URL — jangan hapus, tetapi jangan tambah lagi; (2) satukan format galat lewat satu penangan galat dengan code dan request_id; (3) catat pemakaian per endpoint dan per klien, karena tanpa data itu kamu tidak akan pernah bisa menghapus apa pun.

Kalau API-nya belum ada, tulis dulu lima endpoint pertamanya sebagai daftar URL dan contoh respons di satu berkas markdown, sebelum menulis kode. Setengah jam di tahap itu lebih murah daripada versi kedua enam bulan kemudian.