Setiap endpoint yang kita rilis adalah kontrak: ratusan client — aplikasi mobile versi lama, skrip otomatisasi, integrasi milik tim lain — bergantung pada bentuk response yang sudah ada. Mengganti nama field title menjadi nama terdengar seperti perbaikan kecil, tetapi bagi client yang membaca title, perubahan itu memutus aplikasi mereka dalam semalam. Versioning adalah jawabannya: beberapa versi API berjalan berdampingan, client lama tetap aman di versi lamanya, dan migrasi berlangsung bertahap. Pada Chapter ini kita mempelajari kapan versi baru dibutuhkan, dua cara populer menyandikannya — di jalur URL dan di header — serta strategi memensiunkan versi lama.
Perubahan yang Melanggar Kontrak #
Tidak semua perubahan memerlukan versi baru. Pembeda utamanya: apakah client lama masih bisa bekerja tanpa perubahan kode?
| Jenis perubahan | Melanggar kontrak? | Perlukah versi baru? |
|---|---|---|
| Menambah field baru pada response | Tidak — client lama mengabaikan field asing | Tidak |
| Menambah endpoint baru | Tidak | Tidak |
Mengganti nama field (title → nama) | Ya | Ya |
| Mengubah tipe field (string → angka) | Ya | Ya |
| Menjadikan field opsional menjadi wajib | Ya | Ya |
| Menghapus endpoint atau field | Ya | Ya |
Aturan praktisnya: perubahan aditif aman disematkan pada versi yang sama, perubahan merusak menuntut nomor versi baru. Karena itu nomor versi pada API lazimnya hanya angka besar — /v1, /v2 — mengikuti semangat major version pada Semantic Versioning
; pembaruan kecil yang tidak merusak tidak menaikkan angka tersebut.
Versioning di Jalur URL #
Cara paling umum: sematkan versi di awal jalur. Kedua versi hidup sebagai endpoint terpisah — /v1/tugas dan /v2/tugas — dan masing-masing bebas mengembalikan representasi berbeda. Demo berikut menjalankan keduanya berdampingan di atas satu penyimpanan yang sama:
import http from 'node:http';
const host = '127.0.0.1';
const tugas = [
{ id: 1, title: 'Belajar Node.js', status: 'selesai' },
{ id: 2, title: 'Menulis dokumentasi API', status: 'belum' },
];
function kirimJson(res, kode, data) {
res.writeHead(kode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
function kirimError(res, kode, kodeError, pesan) {
kirimJson(res, kode, { error: { kode: kodeError, pesan } });
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${host}`);
if (req.method === 'GET' && url.pathname === '/v1/tugas') {
// Representasi v1: array polos, field bernama "nama"
const versiLama = tugas.map((t) => ({ id: t.id, nama: t.title }));
return kirimJson(res, 200, versiLama);
}
if (req.method === 'GET' && url.pathname === '/v2/tugas') {
// Representasi v2: envelope data + meta, field bernama "title"
const versiBaru = {
data: tugas.map((t) => ({ id: t.id, title: t.title, status: t.status })),
meta: { halaman: 1, batas: 10, total: tugas.length, totalHalaman: 1 },
};
return kirimJson(res, 200, versiBaru);
}
return kirimError(
res,
404,
'TIDAK_ADA',
'Endpoint tidak ditemukan; API tugas tersedia pada /v1/tugas dan /v2/tugas',
);
});
server.listen(0, host, async () => {
const port = server.address().port;
const base = `http://${host}:${port}`;
const v1 = await fetch(`${base}/v1/tugas`);
console.log(`GET /v1/tugas -> ${v1.status}`, JSON.stringify(await v1.json()));
const v2 = await fetch(`${base}/v2/tugas`);
console.log(`GET /v2/tugas -> ${v2.status}`, JSON.stringify(await v2.json()));
const tanpaVersi = await fetch(`${base}/tugas`);
console.log(`GET /tugas -> ${tanpaVersi.status}`, JSON.stringify(await tanpaVersi.json()));
server.close();
server.closeAllConnections();
});Output yang Dihasilkan:
GET /v1/tugas -> 200 [{"id":1,"nama":"Belajar Node.js"},{"id":2,"nama":"Menulis dokumentasi API"}]
GET /v2/tugas -> 200 {"data":[{"id":1,"title":"Belajar Node.js","status":"selesai"},{"id":2,"title":"Menulis dokumentasi API","status":"belum"}],"meta":{"halaman":1,"batas":10,"total":2,"totalHalaman":1}}
GET /tugas -> 404 {"error":{"kode":"TIDAK_ADA","pesan":"Endpoint tidak ditemukan; API tugas tersedia pada /v1/tugas dan /v2/tugas"}}Bedah singkatnya: baris 21-25 merutekan /v1/tugas ke representasi lama — array polos dengan field nama — sementara baris 27-34 merutekan /v2/tugas ke representasi baru dengan envelope data + meta dari Chapter Pagination dan Filtering. Keduanya membaca satu sumber data yang sama (tugas); yang berbeda hanyalah cara data dibungkus. Baris 36-41 menjawab jalur tanpa versi dengan 404 yang sekaligus menunjukkan ke mana harus pergi — pesan error yang baik mengarahkan, bukan sekadar menolak. Dari sisi client, versioning URL terasa paling sederhana: versi terlihat jelas di log, di bookmark, dan di cache, serta mudah dicoba dengan curl tanpa header tambahan.
Versioning di Header #
Cara kedua menyimpan versi bukan di URL, melainkan di header Accept
. Client menyatakan versi yang diminta lewat media type bentukan sendiri — pola application/vnd.<nama>.v<nomor>+json — dan server memilih representasi sesuai isi header:
import http from 'node:http';
const host = '127.0.0.1';
const tugas = [
{ id: 1, title: 'Belajar Node.js', status: 'selesai' },
{ id: 2, title: 'Menulis dokumentasi API', status: 'belum' },
];
const VERSI_URL = 'application/vnd.tugas.v2+json';
function kirimJson(res, kode, data) {
res.writeHead(kode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
function kirimError(res, kode, kodeError, pesan) {
kirimJson(res, kode, { error: { kode: kodeError, pesan } });
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${host}`);
if (req.method === 'GET' && url.pathname === '/tugas') {
const accept = req.headers.accept ?? '';
if (accept.includes(VERSI_URL)) {
// Representasi v2: hanya untuk client yang memintanya secara eksplisit
const versiBaru = {
data: tugas.map((t) => ({ id: t.id, title: t.title, status: t.status })),
meta: { halaman: 1, batas: 10, total: tugas.length, totalHalaman: 1 },
};
return kirimJson(res, 200, versiBaru);
}
if (accept.includes('json')) {
// Representasi v1: bawaan untuk permintaan JSON apa pun
const versiLama = tugas.map((t) => ({ id: t.id, nama: t.title }));
return kirimJson(res, 200, versiLama);
}
return kirimError(
res,
406,
'FORMAT_TIDAK_DUKUNG',
'Server hanya mendukung application/json dan application/vnd.tugas.v2+json',
);
}
return kirimError(res, 404, 'TIDAK_ADA', 'Endpoint tidak ditemukan');
});
server.listen(0, host, async () => {
const port = server.address().port;
const base = `http://${host}:${port}`;
const v1 = await fetch(`${base}/tugas`, { headers: { Accept: 'application/json' } });
console.log(`GET /tugas (Accept: application/json) -> ${v1.status}`, JSON.stringify(await v1.json()));
const v2 = await fetch(`${base}/tugas`, { headers: { Accept: 'application/vnd.tugas.v2+json' } });
console.log(`GET /tugas (Accept: vnd.tugas.v2) -> ${v2.status}`, JSON.stringify(await v2.json()));
const xml = await fetch(`${base}/tugas`, { headers: { Accept: 'application/xml' } });
console.log(`GET /tugas (Accept: application/xml) -> ${xml.status}`, JSON.stringify(await xml.json()));
server.close();
server.closeAllConnections();
});Output yang Dihasilkan:
GET /tugas (Accept: application/json) -> 200 [{"id":1,"nama":"Belajar Node.js"},{"id":2,"nama":"Menulis dokumentasi API"}]
GET /tugas (Accept: vnd.tugas.v2) -> 200 {"data":[{"id":1,"title":"Belajar Node.js","status":"selesai"},{"id":2,"title":"Menulis dokumentasi API","status":"belum"}],"meta":{"halaman":1,"batas":10,"total":2,"totalHalaman":1}}
GET /tugas (Accept: application/xml) -> 406 {"error":{"kode":"FORMAT_TIDAK_DUKUNG","pesan":"Server hanya mendukung application/json dan application/vnd.tugas.v2+json"}}Titik pentingnya ada di baris 23-39: satu jalur /tugas saja, dan keputusan representasi diambil dari req.headers.accept. Client yang meminta application/vnd.tugas.v2+json menerima bentuk baru; client JSON lain jatuh ke bentuk lama agar kompatibel. Permintaan berformat yang sama sekali tidak kita dukung dijawab 406 Not Acceptable — status code yang tepat untuk “server tidak bisa memenuhi header Accept ini”. Perbandingan dua pendekatan ini dirangkum pada tabel berikut:
| Aspek | Versioning URL | Versioning header |
|---|---|---|
| Keterlihatan | Versi tampak jelas di URL dan log | Versi tersembunyi di header |
| Kemudahan mencoba | Cukup curl biasa | Perlu menambah header secara manual |
| Penyimpanan sementara | Aman — tiap versi ber-URL sendiri | Perlu hati-hati — cache umum berkunci URL, bukan header |
| Bentuk URL | Bertambah awalan /v2 | Tetap bersih |
Strategi Depresiasi #
Versi lama tidak boleh dihapus begitu saja. Siklus yang sehat berjalan seperti ini: umumkan versi baru beserta panduan migrasi; jalankan kedua versi berdampingan; pantau trafik per versi — header versi pada log memudahkan penghitungan ini; umumkan tanggal penghentian jauh hari sebelumnya, idealnya juga lewat header Sunset pada setiap response versi lama; setelah tenggat, jawab versi lama dengan 410 Gone yang menunjuk ke panduan migrasi. Lamanya masa transisi bergantung pada jumlah client — API internal bisa berminggu-minggu, API publik lazim memberi waktu berbulan-bulan hingga bertahun.
Dengan versioning, perubahan besar tidak lagi menjadi dilema antara kemajuan dan kompatibilitas. Satu persoalan terakhir menanti: bagaimana calon pengguna mengetahui seluruh endpoint, parameter, dan bentuk response yang kita sediakan? Chapter Dokumentasi API dengan OpenAPI menjawabnya dengan spesifikasi yang bisa dibaca manusia sekaligus mesin.