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.

go
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:

text
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 Allowed

Mari kita telaah baris demi baris program di atas:

  1. Baris 22-26 (type TodoStore): lapisan penyimpanan berisi slice data dan waktu pembuatan; seluruh akses data lewat method Buat, Cari, Ganti, Hapus (baris 28-67) sehingga handler tidak menyentuh struktur data langsung.
  2. Baris 74-82 (Routes): lima pattern terdaftar pada satu mux — method berbeda pada path sama diperbolehkan karena pattern memuat method.
  3. Baris 88-96 (buat): handler memanggil parseBody, store.Buat, menyetel header Location, lalu tulisJSON dengan status 201 — empat langkah dalam sekejap tanpa logika lain.
  4. Baris 140-148 (parseID): r.PathValue("id") mengambil segmen {id} dan strconv.Atoi memastikan isinya angka; kegagalan dijawab 400.
  5. Baris 150-165 (parseBody): decode JSON dengan struct ber-field pointer, lalu validasi title wajib terisi dan tidak kosong setelah dipangkas.
  6. Baris 167-175 (tulisJSON dan tulisError): header Content-Type diset sebelum WriteHeader, body ditulis json.NewEncoder(w).Encode; versi galat memakai peta sederhana {"error": pesan}.
  7. Baris 177-190 (main): store disemai dua tugas lalu tujuh permintaan dijalankan — mencakup jalur sukses dan empat jalur gagal.
  8. Baris 192-208 (kirim): httptest menjalankan 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 #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

routing-demo.go

Unduh berkas
go
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())
	}
}