Belajar Golang dari Nol #11: REST API Pertamamu dengan net/http
Di Belajar Golang dari Nol #10 kamu sudah kenal goroutine dan channel. Materi itu terasa abstrak buat sebagian orang. Hari ini abstraksi itu langsung kepakai. Kita akan membuat REST API pertamamu dengan package bawaan Go, yaitu net/http. Tanpa framework, tanpa library tambahan. Di akhir artikel, kamu punya API produk mini yang bisa diakses lewat curl, browser, atau aplikasi frontend.
Apa itu REST API
Bayangkan kamu bikin aplikasi kasir. Ada aplikasi Android untuk pelayan, ada dashboard web untuk pemilik toko. Dua aplikasi itu butuh data yang sama: daftar produk, harga, stok. Data itu tidak disimpan di HP pelayan atau di browser pemilik. Data itu ada di satu tempat, yaitu backend. Nah, REST API adalah cara aplikasi lain ngobrol dengan backendmu. Mereka kirim permintaan lewat HTTP, backendmu balas dengan data dalam format JSON. Frontend minta “kasih daftar produk”, backend jawab dengan array JSON berisi produk. Sesederhana itu konsepnya.
Kenapa ini penting buat kamu yang lagi belajar Go? Karena hampir semua lowongan backend developer intinya ya ini: membuat dan merawat API. Login, checkout, notifikasi, laporan, semuanya jalan lewat API. Kalau kamu paham cara menerima request, memproses data, dan mengembalikan JSON dengan status code yang benar, kamu sudah pegang gerbang masuk dunia kerja backend. Sisanya tinggal memperdalam.
Server HTTP Pertamamu
Kita mulai dari yang paling kecil. Buat folder baru, jalankan go mod init belajar-api, lalu buat file main.go:
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("GET /produk", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "daftar produk")
})
fmt.Println("Server jalan di http://localhost:8080")
http.ListenAndServe(":8080", nil)
}
Ada dua pemain utama di sini. Pertama, http.HandleFunc mendaftarkan handler, yaitu function yang dipanggil setiap ada request masuk ke pola tertentu. Pola "GET /produk" artinya handler ini hanya melayani request GET ke path /produk. Kedua, http.ListenAndServe(":8080", nil) menyalakan server di port 8080 dan membuatnya menunggu request terus menerus. Program tidak akan berhenti sendiri, dan memang itu yang kita mau.
Setiap handler menerima dua parameter. w http.ResponseWriter adalah tempat kamu menulis balasan. r *http.Request berisi semua detail request yang masuk: method, path, header, dan body. Perhatikan juga, r di sini pointer. Materi pointer di bagian 7 kepakai lagi.
Jalankan dengan go run main.go, lalu buka terminal kedua dan tes dengan curl:
curl http://localhost:8080/produk
Hasilnya:
daftar produk
Selamat, kamu baru saja membuat web server. Serius, itu saja kodenya. Go memang menyertakan HTTP server production-grade di standard library.
Kalau muncul error address already in use, artinya port 8080 sedang dipakai program lain. Matikan program itu, atau ganti port di kodemu jadi ":8081" misalnya. Kamu juga bisa tes lewat browser dengan membuka http://localhost:8080/produk, karena browser mengirim request GET secara default. Untuk POST nanti kita tetap butuh curl.
Membalas dengan JSON
Teks polos tidak berguna buat frontend. Mereka butuh JSON. Di Go, cara paling umum adalah mendefinisikan struct, lalu meng-encode struct itu jadi JSON dengan package encoding/json:
type Produk struct {
ID int `json:"id"`
Nama string `json:"nama"`
Harga int `json:"harga"`
}
Bagian `json:"nama"` disebut struct tag. Tag ini memberi tahu package encoding/json: saat struct ini diubah jadi JSON, pakai nama field nama, bukan Nama. Tanpa tag, field akan tampil persis seperti nama di struct, huruf besar di depan. Konvensi JSON umumnya huruf kecil, jadi tag ini hampir selalu dipakai. Ingat juga aturan dari bagian 4: field harus diawali huruf besar supaya terlihat oleh package lain, termasuk encoding/json. Field huruf kecil akan diabaikan diam diam.
Sekarang ubah handler kita supaya membalas JSON:
http.HandleFunc("GET /produk", func(w http.ResponseWriter, r *http.Request) {
daftar := []Produk{
{ID: 1, Nama: "Kopi Arabika", Harga: 45000},
{ID: 2, Nama: "Teh Melati", Harga: 20000},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(daftar)
})
Dua hal baru. w.Header().Set("Content-Type", "application/json") memberi tahu klien bahwa isi balasan adalah JSON, bukan teks biasa. Banyak library frontend bergantung pada header ini untuk mem-parsing respons secara otomatis. Lalu json.NewEncoder(w).Encode(daftar) mengubah slice kita jadi JSON dan langsung menulisnya ke respons. Praktis, satu baris selesai. Jangan lupa tambahkan "encoding/json" di import.
Tes lagi dengan curl, hasilnya sudah JSON:
[{"id":1,"nama":"Kopi Arabika","harga":45000},{"id":2,"nama":"Teh Melati","harga":20000}]
Routing dengan Method dan Path Parameter
Sejak Go 1.22, router bawaan (ServeMux) jadi jauh lebih enak dipakai. Kamu bisa menulis method langsung di pola, dan bisa menangkap bagian path yang dinamis:
http.HandleFunc("GET /produk", listProduk)
http.HandleFunc("POST /produk", tambahProduk)
http.HandleFunc("GET /produk/{id}", detailProduk)
Di sinilah gaya REST mulai terlihat. Konvensinya sederhana: method HTTP menentukan jenis aksinya. GET untuk membaca data, POST untuk membuat data baru, PUT untuk mengubah, DELETE untuk menghapus. Path-nya menunjuk ke sumber datanya, biasanya kata benda jamak seperti /produk atau /pelanggan. Jadi kamu tidak perlu bikin path seperti /ambilSemuaProduk atau /hapusProdukById. Kombinasi method dan path sudah cukup menjelaskan maksudnya, dan developer lain langsung paham tanpa baca dokumentasi panjang.
Tiga baris ini artinya: GET ke /produk masuk ke listProduk, POST ke path yang sama masuk ke tambahProduk, dan GET ke /produk/5 masuk ke detailProduk. Bagian {id} disebut path parameter. Di dalam handler, nilainya diambil dengan r.PathValue("id"):
func detailProduk(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // untuk /produk/5, id berisi "5"
// ...
}
Nilai yang dikembalikan selalu string, jadi kalau butuh angka kamu konversi dulu dengan strconv.Atoi. Satu catatan: di Go versi lama sebelum 1.22, pola method dan {id} belum ada, jadi orang mengecek r.Method manual atau pakai router pihak ketiga seperti chi dan gorilla/mux. Pastikan saja Go kamu minimal 1.22, cek dengan go version.
Menerima Data Lewat POST
GET dipakai untuk membaca data. POST dipakai untuk mengirim data baru. Data dari klien dikirim lewat body request dalam bentuk JSON, dan tugas kita membacanya dengan json.NewDecoder:
func tambahProduk(w http.ResponseWriter, r *http.Request) {
var p Produk
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "body bukan JSON yang valid"})
return
}
if p.Nama == "" || p.Harga <= 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "nama wajib diisi dan harga harus lebih dari 0"})
return
}
// simpan datanya, lalu balas 201
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(p)
}
Polanya kebalikan dari encoder tadi. json.NewDecoder(r.Body).Decode(&p) membaca body request dan mengisi struct p. Kita kirim pointer supaya function bisa mengubah isi struct aslinya, persis seperti yang kamu pelajari di bagian pointer. Kalau body-nya bukan JSON yang valid, Decode mengembalikan error dan kita hentikan proses di situ.
Setelah decode berhasil, jangan langsung percaya datanya. Klien bisa saja kirim nama kosong atau harga minus. Validasi sederhana seperti di atas sudah cukup untuk tahap ini.
Perhatikan juga w.WriteHeader. Ini cara kita menentukan status code respons. Status code itu bahasa universal HTTP. http.StatusCreated (201) artinya data berhasil dibuat. http.StatusBadRequest (400) artinya request-nya bermasalah dari sisi klien. Kenapa penting? Karena frontend mengambil keputusan berdasarkan angka ini. Kalau semua respons kamu 200 padahal isinya error, frontend harus menebak-nebak dari isi body, dan itu resep bug. Status yang benar bikin API-mu jujur dan mudah dipakai orang lain.
Menyimpan Data di Memory, dan Kenapa Butuh Mutex
API kita butuh tempat menyimpan produk. Untuk sekarang, kita pakai slice biasa yang hidup di memory. Tapi ada satu jebakan. Ingat pelajaran bagian 10: server HTTP di Go menjalankan setiap request di goroutine terpisah. Kalau dua request POST datang bersamaan, dua goroutine bisa menulis ke slice yang sama di saat yang sama. Itu race condition, dan hasilnya data bisa rusak. Solusinya sync.Mutex: kunci dulu sebelum menyentuh data, lepas setelah selesai. Satu goroutine masuk, yang lain antre.
Di program utuh nanti, kamu akan lihat polanya selalu sama: mu.Lock() di awal, lalu defer mu.Unlock() tepat di bawahnya. Pakai defer supaya kunci pasti dilepas saat function selesai, lewat jalur mana pun function itu keluar. Lupa melepas kunci akibatnya fatal, semua request berikutnya menggantung menunggu kunci yang tidak pernah dilepas.
Satu kejujuran penting: data di memory hilang setiap server restart. Kamu matikan program, semua produk yang ditambahkan lenyap. Untuk latihan ini tidak masalah. Penyimpanan permanen pakai database akan kita bahas di bagian 12.
Program Utuh: API Produk Mini
Sekarang kita rakit semuanya jadi satu file. API ini punya tiga endpoint: GET daftar produk, GET satu produk berdasarkan id (balas 404 kalau tidak ketemu), dan POST tambah produk. Supaya tidak mengulang kode, kita buat dua helper kecil untuk menulis respons:
package main
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"sync"
)
type Produk struct {
ID int `json:"id"`
Nama string `json:"nama"`
Harga int `json:"harga"`
}
var (
mu sync.Mutex
produk = []Produk{
{ID: 1, Nama: "Kopi Arabika", Harga: 45000},
{ID: 2, Nama: "Teh Melati", Harga: 20000},
}
nextID = 3
)
func tulisJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func tulisError(w http.ResponseWriter, status int, pesan string) {
tulisJSON(w, status, map[string]string{"error": pesan})
}
func listProduk(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
tulisJSON(w, http.StatusOK, produk)
}
func detailProduk(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
tulisError(w, http.StatusBadRequest, "id harus angka")
return
}
mu.Lock()
defer mu.Unlock()
for _, p := range produk {
if p.ID == id {
tulisJSON(w, http.StatusOK, p)
return
}
}
tulisError(w, http.StatusNotFound, "produk tidak ditemukan")
}
func tambahProduk(w http.ResponseWriter, r *http.Request) {
var p Produk
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
tulisError(w, http.StatusBadRequest, "body bukan JSON yang valid")
return
}
if p.Nama == "" || p.Harga <= 0 {
tulisError(w, http.StatusBadRequest, "nama wajib diisi dan harga harus lebih dari 0")
return
}
mu.Lock()
defer mu.Unlock()
p.ID = nextID
nextID++
produk = append(produk, p)
tulisJSON(w, http.StatusCreated, p)
}
func main() {
http.HandleFunc("GET /produk", listProduk)
http.HandleFunc("GET /produk/{id}", detailProduk)
http.HandleFunc("POST /produk", tambahProduk)
fmt.Println("Server jalan di http://localhost:8080")
http.ListenAndServe(":8080", nil)
}
Jalankan dengan go run main.go, lalu tes ketiga endpoint. Pertama, ambil daftar produk:
curl http://localhost:8080/produk
[{"id":1,"nama":"Kopi Arabika","harga":45000},{"id":2,"nama":"Teh Melati","harga":20000}]
Kedua, ambil satu produk. Coba id yang ada dan yang tidak ada:
curl http://localhost:8080/produk/1
{"id":1,"nama":"Kopi Arabika","harga":45000}
curl http://localhost:8080/produk/99
{"error":"produk tidak ditemukan"}
Ketiga, tambah produk baru lewat POST. Flag -X POST menentukan method, -d mengisi body:
curl -X POST http://localhost:8080/produk \
-H "Content-Type: application/json" \
-d '{"nama":"Gula Aren","harga":15000}'
{"id":3,"nama":"Gula Aren","harga":15000}
Panggil lagi GET /produk, dan Gula Aren sudah muncul di daftar. API-mu hidup dan datanya nyambung antar endpoint.
Satu Kebiasaan Baik: Struktur Respons yang Konsisten
Perhatikan satu detail dari program di atas. Semua respons kita JSON, termasuk saat error. Sukses balas data, gagal balas {"error": "..."}. Klien jadi selalu tahu format apa yang akan diterima, apa pun hasilnya. Kebiasaan kecil ini membedakan API yang enak dipakai dengan API yang bikin frontend developer mengelus dada. Pertahankan sejak proyek pertamamu.
Penutup
Hari ini kamu sudah membuat REST API lengkap hanya dengan standard library: server HTTP, routing dengan method dan path parameter, encode dan decode JSON, validasi input, status code yang benar, plus mutex supaya aman dari race condition. Coba modifikasi sendiri sebagai latihan, misalnya tambah endpoint DELETE /produk/{id} atau field stok di struct Produk.
Masalah yang tersisa jelas: data hilang saat server restart. Di bagian 12, “Menghubungkan API ke Database dengan database/sql”, kita selesaikan itu dengan menyimpan produk ke database sungguhan. Pola API seperti inilah yang kami pakai sehari hari saat membangun sistem aplikasi untuk bisnis dan instansi, jadi kuasai dasarnya baik baik. Sampai ketemu di bagian 12.
Artikel Lainnya di Kategori Golang
Golang 1 Agustus 2026
Belajar Golang dari Nol #6: Perulangan for dan Percabangan
Lanjutan seri Go pemula: tiga bentuk for, for range tuntas, break dan continue, if dengan statement pendek, switch, plus latihan rekap penjualan.
Baca Artikel
Golang 31 Juli 2026
Belajar Golang dari Nol #5: Slice dan Map, Kumpulan Data di Go
Lanjutan seri Go pemula: slice, append, map, comma ok idiom, jebakan nil map, plus latihan keranjang belanja mini dengan pengecekan stok.
Baca Artikel
Golang 28 Juli 2026
Belajar Golang dari Nol #2: Variabel, Tipe Data, dan Zero Value
Lanjutan seri Go pemula: tiga cara deklarasi variabel, tipe data dasar, zero value, konstanta, konversi tipe, plus latihan program hitung belanja.
Baca ArtikelIngin Membaca Artikel Lainnya?
Temukan lebih banyak insight dan tips tentang teknologi dan bisnis digital.
Lihat Semua Artikel