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.

StrategiKelebihanKekurangan
URL path /v1/eksplisit, mudah di-cache, paling umumsatu resource punya banyak alamat
Header AcceptURL tunggal, negosiasi kontentidak terlihat di log, debugging lebih sulit
Subdomain api2.example.comisolasi infrastruktur penuhbiaya 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.

go
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:

text
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:

  1. Baris 19-23 (produkV1): struct tanpa harga menjaga kontrak lama tetap utuh — klien versi 1 tidak pernah melihat field yang tidak dijanjikan.
  2. Baris 25-30 (produkV2): field harga hanya ditambahkan pada representasi versi 2; perubahan additive terjadi di dalam versi baru, bukan di versi lama.
  3. Baris 49-55 (routes): tiga endpoint terdaftar — /v1/produk, /v2/produk, dan /produk yang bertugas menegosiasikan versi dari header.
  4. Baris 57-66 (daftarV1): handler versi 1 mengirim array datar dan menyetel header Sunset dengan format tanggal HTTP dari http.TimeFormat.
  5. Baris 68-76 (daftarV2): perubahan bentuk (envelope data+meta) berada di sini — inilah alasan nomor versi dinaikkan.
  6. Baris 78-85 (daftarNegosiasi): handler tanpa versi membaca hasil versiDariAccept lalu mendelegasikan ke handler v1 atau v2 yang sama.
  7. Baris 87-97 (versiDariAccept): strings.Cut memotong parameter version= dari nilai header; tanpa parameter, default konservatif jatuh ke versi terlama.
  8. Baris 118-137 (main): tanggal Sunset dibekukan pada 31 Desember 2026; lima permintaan memperagakan kedua strategi berdampingan.
  9. Baris 142-158 (kirim): helper mencetak nilai Accept sebagai 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 #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

versioning-demo.go

Unduh berkas
go
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())
	}
}