Desain REST API Lengkap
REST bukan teknologi yang Anda pasang, ia seperangkat kesepakatan yang membuat jutaan klien dan server bisa bicara tanpa koordinasi. Bab ini membongkar standarnya, membangunnya ulang dari krisis skalabilitas yang melahirkannya, dan mengubah setiap pertanyaan kabur (jamak atau tunggal? PUT atau PATCH? status code yang mana?) menjadi aturan yang tidak perlu Anda tebak lagi.
Apa Sebenarnya REST Itu
Tahun 1990 Tim Berners-Lee memulai proyek berbagi pengetahuan ke seluruh dunia, World Wide Web. Dalam waktu sekitar setahun ia menemukan nyaris semua yang masih kita pakai: URI (uniform resource identifier), protokol HTTP, HTML, server web pertama, browser pertama, dan editor HTML WYSIWYG pertama yang tertanam di browser itu. Luar biasa untuk satu orang dalam dua belas bulan.
Lalu masalahnya datang: skala. Web tumbuh eksponensial, jauh melampaui apa pun yang direncanakan Berners-Lee. Desain aslinya tidak sanggup menampung basis pengguna yang ia peroleh setiap hari. Sekitar 1993, Roy Fielding, salah satu pendiri proyek server HTTP Apache, khawatir soal persis ini. Untuk membuat web skalabel ia mengusulkan seperangkat batasan arsitektural. Ia dan Berners-Lee lalu menulis bersama spesifikasi HTTP/1.1, versi terstandar pertama protokol itu. Akhirnya, pada 2000, Fielding menamai dan mendeskripsikan seluruh gaya arsitektur itu dalam disertasi doktornya: REST, REpresentational State Transfer.
Disertasi Fielding adalah dokumen sumber asli REST. Cari "Roy Fielding REST dissertation", bacaan yang benar-benar layak untuk engineer backend mana pun, karena ia menjelaskan kenapa pola-pola ini ada, bukan hanya apa adanya.
Namanya terurai jadi tiga gagasan, dan masing-masing konsep nyata yang Anda pakai sehari-hari:
Representational
Sebuah resource (sepotong data, pengguna, buku, keranjang) bisa direpresentasikan dalam format berbeda tergantung siapa yang bertanya. Record user yang sama bisa dikirim sebagai JSON ke API client (server-to-server), sebagai HTML ke browser, atau sebagai XML ke konsumen warisan. Resource dasarnya satu; representasinya banyak.
State
State adalah kondisi saat ini sebuah resource, atributnya yang sekarang. Bayangkan keranjang Amazon: state-nya adalah set item di dalamnya, jumlahnya, dan total harganya saat ini. State itu hidup di server dan itulah yang dipindah-pindahkan.
Transfer
Karena ada klien dan server, intinya adalah memindahkan representasi-representasi ini di antara keduanya. Transfer terjadi lewat standar bersama, HTTP, memakai method-nya (GET, POST, PUT, PATCH, DELETE, ...). Saat browser Anda meminta halaman dengan GET, ia mentransfer representasi dari server ke klien.
REST = resource yang punya banyak representasi, yang state-nya bisa ditransfer antara klien dan server lewat HTTP, semuanya dalam seperangkat batasan yang menjaga sistem tetap skalabel. Anda tidak perlu menghafal teorinya, tapi mengetahui tiga kata itu memberi tahu apa yang sebenarnya dilakukan setiap panggilan API: memindahkan representasi state saat ini dari sebuah resource.
Enam Batasan
Untuk benar-benar "RESTful", dan untuk mendapat skalabilitas yang dikejar Fielding, sebuah sistem mengikuti enam batasan. Lima wajib; yang keenam opsional. Ini bukan birokrasi; masing-masing membelikan properti skalabilitas yang konkret.
| Batasan | Isinya | Yang ia belikan |
|---|---|---|
| 1 / Client-Server | Pemisahan ketat urusan: klien memegang UI/UX, server memegang data & logika bisnis. | Tiap sisi berkembang independen. |
| 2 / Uniform Interface | Satu cara terstandar bagi semua komponen untuk bicara. (Empat sub-batasan di bawah.) | Konsistensi lintas setiap layanan. |
| 3 / Layered System | Lapisan berhirarki; satu lapisan hanya melihat lapisan tepat di bawahnya. | Pasang load balancer / proxy tanpa menyentuh logika inti. |
| 4 / Cache | Respons wajib melabeli dirinya cacheable atau tidak. | Beban server lebih ringan, respons lebih cepat. |
| 5 / Stateless | Server tidak menyimpan memori request lampau; tiap request membawa semua yang ia butuhkan. | Server mana pun bisa menangani request apa pun -> scaling horizontal. |
| 6 / Code on Demand (opsional) | Server boleh mengirim kode yang bisa dieksekusi (mis. JavaScript) untuk memperluas klien. | Perilaku klien fleksibel saat dibutuhkan. |
Dua yang paling penting dalam praktik
Stateless adalah bintangnya. Server tidak mengingat request Anda sebelumnya; setiap request harus memuat semua informasi yang dibutuhkan untuk memahami dan memprosesnya. Inilah yang membuat scaling mungkin: taruh load balancer di depan tiga server identik, dan karena tidak ada server yang memegang sesi Anda di memori, server mana pun bisa menangani request apa pun secara round-robin. Statelessness adalah alasan Anda bisa menambah server dan semuanya "tinggal jalan".
Layered System adalah yang dimungkinkan statelessness. Karena klien hanya bicara ke lapisan tepat di depannya, Anda bisa menyelipkan CDN, reverse proxy, load balancer, dan API gateway antara klien dan origin, dan klien tidak pernah tahu atau peduli. Ini seluruh fondasi cara aplikasi kecil tumbuh menjadi yang melayani jutaan.
Karena tidak ada server yang menyimpan sesi Anda, load balancer bisa mengirim request Anda ke server 1, 2, atau 3 secara bergantian, masing-masing membawa semua yang ia butuhkan di dalam request. Tambah server keempat dan ia langsung bekerja, tanpa koordinasi. Itulah scaling horizontal, dan statelessness yang membuatnya gratis.
Empat sub-batasan Uniform Interface
- Identifikasi resource: setiap resource bisa dialamatkan lewat URI.
- Manipulasi melalui representasi: Anda bertindak pada resource dengan mengirim/menerima representasinya (JSON yang Anda PUT adalah cara Anda mengubahnya).
- Pesan yang mendeskripsikan diri: tiap pesan membawa metadata yang cukup (header, content-type, method) untuk dipahami sendiri.
- HATEOAS: Hypermedia As The Engine of Application State: respons bisa menyertakan tautan yang memberi tahu klien apa yang bisa ia lakukan selanjutnya. (Sub-batasan paling ambisius; sedikit API yang mengimplementasikannya penuh.)
Kenapa REST Membingungkan Kita Sekarang
Inilah latar belakang jujur yang bersandar pada transkrip: orang masih tersandung REST, path jamak atau tunggal? PUT atau PATCH? status code mana untuk aksi kustom?, dan alasannya historis. Saat standar ini terbentuk, web berjalan di atas MPA (multi-page application): setiap interaksi adalah request satu halaman penuh ke server, yang merender HTML dan mengirimnya kembali.
Hari ini kebanyakan kita membangun SPA (single-page application): request pertama mengunduh bundel JavaScript besar, dan sejak itu browser melakukan routing-nya sendiri di sisi klien, mengambil data (JSON) dari API alih-alih halaman utuh. Klien jadi berat; server menjadi API data murni. Standar lama tidak pernah mengantisipasi pergeseran ini, sebab itu sebagian aturannya terasa ambigu terhadap penggunaan modern.
Bukan untuk menciptakan aturan baru, standarnya sudah ada, tapi untuk mengekstrak pedoman yang jelas dan praktis darinya serta berkomitmen pada satu gaya konsisten. Begitu Anda menginternalisasi konvensinya, Anda berhenti menggugat "apakah ini RESTful?" di tiap endpoint dan mengalihkan energi ke logika bisnis.
Anatomi Sebuah Route
Mulai dari URL situs web biasa dan bagian-bagiannya:
https, the encrypted transport.api. subdomain.v1, API versioning via path./ means hierarchy.Bentuk standar industri sebuah route API (konvensi, bukan aturan keras) menumpuk semuanya: https:// + subdomain api. + versi seperti /v1 + path resource.
Aturan 1: selalu kata benda jamak
Resource di path selalu jamak, bahkan saat mengambil satu item. Daftar semua buku -> /books. Ambil satu buku -> /books/123, bukan /book/123. Tipe resource ("books") adalah koleksi; ID hanya mempersempit ke dalamnya. Ini kesalahan pemula paling umum.
Aturan 2: tanpa spasi atau underscore; pakai slug
URL bepergian melewati banyak lingkungan server/klien/OS, jadi jaga tetap bersih. Jangan pernah menaruh spasi atau underscore di path. Untuk menaruh nama yang bisa dibaca manusia di URL, bangun slug:
Harry Potterharry potterharry-potter -> /books/harry-potterAturan 3: garis miring berarti hirarki
Garis miring depan mengekspresikan hubungan hirarkis antar resource. /organizations/123/projects terbaca "proyek-proyek yang khusus milik organisasi 123". Tingkat pertama: koleksi. Tingkat berikutnya: anggota spesifik. Berikutnya: sub-koleksi anggota itu. Rancang path Anda supaya nesting menceritakan kisahnya.
Method & Idempotensi
Idempotensi adalah konsep yang membuka "method mana yang sebaiknya kupakai?". Sebuah operasi idempotent jika menjalankannya berkali-kali punya efek yang sama di server seperti menjalankannya sekali. Kehalusan kuncinya: idempotensi soal efek samping yang Anda timbulkan di server, bukan soal apakah byte responsnya identik.
| Method | Tujuan | Idempotent? | Punya body? |
|---|---|---|---|
GET | Baca / ambil representasi | ya + safe | Tidak |
POST | Buat resource / aksi kustom | tidak | Ya |
PUT | Ganti resource sepenuhnya | ya | Ya (penuh) |
PATCH | Perbarui sebagian resource | biasanya | Ya (parsial) |
DELETE | Hapus resource | ya | Tidak |
HEAD (hanya mengambil header) dan OPTIONS (dipakai di CORS pre-flight untuk bertanya "apakah origin ini diizinkan?") juga ada, tapi lima ini yang membawa kerja data yang sesungguhnya.
GET: idempotent & safe
Mengambil data; tidak mengubah apa pun di server. Panggil sekali atau seribu kali, state server identik. "Tapi bagaimana kalau orang lain membuat buku di antara panggilanku dan responsnya berubah?" Itu tidak merusak idempotensi: idempotensi menanyakan efek samping apa yang panggilan Anda timbulkan, dan GET tidak menimbulkan apa pun. Karena safe, ia bebas diulang dan bisa di-cache.
Di kabel, GET hanyalah request line, header, dan baris kosong, tanpa body. Semua input menumpang di URL (path + query string), sebab itu Anda tidak pernah menaruh rahasia di GET: seluruh URL mendarat di riwayat browser, log server, dan log CDN.
| Kode | Pada GET, ini artinya |
|---|---|
200 OK | Ditemukan; body-nya representasinya. |
304 Not Modified | Salinan cache Anda masih segar (Anda mengirim If-None-Match); tanpa body yang dikembalikan, menghemat payload. |
301 / 302 | Pindah; ikuti header Location. |
400 Bad Request | Query string Anda salah bentuk. |
401 / 403 | Belum terautentikasi / terautentikasi tapi tidak diizinkan. |
404 Not Found | Tidak ada resource tunggal seperti itu. |
429 Too Many Requests | Kena rate-limit; periksa Retry-After. |
PUT & PATCH: pembaruan idempotent
Keduanya memperbarui. Bedanya cakupan:
- PATCH: pembaruan parsial. Kirim hanya field yang mau Anda ubah (mis. cuma
status). Paling cocok untuk aplikasi JSON era SPA, tempat Anda hampir selalu memperbarui beberapa field, bukan seluruh record. - PUT: penggantian penuh. Anda wajib mengirim seluruh representasi; server mengganti yang ia miliki dengan payload Anda. Lewati satu field dan Anda menghapusnya.
Kenapa idempotent? Anggap nama pengguna A dan Anda PATCH ke B. Panggilan pertama: A -> B. Panggilan kedua yang identik: B -> B. Panggilan keseribu: tetap B. State akhir tidak pernah berubah setelah panggilan pertama, jadi mengulang payload pembaruan yang sama itu idempotent.
Developer sering memakai keduanya bergantian, dan secara internal itu umumnya tidak masalah. Tapi untuk API publik, patuhi semantiknya: PATCH untuk parsial, PUT untuk penggantian penuh. Konsumen berasumsi Anda mengikuti standar; jika Anda memakai PUT padahal maksudnya PATCH, Anda memberi mereka asumsi yang salah dan membingungkan. Dalam praktik kebanyakan API (yang digerakkan SPA) condong ke PATCH, karena Anda jarang ingin mengganti seluruh entitas.
Di kabel: body adalah seluruh perbedaannya
PUT mengganti. Jika pengguna tersimpan punya name, email, dan city, tapi Anda PUT hanya {"city":"Bangalore"}, Anda baru saja menghapus name dan email. Field yang lupa Anda kirim hilang. Jika Anda hanya punya sebagian record di tangan, pakai PATCH, jangan pernah PUT. Ini cara nomor satu orang menghancurkan data lewat API tanpa sengaja.
Edit serentak: masalah lost-update
Dua klien GET record yang sama. A mengubah email dan menyimpan. B (yang tidak pernah melihat perubahan A) menyimpan city memakai salinan B yang basi, dan tulisan B diam-diam menimpa email A. Perbaikannya konkurensi optimistis dengan ETag + If-Match: server membagikan sidik jari versi, dan hanya menerapkan tulisan Anda jika versinya masih yang terkini.
func UpdateOrganization(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
org := store.Get(id)
if org == nil {
writeJSON(w, 404, errBody("organization not found"))
return
}
// optimistic concurrency: reject stale writes
if m := r.Header.Get("If-Match"); m != "" && m != org.ETag {
writeJSON(w, 412, errBody("resource changed; re-fetch and retry"))
return
}
var patch map[string]any
json.NewDecoder(r.Body).Decode(&patch) // only the fields to change
updated := store.Patch(id, patch) // merge, don't replace
w.Header().Set("ETag", updated.ETag)
writeJSON(w, 200, updated) // 200 + updated entity
}
| Kode | Pada PUT / PATCH, ini artinya |
|---|---|
200 OK | Diperbarui; body memegang state baru. |
201 Created | Khusus PUT, membuat resource di URL pilihan klien (upsert). PATCH tidak pernah membuat. |
204 No Content | Diperbarui; tidak ada yang dikembalikan. |
400 Bad Request | Body salah bentuk. |
404 Not Found | Resource tidak ada (PATCH); PUT tanpa upsert. |
409 Conflict | Perubahan bertabrakan dengan state saat ini (mis. bentrok field unik). |
412 Precondition Failed | If-Match basi, ada yang mengedit lebih dulu. |
415 Unsupported Media Type | Content-Type salah (terutama merge-patch vs json-patch milik PATCH). |
422 Unprocessable Entity | JSON rapi, tapi gagal aturan bisnis (mis. age: -5). |
DELETE: idempotent
Hapus pengguna 1. Panggilan pertama: pengguna dihapus (efek samping nyata). Panggilan kedua: server memeriksa, tidak menemukan pengguna seperti itu, dan mengembalikan 404, tapi tidak ada yang berubah di server. Tidak ada efek samping baru. Anda hanya mengubah state di panggilan pertama; setiap panggilan sesudahnya no-op yang sekadar melaporkan "not found". Maka DELETE idempotent.
POST: satu-satunya yang non-idempotent
POST membuat. Kirim payload "create book" sekali -> satu buku dengan ID 1. Kirim payload yang sama persis lagi -> buku kedua dengan ID 2 (ID dibuat di sisi server/DB, dan nama biasanya tidak harus unik). Sepuluh POST identik -> sepuluh buku berbeda. Efek sampingnya berubah tiap kali, jadi POST non-idempotent.
Di kabel, POST membawa body (dideklarasikan Content-Type), dan create yang sukses mengembalikan 201 dengan header Location yang menunjuk ke resource baru:
Anggap Anda POST /payments {amount: 5000} dan jaringan putus setelah server menagih kartu tapi sebelum respons sampai ke Anda. Anda tidak tahu apakah berhasil, jadi Anda mengulang, dan sekarang Anda menagih INR 10.000. Perbaikan standarnya idempotency key: klien mengirim ID unik, server mencatatnya bersama hasilnya, dan pengulangan apa pun yang membawa key yang sama mengembalikan hasil asli alih-alih menagih lagi. Begitulah Stripe dan setiap API pembayaran serius membuat POST aman diulang.
func CreatePayment(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("Idempotency-Key") // client-generated UUID
// already processed this exact request? return the SAME result, don't re-charge
if prior, ok := idemStore.Get(key); ok {
writeJSON(w, prior.Status, prior.Body)
return
}
var in struct{ Amount int `json:"amount"` }
json.NewDecoder(r.Body).Decode(&in)
payment := charge(in.Amount) // the real, non-idempotent side effect
idemStore.Save(key, 201, payment) // remember it, keyed by the idempotency key
writeJSON(w, 201, payment)
}
| Kode | Pada POST, ini artinya |
|---|---|
200 OK | Aksi berjalan; tidak ada resource baru yang dibuat (mis. "send email", aksi kustom). |
201 Created | Resource baru dibuat; Location menunjuk ke sana. |
202 Accepted | Dikenakan antrean pemrosesan asinkron, "nanti kulakukan" (laporan, pekerjaan berat). |
400 Bad Request | Body salah bentuk atau field wajib hilang. |
409 Conflict | Bertabrakan dengan state yang ada (mis. "username sudah dipakai"). |
413 Payload Too Large | Body melebihi batas ukuran server. |
415 Unsupported Media Type | Server tidak menerima Content-Type yang Anda kirim. |
422 Unprocessable Entity | JSON valid, tapi gagal validasi di tingkat aturan bisnis. |
429 Too Many Requests | Kena rate-limit. |
Aksi Kustom: melampaui CRUD
Kadang sebuah aksi tidak muat di Create/Read/Update/Delete. "Clone project," "arsipkan organisasi," "kirim email", ini memicu jejaring kerja latar yang melampaui tulisan database sederhana. Spesifikasi REST menjadikan POST method yang terbuka untuk kasus persis seperti ini.
Aturan & bentuk route
Saat sebuah aksi tidak memetakan ke method standar, jadikan ia POST dan tempelkan aksinya sebagai verb di ujung path resource spesifik:
Ini menjaga hirarki tetap jujur: semua organisasi -> satu organisasi spesifik -> aksi yang dijalankan padanya.
Di permukaan, arsip tampak seperti men-set status = "archived", jadi kenapa tidak PATCH? Karena mengarsipkan organisasi bisa memicu jauh lebih dari tulisan field: menghapus semua proyek dan task-nya, mengirim email ke pemilik, mencabut akses, mengantre pekerjaan pembersihan. Pembalikan status adalah efek samping dari aksi, bukan aksinya sendiri. Saat operasi sebenarnya lebih besar dari perubahan datanya, itu aksi kustom -> POST.
Contoh "kirim email"
Anggap POST /emails dengan body {"target": "someone@example.com"}. Apakah mengirim email itu fetch? Bukan. Create? Bukan juga. Update atau delete? Bukan. Itu aksi kustom, jadi ia hidup di bawah POST. Dan perhatikan: POST aksi kustom sering mengembalikan 200 OK (aksinya berjalan), bukan 201 Created, karena belum tentu ada yang baru dibuat. Jangan pernah berasumsi "POST => 201".
API Daftar: Page / Sort / Filter
Endpoint daftar tidak bisa sekadar membuang semua baris. Tiga fitur membuatnya kokoh, dan ketiganya menumpang di parameter query.
Pagination: kenapa & bagaimana
Mengembalikan seribu record sekaligus mahal di kedua ujung: serialisasi/deserialisasi JSON kerja berat, jaringan jadi bottleneck, dan pengguna merasakan jeda beberapa detik, padahal di layar mereka hanya melihat 10-20 item pertama sebelum menggulir. Jadi server mengembalikan data dalam potongan (halaman).
Respons ter-paginasi memuat empat field:
data, larik item untuk halaman ini.total, hitungan absolut item di DB (supaya UI bisa bilang "menampilkan 10 dari 50").page, halaman mana yang diwakili respons ini.totalPages, jumlah halaman maksimum (frontend memakainya untuk tahu kapan berhenti mengambil pada infinite scroll: saatpage === totalPages, berhenti).
Klien mengendalikannya dengan dua param: limit (berapa per halaman) dan page (potongan mana). Dan, momen default yang masuk akal, jika klien tidak mengirim keduanya, server harus men-default page ke 1 dan limit ke sekitar 10 atau 20, jangan pernah crash.
Sorting
Dua param: sortBy (field mana) dan sortOrder (ascending / descending). Default yang krusial: bahkan tanpa param sort, server harus menyortir berdasarkan sesuatu, kalau tidak DB mengembalikan baris dalam urutan arbitrer dan panggilan yang sama menghasilkan urutan berbeda tiap kali. Default alaminya sortBy=createdAt, sortOrder=descending (terbaru dulu).
Filtering
Oper field resource sebagai query param untuk mempersempit daftar: ?status=active, atau gabungkan: ?status=archived&name=org. Server menerapkan masing-masing sebagai filter pada set hasil.
// GET /v1/organizations?status=active&sortBy=name&sortOrder=ascending&page=1&limit=10
func ListOrganizations(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
// --- sane defaults: never crash if the client omits params ---
page := atoiDefault(q.Get("page"), 1)
limit := atoiDefault(q.Get("limit"), 10)
sortBy := defaultStr(q.Get("sortBy"), "createdAt")
sortOrder := defaultStr(q.Get("sortOrder"), "descending")
filters := map[string]string{}
if s := q.Get("status"); s != "" {
filters["status"] = s // ?status=active
}
rows, total := store.Query(filters, sortBy, sortOrder, page, limit)
totalPages := (total + limit - 1) / limit // ceil division
writeJSON(w, 200, map[string]any{
"data": rows,
"total": total,
"page": page,
"totalPages": totalPages,
})
}
Status Code: yang benar
| Kode | Makna | Pakai saat |
|---|---|---|
200 OK | Sukses | Fetch, pembaruan (PATCH/PUT), atau aksi kustom yang berjalan. |
201 Created | Dibuat | POST berhasil membuat entitas baru. Kembalikan entitas baru di body. |
204 No Content | Sukses, body kosong | DELETE yang berhasil, tidak ada yang perlu dikirim balik. |
404 Not Found | Resource hilang | Klien minta satu ID spesifik yang tidak ada. |
Aturan 404: yang sering salah dipahami orang
Kembalikan 404 hanya saat klien meminta satu resource spesifik yang tidak ada (mis. GET /users/999). Jika klien memukul API daftar dan tidak ada yang cocok (mis. "semua pengguna bernama Zack," atau filter yang tidak cocok apa pun), jangan mengembalikan 404. Kembalikan 200 OK dengan larik kosong []. Daftar yang tidak menemukan apa pun tetap sukses, ia hanya tidak menemukan apa pun.
Lima keluarga: baca digit pertama
Digit pertama setiap status code memberi tahu hampir semuanya. Ini insting debugging tercepat yang bisa Anda bangun.
| Keluarga | Kelas | Masalah siapa |
|---|---|---|
1xx | Informatif | Langka; pada dasarnya tidak akan pernah Anda tangani langsung. |
2xx | Sukses | Berhasil. |
3xx | Redireksi | Pergi lihat tempat lain / pakai cache Anda. |
4xx | Error klien | Anda mengirim yang salah, perbaiki request-nya. |
5xx | Error server | Server yang rusak, bukan salah Anda; ulangi / hubungi tim ops. |
Saat panggilan gagal: 4xx -> perbaiki request Anda (periksa body, auth, URL). 5xx -> bukan salah Anda; ulangi dengan backoff atau hubungi tim server. Saat sukses: 20x -> selesai, 30x -> ikuti redirect. Pembagian tunggal itu menyelesaikan sebagian besar pertanyaan "apa yang kulakukan dengan respons ini?" seketika.
Kode 4xx yang sering dipertukarkan orang
401 vs 403
401 Unauthorized berarti "saya tidak tahu siapa Anda", tanpa kredensial, atau kredensial tidak valid/kedaluwarsa. Perbaikannya login atau refresh token. 403 Forbidden berarti "saya tahu persis siapa Anda, dan Anda tidak diizinkan", kredensial valid, izin kurang. Login ulang tidak akan membantu. (Namanya memang tertukar secara historis, 401 sebenarnya soal autentikasi, 403 soal otorisasi.)
409 Conflict
Request bertabrakan dengan state resource saat ini: membuat yang sudah ada, bentrok field unik, atau menghapus sesuatu yang masih bergantung pada record lain ("tidak bisa menghapus pengguna dengan order aktif").
422 vs 400
400 Bad Request = request salah bentuk di level sintaks (JSON rusak, string di tempat yang butuh angka). 422 Unprocessable Entity = sintaksnya sempurna, tapi gagal aturan semantik/bisnis (usia negatif, email yang bukan email, tanggal akhir sebelum tanggal mulai). Pakai 422 saat Anda berhasil mem-parse body tapi nilainya tidak masuk akal.
429 Too Many Requests
Kena rate-limit. Hormati header Retry-After (detik yang harus ditunggu) alih-alih menghantam, atau Anda akan tetap ter-throttle.
Kode yang akan Anda temui 90% dari waktu
| Kode | Makna polos |
|---|---|
200 | berhasil |
201 | dibuat (sukses POST) |
204 | berhasil, tanpa body (sukses DELETE) |
301 / 302 | redirect |
304 | cache Anda masih bagus |
400 | request Anda rusak (sintaks) |
401 | login |
403 | sudah login, tapi tidak diizinkan |
404 | benda satu itu tidak ada |
409 | bertabrakan dengan state saat ini |
422 | data gagal validasi (semantik) |
429 | pelankan |
500 | mereka crash |
503 | mereka kelebihan beban / sedang pemeliharaan |
Contoh Kerja: platform PM
Mengikuti pembangunan transkrip: produk manajemen proyek (bayangkan Jira / Linear). Dari wireframe kita ekstrak kata benda -> resource: organizations, projects, tasks (juga users, tags). Itu menjadi tabel, lalu endpoint. Ini set endpoint lengkap untuk satu resource, pola yang sama berulang untuk setiap resource lain.
| Aksi | Method + Route | Sukses |
|---|---|---|
| Daftar organisasi | GET /v1/organizations | 200 / ter-paginasi |
| Buat organisasi | POST /v1/organizations | 201 / entitas baru |
| Ambil satu | GET /v1/organizations/:id | 200 / 404 |
| Perbarui (parsial) | PATCH /v1/organizations/:id | 200 / entitas diperbarui |
| Hapus | DELETE /v1/organizations/:id | 204 / kosong |
| Arsipkan (kustom) | POST /v1/organizations/:id/archive | 200 / entitas diarsipkan |
Perhatikan simetrinya: list dan create berbagi URL koleksi (/organizations) dan hanya berbeda method, server mengarahkan GET ke handler list dan POST ke handler create. Begitu pula get-one / update / delete berbagi /organizations/:id dan hanya berbeda method. Aksi kustom menambahkan verb. projects akan mencerminkan persis ini, dengan clone sebagai aksi kustomnya.
func RegisterOrgRoutes(mux *http.ServeMux) {
// list + create share the collection URL, split by method
mux.HandleFunc("GET /v1/organizations", ListOrganizations)
mux.HandleFunc("POST /v1/organizations", CreateOrganization)
// get-one / update / delete share /:id, split by method
mux.HandleFunc("GET /v1/organizations/{id}", GetOrganization)
mux.HandleFunc("PATCH /v1/organizations/{id}", UpdateOrganization)
mux.HandleFunc("DELETE /v1/organizations/{id}", DeleteOrganization)
// custom action: verb at the end of a specific resource
mux.HandleFunc("POST /v1/organizations/{id}/archive", ArchiveOrganization)
}
func CreateOrganization(w http.ResponseWriter, r *http.Request) {
var in struct {
Name string `json:"name"`
Status string `json:"status"`
Description string `json:"description"`
}
json.NewDecoder(r.Body).Decode(&in)
if in.Status == "" {
in.Status = "active" // sane default, don't force the client to send the obvious
}
org := store.Insert(in.Name, in.Status, in.Description) // id, createdAt set server-side
writeJSON(w, 201, org) // 201 Created + the new entity
}
func DeleteOrganization(w http.ResponseWriter, r *http.Request) {
store.Delete(r.PathValue("id"))
w.WriteHeader(204) // No Content
}
func ArchiveOrganization(w http.ResponseWriter, r *http.Request) {
org := store.Archive(r.PathValue("id")) // flips status + cascades: projects, tasks, emails...
writeJSON(w, 200, org) // custom action -> 200, NOT 201
}
Aturan Emas
Ekstrak kata benda dari UI lebih dulu
Sebelum menulis satu baris logika bisnis, lihat wireframe (Figma) atau bicara dengan orang produk. Kata benda yang disentuh pengguna akhir, proyek, pengguna, task, tag, adalah resource Anda. Melihat cara pengguna akhir menyentuh data memberi tahu cara lapisan terendah (DB) berhubungan dengan lapisan teratas (layar), dan itulah titik awal yang tepat untuk merancang antarmuka.
Rancang antarmuka sebelum coding
REST API itu dirancang, bukan diprogram-dulu. Susun route, payload, dan respons di tool seperti Insomnia atau Postman sebelum menyentuh Go, Python, atau framework apa pun. Intinya antarmuka yang menyenangkan, intuitif, dan bebas ambiguitas, jadi konsumen tidak perlu membaca source code Anda atau menebak perilaku Anda dengan coba-coba.
Sediakan default yang masuk akal
Jangan pernah crash karena klien menghilangkan sesuatu yang jelas. Tanpa page -> default 1. Tanpa limit -> default 10. Tanpa sortBy -> createdAt menurun. Tanpa status saat create -> active. Wajibkan hanya informasi yang benar-benar tidak bisa Anda simpulkan.
Konsisten tanpa ampun
- JSON dalam
camelCase, selalu: baik payload maupun respons. - Tanpa singkatan: jika sebuah field
description, jangan pernah menyebutnyadescdi endpoint lain. Anda punya konteks yang tidak dimiliki konsumen; singkatan yang tidak bisa mereka decode membebani mereka satu panggilan gagal dan satu perburuan dokumentasi. - Bentuk yang sama lintas resource: jika
organizationsjamak dan ter-paginasi dengan{data,total,page,totalPages}, makaprojectsdantasksjuga. Konsumen mengintegrasikan satu endpoint, lalu berasumsi sisanya mengikuti gaya yang sama. Imbal asumsi itu.
Mengikuti standar bersama menghilangkan tebak-tebakan, asumsi, dan kesalahan manusia dari integrasi. Jika konsumen bisa berasumsi API Anda ±80% patuh standar, waktu integrasi mereka turun drastis, lebih sedikit bug, lebih sedikit panggilan "bagaimana perilaku endpoint ini?". Konsistensi gaya adalah salah satu tanda paling jelas dari engineer backend yang baik.
Kirim dokumentasi interaktif
Buat playground interaktif dengan Swagger / OpenAPI sejak awal. Ia berfungsi ganda: dokumentasi bagi engineer frontend yang mengintegrasikan Anda, dan lahan uji bagi Anda. Seberapa konsisten Anda memelihara spesifikasi OpenAPI benar-benar membedakan Anda.
Ambil resource (kata benda jamak) dari UI Anda, beri masing-masing set CRUD di URL hirarkis yang bersih, pilih method sesuai maksud (GET baca / POST buat/aksi / PUT ganti / PATCH parsial / DELETE hapus), biarkan idempotensi memutuskan keterulangan, kembalikan status code yang jujur (201 dibuat / 204 dihapus / 404 hanya untuk satu resource yang hilang), buat daftar halaman/sort/filter dengan default masuk akal, dan tetap konsisten tanpa ampun, supaya Anda berhenti berdebat soal REST dan kembali ke logika bisnis Anda.
Versioning: tidak memecah konsumen Anda
Saat tim lain atau perusahaan lain mengintegrasikan API Anda, Anda tidak bisa lagi mengubahnya dengan bebas; field yang Anda ganti nama atau respons yang Anda bentuk ulang memecah kode mereka di produksi. Versioning adalah cara Anda berkembang tanpa memecah yang sudah ter-deploy. Tiga strategi umum:
| Strategi | Bentuknya | Kompromi |
|---|---|---|
| Path URL (paling umum) | /v1/books -> /v2/books | Sangat jelas, sepele di-debug, mudah di-route. Sedikit "tidak murni" REST (versi bukan bagian dari identitas resource). Default pragmatis. |
| Header | Accept: application/vnd.example.v2+json | Menjaga URL bersih dan "murni," tapi tak terlihat di browser dan lebih sulit diuji/di-debug. Dipakai GitHub bertahun-tahun. |
| Query param | /books?version=2 | Sederhana tapi mengotori setiap URL dan mengeruhkan caching. Paling tidak direkomendasikan. |
Kapan menaikkan versi
Hanya saat perubahan yang memecah: sesuatu yang akan memecah konsumen yang ada:
- Menghapus atau mengganti nama field; mengubah tipe atau maknanya.
- Menghapus endpoint, atau mengubah method/route-nya.
- Membuat field yang tadinya opsional jadi wajib, atau mengubah perilaku default.
Perubahan aditif TIDAK memecah: dan tidak butuh versi baru. Menambah field opsional baru, endpoint baru, atau query param opsional baru membuat klien lama tetap bekerja tanpa tersentuh (mereka hanya mengabaikan yang tak mereka kenal). Menaikkan versi untuk perubahan aditif adalah penggantian yang sia-sia.
Saat Anda harus pensiunkan satu versi, jangan mencabutnya mendadak. Umumkan linimasa, jalankan v1 dan v2 paralel selama jendela migrasi, dan kirim header respons Deprecation / Sunset pada versi lama supaya integrator dapat pemberitahuan programatik. Memecah orang diam-diam adalah cara Anda kehilangan kepercayaan mereka.
Respons Error: separuh lainnya dari kontrak
Orang merancang happy path dengan hati-hati lalu mengembalikan stack trace mentah atau string telanjang saat gagal. Padahal bentuk error adalah bagian dari kontrak API Anda, kode penanganan error konsumen bergantung pada bentuk yang konsisten dan bisa dibaca mesin. Status code saja tidak cukup; body harus menjelaskan apa yang salah dalam struktur yang bisa diprediksi.
Amplop error yang konsisten
Pilih satu bentuk error dan pakai di setiap error, lintas setiap endpoint. Amplop minimal yang kokoh:
Kenapa tiap bagian layak tempatnya:
code: string stabil yang bisa di-switchklien. Jangan pernah menyuruh mereka mem-parse pesan manusia; pesan bisa diubah bahasanya, code tidak.message: untuk manusia/log. Aman ditampilkan atau dimunculkan.details: error per field supaya form bisa menyorot persis input mana yang gagal (berpasangan alami dengan422).requestId: field paling berguna di produksi: konsumen menyebutnya di laporan bug dan Anda menemukan request persisnya di log dalam hitungan detik.
Jangan kembalikan stack trace, error SQL, path file, atau dump exception framework ke klien; itu sia-sia bagi konsumen dan hadiah bagi penyerang (mereka membocorkan tumpukan, skema, dan struktur Anda). Log detail berdarahnya di sisi server terhadap requestId; kembalikan amplop bersih ke klien. Body 500 harus berbunyi "ada yang salah, ini request id Anda", tidak lebih.
type FieldError struct {
Field string `json:"field"`
Issue string `json:"issue"`
}
func writeError(w http.ResponseWriter, status int, code, msg string, details ...FieldError) {
body := map[string]any{"error": map[string]any{
"code": code,
"message": msg,
"details": details,
"requestId": requestIDFromCtx(),
}}
writeJSON(w, status, body) // SAME shape for every error in the whole API
}
// usage
writeError(w, 422, "validation_failed", "Some fields are invalid.",
FieldError{"email", "must be a valid email"},
FieldError{"age", "must be >= 0"},
)
Jika Anda lebih suka mengadopsi spesifikasi daripada menciptakan amplop, RFC 9457 (Problem Details for HTTP APIs) mendefinisikan body application/problem+json standar dengan field type, title, status, detail, dan instance. Kedua pendekatan boleh; satu-satunya langkah yang salah adalah tidak konsisten soal bentuk error lintas endpoint Anda.
Kembalikan status code yang jujur, bungkus setiap kegagalan dalam satu amplop JSON konsisten berisi code yang bisa dibaca mesin + message manusia + details per field + requestId, dan jangan pernah membocorkan internal, supaya konsumen bisa menangani error Anda seandal menangani sukses Anda.
Batasan vs Konvensi
Mundur selangkah. Jamak-atau-tunggal dan PUT-atau-PATCH bukan satu-satunya yang membuat orang tersandung REST, begitu juga jumlahnya sendiri. Tanya satu engineer "prinsip REST" dan Anda akan mendengar enam; tanya yang lain dan Anda mendapat daftar cek delapan atau sepuluh. Keduanya benar, karena mereka menjawab dua pertanyaan berbeda. Bagian penutup ini kartu referensi untuk membedakannya.
Dua daftar, dua pertanyaan
Yang enam adalah batasan arsitektural formal Roy Fielding, mereka mendefinisikan apa yang membuat sebuah sistem RESTful sama sekali. Itu jawaban akademis, dan itulah seluruh bagian 02. Yang delapan adalah konvensi praktis, cara merancang API yang bersih dan bisa diprediksi setelah Anda menerima batasan-batasan itu. Itulah yang sebagian besar manual ini ajarkan, tersebar di bab-babnya. Batasan adalah teorinya; konvensi adalah kerajinannya.
Enam batasan: definisi REST
- Client-Server: pisahkan UI dari data & logika supaya tiap sisi berkembang sendiri.
- Uniform Interface: satu cara standar untuk mengalamati dan memanipulasi setiap resource.
- Layered System: biarkan proxy, cache, dan gateway duduk di antara klien dan origin, tanpa terlihat.
- Cacheable: setiap respons mendeklarasikan boleh-tidaknya di-cache.
- Stateless: tiap request membawa semua yang ia butuhkan; server tidak mengingat apa pun antar panggilan.
- Code on Demand (opsional), server boleh mengirim kode yang bisa dieksekusi untuk memperluas klien.
Keenamnya, plus empat sub-batasan uniform interface, termasuk HATEOAS, diurai di bagian 02.
Delapan konvensi: cara membangun yang baik
| Konvensi | Satu baris | Dibahas di |
|---|---|---|
| Resource sebagai kata benda jamak | Modelkan benda, bukan aksi; method adalah verb-nya. /orders/123, jangan pernah /getOrder. | bagian 04 Routes |
| Method sesuai maksud | GET baca / POST buat/aksi / PUT ganti / PATCH parsial / DELETE hapus. | bagian 05 Methods |
| Status code jujur | Biarkan kode membawa hasil, jangan kubur error di dalam 200. | bagian 08 Status |
| Statelessness * | Setiap request mandiri -> server mana pun bisa menjawabnya -> scaling horizontal. | bagian 02 Constraints |
| Konsistensi tanpa ampun | Satu gaya penulisan, satu format tanggal, satu bentuk pagination, di mana-mana. | bagian 10 Golden Rules |
| Versioning | Naikkan hanya saat perubahan memecah; perubahan aditif tidak butuh versi baru. | bagian 11 Versioning |
| Page / sort / filter | Jangan pernah membuang seluruh tabel; paginate daftar dan beri default yang masuk akal. | bagian 07 List APIs |
| Error terstruktur | Satu amplop konsisten yang bisa dibaca mesin di setiap kegagalan. | bagian 12 Errors |
* Statelessness satu-satunya item yang mendarat di kedua daftar, lihat di bawah.
Mereka bukan pesaing, konvensi adalah cara Anda menghormati batasan sehari-hari. Tumpang tindih paling jelas adalah statelessness, yang sekaligus batasan #5 Fielding dan aturan yang Anda ikuti di tiap endpoint. Yang lain ikut menggema: cacheability muncul sebagai header Cache-Control / ETag Anda, dan uniform interface persis yang "URL kata benda jamak + method sesuai maksud + bentuk konsisten" berikan dalam praktik. HATEOAS: sub-batasan paling ambisius, adalah satu-satunya bagian teori yang kebanyakan API dunia nyata diam-diam lewati.
Jika pertanyaannya akademis, wawancara, "definisikan REST," ujian sistem, mereka ingin enam batasan. Jika pertanyaannya "bagaimana merancang API yang bersih?", review kode, layanan baru, mereka ingin delapan konvensi. Standar yang sama, dua ketinggian: yang enam mengatakan apa REST itu; yang delapan cara melakukannya dengan baik.
Delapan Prinsip
bagian 13 menjajarkan delapan ini dengan enam milik Fielding sekilas; ini delapan yang sama ditulis lengkap, daftar cek bahasa polos. Masing-masing konvensi kerja yang mengubah teori menjadi API yang benar-benar dinikmati developer lain, dan masing-masing menautkan balik ke bab yang mengurainya secara mendalam.
1. Modelkan resource sebagai kata benda, bukan aksi
URL harus mewakili benda, bukan operasi. Pakai /users/123/orders alih-alih /getUserOrders?id=123. Method HTTP menyediakan verb-nya, jadi kata benda dan method bersama-sama menggambarkan aksinya. Koleksi jamak, /orders, bukan /order. (Perlakuan lengkap di bagian 04.)
2. Pakai method HTTP sesuai semantik yang dimaksud
GET membaca (dan tidak pernah mengubah state), POST membuat, PUT mengganti resource sekaligus, PATCH memperbarui sebagian, dan DELETE menghapus. Menghormati ini membuat klien, cache, dan proxy bisa menalar API Anda dengan benar; GET aman diulang, PUT idempotent (memanggilnya dua kali efeknya sama dengan sekali), tapi POST umumnya tidak. (Lihat bagian 05.)
3. Kembalikan status code yang bermakna
Andalkan yang standar alih-alih selalu mengembalikan 200 dengan error terkubur di body: 200/201/204 untuk sukses, 400 untuk input buruk, 401/403 untuk masalah auth, 404 saat ada yang hilang, 409 untuk bentrok, 422 untuk kegagalan validasi, 500 untuk kesalahan server. Ini membuat klien menangani hasil secara programatik. (Lihat bagian 08.)
4. Tetap stateless
Setiap request harus membawa semua yang dibutuhkan server untuk memprosesnya, token auth, parameter, tanpa mengandalkan memori sesi sisi server dari panggilan sebelumnya. Inilah yang membuat Anda bisa scaling horizontal: server mana pun bisa menangani request apa pun. Ini juga salah satu batasan inti Fielding (bagian 02).
5. Konsisten
Pilih konvensi dan terapkan di mana-mana: gaya penamaan (snake_case vs camelCase di JSON), format tanggal (ISO 8601), bentuk error, cara kerja pagination. Kemampuan diprediksi lebih berharga daripada kecerdasan; developer yang mempelajari satu endpoint harus bisa menebak yang lain. (Lihat bagian 10.)
6. Beri versi API Anda
Perubahan yang memecah tak terhindarkan, jadi rencanakan sejak awal, umumnya via path URL (/v1/users) atau header. Ini membuat Anda berkembang tanpa memecah klien yang ada. (bagian 11 membahas lebih dalam kapan menaikkan versi.)
7. Tangani koleksi dengan anggun
Daftar besar butuh pagination (?page=2&limit=50 atau berbasis cursor), plus filtering (?status=active), sorting (?sort=-created_at), dan sering seleksi field. Mengembalikan sepuluh ribu record dalam satu respons tidak melayani siapa pun. (bagian 07 punya pola lengkapnya.)
8. Rancang error yang jelas dan terstruktur
Respons error yang baik memberi tahu klien apa yang salah dan idealnya cara memperbaikinya: code yang bisa dibaca mesin, pesan yang bisa dibaca manusia, dan sering field mana yang gagal. Konsistensi di sini sama pentingnya dengan konsistensi di respons sukses. (bagian 12 menunjukkan amplop konkret.)
API adalah kontrak yang dimaksudkan dipakai developer lain, jadi kemampuan diprediksi, kepatuhan standar, dan komunikasi yang jelas mengalahkan kebaruan setiap saat. Kuasai delapan ini dan konsumen bisa mengintegrasikan satu endpoint, lalu dengan benar menebak sisanya.
Kode Kerja: API Task di Go
Segala sesuatu di bab ini, dikompilasi ke satu file yang bisa dijalankan: REST API lengkap untuk satu resource, tasks, dibangun di atas perpustakaan standar Go saja dan router yang sadar method yang diperkenalkan di Go 1.22. Sebelum source-nya sendiri, begini perilakunya saat runtime, bagaimana ia boot, bagaimana request di-dispatch, dan setiap cabang yang bisa diambil sebuah request.
main() membangun store, menyemai dua belas task, memasang route, men-set timeout baca/tulis, dan menyerahkan ke ListenAndServe. Panggilan itu memblokir: proses kini duduk dan menunggu, siap melayani satu request demi satu saat tiba.
Karena router didaftarkan dengan pola "METHOD /path", method yang salah di path yang benar (mis. POST /v1/tasks/5) tidak cocok dengan route mana pun dan jatuh ke 404 bawaan. Tag di tiap baris adalah kode sukses route itu; cabang error-nya hidup di diagram berikutnya dan tabel di bawah.
Empat gerbang, masing-masing dengan pintu keluar awal 400; apa pun yang lulus difilter, disortir, dan di-paginasi. Perhatikan dua hal yang bukan error: parameter yang hilang jatuh ke default yang masuk akal, dan halaman yang melewati akhir mengembalikan [] kosong dengan 200: daftar kosong bukan "not found" (bagian 08).
Setiap respons yang bisa dikembalikan API
Membaca tiga diagram secara bersamaan, inilah set hasil lengkap, setiap status code yang bisa dihasilkan server ini, dan kondisi yang memicu masing-masing.
| Request | Saat | Respons |
|---|---|---|
GET /v1/tasks | Query valid, atau halaman melewati akhir | 200: {data, pagination}; halaman kosong -> data: [] |
GET /v1/tasks | done, sortBy, sortOrder, page atau limit salah bentuk | 400: pesan menyebut param yang buruk |
GET /v1/tasks/{id} | ID numerik yang ada | 200: task-nya |
GET /v1/tasks/{id} | ID bukan angka | 400: invalid id |
GET /v1/tasks/{id} | ID numerik yang tidak ada | 404: task not found |
POST /v1/tasks | JSON valid dengan title tidak kosong | 201: task yang dibuat |
POST /v1/tasks | Body bukan JSON valid | 400: invalid JSON body |
POST /v1/tasks | title hilang atau kosong | 400: title is required |
PUT /v1/tasks/{id} | ID valid + body valid | 200: task yang diperbarui |
PUT /v1/tasks/{id} | ID buruk, JSON buruk, atau title kosong | 400: pesan yang cocok |
PUT /v1/tasks/{id} | ID tidak ada | 404: task not found |
DELETE /v1/tasks/{id} | ID numerik yang ada | 204: tanpa body |
DELETE /v1/tasks/{id} | ID buruk | 400: invalid id |
DELETE /v1/tasks/{id} | ID tidak ada | 404: task not found |
GET /healthz | Selalu | 200: {status: "ok"} |
| yang lain apa pun | Tidak ada route yang cocok dengan method + path | 404: mux bawaan |
Program lengkapnya
// A REST API in Go (standard library) for managing "tasks".
//
// This version applies three REST-design rules from the course notes:
//
// sec 3 URL versioning -> every route lives under /v1
// sec 6 List query parameters -> pagination + sorting + filtering
// sec 8 Consistent naming -> JSON fields use camelCase, with sane defaults
//
// Requires Go 1.22+ for the method-aware router.
//
// Run: go run ./RestAPI (from the module root, where go.mod is)
// Then: curl "http://localhost:8080/v1/tasks?limit=5&sortBy=title&sortOrder=asc&done=false"
///- stateless HTTP server that exposes one resource (tasks) as a REST API:
package main
import (
"encoding/json"
"errors"
"log"
"math"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// ---------- Model ----------
// Task is a single to-do item.
//
// sec 8 (consistent naming): every JSON field is camelCase, note `createdAt`,
// NOT `created_at`. The Go field names stay idiomatic PascalCase; only the
// json struct tags define the wire format the client sees.
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
CreatedAt time.Time `json:"createdAt"`
}
// ---------- Store (unchanged: thread-safe in-memory map) ----------
var errNotFound = errors.New("task not found")
type store struct {
mu sync.RWMutex
tasks map[int]Task
nextID int
}
func newStore() *store { return &store{tasks: make(map[int]Task), nextID: 1} }
func (s *store) list() []Task {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Task, 0, len(s.tasks)) // non-nil so an empty result encodes as [] not null
for _, t := range s.tasks {
out = append(out, t)
}
return out
}
func (s *store) get(id int) (Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
t, ok := s.tasks[id]
if !ok {
return Task{}, errNotFound
}
return t, nil
}
func (s *store) create(title string, done bool) Task {
s.mu.Lock()
defer s.mu.Unlock()
t := Task{ID: s.nextID, Title: title, Done: done, CreatedAt: time.Now()}
s.tasks[t.ID] = t
s.nextID++
return t
}
func (s *store) update(id int, title string, done bool) (Task, error) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[id]
if !ok {
return Task{}, errNotFound
}
t.Title, t.Done = title, done
s.tasks[id] = t
return t, nil
}
func (s *store) delete(id int) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.tasks[id]; !ok {
return errNotFound
}
delete(s.tasks, id)
return nil
}
// ---------- HTTP helpers ----------
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if v != nil {
_ = json.NewEncoder(w).Encode(v)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func idFromPath(r *http.Request) (int, error) { return strconv.Atoi(r.PathValue("id")) }
// ---------- List endpoint: pagination, sorting, filtering (sec 6) ----------
// listResponse is the envelope returned by GET /v1/tasks. Wrapping the array
// in an object gives us a place to attach pagination metadata beside the data.
// sec 8: these fields are camelCase too.
type listResponse struct {
Data []Task `json:"data"`
Pagination pagination `json:"pagination"`
}
type pagination struct {
Page int `json:"page"`
Limit int `json:"limit"`
TotalItems int `json:"totalItems"`
TotalPages int `json:"totalPages"`
}
// sortableFields is an allowlist, clients may only sort by these, never by an
// arbitrary field name they invent.
var sortableFields = map[string]bool{"id": true, "title": true, "createdAt": true}
func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
tasks := s.store.list()
// --- Filtering (sec 6): narrow the set by matching query params. ---
if raw := q.Get("done"); raw != "" {
done, err := strconv.ParseBool(raw)
if err != nil {
writeError(w, http.StatusBadRequest, "query param 'done' must be true or false")
return
}
filtered := make([]Task, 0)
for _, t := range tasks {
if t.Done == done {
filtered = append(filtered, t)
}
}
tasks = filtered
}
if title := strings.TrimSpace(q.Get("title")); title != "" {
needle := strings.ToLower(title)
filtered := make([]Task, 0)
for _, t := range tasks {
if strings.Contains(strings.ToLower(t.Title), needle) {
filtered = append(filtered, t)
}
}
tasks = filtered
}
// --- Sorting (sec 6) with sane defaults (sec 8: default = createdAt, descending). ---
sortBy := q.Get("sortBy")
if sortBy == "" {
sortBy = "createdAt" // sane default
}
if !sortableFields[sortBy] {
writeError(w, http.StatusBadRequest, "query param 'sortBy' must be one of: id, title, createdAt")
return
}
sortOrder := q.Get("sortOrder")
if sortOrder == "" {
sortOrder = "desc" // sane default
}
if sortOrder != "asc" && sortOrder != "desc" {
writeError(w, http.StatusBadRequest, "query param 'sortOrder' must be 'asc' or 'desc'")
return
}
sort.Slice(tasks, func(i, j int) bool {
var less bool
switch sortBy {
case "id":
less = tasks[i].ID < tasks[j].ID
case "title":
less = strings.ToLower(tasks[i].Title) < strings.ToLower(tasks[j].Title)
case "createdAt":
less = tasks[i].CreatedAt.Before(tasks[j].CreatedAt)
}
if sortOrder == "desc" {
return !less
}
return less
})
// --- Pagination (sec 6) with sane defaults (sec 8: default limit = 10). ---
page, err := parsePositiveInt(q.Get("page"), 1)
if err != nil {
writeError(w, http.StatusBadRequest, "query param 'page' must be a positive integer")
return
}
limit, err := parsePositiveInt(q.Get("limit"), 10)
if err != nil {
writeError(w, http.StatusBadRequest, "query param 'limit' must be a positive integer")
return
}
if limit > 100 {
limit = 100 // cap the page size so a client can't ask for everything at once
}
totalItems := len(tasks)
totalPages := int(math.Ceil(float64(totalItems) / float64(limit)))
// Slice out the requested page. A page past the end yields an EMPTY data
// array, still 200 OK, never 404 (sec 7: an empty list is not "not found").
start := (page - 1) * limit
if start > totalItems {
start = totalItems
}
end := start + limit
if end > totalItems {
end = totalItems
}
data := tasks[start:end]
if data == nil {
data = []Task{} // guarantee [] rather than null in the JSON
}
writeJSON(w, http.StatusOK, listResponse{
Data: data,
Pagination: pagination{
Page: page,
Limit: limit,
TotalItems: totalItems,
TotalPages: totalPages,
},
})
}
// parsePositiveInt returns def when raw is empty, otherwise the parsed value if
// it is a positive integer. Anything else (non-numeric, zero, negative) errors.
func parsePositiveInt(raw string, def int) (int, error) {
if raw == "" {
return def, nil
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
return 0, errors.New("invalid")
}
return n, nil
}
// ---------- Single-resource handlers (unchanged logic) ----------
type server struct{ store *store }
func (s *server) handleGet(w http.ResponseWriter, r *http.Request) {
id, err := idFromPath(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
t, err := s.store.get(id)
if err != nil {
writeError(w, http.StatusNotFound, "task not found")
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *server) handleCreate(w http.ResponseWriter, r *http.Request) {
var body struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if body.Title == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
writeJSON(w, http.StatusCreated, s.store.create(body.Title, false))
}
func (s *server) handleUpdate(w http.ResponseWriter, r *http.Request) {
id, err := idFromPath(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var body struct {
Title string `json:"title"`
Done bool `json:"done"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if body.Title == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
t, err := s.store.update(id, body.Title, body.Done)
if err != nil {
writeError(w, http.StatusNotFound, "task not found")
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *server) handleDelete(w http.ResponseWriter, r *http.Request) {
id, err := idFromPath(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.store.delete(id); err != nil {
writeError(w, http.StatusNotFound, "task not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------- Wiring ----------
func (s *server) routes() *http.ServeMux {
mux := http.NewServeMux()
// sec 3 (versioning): everything is namespaced under /v1, so a future breaking
// change can ship as /v2 without disturbing clients still using /v1.
mux.HandleFunc("GET /v1/tasks", s.handleList)
mux.HandleFunc("POST /v1/tasks", s.handleCreate)
mux.HandleFunc("GET /v1/tasks/{id}", s.handleGet)
mux.HandleFunc("PUT /v1/tasks/{id}", s.handleUpdate)
mux.HandleFunc("DELETE /v1/tasks/{id}", s.handleDelete)
// Health check stays unversioned, it's an infrastructure concern, not part
// of the versioned resource API.
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
return mux
}
func main() {
srv := &server{store: newStore()}
// Seed enough varied data to actually exercise pagination/sorting/filtering.
seed := []struct {
title string
done bool
}{
{"Learn Go", true},
{"Build a REST API", true},
{"Add pagination", false},
{"Add sorting", false},
{"Add filtering", false},
{"Write unit tests", false},
{"Add request logging", false},
{"Write API docs", false},
{"Set up CI pipeline", false},
{"Dockerize the service", false},
{"Add authentication", false},
{"Deploy to staging", false},
}
for _, sd := range seed {
srv.store.create(sd.title, sd.done)
}
const addr = ":8080"
httpServer := &http.Server{
Addr: addr,
Handler: srv.routes(),
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
log.Printf("listening on http://localhost%s (try /v1/tasks)", addr)
log.Fatal(httpServer.ListenAndServe())
}
Apa yang diimplementasikan kode
Satu file, tiga lapisan bersih, bentuk yang sama dari setiap layanan nyata tumbuh. Baca dari bawah ke atas, tiap lapisan hanya tahu tentang yang tepat di bawahnya.
1. Model
Satu struct, Task, dengan empat field. Struct tag json melakukan kerja yang tenang tapi penting: field Go tetap PascalCase idiomatis (CreatedAt) sementara format kabel yang dilihat klien camelCase (createdAt, jangan pernah created_at). Tag adalah satu-satunya tempat konvensi penamaan (bagian 10) ditegakkan.
2. Store
map[int]Task di memori yang dijaga sync.RWMutex, pengganti database. Baca (list, get) memakai RLock bersama; tulis (create, update, delete) memakai Lock eksklusif, jadi request serentak tidak bisa merusak map. Dua detail penting untuk kontrak API: list() mengembalikan irisan non-nil supaya hasil kosong terserialisasi sebagai [] bukan null, dan key yang hilang mengembalikan sentinel errNotFound yang diubah lapisan HTTP menjadi 404. Ganti lapisan ini dengan Postgres nanti dan tidak ada satu handler pun yang berubah.
3. Lapisan HTTP
Di sinilah REST hidup. Tiga helper kecil menjaga setiap handler tetap jujur: writeJSON men-set content type, menulis status, dan meng-encode body di satu tempat; writeError membungkus pesan dalam amplop {"error": ...} yang konsisten; idFromPath menarik {id} dari URL. Lima handler resource plus health check masing-masing melakukan satu tugas, dan routes() mengikatnya ke pola "METHOD /path" di ServeMux Go 1.22; router-nya sendiri yang meng-encode verb mana milik URL mana, jadi handler tidak pernah perlu memeriksa method manual.
Sebuah request, dari awal sampai akhir
Ambil perintah persis dari atas file: curl ".../v1/tasks?limit=5&sortBy=title&sortOrder=asc&done=false". Mengikuti Diagram C: router mencocokkan GET /v1/tasks dan memanggil handleList. Ia memotret dua belas task yang disemai, memfilter ke sepuluh yang done=false, memvalidasi sortBy=title dan sortOrder=asc (keduanya lulus), menyortir A->Z, lalu mengambil halaman 1 dengan 5 per halaman, lima baris pertama. Balasannya:
Ubah satu hal dan cabangnya ikut berubah: POST /v1/tasks dengan title mengembalikan 201 dan baris baru; GET /v1/tasks/999 mengembalikan 404; DELETE /v1/tasks/3 mengembalikan 204 tanpa body; dan ?sortBy=colour ditolak di depan dengan 400 karena tidak ada di allow-list.
Prinsip mana yang ia peragakan
Satu file ini menyentuh hampir setiap aturan di bab ini, pengulangan yang disengaja, supaya Anda bisa melihat teori mendarat di kode nyata.
| Prinsip | Tempat ia muncul di kode |
|---|---|
| Resource kata benda, jamak bagian 04 | /v1/tasks dan /v1/tasks/{id}, sebuah benda, tidak pernah verb seperti /getTask |
| Method sesuai maksud bagian 05 | GET membaca / POST membuat / PUT mengganti / DELETE menghapus, satu handler masing-masing |
| Status code jujur bagian 08 | 200 / 201 / 204 / 400 / 404, lihat tabel hasil |
| Statelessness bagian 02 | Tanpa sesi; store dibagi dan tiap request membawa semua yang ia butuhkan |
| Konsistensi bagian 10 | JSON camelCase, satu amplop {data, pagination}, default masuk akal di mana-mana |
| Versioning bagian 11 | Setiap route resource di bawah /v1; /healthz sengaja tidak diberi versi |
| Page / sort / filter bagian 07 | ?page ?limit ?sortBy ?sortOrder ?done ?title, dengan limit dibatasi 100 |
| Error terstruktur bagian 12 | Body {"error": ...} konsisten di setiap kegagalan |
Allow-list pada sortBy berarti klien hanya bisa menyortir field yang Anda setujui, jangan pernah nama kolom arbitrer yang mereka karang. Membatasi limit di 100 menghentikan satu request meminta seluruh tabel. Mengembalikan [] alih-alih null menyelamatkan setiap konsumen dari satu null-check. Dan DELETE membalas 204 tanpa body, karena tidak ada yang bermakna tersisa untuk dikirim.
Ini kerangka pengajaran, jadi beberapa urusan produksi sengaja tidak ada: persistensi (ganti map dengan database sungguhan di balik interface store yang sama), autentikasi, route PATCH untuk pembaruan parsial (hanya PUT/penggantian penuh di sini), dan amplop error RFC 9457 yang lebih kaya dari bagian 12. Masing-masing langkah lanjutan yang wajar, dan tak satu pun mengubah bentuk yang Anda lihat di sini.
Satu resource, tiga lapisan, router sadar method, dan store di memori di balik mutex, setiap aturan REST di bab ini diterapkan dalam beberapa ratus baris yang benar-benar bisa Anda go run dan pukul dengan curl.