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 /getTodos | GET /todos | baca daftar tugas |
POST /buatTugas | POST /todos | buat tugas baru |
POST /updateTugas | PUT /todos/7 | ubah tugas nomor 7 |
POST /hapusTugas?id=7 | DELETE /todos/7 | hapus 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:
| Method | Operasi CRUD | Contoh | Status sukses |
|---|---|---|---|
GET | read | GET /todos/7 | 200 |
POST | create | POST /todos | 201 |
PUT | update (ganti utuh) | PUT /todos/7 | 200 atau 204 |
PATCH | update (sebagian) | PATCH /todos/7 | 200 atau 204 |
DELETE | delete | DELETE /todos/7 | 204 |
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.
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:
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:
- Baris 15-19 (
type Endpoint): struct kecil merangkum satu baris rancangan — method, path, dan deskripsi — supaya tabel endpoint lahir dari data, bukan teks manual. - Baris 23-30 (slice
rancangan): lima operasi CRUD pada resource jamak/todosditambah satu contoh nesting/users/{id}/orders. - Baris 42-52 (handler
POST /todos): decode body, simpan dengan identitas barulen(data)+1, jawab 201. - Baris 54-64 (handler
PUT /todos/{id}): menimpa isi kunci 7 (contoh mengunci id agar fokus pada efek method) dan menjawab 200. - Baris 66-69 (handler
DELETE /todos/{id}): menghapus kunci 7 dan menjawab 204 tanpa body. - Baris 71-80 (fungsi
kirim):httptest.NewRequestdanhttptest.NewRecordermenjalankan permintaan tanpa membuka port; status dan isi store dicetak. - Baris 82-97 (skenario dobel panggilan): tiga method dipanggil dua kali dengan input sama persis.
- Baris 100-107 (
isiData): kunci map diurutkan lewatslices.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 #
- Dokumentasi package net/http — daftar method, status code, dan tipe inti server HTTP.
- Tutorial: Web service with Gin — contoh resmi merancang REST API resource album.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
desain-demo.go
Unduh berkaspackage 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, ", ") + "]"
}