API yang dipakai klien di luar kendali kita akan berubah: field baru ditambahkan, perilaku diperbaiki, bentuk respons disempurnakan. Perubahan itu harus terjadi tanpa mematahkan aplikasi yang sudah berjalan di tangan pengguna. Versioning memecahkan masalahnya dengan menjalankan beberapa kontrak sekaligus: klien lama tetap memakai /v1/, klien baru pindah ke /v2/. Kita akan membandingkan strategi versi di URL path dan di header Accept, menerapkan keduanya dalam satu program, lalu mengomunikasikan deprekasi lewat header Sunset.
Perubahan Additive dan Breaking #
Jenis perubahan menentukan perlakuan kita. Perubahan additive — menambah field baru pada respons — aman dilakukan di versi yang sama karena klien lama mengabaikan field yang tidak dikenalnya. Perubahan breaking — mengganti tipe field, mengubah nama, memindahkan data ke envelope, atau mengubah makna — mematahkan klien lama dan wajib menaikkan nomor versi. Aturan praktisnya: dalam satu versi hanya boleh ada perubahan additive; perubahan apa pun yang bisa membuat kode klien salah berhenti harus menunggu versi baru.
Versi di URL Path #
Strategi paling umum menempatkan versi pada path: GET /v1/produk dan GET /v2/produk. Versi terlihat eksplisit di log, di dokumentasi, dan di percakapan support; cache per URL otomatis terpisah antar versi; routing http.ServeMux mendukungnya tanpa alat tambahan. Kekurangannya: URL resource yang sama menjadi dua alamat berbeda, dan hypermedia yang menyematkan URL harus ikut memperhatikan versinya.
Versi di Header Accept #
Strategi kedua memakai header Accept: Accept: application/json;version=2. URL tetap satu, resource tetap punya satu alamat, dan versi menjadi bagian dari negosiasi konten HTTP. Namun versi tidak lagi terlihat di log maupun URL — debugging memerlukan inspeksi header; cache berbasis URL harus dikonfigurasi agar header ikut membedakan variasi; klien sederhana seperti curl perlu flag tambahan untuk memilih versi.
| Strategi | Kelebihan | Kekurangan |
|---|---|---|
URL path /v1/ | eksplisit, mudah di-cache, paling umum | satu resource punya banyak alamat |
Header Accept | URL tunggal, negosiasi konten | tidak terlihat di log, debugging lebih sulit |
Subdomain api2.example.com | isolasi infrastruktur penuh | biaya DNS/TLS, jarang untuk API kecil |
Depresiasi dengan Sunset #
Versi lama tidak dihapus mendadak. Header Sunset mengirim tanggal HTTP — format http.TimeFormat di Go — kapan endpoint dihentikan, sehingga klien punya waktu berminggu-minggu untuk bermigrasi. Praktik lengkapnya: umumkan jauh hari, tampilkan Sunset pada setiap respons versi lama, pantau trafik yang tersisa, dan baru matikan setelah aman.
Program: Dua Versi, Dua Strategi #
Program versioning-demo.go melayani daftar produk dengan dua kontrak: versi 1 mengirim array datar tanpa field harga, versi 2 mengirim envelope data+meta lengkap dengan harga. Endpoint /produk tanpa versi memilih kontrak dari header Accept.
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"time"
)
// Produk adalah data internal; representasi tiap versi dipilih saat encoding.
type Produk struct {
ID int
Nama string
Harga int
}
// produkV1 mempertahankan kontrak lama: tanpa field harga.
type produkV1 struct {
ID int `json:"id"`
Nama string `json:"nama"`
}
// produkV2 menambah field harga — perubahan additive di dalam versi baru.
type produkV2 struct {
ID int `json:"id"`
Nama string `json:"nama"`
Harga int `json:"harga"`
}
// amplopV2 membungkus daftar versi 2 dengan informasi meta.
type amplopV2 struct {
Data []produkV2 `json:"data"`
Meta metaV2 `json:"meta"`
}
type metaV2 struct {
Versi string `json:"versi"`
Jumlah int `json:"jumlah"`
}
// Handler menyimpan data dan tanggal kedaluwarsa versi 1.
type Handler struct {
produk []Produk
sunset time.Time
}
func (h *Handler) routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/produk", h.daftarV1)
mux.HandleFunc("GET /v2/produk", h.daftarV2)
mux.HandleFunc("GET /produk", h.daftarNegosiasi)
return mux
}
// daftarV1 mengirim array datar — bentuk yang dijanjikan sejak awal.
func (h *Handler) daftarV1(w http.ResponseWriter, r *http.Request) {
daftar := make([]produkV1, 0, len(h.produk))
for _, p := range h.produk {
daftar = append(daftar, produkV1{ID: p.ID, Nama: p.Nama})
}
// Sunset memberi tahu klien kapan versi ini akan dihentikan.
w.Header().Set("Sunset", h.sunset.Format(http.TimeFormat))
tulisJSON(w, http.StatusOK, daftar)
}
// daftarV2 memakai envelope dan field harga: kontrak baru yang terpisah.
func (h *Handler) daftarV2(w http.ResponseWriter, r *http.Request) {
daftar := make([]produkV2, 0, len(h.produk))
for _, p := range h.produk {
daftar = append(daftar, produkV2{ID: p.ID, Nama: p.Nama, Harga: p.Harga})
}
tulisJSON(w, http.StatusOK,
amplopV2{Data: daftar, Meta: metaV2{Versi: "2", Jumlah: len(daftar)}})
}
// daftarNegosiasi memilih versi dari parameter version pada header Accept.
func (h *Handler) daftarNegosiasi(w http.ResponseWriter, r *http.Request) {
if versiDariAccept(r.Header.Get("Accept")) == "2" {
h.daftarV2(w, r)
return
}
h.daftarV1(w, r)
}
// versiDariAccept membaca "version=" dari header Accept; default "1".
func versiDariAccept(accept string) string {
_, sisa, ada := strings.Cut(accept, "version=")
if !ada {
return "1"
}
if titikKoma := strings.IndexByte(sisa, ';'); titikKoma >= 0 {
sisa = sisa[:titikKoma]
}
return strings.TrimSpace(sisa)
}
type galatBody struct {
Error galatRincian `json:"error"`
}
type galatRincian struct {
Code string `json:"code"`
Message string `json:"message"`
}
func tulisGalat(w http.ResponseWriter, status int, kode, pesan string) {
tulisJSON(w, status, galatBody{Error: galatRincian{Code: kode, Message: pesan}})
}
func tulisJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func main() {
h := &Handler{
produk: []Produk{
{ID: 1, Nama: "Buku Tulis", Harga: 4500},
{ID: 2, Nama: "Pensil", Harga: 2500},
},
sunset: time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC),
}
mux := h.routes()
// Versi di URL path: eksplisit, terlihat di log, mudah di-cache per versi.
kirim(mux, "GET", "/v1/produk", "")
kirim(mux, "GET", "/v2/produk", "")
// Versi di header Accept: URL tetap sama, versi dinegosiasikan.
kirim(mux, "GET", "/produk", "application/json;version=1")
kirim(mux, "GET", "/produk", "application/json;version=2")
// Tanpa parameter versi: default konservatif ke versi terlama.
kirim(mux, "GET", "/produk", "")
}
// kirim mengeksekusi satu permintaan lalu mencetak status, header Sunset,
// dan body; nilai Accept ditampilkan agar strategi tiap baris terlihat.
func kirim(mux *http.ServeMux, metode, target, accept string) {
req := httptest.NewRequest(metode, target, nil)
if accept != "" {
req.Header.Set("Accept", accept)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
label := accept
if label == "" {
label = "-"
}
fmt.Printf("%-5s %-12s %-28s -> %d", metode, target, label, rec.Code)
if sunset := rec.Header().Get("Sunset"); sunset != "" {
fmt.Printf(" (Sunset: %s)", sunset)
}
fmt.Println()
if rec.Body.Len() > 0 {
fmt.Printf(" %s", rec.Body.String())
}
}Output yang Dihasilkan:
GET /v1/produk - -> 200 (Sunset: Thu, 31 Dec 2026 23:59:59 GMT)
[{"id":1,"nama":"Buku Tulis"},{"id":2,"nama":"Pensil"}]
GET /v2/produk - -> 200
{"data":[{"id":1,"nama":"Buku Tulis","harga":4500},{"id":2,"nama":"Pensil","harga":2500}],"meta":{"versi":"2","jumlah":2}}
GET /produk application/json;version=1 -> 200 (Sunset: Thu, 31 Dec 2026 23:59:59 GMT)
[{"id":1,"nama":"Buku Tulis"},{"id":2,"nama":"Pensil"}]
GET /produk application/json;version=2 -> 200
{"data":[{"id":1,"nama":"Buku Tulis","harga":4500},{"id":2,"nama":"Pensil","harga":2500}],"meta":{"versi":"2","jumlah":2}}
GET /produk - -> 200 (Sunset: Thu, 31 Dec 2026 23:59:59 GMT)
[{"id":1,"nama":"Buku Tulis"},{"id":2,"nama":"Pensil"}]Mari kita telaah baris demi baris program di atas:
- Baris 19-23 (
produkV1): struct tanpahargamenjaga kontrak lama tetap utuh — klien versi 1 tidak pernah melihat field yang tidak dijanjikan. - Baris 25-30 (
produkV2): fieldhargahanya ditambahkan pada representasi versi 2; perubahan additive terjadi di dalam versi baru, bukan di versi lama. - Baris 49-55 (
routes): tiga endpoint terdaftar —/v1/produk,/v2/produk, dan/produkyang bertugas menegosiasikan versi dari header. - Baris 57-66 (
daftarV1): handler versi 1 mengirim array datar dan menyetel headerSunsetdengan format tanggal HTTP darihttp.TimeFormat. - Baris 68-76 (
daftarV2): perubahan bentuk (envelopedata+meta) berada di sini — inilah alasan nomor versi dinaikkan. - Baris 78-85 (
daftarNegosiasi): handler tanpa versi membaca hasilversiDariAcceptlalu mendelegasikan ke handler v1 atau v2 yang sama. - Baris 87-97 (
versiDariAccept):strings.Cutmemotong parameterversion=dari nilai header; tanpa parameter, default konservatif jatuh ke versi terlama. - Baris 118-137 (
main): tanggalSunsetdibekukan pada 31 Desember 2026; lima permintaan memperagakan kedua strategi berdampingan. - Baris 142-158 (
kirim): helper mencetak nilaiAcceptsebagai label sehingga strategi tiap baris output langsung terbaca.
Output menunjukkan perilaku berbeda untuk data yang sama: /v1/produk mengirim array datar dengan Sunset, /v2/produk mengirim envelope dengan harga, dan /produk meniru salah satunya sesuai header. Kontrak yang terpelihara ini layak ditulis menjadi dokumen yang bisa dibaca mesin — OpenAPI menjadi chapter berikutnya.
Referensi #
- Dokumentasi package net/http
— pola routing per versi dan
http.TimeFormat. - Dokumentasi package strings — pemotongan parameter versi dari header.
- Go 1 compatibility promise — contoh nyata komitmen kompatibilitas jangka panjang.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
versioning-demo.go
Unduh berkaspackage main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"time"
)
// Produk adalah data internal; representasi tiap versi dipilih saat encoding.
type Produk struct {
ID int
Nama string
Harga int
}
// produkV1 mempertahankan kontrak lama: tanpa field harga.
type produkV1 struct {
ID int `json:"id"`
Nama string `json:"nama"`
}
// produkV2 menambah field harga — perubahan additive di dalam versi baru.
type produkV2 struct {
ID int `json:"id"`
Nama string `json:"nama"`
Harga int `json:"harga"`
}
// amplopV2 membungkus daftar versi 2 dengan informasi meta.
type amplopV2 struct {
Data []produkV2 `json:"data"`
Meta metaV2 `json:"meta"`
}
type metaV2 struct {
Versi string `json:"versi"`
Jumlah int `json:"jumlah"`
}
// Handler menyimpan data dan tanggal kedaluwarsa versi 1.
type Handler struct {
produk []Produk
sunset time.Time
}
func (h *Handler) routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/produk", h.daftarV1)
mux.HandleFunc("GET /v2/produk", h.daftarV2)
mux.HandleFunc("GET /produk", h.daftarNegosiasi)
return mux
}
// daftarV1 mengirim array datar — bentuk yang dijanjikan sejak awal.
func (h *Handler) daftarV1(w http.ResponseWriter, r *http.Request) {
daftar := make([]produkV1, 0, len(h.produk))
for _, p := range h.produk {
daftar = append(daftar, produkV1{ID: p.ID, Nama: p.Nama})
}
// Sunset memberi tahu klien kapan versi ini akan dihentikan.
w.Header().Set("Sunset", h.sunset.Format(http.TimeFormat))
tulisJSON(w, http.StatusOK, daftar)
}
// daftarV2 memakai envelope dan field harga: kontrak baru yang terpisah.
func (h *Handler) daftarV2(w http.ResponseWriter, r *http.Request) {
daftar := make([]produkV2, 0, len(h.produk))
for _, p := range h.produk {
daftar = append(daftar, produkV2{ID: p.ID, Nama: p.Nama, Harga: p.Harga})
}
tulisJSON(w, http.StatusOK,
amplopV2{Data: daftar, Meta: metaV2{Versi: "2", Jumlah: len(daftar)}})
}
// daftarNegosiasi memilih versi dari parameter version pada header Accept.
func (h *Handler) daftarNegosiasi(w http.ResponseWriter, r *http.Request) {
if versiDariAccept(r.Header.Get("Accept")) == "2" {
h.daftarV2(w, r)
return
}
h.daftarV1(w, r)
}
// versiDariAccept membaca "version=" dari header Accept; default "1".
func versiDariAccept(accept string) string {
_, sisa, ada := strings.Cut(accept, "version=")
if !ada {
return "1"
}
if titikKoma := strings.IndexByte(sisa, ';'); titikKoma >= 0 {
sisa = sisa[:titikKoma]
}
return strings.TrimSpace(sisa)
}
type galatBody struct {
Error galatRincian `json:"error"`
}
type galatRincian struct {
Code string `json:"code"`
Message string `json:"message"`
}
func tulisGalat(w http.ResponseWriter, status int, kode, pesan string) {
tulisJSON(w, status, galatBody{Error: galatRincian{Code: kode, Message: pesan}})
}
func tulisJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func main() {
h := &Handler{
produk: []Produk{
{ID: 1, Nama: "Buku Tulis", Harga: 4500},
{ID: 2, Nama: "Pensil", Harga: 2500},
},
sunset: time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC),
}
mux := h.routes()
// Versi di URL path: eksplisit, terlihat di log, mudah di-cache per versi.
kirim(mux, "GET", "/v1/produk", "")
kirim(mux, "GET", "/v2/produk", "")
// Versi di header Accept: URL tetap sama, versi dinegosiasikan.
kirim(mux, "GET", "/produk", "application/json;version=1")
kirim(mux, "GET", "/produk", "application/json;version=2")
// Tanpa parameter versi: default konservatif ke versi terlama.
kirim(mux, "GET", "/produk", "")
}
// kirim mengeksekusi satu permintaan lalu mencetak status, header Sunset,
// dan body; nilai Accept ditampilkan agar strategi tiap baris terlihat.
func kirim(mux *http.ServeMux, metode, target, accept string) {
req := httptest.NewRequest(metode, target, nil)
if accept != "" {
req.Header.Set("Accept", accept)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
label := accept
if label == "" {
label = "-"
}
fmt.Printf("%-5s %-12s %-28s -> %d", metode, target, label, rec.Code)
if sunset := rec.Header().Get("Sunset"); sunset != "" {
fmt.Printf(" (Sunset: %s)", sunset)
}
fmt.Println()
if rec.Body.Len() > 0 {
fmt.Printf(" %s", rec.Body.String())
}
}