REST API yang nyaman dipakai biasanya sudah nyaman sejak tahap perancangan. Keputusan paling menentukan bukan pemilihan framework atau database, melainkan tiga hal sederhana: resource apa yang diekspos, method HTTP mana yang dipakai, dan URL seperti apa yang membentuk semuanya. Pada chapter ini kita merancang API untuk aplikasi daftar tugas (todos) dari nol: menyusun resource berbentuk noun, memetakan method HTTP ke operasi CRUD, lalu membuktikan lewat program kecil mengapa PUT dan DELETE idempotent sedangkan POST tidak. Seluruh contoh memakai standard library net/http; framework seperti Gin atau Echo pada akhirnya hanya membungkus keputusan desain yang sama.

Resource Adalah Noun, Bukan Verb #

REST memandang URL sebagai alamat resource, yaitu entitas data yang dapat dibaca, dibuat, diubah, atau dihapus. Karena URL menunjuk benda, bentuk terbaik untuk URL adalah noun: /todos untuk kumpulan tugas dan /todos/7 untuk satu tugas. Gaya lama dari era RPC memakai URL sebagai perintah — /getItems, /hapusTugas?id=7 — dan membuang potensi method HTTP yang sudah tersedia.

Tabel berikut membandingkan kedua gaya untuk operasi yang sama:

Bentuk verb (hindari)Bentuk noun (pakai)Operasi
POST /getTodosGET /todosbaca daftar tugas
POST /buatTugasPOST /todosbuat tugas baru
POST /updateTugasPUT /todos/7ubah tugas nomor 7
POST /hapusTugas?id=7DELETE /todos/7hapus tugas nomor 7

Operasi baca tidak lagi butuh kata “get” karena method GET sudah menyatakannya.

Konvensi Jamak dan Nesting #

Koleksi lazim memakai bentuk jamak: /todos mewakili seluruh tugas, sementara /todos/7 merujuk satu anggota koleksi. Konvensi jamak menjaga aturan seragam (/users, /orders) sehingga klien bisa menebak pola URL resource lain. Hubungan kepemilikan direpresentasikan dengan nesting: /users/42/orders berarti pesanan milik pengguna nomor 42.

Method HTTP dan Operasi CRUD #

Method HTTP adalah kata kerja dari rancangan REST. Satu pasangan resource-method menggantikan satu endpoint RPC:

MethodOperasi CRUDContohStatus sukses
GETreadGET /todos/7200
POSTcreatePOST /todos201
PUTupdate (ganti utuh)PUT /todos/7200 atau 204
PATCHupdate (sebagian)PATCH /todos/7200 atau 204
DELETEdeleteDELETE /todos/7204

Method POST membuat resource baru dan server menentukan identitasnya; PUT mengganti seluruh representasi resource di URL; PATCH mengubah field tertentu. Method GET tidak boleh mengubah state sehingga aman di-cache.

Idempotency: Kenapa PUT dan DELETE Aman Diulang #

Sebuah operasi disebut idempotent bila permintaan yang sama dijalankan sekali atau sepuluh kali meninggalkan state akhir yang sama. Sifat ini menentukan bagaimana klien menangani kegagalan jaringan: PUT dan DELETE aman dikirim ulang begitu saja, sementara POST tidak — pengiriman ulang POST /todos menghasilkan dua tugas kembar karena setiap panggilan menciptakan resource dengan identitas baru.

PUT /todos/7 dengan body yang sama selalu mengembalikan tugas nomor 7 ke isi yang sama. DELETE /todos/7 membuat tugas nomor 7 hilang; panggilan kedua tidak menambah efek apa pun, meskipun API boleh memilih menjawab 204 terus atau 404 setelah resource hilang.

Statelessness dan Evolusi API #

Setiap permintaan REST membawa seluruh informasi yang dibutuhkan server — kredensial, parameter, body — sehingga server tidak menyimpan ingatan antar permintaan dan penyeimbang beban bebas mengarahkan permintaan ke instance mana pun. Aspek hypermedia (HATEOAS) — respons memuat tautan aksi terkait — adalah tingkat kematangan REST lebih tinggi; kita memakai bentuk ringannya berupa header Link pada chapter pagination. Ketika kontrak API perlu berubah tanpa merusak klien lama, gunakan pemisahan versi seperti /v1/todos — materi chapter API Versioning .

Program: Rancangan API Tugas #

Program desain-demo.go mencetak tabel rancangan endpoint API tugas, lalu membuktikan idempotency lewat httptest: tiga method dipanggil dua kali dan isi penyimpanan dicetak setiap kali.

go
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"maps"
	"net/http"
	"net/http/httptest"
	"slices"
	"strings"
)

// Endpoint merangkum satu rute hasil rancangan API tugas.
type Endpoint struct {
	Metode    string
	Path      string
	Deskripsi string
}

func main() {
	// Rancangan endpoint: URL memakai noun resource, bukan verb.
	rancangan := []Endpoint{
		{http.MethodGet, "/todos", "daftar seluruh tugas"},
		{http.MethodPost, "/todos", "buat tugas baru"},
		{http.MethodGet, "/todos/{id}", "baca satu tugas"},
		{http.MethodPut, "/todos/{id}", "ganti seluruh isi tugas"},
		{http.MethodDelete, "/todos/{id}", "hapus satu tugas"},
		{http.MethodGet, "/users/{id}/orders", "baca pesanan milik satu pengguna"},
	}

	fmt.Println("Rancangan endpoint API tugas:")
	for _, e := range rancangan {
		fmt.Printf("  %-7s %-19s %s\n", e.Metode, e.Path, e.Deskripsi)
	}
	fmt.Println()

	// State di memori untuk membuktikan efek tiap method terhadap resource.
	data := make(map[int]string)
	mux := http.NewServeMux()

	mux.HandleFunc("POST /todos", func(w http.ResponseWriter, r *http.Request) {
		var masukan struct {
			Title string `json:"title"`
		}
		if err := json.NewDecoder(r.Body).Decode(&masukan); err != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}
		data[len(data)+1] = masukan.Title
		w.WriteHeader(http.StatusCreated)
	})

	mux.HandleFunc("PUT /todos/{id}", func(w http.ResponseWriter, r *http.Request) {
		var masukan struct {
			Title string `json:"title"`
		}
		if err := json.NewDecoder(r.Body).Decode(&masukan); err != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}
		data[7] = masukan.Title // disederhanakan: contoh mengunci id 7
		w.WriteHeader(http.StatusOK)
	})

	mux.HandleFunc("DELETE /todos/{id}", func(w http.ResponseWriter, r *http.Request) {
		delete(data, 7) // disederhanakan: contoh mengunci id 7
		w.WriteHeader(http.StatusNoContent)
	})

	kirim := func(metode, target, body string) {
		var pembaca io.Reader
		if body != "" {
			pembaca = strings.NewReader(body)
		}
		req := httptest.NewRequest(metode, target, pembaca)
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, req)
		fmt.Printf("  %s %s -> %d ; data = %s\n", metode, target, rec.Code, isiData(data))
	}

	fmt.Println("POST /todos dua kali dengan body yang sama:")
	kirim("POST", "/todos", `{"title":"Belajar Go"}`)
	kirim("POST", "/todos", `{"title":"Belajar Go"}`)
	fmt.Println("  POST tidak idempotent: jumlah data bertambah pada panggilan kedua.")
	fmt.Println()

	fmt.Println("PUT /todos/7 dua kali dengan body yang sama:")
	kirim("PUT", "/todos/7", `{"title":"Rapat tim"}`)
	kirim("PUT", "/todos/7", `{"title":"Rapat tim"}`)
	fmt.Println("  PUT idempotent: state setelah panggilan kedua tidak berubah.")
	fmt.Println()

	fmt.Println("DELETE /todos/7 dua kali:")
	kirim("DELETE", "/todos/7", "")
	kirim("DELETE", "/todos/7", "")
	fmt.Println("  DELETE idempotent: state tetap sama dan respons tetap 204.")
}

// isiData merangkai isi store menjadi teks dengan kunci terurut.
func isiData(data map[int]string) string {
	bagian := make([]string, 0, len(data))
	for _, kunci := range slices.Sorted(maps.Keys(data)) {
		bagian = append(bagian, fmt.Sprintf("%d:%s", kunci, data[kunci]))
	}
	return "[" + strings.Join(bagian, ", ") + "]"
}

Output yang Dihasilkan:

text
Rancangan endpoint API tugas:
  GET     /todos              daftar seluruh tugas
  POST    /todos              buat tugas baru
  GET     /todos/{id}         baca satu tugas
  PUT     /todos/{id}         ganti seluruh isi tugas
  DELETE  /todos/{id}         hapus satu tugas
  GET     /users/{id}/orders  baca pesanan milik satu pengguna

POST /todos dua kali dengan body yang sama:
  POST /todos -> 201 ; data = [1:Belajar Go]
  POST /todos -> 201 ; data = [1:Belajar Go, 2:Belajar Go]
  POST tidak idempotent: jumlah data bertambah pada panggilan kedua.

PUT /todos/7 dua kali dengan body yang sama:
  PUT /todos/7 -> 200 ; data = [1:Belajar Go, 2:Belajar Go, 7:Rapat tim]
  PUT /todos/7 -> 200 ; data = [1:Belajar Go, 2:Belajar Go, 7:Rapat tim]
  PUT idempotent: state setelah panggilan kedua tidak berubah.

DELETE /todos/7 dua kali:
  DELETE /todos/7 -> 204 ; data = [1:Belajar Go, 2:Belajar Go]
  DELETE /todos/7 -> 204 ; data = [1:Belajar Go, 2:Belajar Go]
  DELETE idempotent: state tetap sama dan respons tetap 204.

Mari kita telaah baris demi baris program di atas:

  1. Baris 15-19 (type Endpoint): struct kecil merangkum satu baris rancangan — method, path, dan deskripsi — supaya tabel endpoint lahir dari data, bukan teks manual.
  2. Baris 23-30 (slice rancangan): lima operasi CRUD pada resource jamak /todos ditambah satu contoh nesting /users/{id}/orders.
  3. Baris 42-52 (handler POST /todos): decode body, simpan dengan identitas baru len(data)+1, jawab 201.
  4. Baris 54-64 (handler PUT /todos/{id}): menimpa isi kunci 7 (contoh mengunci id agar fokus pada efek method) dan menjawab 200.
  5. Baris 66-69 (handler DELETE /todos/{id}): menghapus kunci 7 dan menjawab 204 tanpa body.
  6. Baris 71-80 (fungsi kirim): httptest.NewRequest dan httptest.NewRecorder menjalankan permintaan tanpa membuka port; status dan isi store dicetak.
  7. Baris 82-97 (skenario dobel panggilan): tiga method dipanggil dua kali dengan input sama persis.
  8. Baris 100-107 (isiData): kunci map diurutkan lewat slices.Sorted(maps.Keys(data)) agar output deterministik.

Baris kedua skenario POST memperlihatkan store bertambah dari satu menjadi dua entri — bukti POST tidak idempotent. Sebaliknya, dua panggilan PUT dan DELETE meninggalkan isi store seperti panggilan pertamanya. Rancangan teruji semacam ini siap diturunkan menjadi routing dan handler sungguhan — materi chapter berikutnya.

Referensi #

Berkas Sumber

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

desain-demo.go

Unduh berkas
go
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"maps"
	"net/http"
	"net/http/httptest"
	"slices"
	"strings"
)

// Endpoint merangkum satu rute hasil rancangan API tugas.
type Endpoint struct {
	Metode    string
	Path      string
	Deskripsi string
}

func main() {
	// Rancangan endpoint: URL memakai noun resource, bukan verb.
	rancangan := []Endpoint{
		{http.MethodGet, "/todos", "daftar seluruh tugas"},
		{http.MethodPost, "/todos", "buat tugas baru"},
		{http.MethodGet, "/todos/{id}", "baca satu tugas"},
		{http.MethodPut, "/todos/{id}", "ganti seluruh isi tugas"},
		{http.MethodDelete, "/todos/{id}", "hapus satu tugas"},
		{http.MethodGet, "/users/{id}/orders", "baca pesanan milik satu pengguna"},
	}

	fmt.Println("Rancangan endpoint API tugas:")
	for _, e := range rancangan {
		fmt.Printf("  %-7s %-19s %s\n", e.Metode, e.Path, e.Deskripsi)
	}
	fmt.Println()

	// State di memori untuk membuktikan efek tiap method terhadap resource.
	data := make(map[int]string)
	mux := http.NewServeMux()

	mux.HandleFunc("POST /todos", func(w http.ResponseWriter, r *http.Request) {
		var masukan struct {
			Title string `json:"title"`
		}
		if err := json.NewDecoder(r.Body).Decode(&masukan); err != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}
		data[len(data)+1] = masukan.Title
		w.WriteHeader(http.StatusCreated)
	})

	mux.HandleFunc("PUT /todos/{id}", func(w http.ResponseWriter, r *http.Request) {
		var masukan struct {
			Title string `json:"title"`
		}
		if err := json.NewDecoder(r.Body).Decode(&masukan); err != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}
		data[7] = masukan.Title // disederhanakan: contoh mengunci id 7
		w.WriteHeader(http.StatusOK)
	})

	mux.HandleFunc("DELETE /todos/{id}", func(w http.ResponseWriter, r *http.Request) {
		delete(data, 7) // disederhanakan: contoh mengunci id 7
		w.WriteHeader(http.StatusNoContent)
	})

	kirim := func(metode, target, body string) {
		var pembaca io.Reader
		if body != "" {
			pembaca = strings.NewReader(body)
		}
		req := httptest.NewRequest(metode, target, pembaca)
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, req)
		fmt.Printf("  %s %s -> %d ; data = %s\n", metode, target, rec.Code, isiData(data))
	}

	fmt.Println("POST /todos dua kali dengan body yang sama:")
	kirim("POST", "/todos", `{"title":"Belajar Go"}`)
	kirim("POST", "/todos", `{"title":"Belajar Go"}`)
	fmt.Println("  POST tidak idempotent: jumlah data bertambah pada panggilan kedua.")
	fmt.Println()

	fmt.Println("PUT /todos/7 dua kali dengan body yang sama:")
	kirim("PUT", "/todos/7", `{"title":"Rapat tim"}`)
	kirim("PUT", "/todos/7", `{"title":"Rapat tim"}`)
	fmt.Println("  PUT idempotent: state setelah panggilan kedua tidak berubah.")
	fmt.Println()

	fmt.Println("DELETE /todos/7 dua kali:")
	kirim("DELETE", "/todos/7", "")
	kirim("DELETE", "/todos/7", "")
	fmt.Println("  DELETE idempotent: state tetap sama dan respons tetap 204.")
}

// isiData merangkai isi store menjadi teks dengan kunci terurut.
func isiData(data map[int]string) string {
	bagian := make([]string, 0, len(data))
	for _, kunci := range slices.Sorted(maps.Keys(data)) {
		bagian = append(bagian, fmt.Sprintf("%d:%s", kunci, data[kunci]))
	}
	return "[" + strings.Join(bagian, ", ") + "]"
}