Payload yang berhasil didecode belum tentu payload yang layak diproses: {"title":"Bo","priority":9} adalah JSON yang sah secara sintaks, tetapi melanggar aturan bisnis judul minimal tiga huruf dan prioritas rentang 1-5. Server yang melewatkan pemeriksaan semacam ini akan menyimpan data sampah yang kembali menyusahkan lewat laporan bug minggu-minggu berikutnya. Pada chapter ini kita membangun lapisan validasi di depan handler: memeriksa content-type, membatasi ukuran body, decode, lalu menjalankan method Validate() yang mengumpulkan seluruh pelanggaran sekaligus dan mengembalikannya sebagai peta field demi field.

Urutan Pemeriksaan di Depan Handler #

Validasi request paling efektif bila dijalankan berlapis dari yang paling murah: content-type diperiksa lebih dulu karena body tidak perlu dibaca sama sekali, batas ukuran body dipasang sebelum decode agar memori server tidak tersedot request raksasa, dan decode JSON baru berjalan setelah kedua gerbang itu lolos. Aturan bisnis diperiksa paling akhir karena hanya bermakna pada payload yang bentuknya sudah benar. Program di akhir chapter memakai persis urutan ini dan menunjukkan status yang berbeda untuk tiap lapisan: 415 untuk content-type salah, 413 untuk body terlalu besar, 400 untuk JSON rusak maupun pelanggaran aturan.

Method Validate dan errors.Join #

Validasi satu-per-satu yang berhenti pada pelanggaran pertama memaksa klien memperbaiki error secara bertahap — satu request, satu pesan. Pola yang lebih ramah mengumpulkan semua pelanggaran dalam slice []error, lalu menggabungkannya dengan errors.Join menjadi satu nilai error; slice kosong menghasilkan nil, tanda payload lolos. Setiap pelanggaran dibungkus struct FieldError yang membawa nama field dan pesan, sehingga pesan gabungan tetap bisa dipetakan kembali per field. Idiom ini sama dengan yang dibahas di chapter Validasi Data .

Pointer Field untuk Membedakan “Tidak Dikirim” #

Field string biasa tidak bisa membedakan dua kondisi: field tidak ada di JSON (nilainya "") dan field dikirim kosong (juga ""). Field pointer menyelesaikannya — pointer nil berarti field tidak dikirim, pointer ke nilai berarti field dikirim. Aturan “wajib diisi” cukup memeriksa nil, sementara aturan panjang minimum memeriksa isi di balik pointer. Struct TugasInput pada program memakai *string untuk title, *int untuk priority, dan *string opsional untuk due_date.

Pesan Galat per Field yang Terurut #

Respons galat validasi memakai peta map[string]string dengan field sebagai kunci. Encoding JSON dari map di Go selalu menulis kunci terurut secara alfabetis — "due_date", "priority", "title" — sehingga output deterministik dan mudah dibandingkan pada pengujian. Klien cukup membaca peta fields untuk menampilkan seluruh masalah sekaligus di satu formulir.

Program: Validasi Pembuatan Tugas #

Program validation-demo.go melayani POST /todos dengan lima langkah berlapis, lalu mengujinya dengan lima kasus: payload sah, content-type salah, JSON rusak, tiga field melanggar aturan sekaligus, dan body dua ribu byte.

go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"time"
)

// TugasInput memakai pointer field agar kehadiran tiap field
// bisa dibedakan dari nilai kosong setelah decode.
type TugasInput struct {
	Title    *string `json:"title"`
	Priority *int    `json:"priority"`
	DueDate  *string `json:"due_date"`
}

// FieldError membawa satu pelanggaran validasi: field mana dan alasannya.
type FieldError struct {
	Field string
	Pesan string
}

func (e FieldError) Error() string { return e.Field + " " + e.Pesan }

// Validate memeriksa seluruh aturan sekaligus lalu menggabungkan
// semua pelanggaran memakai errors.Join.
func (in TugasInput) Validate() error {
	var galat []error
	if in.Title == nil {
		galat = append(galat, FieldError{Field: "title", Pesan: "wajib diisi"})
	} else if len(*in.Title) < 3 {
		galat = append(galat, FieldError{
			Field: "title",
			Pesan: fmt.Sprintf("minimal 3 huruf, dapat %d", len(*in.Title)),
		})
	}
	if in.Priority == nil {
		galat = append(galat, FieldError{Field: "priority", Pesan: "wajib diisi"})
	} else if *in.Priority < 1 || *in.Priority > 5 {
		galat = append(galat, FieldError{
			Field: "priority",
			Pesan: fmt.Sprintf("harus 1-5, dapat %d", *in.Priority),
		})
	}
	if in.DueDate != nil {
		if _, err := time.Parse("2006-01-02", *in.DueDate); err != nil {
			galat = append(galat, FieldError{Field: "due_date", Pesan: "harus format YYYY-MM-DD"})
		}
	}
	return errors.Join(galat...)
}

// Handler menyimpan jumlah tugas yang berhasil dibuat.
type Handler struct {
	jumlah int
}

func (h *Handler) buat(w http.ResponseWriter, r *http.Request) {
	// Langkah 1: pastikan klien benar-benar mengirim JSON.
	if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
		tulisGalat(w, http.StatusUnsupportedMediaType, "UNSUPPORTED_MEDIA_TYPE",
			"Content-Type harus application/json, dapat "+ct)
		return
	}

	// Langkah 2: batasi ukuran body sebelum didecode.
	r.Body = http.MaxBytesReader(w, r.Body, 1024)

	// Langkah 3: decode payload.
	var in TugasInput
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		var terlaluBesar *http.MaxBytesError
		if errors.As(err, &terlaluBesar) {
			tulisGalat(w, http.StatusRequestEntityTooLarge, "BODY_TOO_LARGE",
				fmt.Sprintf("body melebihi batas %d byte", terlaluBesar.Limit))
			return
		}
		tulisGalat(w, http.StatusBadRequest, "INVALID_JSON", "body bukan JSON yang valid")
		return
	}

	// Langkah 4: validasi aturan bisnis secara menyeluruh.
	if err := in.Validate(); err != nil {
		tulisJSON(w, http.StatusBadRequest, galatBody{Error: galatRincian{
			Code:    "VALIDATION_ERROR",
			Message: "payload tidak lolos validasi",
			Fields:  petakanGalat(err),
		}})
		return
	}

	// Langkah 5: data sah — simpan lalu balas 201.
	h.jumlah++
	tulisJSON(w, http.StatusCreated, tugas{ID: h.jumlah, Title: *in.Title, Priority: *in.Priority})
}

type tugas struct {
	ID       int    `json:"id"`
	Title    string `json:"title"`
	Priority int    `json:"priority"`
}

type galatBody struct {
	Error galatRincian `json:"error"`
}

type galatRincian struct {
	Code    string            `json:"code"`
	Message string            `json:"message"`
	Fields  map[string]string `json:"fields,omitempty"`
}

// petakanGalat mengubah hasil errors.Join menjadi peta field -> pesan
// sehingga klien bisa memperbaiki seluruh field sekaligus.
func petakanGalat(err error) map[string]string {
	hasil := make(map[string]string)
	if gabungan, ok := err.(interface{ Unwrap() []error }); ok {
		for _, anak := range gabungan.Unwrap() {
			if fe, ok := anak.(FieldError); ok {
				hasil[fe.Field] = fe.Pesan
			}
		}
	}
	return hasil
}

func tulisGalat(w http.ResponseWriter, status int, kode, pesan string) {
	tulisJSON(w, status, galatBody{Error: galatRincian{Code: kode, Message: pesan}})
}

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 main() {
	h := &Handler{}
	mux := http.NewServeMux()
	mux.HandleFunc("POST /todos", h.buat)

	// Kasus 1: payload sah, respons 201.
	kirim(mux, "application/json", `{"title":"Belajar REST API","priority":2,"due_date":"2026-01-20"}`)

	// Kasus 2: body JSON tapi Content-Type salah, respons 415.
	kirim(mux, "text/plain", `{"title":"Belajar REST API","priority":2}`)

	// Kasus 3: JSON rusak, respons 400.
	kirim(mux, "application/json", `{"title":`)

	// Kasus 4: tiga field melanggar aturan sekaligus, respons 400 dengan detail.
	kirim(mux, "application/json", `{"title":"Bo","priority":9,"due_date":"besok"}`)

	// Kasus 5: body melebihi 1024 byte, respons 413.
	besar := `{"title":"` + strings.Repeat("a", 2000) + `"}`
	kirim(mux, "application/json", besar)
}

// kirim mengeksekusi satu POST lalu mencetak dump status dan body respons.
func kirim(mux *http.ServeMux, ct, body string) {
	req := httptest.NewRequest(http.MethodPost, "/todos", strings.NewReader(body))
	req.Header.Set("Content-Type", ct)
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)
	fmt.Printf("POST /todos (Content-Type: %s) -> %d\n", ct, rec.Code)
	fmt.Printf("   %s", rec.Body.String())
}

Output yang Dihasilkan:

text
POST /todos (Content-Type: application/json) -> 201
   {"id":1,"title":"Belajar REST API","priority":2}
POST /todos (Content-Type: text/plain) -> 415
   {"error":{"code":"UNSUPPORTED_MEDIA_TYPE","message":"Content-Type harus application/json, dapat text/plain"}}
POST /todos (Content-Type: application/json) -> 400
   {"error":{"code":"INVALID_JSON","message":"body bukan JSON yang valid"}}
POST /todos (Content-Type: application/json) -> 400
   {"error":{"code":"VALIDATION_ERROR","message":"payload tidak lolos validasi","fields":{"due_date":"harus format YYYY-MM-DD","priority":"harus 1-5, dapat 9","title":"minimal 3 huruf, dapat 2"}}}
POST /todos (Content-Type: application/json) -> 413
   {"error":{"code":"BODY_TOO_LARGE","message":"body melebihi batas 1024 byte"}}

Mari kita telaah baris demi baris program di atas:

  1. Baris 15-19 (type TugasInput): seluruh field masukan bertipe pointer sehingga kehadiran tiap field dapat dibedakan dari nilainya.
  2. Baris 21-27 (type FieldError): satu pelanggaran dibungkus struct berisi Field dan Pesan; method Error() merangkai keduanya.
  3. Baris 31-55 (Validate): tiga blok pemeriksaan menumpuk hasil ke galat, lalu baris 54 menggabungkannya dengan errors.Join; slice kosong menghasilkan nil.
  4. Baris 63-68 (langkah 1): content-type dibaca dari header; selain application/json, handler menjawab 415 tanpa menyentuh body.
  5. Baris 70-71 (langkah 2): http.MaxBytesReader membatasi pembacaan body ke 1024 byte sebelum decode dijalankan.
  6. Baris 75-84 (langkah 3): kegagalan decode diperiksa dengan errors.As terhadap *http.MaxBytesError — body kebesaran dijawab 413, JSON rusak dijawab 400.
  7. Baris 87-94 (langkah 4): hasil Validate diubah petakanGalat menjadi peta field-pesan, dikirim sebagai 400 dengan rincian lengkap.
  8. Baris 117-129 (petakanGalat): hasil errors.Join dibuka lewat antarmuka Unwrap() []error, tiap anak bertipe FieldError masuk ke peta hasil.
  9. Baris 141-161 (main): lima kasus uji berjalan lewat httptest dan dicetak status serta bodynya oleh kirim (baris 163-171).

Kasus keempat pada output membuktikan tujuan utama chapter ini: satu respons 400 memuat tiga pesan sekaligus untuk title, priority, dan due_date, terurut rapi karena kunci map diurutkan encoder. Format {"error": ...} pada respons masih sesederhana yang dibutuhkan materi ini; pembentukan format respons yang penuh dengan envelope dan representasi resource menjadi fokus chapter Response Format .

Referensi #

Berkas Sumber

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

validation-demo.go

Unduh berkas
go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"time"
)

// TugasInput memakai pointer field agar kehadiran tiap field
// bisa dibedakan dari nilai kosong setelah decode.
type TugasInput struct {
	Title    *string `json:"title"`
	Priority *int    `json:"priority"`
	DueDate  *string `json:"due_date"`
}

// FieldError membawa satu pelanggaran validasi: field mana dan alasannya.
type FieldError struct {
	Field string
	Pesan string
}

func (e FieldError) Error() string { return e.Field + " " + e.Pesan }

// Validate memeriksa seluruh aturan sekaligus lalu menggabungkan
// semua pelanggaran memakai errors.Join.
func (in TugasInput) Validate() error {
	var galat []error
	if in.Title == nil {
		galat = append(galat, FieldError{Field: "title", Pesan: "wajib diisi"})
	} else if len(*in.Title) < 3 {
		galat = append(galat, FieldError{
			Field: "title",
			Pesan: fmt.Sprintf("minimal 3 huruf, dapat %d", len(*in.Title)),
		})
	}
	if in.Priority == nil {
		galat = append(galat, FieldError{Field: "priority", Pesan: "wajib diisi"})
	} else if *in.Priority < 1 || *in.Priority > 5 {
		galat = append(galat, FieldError{
			Field: "priority",
			Pesan: fmt.Sprintf("harus 1-5, dapat %d", *in.Priority),
		})
	}
	if in.DueDate != nil {
		if _, err := time.Parse("2006-01-02", *in.DueDate); err != nil {
			galat = append(galat, FieldError{Field: "due_date", Pesan: "harus format YYYY-MM-DD"})
		}
	}
	return errors.Join(galat...)
}

// Handler menyimpan jumlah tugas yang berhasil dibuat.
type Handler struct {
	jumlah int
}

func (h *Handler) buat(w http.ResponseWriter, r *http.Request) {
	// Langkah 1: pastikan klien benar-benar mengirim JSON.
	if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
		tulisGalat(w, http.StatusUnsupportedMediaType, "UNSUPPORTED_MEDIA_TYPE",
			"Content-Type harus application/json, dapat "+ct)
		return
	}

	// Langkah 2: batasi ukuran body sebelum didecode.
	r.Body = http.MaxBytesReader(w, r.Body, 1024)

	// Langkah 3: decode payload.
	var in TugasInput
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		var terlaluBesar *http.MaxBytesError
		if errors.As(err, &terlaluBesar) {
			tulisGalat(w, http.StatusRequestEntityTooLarge, "BODY_TOO_LARGE",
				fmt.Sprintf("body melebihi batas %d byte", terlaluBesar.Limit))
			return
		}
		tulisGalat(w, http.StatusBadRequest, "INVALID_JSON", "body bukan JSON yang valid")
		return
	}

	// Langkah 4: validasi aturan bisnis secara menyeluruh.
	if err := in.Validate(); err != nil {
		tulisJSON(w, http.StatusBadRequest, galatBody{Error: galatRincian{
			Code:    "VALIDATION_ERROR",
			Message: "payload tidak lolos validasi",
			Fields:  petakanGalat(err),
		}})
		return
	}

	// Langkah 5: data sah — simpan lalu balas 201.
	h.jumlah++
	tulisJSON(w, http.StatusCreated, tugas{ID: h.jumlah, Title: *in.Title, Priority: *in.Priority})
}

type tugas struct {
	ID       int    `json:"id"`
	Title    string `json:"title"`
	Priority int    `json:"priority"`
}

type galatBody struct {
	Error galatRincian `json:"error"`
}

type galatRincian struct {
	Code    string            `json:"code"`
	Message string            `json:"message"`
	Fields  map[string]string `json:"fields,omitempty"`
}

// petakanGalat mengubah hasil errors.Join menjadi peta field -> pesan
// sehingga klien bisa memperbaiki seluruh field sekaligus.
func petakanGalat(err error) map[string]string {
	hasil := make(map[string]string)
	if gabungan, ok := err.(interface{ Unwrap() []error }); ok {
		for _, anak := range gabungan.Unwrap() {
			if fe, ok := anak.(FieldError); ok {
				hasil[fe.Field] = fe.Pesan
			}
		}
	}
	return hasil
}

func tulisGalat(w http.ResponseWriter, status int, kode, pesan string) {
	tulisJSON(w, status, galatBody{Error: galatRincian{Code: kode, Message: pesan}})
}

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 main() {
	h := &Handler{}
	mux := http.NewServeMux()
	mux.HandleFunc("POST /todos", h.buat)

	// Kasus 1: payload sah, respons 201.
	kirim(mux, "application/json", `{"title":"Belajar REST API","priority":2,"due_date":"2026-01-20"}`)

	// Kasus 2: body JSON tapi Content-Type salah, respons 415.
	kirim(mux, "text/plain", `{"title":"Belajar REST API","priority":2}`)

	// Kasus 3: JSON rusak, respons 400.
	kirim(mux, "application/json", `{"title":`)

	// Kasus 4: tiga field melanggar aturan sekaligus, respons 400 dengan detail.
	kirim(mux, "application/json", `{"title":"Bo","priority":9,"due_date":"besok"}`)

	// Kasus 5: body melebihi 1024 byte, respons 413.
	besar := `{"title":"` + strings.Repeat("a", 2000) + `"}`
	kirim(mux, "application/json", besar)
}

// kirim mengeksekusi satu POST lalu mencetak dump status dan body respons.
func kirim(mux *http.ServeMux, ct, body string) {
	req := httptest.NewRequest(http.MethodPost, "/todos", strings.NewReader(body))
	req.Header.Set("Content-Type", ct)
	rec := httptest.NewRecorder()
	mux.ServeHTTP(rec, req)
	fmt.Printf("POST /todos (Content-Type: %s) -> %d\n", ct, rec.Code)
	fmt.Printf("   %s", rec.Body.String())
}