A detailed backend reference

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.

Versi Sederhana


01

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.

Worth reading

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.

One resource / many representations
user id / name / createdAt JSON API client / server<->server HTML browser / human XML legacy consumer same data, different clothes

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.

State / the resource right now
cart id / userId / status earlier snapshot items: 1 / qty: 1 / total: $18.00 current state (now) items: 2 / qty: 3 / total: $47.00 updatedAt: just now a snapshot in time, not a history

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.

Transfer / representations on the wire
CLIENT browser / human SERVER where state lives GET /cart JSON { cart: ... } 200 / current cart state state stays put only copies travel
Mental model

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.

02

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.

The six constraints & what each one buys
BatasanIsinyaYang ia belikan
1 / Client-ServerPemisahan ketat urusan: klien memegang UI/UX, server memegang data & logika bisnis.Tiap sisi berkembang independen.
2 / Uniform InterfaceSatu cara terstandar bagi semua komponen untuk bicara. (Empat sub-batasan di bawah.)Konsistensi lintas setiap layanan.
3 / Layered SystemLapisan berhirarki; satu lapisan hanya melihat lapisan tepat di bawahnya.Pasang load balancer / proxy tanpa menyentuh logika inti.
4 / CacheRespons wajib melabeli dirinya cacheable atau tidak.Beban server lebih ringan, respons lebih cepat.
5 / StatelessServer 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.

Statelessness -> any server can answer
client full request load balancer round-robin server 1 server 2 server 3 DB

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.)
03

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.

The goal of this chapter

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.

04

Anatomi Sebuah Route

Mulai dari URL situs web biasa dan bagian-bagiannya:

A typical URL, decomposed
https://api.example.com/v1/books/harry-potter?sort=name#reviews
Scheme: https, the encrypted transport.
Authority: domain + optional api. subdomain.
Version: v1, API versioning via path.
Path / resource: the thing you're accessing; / means hierarchy.
Query params: key/value pairs for filters & options.
Fragment: scrolls the browser to a section.

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:

1
Take the name: Harry Potter
2
Lowercase everything (avoid case-mismatch bugs across environments): harry potter
3
Replace spaces with hyphens: harry-potter -> /books/harry-potter

Aturan 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.

05

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.

The five methods at a glance
MethodTujuanIdempotent?Punya body?
GETBaca / ambil representasiya + safeTidak
POSTBuat resource / aksi kustomtidakYa
PUTGanti resource sepenuhnyayaYa (penuh)
PATCHPerbarui sebagian resourcebiasanyaYa (parsial)
DELETEHapus resourceyaTidak

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.

GET /v1/books/123 HTTP/1.1 Host: api.example.com Authorization: Bearer eyJhbGci... Accept: application/json <- blank line = "headers done, no body" HTTP/1.1 200 OK Content-Type: application/json Cache-Control: max-age=300 ETag: "9a3f-1f" { "id": 123, "title": "The Hobbit" }
KodePada GET, ini artinya
200 OKDitemukan; body-nya representasinya.
304 Not ModifiedSalinan cache Anda masih segar (Anda mengirim If-None-Match); tanpa body yang dikembalikan, menghemat payload.
301 / 302Pindah; ikuti header Location.
400 Bad RequestQuery string Anda salah bentuk.
401 / 403Belum terautentikasi / terautentikasi tapi tidak diizinkan.
404 Not FoundTidak ada resource tunggal seperti itu.
429 Too Many RequestsKena 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.

PUT vs PATCH in the real world

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

# PATCH, send ONLY what changes PATCH /v1/users/divyansh HTTP/1.1 Content-Type: application/json { "city": "Bangalore" } <- name & email untouched # PUT, send the ENTIRE representation PUT /v1/users/divyansh HTTP/1.1 Content-Type: application/json { "name": "Divyansh", "email": "d@x.com", "city": "Bangalore" }
The PUT trap / silent field wipe

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.

# GET first, server returns a version tag ETag: "v7" # later, when writing back, echo the version you saw PATCH /v1/users/divyansh HTTP/1.1 If-Match: "v7" # if someone else already bumped it to "v8": HTTP/1.1 412 Precondition Failed <- re-fetch, re-merge, retry
PATCH with optimistic-concurrency guard
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
}
KodePada PUT / PATCH, ini artinya
200 OKDiperbarui; body memegang state baru.
201 CreatedKhusus PUT, membuat resource di URL pilihan klien (upsert). PATCH tidak pernah membuat.
204 No ContentDiperbarui; tidak ada yang dikembalikan.
400 Bad RequestBody salah bentuk.
404 Not FoundResource tidak ada (PATCH); PUT tanpa upsert.
409 ConflictPerubahan bertabrakan dengan state saat ini (mis. bentrok field unik).
412 Precondition FailedIf-Match basi, ada yang mengedit lebih dulu.
415 Unsupported Media TypeContent-Type salah (terutama merge-patch vs json-patch milik PATCH).
422 Unprocessable EntityJSON 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:

POST /v1/orders HTTP/1.1 Authorization: Bearer eyJhbGci... Content-Type: application/json { "productId": "sku_42", "quantity": 2 } HTTP/1.1 201 Created Location: /v1/orders/ord_9f2a <- where the new resource lives { "id": "ord_9f2a", "status": "pending" }
The non-idempotency danger / and the fix

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.

making POST retry-safe with an idempotency key
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)
}
KodePada POST, ini artinya
200 OKAksi berjalan; tidak ada resource baru yang dibuat (mis. "send email", aksi kustom).
201 CreatedResource baru dibuat; Location menunjuk ke sana.
202 AcceptedDikenakan antrean pemrosesan asinkron, "nanti kulakukan" (laporan, pekerjaan berat).
400 Bad RequestBody salah bentuk atau field wajib hilang.
409 ConflictBertabrakan dengan state yang ada (mis. "username sudah dipakai").
413 Payload Too LargeBody melebihi batas ukuran server.
415 Unsupported Media TypeServer tidak menerima Content-Type yang Anda kirim.
422 Unprocessable EntityJSON valid, tapi gagal validasi di tingkat aturan bisnis.
429 Too Many RequestsKena rate-limit.
Same call, twice / what changes on the server
PATCH name=B A -> B then B -> B state stable / idempotent DELETE /users/1 deleted then 404 no new effect / idempotent POST /books book id=1 then book id=2 new each time / NOT idempotent
06

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:

POST /organizations/5/archive POST /projects/123/clone

Ini menjaga hirarki tetap jujur: semua organisasi -> satu organisasi spesifik -> aksi yang dijalankan padanya.

Why archive isn't just a PATCH

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".

07

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).

limit = 2, five organizations -> three pages
sorted newest -> oldest org 5 org 4 org 3 org 2 org 1 page 1 page 2 page 3 ?limit=2&page=2 -> returns org 3, org 2 / total=5, page=2, totalPages=3

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: saat page === 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.

list endpoint / page / sort / filter / sane defaults
// 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,
	})
}
08

Status Code: yang benar

KodeMaknaPakai saat
200 OKSuksesFetch, pembaruan (PATCH/PUT), atau aksi kustom yang berjalan.
201 CreatedDibuatPOST berhasil membuat entitas baru. Kembalikan entitas baru di body.
204 No ContentSukses, body kosongDELETE yang berhasil, tidak ada yang perlu dikirim balik.
404 Not FoundResource hilangKlien 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.

404 or empty array?
request found nothing asked for ONE id GET /users/999 -> 404 Not Found asked for a LIST GET /users?name=Zack -> 200 OK / data: []

Lima keluarga: baca digit pertama

Digit pertama setiap status code memberi tahu hampir semuanya. Ini insting debugging tercepat yang bisa Anda bangun.

KeluargaKelasMasalah siapa
1xxInformatifLangka; pada dasarnya tidak akan pernah Anda tangani langsung.
2xxSuksesBerhasil.
3xxRedireksiPergi lihat tempat lain / pakai cache Anda.
4xxError klienAnda mengirim yang salah, perbaiki request-nya.
5xxError serverServer yang rusak, bukan salah Anda; ulangi / hubungi tim ops.
The first-digit reflex

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

KodeMakna polos
200berhasil
201dibuat (sukses POST)
204berhasil, tanpa body (sukses DELETE)
301 / 302redirect
304cache Anda masih bagus
400request Anda rusak (sintaks)
401login
403sudah login, tapi tidak diizinkan
404benda satu itu tidak ada
409bertabrakan dengan state saat ini
422data gagal validasi (semantik)
429pelankan
500mereka crash
503mereka kelebihan beban / sedang pemeliharaan
09

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.

AksiMethod + RouteSukses
Daftar organisasiGET /v1/organizations200 / ter-paginasi
Buat organisasiPOST /v1/organizations201 / entitas baru
Ambil satuGET /v1/organizations/:id200 / 404
Perbarui (parsial)PATCH /v1/organizations/:id200 / entitas diperbarui
HapusDELETE /v1/organizations/:id204 / kosong
Arsipkan (kustom)POST /v1/organizations/:id/archive200 / 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.

wiring the full resource / CRUD + custom action
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
}
10

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 menyebutnya desc di 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 organizations jamak dan ter-paginasi dengan {data,total,page,totalPages}, maka projects dan tasks juga. Konsumen mengintegrasikan satu endpoint, lalu berasumsi sisanya mengikuti gaya yang sama. Imbal asumsi itu.
Why consistency is the whole game

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.

The chapter in one breath

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.

11

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:

StrategiBentuknyaKompromi
Path URL (paling umum)/v1/books -> /v2/booksSangat jelas, sepele di-debug, mudah di-route. Sedikit "tidak murni" REST (versi bukan bagian dari identitas resource). Default pragmatis.
HeaderAccept: application/vnd.example.v2+jsonMenjaga URL bersih dan "murni," tapi tak terlihat di browser dan lebih sulit diuji/di-debug. Dipakai GitHub bertahun-tahun.
Query param/books?version=2Sederhana 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.

Deprecation, done kindly

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.

12

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:

HTTP/1.1 422 Unprocessable Entity Content-Type: application/json { "error": { "code": "validation_failed", <- stable, machine-readable "message": "Some fields are invalid.", <- human-readable "details": [ <- per-field, for forms { "field": "email", "issue": "must be a valid email" }, { "field": "age", "issue": "must be >= 0" } ], "requestId": "req_9f2a3c" <- trace it in your logs } }

Kenapa tiap bagian layak tempatnya:

  • code: string stabil yang bisa di-switch klien. 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 dengan 422).
  • requestId: field paling berguna di produksi: konsumen menyebutnya di laporan bug dan Anda menemukan request persisnya di log dalam hitungan detik.
Never leak internals

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.

one error helper, used everywhere
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"},
)
There's a standard for this too

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.

The whole error story in one line

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.

13

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

  1. Client-Server: pisahkan UI dari data & logika supaya tiap sisi berkembang sendiri.
  2. Uniform Interface: satu cara standar untuk mengalamati dan memanipulasi setiap resource.
  3. Layered System: biarkan proxy, cache, dan gateway duduk di antara klien dan origin, tanpa terlihat.
  4. Cacheable: setiap respons mendeklarasikan boleh-tidaknya di-cache.
  5. Stateless: tiap request membawa semua yang ia butuhkan; server tidak mengingat apa pun antar panggilan.
  6. 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

The eight conventions & where this manual covers them
KonvensiSatu barisDibahas di
Resource sebagai kata benda jamakModelkan benda, bukan aksi; method adalah verb-nya. /orders/123, jangan pernah /getOrder.bagian 04 Routes
Method sesuai maksudGET baca / POST buat/aksi / PUT ganti / PATCH parsial / DELETE hapus.bagian 05 Methods
Status code jujurBiarkan 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 ampunSatu gaya penulisan, satu format tanggal, satu bentuk pagination, di mana-mana.bagian 10 Golden Rules
VersioningNaikkan hanya saat perubahan memecah; perubahan aditif tidak butuh versi baru.bagian 11 Versioning
Page / sort / filterJangan pernah membuang seluruh tabel; paginate daftar dan beri default yang masuk akal.bagian 07 List APIs
Error terstrukturSatu amplop konsisten yang bisa dibaca mesin di setiap kegagalan.bagian 12 Errors

* Statelessness satu-satunya item yang mendarat di kedua daftar, lihat di bawah.

Where the two lists touch

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.

Which list does someone want?

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.

14

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.)

The through-line

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.

15

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.

Execution flow / from "go run" to a listening server
go run ./RestAPI compile -> run main() entry point newStore() seed 12 tasks http.Server timeouts 10s ListenAndServe :8080 / ready now serving, every incoming request runs the flow below

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.

Request flow / the router dispatches on METHOD + PATH
HTTP request method + path ServeMux method-aware / Go 1.22 GET /v1/tasks -> handleList 200 POST /v1/tasks -> handleCreate 201 GET /v1/tasks/{id} -> handleGet 200 PUT /v1/tasks/{id} -> handleUpdate 200 DELETE /v1/tasks/{id} -> handleDelete 204 GET /healthz -> health check 200 / unmatched route / method 404

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.

Deep dive / every branch inside "GET /v1/tasks"
GET /v1/tasks handleList() list(), snapshot of all tasks (RLock) is ?done a valid bool? strconv.ParseBool no valid v 400 Bad Request done -> true or false filter: done + title (substring) sortBy in allow-list? & sortOrder asc | desc? no defaults: createdAt / desc 400 Bad Request bad sortBy / sortOrder sort.Slice(tasks, ...) page >= 1 and limit >= 1? parsePositiveInt no defaults: page 1 / limit 10 cap 100 400 Bad Request page / limit must be >= 1 slice out the page / cap 100 past the end -> [] (still 200) 200 OK { data, pagination }

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.

RequestSaatRespons
GET /v1/tasksQuery valid, atau halaman melewati akhir200: {data, pagination}; halaman kosong -> data: []
GET /v1/tasksdone, sortBy, sortOrder, page atau limit salah bentuk400: pesan menyebut param yang buruk
GET /v1/tasks/{id}ID numerik yang ada200: task-nya
GET /v1/tasks/{id}ID bukan angka400: invalid id
GET /v1/tasks/{id}ID numerik yang tidak ada404: task not found
POST /v1/tasksJSON valid dengan title tidak kosong201: task yang dibuat
POST /v1/tasksBody bukan JSON valid400: invalid JSON body
POST /v1/taskstitle hilang atau kosong400: title is required
PUT /v1/tasks/{id}ID valid + body valid200: task yang diperbarui
PUT /v1/tasks/{id}ID buruk, JSON buruk, atau title kosong400: pesan yang cocok
PUT /v1/tasks/{id}ID tidak ada404: task not found
DELETE /v1/tasks/{id}ID numerik yang ada204: tanpa body
DELETE /v1/tasks/{id}ID buruk400: invalid id
DELETE /v1/tasks/{id}ID tidak ada404: task not found
GET /healthzSelalu200: {status: "ok"}
yang lain apa punTidak ada route yang cocok dengan method + path404: mux bawaan

Program lengkapnya

task API / net/http / Go 1.22+
// 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:

GET /v1/tasks?limit=5&sortBy=title&sortOrder=asc&done=false 200 OK { "data": [ { "id": 5, "title": "Add filtering", "done": false, "createdAt": "2026-07-02T..." }, ... four more, ordered by title A->Z ... ], "pagination": { "page": 1, "limit": 5, "totalItems": 10, "totalPages": 2 } }

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.

PrinsipTempat 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 05GET membaca / POST membuat / PUT mengganti / DELETE menghapus, satu handler masing-masing
Status code jujur bagian 08200 / 201 / 204 / 400 / 404, lihat tabel hasil
Statelessness bagian 02Tanpa sesi; store dibagi dan tiap request membawa semua yang ia butuhkan
Konsistensi bagian 10JSON camelCase, satu amplop {data, pagination}, default masuk akal di mana-mana
Versioning bagian 11Setiap 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 12Body {"error": ...} konsisten di setiap kegagalan
Small touches worth stealing

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.

What's intentionally left out

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.

The file in one breath

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.