Sejak Go 1.22, http.ServeMux mampu mencocokkan method dan segmen path seperti GET /todos/{id} tanpa library tambahan, sehingga routing REST API bisa sepenuhnya digarap oleh standard library. Tantangan berikutnya bukan mencocokkan URL, melainkan menjaga handler tetap kecil: handler yang menangani parsing, validasi, akses data, dan pembentukan respons sekaligus akan cepat membesar dan sulit diuji. Pada chapter ini kita akan menyusun mini CRUD tugas di memori dengan pola handler tipis — parse, validasi, panggil service, encode — lalu menjalankan seluruh endpoint lewat httptest dan membaca dump status serta bodynya.
Pattern Routing pada ServeMux #
Pattern http.ServeMux sekarang tersusun dari method, path, dan segmen variabel. Pattern "GET /todos/{id}" hanya cocok untuk permintaan GET dengan path dua segmen semacam /todos/7; nilainya di segmen ketiga dapat dibaca lewat r.PathValue("id"). Ketika path cocok tetapi method tidak terdaftar, mux menjawab 405 Method Not Allowed secara otomatis — kita tidak perlu menulis cabang penolak method sendiri.
Framework pihak ketiga seperti Gin atau Echo menawarkan router dengan fitur tambahan (grup, middleware siap pakai), tetapi pola berpikirnya sama: deklarasikan method + path, baca parameter, panggil handler. Kode pada chapter ini sengaja memakai net/http murni supaya mekanismenya terlihat tanpa lapisan framework.
Handler Tipis: Parse, Validasi, Service, Encode #
Handler yang sehat hanya menjadi penerjemah dua arah: permintaan HTTP diubah menjadi pemanggilan fungsi biasa, hasilnya diubah kembali menjadi respons HTTP. Alurnya selalu empat langkah: decode dan validasi masukan, panggil lapisan penyimpanan (service), tulis header dan status, lalu encode body. Semua aturan bisnis — cara ID dibuat, cara data dicari — hidup di luar handler, pada TodoStore di program kita. Pemisahan semacam ini dibahas tuntas di chapter Pemisahan Tanggung Jawab
.
Program: CRUD Tugas di Memori #
Program routing-demo.go mengimplementasikan lima endpoint di atas satu slice: daftar, buat, baca satu, ubah, dan hapus. Fungsi kirim menjalankan tujuh permintaan dan mencetak status serta body dari masing-masing.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"time"
)
// Todo adalah representasi resource tugas yang dikirim ke klien.
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
}
// TodoStore menyimpan data sekaligus aturan aksesnya (lapisan service).
type TodoStore struct {
waktu time.Time
data []Todo
}
func (s *TodoStore) Buat(title string, done bool) Todo {
t := Todo{
ID: len(s.data) + 1,
Title: title,
Done: done,
CreatedAt: s.waktu,
}
s.data = append(s.data, t)
return t
}
func (s *TodoStore) Cari(id int) (Todo, bool) {
for _, t := range s.data {
if t.ID == id {
return t, true
}
}
return Todo{}, false
}
func (s *TodoStore) Ganti(id int, title string, done bool) (Todo, bool) {
for i := range s.data {
if s.data[i].ID == id {
s.data[i].Title = title
s.data[i].Done = done
return s.data[i], true
}
}
return Todo{}, false
}
func (s *TodoStore) Hapus(id int) bool {
for i := range s.data {
if s.data[i].ID == id {
s.data = append(s.data[:i], s.data[i+1:]...)
return true
}
}
return false
}
// Handler tipis: parse permintaan, validasi, panggil service, encode respons.
type Handler struct {
store *TodoStore
}
func (h *Handler) Routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /todos", h.daftar)
mux.HandleFunc("POST /todos", h.buat)
mux.HandleFunc("GET /todos/{id}", h.detail)
mux.HandleFunc("PUT /todos/{id}", h.ubah)
mux.HandleFunc("DELETE /todos/{id}", h.hapus)
return mux
}
func (h *Handler) daftar(w http.ResponseWriter, r *http.Request) {
tulisJSON(w, http.StatusOK, h.store.data)
}
func (h *Handler) buat(w http.ResponseWriter, r *http.Request) {
title, done, ok := h.parseBody(w, r)
if !ok {
return
}
t := h.store.Buat(title, done)
w.Header().Set("Location", fmt.Sprintf("/todos/%d", t.ID))
tulisJSON(w, http.StatusCreated, t)
}
func (h *Handler) detail(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
t, ada := h.store.Cari(id)
if !ada {
tulisError(w, http.StatusNotFound, "tugas tidak ditemukan")
return
}
tulisJSON(w, http.StatusOK, t)
}
func (h *Handler) ubah(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
title, done, ok := h.parseBody(w, r)
if !ok {
return
}
t, ada := h.store.Ganti(id, title, done)
if !ada {
tulisError(w, http.StatusNotFound, "tugas tidak ditemukan")
return
}
tulisJSON(w, http.StatusOK, t)
}
func (h *Handler) hapus(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
if !h.store.Hapus(id) {
tulisError(w, http.StatusNotFound, "tugas tidak ditemukan")
return
}
w.WriteHeader(http.StatusNoContent)
}
// parseID membaca segmen {id} dari pattern dan memastikan isinya angka.
func parseID(w http.ResponseWriter, r *http.Request) (int, bool) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
tulisError(w, http.StatusBadRequest, "id harus angka")
return 0, false
}
return id, true
}
// parseBody decode payload JSON lalu memvalidasi field title.
func (h *Handler) parseBody(w http.ResponseWriter, r *http.Request) (string, bool, bool) {
var in struct {
Title *string `json:"title"`
Done bool `json:"done"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
tulisError(w, http.StatusBadRequest, "body bukan JSON yang valid")
return "", false, false
}
if in.Title == nil || strings.TrimSpace(*in.Title) == "" {
tulisError(w, http.StatusBadRequest, "title wajib diisi")
return "", false, false
}
return strings.TrimSpace(*in.Title), in.Done, true
}
func tulisJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func tulisError(w http.ResponseWriter, status int, pesan string) {
tulisJSON(w, status, map[string]string{"error": pesan})
}
func main() {
store := &TodoStore{waktu: time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)}
store.Buat("Belajar routing", false)
store.Buat("Belajar handler", true)
mux := (&Handler{store: store}).Routes()
kirim(mux, "GET", "/todos", "")
kirim(mux, "POST", "/todos", `{"title":"Belajar REST"}`)
kirim(mux, "POST", "/todos", `{"title":""}`)
kirim(mux, "GET", "/todos/99", "")
kirim(mux, "PUT", "/todos/1", `{"title":"Belajar routing HTTP","done":true}`)
kirim(mux, "DELETE", "/todos/2", "")
kirim(mux, "PATCH", "/todos/1", "")
}
// kirim mengeksekusi satu permintaan ke mux lalu mencetak dump status dan body.
func kirim(mux *http.ServeMux, metode, target, body string) {
var pembaca io.Reader
if body != "" {
pembaca = strings.NewReader(body)
}
req := httptest.NewRequest(metode, target, pembaca)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
fmt.Printf("%-7s %-11s -> %d\n", metode, target, rec.Code)
if rec.Body.Len() > 0 {
fmt.Printf(" %s", rec.Body.String())
}
}Output yang Dihasilkan:
GET /todos -> 200
[{"id":1,"title":"Belajar routing","done":false,"created_at":"2026-01-15T08:00:00Z"},{"id":2,"title":"Belajar handler","done":true,"created_at":"2026-01-15T08:00:00Z"}]
POST /todos -> 201
{"id":3,"title":"Belajar REST","done":false,"created_at":"2026-01-15T08:00:00Z"}
POST /todos -> 400
{"error":"title wajib diisi"}
GET /todos/99 -> 404
{"error":"tugas tidak ditemukan"}
PUT /todos/1 -> 200
{"id":1,"title":"Belajar routing HTTP","done":true,"created_at":"2026-01-15T08:00:00Z"}
DELETE /todos/2 -> 204
PATCH /todos/1 -> 405
Method Not AllowedMari kita telaah baris demi baris program di atas:
- Baris 22-26 (
type TodoStore): lapisan penyimpanan berisi slicedatadan waktu pembuatan; seluruh akses data lewat methodBuat,Cari,Ganti,Hapus(baris 28-67) sehingga handler tidak menyentuh struktur data langsung. - Baris 74-82 (
Routes): lima pattern terdaftar pada satu mux — method berbeda pada path sama diperbolehkan karena pattern memuat method. - Baris 88-96 (
buat): handler memanggilparseBody,store.Buat, menyetel headerLocation, lalutulisJSONdengan status 201 — empat langkah dalam sekejap tanpa logika lain. - Baris 140-148 (
parseID):r.PathValue("id")mengambil segmen{id}danstrconv.Atoimemastikan isinya angka; kegagalan dijawab 400. - Baris 150-165 (
parseBody): decode JSON dengan struct ber-field pointer, lalu validasititlewajib terisi dan tidak kosong setelah dipangkas. - Baris 167-175 (
tulisJSONdantulisError): headerContent-Typediset sebelumWriteHeader, body ditulisjson.NewEncoder(w).Encode; versi galat memakai peta sederhana{"error": pesan}. - Baris 177-190 (
main): store disemai dua tugas lalu tujuh permintaan dijalankan — mencakup jalur sukses dan empat jalur gagal. - Baris 192-208 (
kirim):httptestmenjalankan permintaan tanpa jaringan; status selalu dicetak, body hanya bila ada isinya.
Dump di atas memperlihatkan seluruh jalur penting. Permintaan POST dengan title kosong dijawab 400 karena validasi di parseBody menolaknya sebelum store tersentuh. Permintaan GET /todos/99 menemukan pattern tetapi tidak menemukan datanya, sehingga handler menjawab 404. DELETE /todos/2 menghasilkan 204 tanpa body, dan PATCH /todos/1 ditolak 405 oleh mux itu sendiri karena tidak ada pattern PATCH yang terdaftar — lengkap dengan body Method Not Allowed bawaan mux.
Perhatikan satu kelemahan yang masih tersisa: validasi pada program ini hanya memeriksa satu field dengan satu aturan. Payload nyata punya lusinan field dan aturan yang saling bergantung — kerangka validasi yang rapi untuk kebutuhan itu menjadi materi chapter Request Validation .
Referensi #
- Dokumentasi http.ServeMux — aturan pattern method, path, dan wildcard.
- Dokumentasi package httptest
—
NewRequestdanNewRecorderuntuk uji handler. - Tutorial: Web service with Gin — perbandingan pola handler pada framework.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
routing-demo.go
Unduh berkaspackage main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"time"
)
// Todo adalah representasi resource tugas yang dikirim ke klien.
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
}
// TodoStore menyimpan data sekaligus aturan aksesnya (lapisan service).
type TodoStore struct {
waktu time.Time
data []Todo
}
func (s *TodoStore) Buat(title string, done bool) Todo {
t := Todo{
ID: len(s.data) + 1,
Title: title,
Done: done,
CreatedAt: s.waktu,
}
s.data = append(s.data, t)
return t
}
func (s *TodoStore) Cari(id int) (Todo, bool) {
for _, t := range s.data {
if t.ID == id {
return t, true
}
}
return Todo{}, false
}
func (s *TodoStore) Ganti(id int, title string, done bool) (Todo, bool) {
for i := range s.data {
if s.data[i].ID == id {
s.data[i].Title = title
s.data[i].Done = done
return s.data[i], true
}
}
return Todo{}, false
}
func (s *TodoStore) Hapus(id int) bool {
for i := range s.data {
if s.data[i].ID == id {
s.data = append(s.data[:i], s.data[i+1:]...)
return true
}
}
return false
}
// Handler tipis: parse permintaan, validasi, panggil service, encode respons.
type Handler struct {
store *TodoStore
}
func (h *Handler) Routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /todos", h.daftar)
mux.HandleFunc("POST /todos", h.buat)
mux.HandleFunc("GET /todos/{id}", h.detail)
mux.HandleFunc("PUT /todos/{id}", h.ubah)
mux.HandleFunc("DELETE /todos/{id}", h.hapus)
return mux
}
func (h *Handler) daftar(w http.ResponseWriter, r *http.Request) {
tulisJSON(w, http.StatusOK, h.store.data)
}
func (h *Handler) buat(w http.ResponseWriter, r *http.Request) {
title, done, ok := h.parseBody(w, r)
if !ok {
return
}
t := h.store.Buat(title, done)
w.Header().Set("Location", fmt.Sprintf("/todos/%d", t.ID))
tulisJSON(w, http.StatusCreated, t)
}
func (h *Handler) detail(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
t, ada := h.store.Cari(id)
if !ada {
tulisError(w, http.StatusNotFound, "tugas tidak ditemukan")
return
}
tulisJSON(w, http.StatusOK, t)
}
func (h *Handler) ubah(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
title, done, ok := h.parseBody(w, r)
if !ok {
return
}
t, ada := h.store.Ganti(id, title, done)
if !ada {
tulisError(w, http.StatusNotFound, "tugas tidak ditemukan")
return
}
tulisJSON(w, http.StatusOK, t)
}
func (h *Handler) hapus(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
if !h.store.Hapus(id) {
tulisError(w, http.StatusNotFound, "tugas tidak ditemukan")
return
}
w.WriteHeader(http.StatusNoContent)
}
// parseID membaca segmen {id} dari pattern dan memastikan isinya angka.
func parseID(w http.ResponseWriter, r *http.Request) (int, bool) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
tulisError(w, http.StatusBadRequest, "id harus angka")
return 0, false
}
return id, true
}
// parseBody decode payload JSON lalu memvalidasi field title.
func (h *Handler) parseBody(w http.ResponseWriter, r *http.Request) (string, bool, bool) {
var in struct {
Title *string `json:"title"`
Done bool `json:"done"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
tulisError(w, http.StatusBadRequest, "body bukan JSON yang valid")
return "", false, false
}
if in.Title == nil || strings.TrimSpace(*in.Title) == "" {
tulisError(w, http.StatusBadRequest, "title wajib diisi")
return "", false, false
}
return strings.TrimSpace(*in.Title), in.Done, true
}
func tulisJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func tulisError(w http.ResponseWriter, status int, pesan string) {
tulisJSON(w, status, map[string]string{"error": pesan})
}
func main() {
store := &TodoStore{waktu: time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)}
store.Buat("Belajar routing", false)
store.Buat("Belajar handler", true)
mux := (&Handler{store: store}).Routes()
kirim(mux, "GET", "/todos", "")
kirim(mux, "POST", "/todos", `{"title":"Belajar REST"}`)
kirim(mux, "POST", "/todos", `{"title":""}`)
kirim(mux, "GET", "/todos/99", "")
kirim(mux, "PUT", "/todos/1", `{"title":"Belajar routing HTTP","done":true}`)
kirim(mux, "DELETE", "/todos/2", "")
kirim(mux, "PATCH", "/todos/1", "")
}
// kirim mengeksekusi satu permintaan ke mux lalu mencetak dump status dan body.
func kirim(mux *http.ServeMux, metode, target, body string) {
var pembaca io.Reader
if body != "" {
pembaca = strings.NewReader(body)
}
req := httptest.NewRequest(metode, target, pembaca)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
fmt.Printf("%-7s %-11s -> %d\n", metode, target, rec.Code)
if rec.Body.Len() > 0 {
fmt.Printf(" %s", rec.Body.String())
}
}