Kodeloka
  1. Kodeloka
  2. Kelas Golang
  3. Materi

Domain 13 · 8 bab

Golang Validation

150. Pengenalan Data Validation & Library go-playground/validator

Memahami pentingnya validasi data input di Golang dan menginstal package go-playground/validator.

Mengapa Data Validation Sangat Penting?

Dalam aplikasi backend, data input dari pengguna (seperti JSON request body, query parameter, atau form) tidak pernah boleh dipercaya langsung. Data yang invalid dapat menyebabkan panik runtime, query SQL yang bermasalah, atau celah keamanan (security vulnerabilities).

💡 Apa itu go-playground/validator?

go-playground/validator adalah library validasi paling populer di ekosistem Go. Library ini memungkinkan developer menentukan aturan validasi langsung pada Struct Field menggunakan Struct Tags.

Cara Kerja Validator:

  • Inisialisasi Validator: Membuat instance `validate = validator.New()`.
  • Struct Tag `validate`: Menambahkan aturan seperti `validate:"required,email"`.
  • Eksekusi Validasi: Memanggil `err := validate.Struct(user)`.

Instalasi Package:

go get github.com/go-playground/validator/v10

Contoh program

package main

import (
	"fmt"
	"strings"
)

// Simulasi Struct & Validator Engine
type UserRequest struct {
	Username string
	Email    string
	Age      int
}

func (u UserRequest) Validate() []string {
	var errs []string
	if strings.TrimSpace(u.Username) == "" {
		errs = append(errs, "Username wajib diisi")
	}
	if !strings.Contains(u.Email, "@") {
		errs = append(errs, "Email harus berupa format email yang valid")
	}
	if u.Age < 17 {
		errs = append(errs, "Umur minimal 17 tahun")
	}
	return errs
}

func main() {
	fmt.Println("=== DEMO DATA VALIDATION GOLANG ===")
	user := UserRequest{
		Username: "",
		Email:    "invalid-email-format",
		Age:      15,
	}

	errs := user.Validate()
	if len(errs) > 0 {
		fmt.Println("Validation Errors:")
		for i, err := range errs {
			fmt.Printf("  %d. %s\n", i+1, err)
		}
	} else {
		fmt.Println("Data Valid!")
	}
}

Praktik dan kuis bab ini di kelas interaktif →

151. Validasi Field Dasar (Required, Email, Min/Max, Numeric)

Menggunakan tag-tag bawaan validator seperti required, email, min, max, gte, lte, dan alpha.

Tag Validasi Standar yang Sering Digunakan

go-playground/validator memiliki puluhan tag bawaan yang siap pakai untuk menguji format data field secara deklaratif.

Tag Keterangan Contoh Penggunaan Tag
required Field tidak boleh kosong atau bernilai zero value. validate:"required"
email Memastikan format string adalah alamat email valid. validate:"email"
min/max Membatasi panjang string/slice atau nilai minimal/maksimal angka. validate:"min=3,max=20"
gte/lte Greater Than or Equal (>=) / Less Than or Equal (<=). validate:"gte=18,lte=60"
numeric Memastikan string hanya berisi angka 0-9. validate:"numeric"
📌 Menggabungkan Banyak Tag:

Gunakan koma (,) tanpa spasi untuk menggabungkan beberapa aturan validasi sekaligus:

type RegisterDTO struct {
    Username string `json:"username" validate:"required,min=4,max=32"`
    Email    string `json:"email" validate:"required,email"`
    Age      int    `json:"age" validate:"required,gte=17"`
}

Contoh program

package main

import (
	"fmt"
	"regexp"
)

type Employee struct {
	Name   string
	Email  string
	Age    int
	Salary float64
}

func ValidateEmployee(e Employee) map[string]string {
	errs := make(map[string]string)
	if len(e.Name) < 3 || len(e.Name) > 20 {
		errs["Name"] = "Name harus antara 3 hingga 20 karakter"
	}
	emailRegex := regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`)
	if !emailRegex.MatchString(strings.ToLower(e.Email)) {
		errs["Email"] = "Format email tidak valid"
	}
	if e.Age < 18 || e.Age > 65 {
		errs["Age"] = "Age harus antara 18 dan 65 tahun"
	}
	if e.Salary <= 0 {
		errs["Salary"] = "Salary harus lebih besar dari 0"
	}
	return errs
}

func main() {
	emp := Employee{
		Name:   "B",
		Email:  "budi.com",
		Age:    15,
		Salary: 0,
	}

	errs := ValidateEmployee(emp)
	fmt.Println("=== HASIL VALIDASI EMPLOYEE ===")
	for field, msg := range errs {
		fmt.Printf("- Field '%s': %s\n", field, msg)
	}
}

Praktik dan kuis bab ini di kelas interaktif →

152. Validasi Struct & Nested Struct Validation

Melakukan validasi bertingkat pada nested struct, slice of struct, dan pointer struct dengan tag dive.

Nested Struct & Slice Validation

Saat aplikasi kita mengolah data kompleks (seperti Order yang berisi daftar Items, atau User yang memiliki Address), kita perlu memastikan field di dalam sub-struct atau slice ikut divalidasi secara rekursif.

💡 Tag `dive`

Tag dive memberi tahu validator untuk "menyelam" ke dalam elemen slice, array, atau map, lalu menerapkan aturan validasi ke setiap item di dalamnya.

Contoh Sintaks Nested Validation:

type Address struct {
    Street  string `validate:"required"`
    City    string `validate:"required"`
    ZipCode string `validate:"required,numeric,len=5"`
}

type User struct {
    Name    string    `validate:"required"`
    Address Address   `validate:"required"` // Otomatis menguji sub-struct Address
    Hobbies []string  `validate:"required,dive,min=2"` // Memvalidasi tiap elemen hobby
}

Contoh program

package main

import (
	"fmt"
)

type Item struct {
	Name  string
	Price float64
	Qty   int
}

type Order struct {
	OrderID     string
	Customer    string
	Items       []Item
}

func ValidateOrder(o Order) []string {
	var errs []string
	if o.OrderID == "" {
		errs = append(errs, "OrderID tidak boleh kosong")
	}
	if o.Customer == "" {
		errs = append(errs, "Customer tidak boleh kosong")
	}
	if len(o.Items) == 0 {
		errs = append(errs, "Order harus memiliki setidaknya 1 item")
	}
	for i, item := range o.Items {
		if item.Name == "" {
			errs = append(errs, fmt.Sprintf("Item [%d]: Name tidak boleh kosong", i))
		}
		if item.Price <= 0 {
			errs = append(errs, fmt.Sprintf("Item [%d]: Price harus > 0", i))
		}
		if item.Qty <= 0 {
			errs = append(errs, fmt.Sprintf("Item [%d]: Qty harus > 0", i))
		}
	}
	return errs
}

func main() {
	order := Order{
		OrderID:  "ORD-991",
		Customer: "Andi",
		Items: []Item{
			{Name: "Laptop", Price: 12000000, Qty: 1},
			{Name: "", Price: 0, Qty: 0}, // Invalid item
		},
	}

	errs := ValidateOrder(order)
	fmt.Println("=== VALIDASI NESTED ORDER ===")
	if len(errs) > 0 {
		for _, e := range errs {
			fmt.Println("ERROR:", e)
		}
	} else {
		fmt.Println("Order Valid!")
	}
}

Praktik dan kuis bab ini di kelas interaktif →

153. Custom Validation Function & Registration

Membuat aturan validasi sendiri (custom validator) menggunakan RegisterValidation untuk logika bisnis spesifik.

Kebutuhan Custom Validation

Tag bawaan tidak selalu cukup. Misalnya, kita ingin memastikan input berupa Plat Nomor Indonesia (misal: "B 1234 CD"), NIK KTP (16 digit), atau Slug string tanpa spasi.

📌 Langkah Membuat Custom Validator:
  1. Buat fungsi ber-signature func(fl validator.FieldLevel) bool.
  2. Dapatkan nilai field via fl.Field().String() atau fl.Field().Interface().
  3. Daftarkan tag kustom via validate.RegisterValidation("nama_tag", fungsiSaya).
  4. Gunakan tag tersebut di Struct Tag: `validate:"nama_tag"`.

Contoh program

package main

import (
	"fmt"
	"regexp"
)

// Custom validation function simulation
func IsIndonesianPhone(phone string) bool {
	// Memastikan berawalan +62 atau 08 dan memiliki panjang 10-14 digit
	re := regexp.MustCompile(`^(?:\+62|08)[0-9]{8,12}$`)
	return re.MatchString(phone)
}

type Contact struct {
	Name  string
	Phone string
}

func main() {
	contacts := []Contact{
		{Name: "Budi", Phone: "+628123456789"},
		{Name: "Siti", Phone: "085712345678"},
		{Name: "Joko", Phone: "12345"}, // Invalid
	}

	fmt.Println("=== TESTING CUSTOM PHONE VALIDATOR ===")
	for _, c := range contacts {
		valid := IsIndonesianPhone(c.Phone)
		status := "VALID"
		if !valid {
			status = "INVALID"
		}
		fmt.Printf("Kontak %s (%s) => %s\n", c.Name, c.Phone, status)
	}
}

Praktik dan kuis bab ini di kelas interaktif →

154. Cross-Field & Struct Level Validation

Validasi yang membandingkan antar dua field berbeda (seperti Password vs ConfirmPassword, StartDate vs EndDate).

Validasi Lintas Field (Cross-Field)

Sering kali keabsahan suatu field tergantung pada nilai field lain di struct yang sama. Contoh klasik:

  • Confirm Password: Harus sama persis dengan field `Password` (eqfield=Password).
  • Rentang Tanggal: `EndDate` harus lebih besar dari `StartDate` (gtfield=StartDate).
💡 Tag Cross-Field Populer:
  • eqfield=OtherField: Nilai harus sama dengan OtherField.
  • nefield=OtherField: Nilai tidak boleh sama dengan OtherField.
  • gtfield=OtherField: Nilai numerik/tanggal lebih besar dari OtherField.
  • gtefield=OtherField: Nilai numerik/tanggal lebih besar atau sama dengan OtherField.

Contoh program

package main

import (
	"fmt"
)

type RegisterForm struct {
	Password        string
	ConfirmPassword string
	MinAge          int
	MaxAge          int
}

func ValidateRegisterForm(f RegisterForm) []string {
	var errs []string
	if f.Password != f.ConfirmPassword {
		errs = append(errs, "ConfirmPassword tidak cocok dengan Password (eqfield violation)")
	}
	if f.MaxAge <= f.MinAge {
		errs = append(errs, "MaxAge harus lebih besar dari MinAge (gtfield violation)")
	}
	return errs
}

func main() {
	form := RegisterForm{
		Password:        "secret123",
		ConfirmPassword: "secret456", // Berbeda
		MinAge:          20,
		MaxAge:          18, // Salah, harus > MinAge
	}

	fmt.Println("=== DEMO CROSS-FIELD VALIDATION ===")
	errs := ValidateRegisterForm(form)
	for _, err := range errs {
		fmt.Println("VALIDATION ERROR:", err)
	}
}

Praktik dan kuis bab ini di kelas interaktif →

155. Alias Tag & Custom Error Messages (Translation)

Meringkas sekumpulan tag dengan Alias dan mengkonfigurasi i18n translator untuk merender error berbahasa Indonesia.

Alias Tag & Internationalization (i18n)

Jika kita sering mengulang sekumpulan tag rumit, kita bisa mendaftarkan Alias Tag. Selain itu, pesan error bawaan validator berformat teknis dalam bahasa Inggris. Kita dapat menerjemahkannya ke Bahasa Indonesia menggunakan package `go-playground/universal-translator`.

📌 Registrasi Alias Tag:
validate.RegisterAlias("is-password", "required,min=8,max=64")
// Sekarang di struct tag cukup gunakan:
// Password string `validate:"is-password"`

Contoh program

package main

import (
	"fmt"
)

// Simulasi Formatter Error Terjemahan
func FormatErrorMessage(field, tag, param string) string {
	switch tag {
	case "required":
		return fmt.Sprintf("Field '%s' tidak boleh kosong.", field)
	case "email":
		return fmt.Sprintf("Field '%s' harus berupa alamat email yang valid.", field)
	case "min":
		return fmt.Sprintf("Field '%s' minimal harus berisi %s karakter.", field, param)
	default:
		return fmt.Sprintf("Field '%s' tidak memenuhi aturan %s.", field, tag)
	}
}

func main() {
	fmt.Println("=== DEMO CUSTOM TRANSLATION ERROR ===")
	fmt.Println(FormatErrorMessage("Email", "email", ""))
	fmt.Println(FormatErrorMessage("Username", "required", ""))
	fmt.Println(FormatErrorMessage("Password", "min", "8"))
}

Praktik dan kuis bab ini di kelas interaktif →

156. Handling & Formatting Validation Errors

Mengekstrak ValidationErrors interface menjadi struktur JSON Error Response yang rapi untuk REST API.

Struktur Response Error yang User-Friendly

Saat error validasi terjadi, `validate.Struct()` mengembalikan error bertipe `validator.ValidationErrors`. Kita harus melakukan type assertion untuk mengambil detail field mana yang gagal beserta parameternya.

💡 Format JSON Response Error Standar:
{
  "code": 400,
  "status": "BAD_REQUEST",
  "errors": [
    {"field": "email", "message": "Email is required"},
    {"field": "age", "message": "Age must be at least 17"}
  ]
}

Contoh program

package main

import (
	"encoding/json"
	"fmt"
)

type FieldErrorResponse struct {
	Field   string `json:"field"`
	Message string `json:"message"`
}

type APIErrorResponse struct {
	Code   int                  `json:"code"`
	Status string               `json:"status"`
	Errors []FieldErrorResponse `json:"errors"`
}

func main() {
	resp := APIErrorResponse{
		Code:   400,
		Status: "BAD_REQUEST",
		Errors: []FieldErrorResponse{
			{Field: "username", Message: "Username minimal 4 karakter"},
			{Field: "email", Message: "Format email tidak sesuai"},
		},
	}

	jsonBytes, _ := json.MarshalIndent(resp, "", "  ")
	fmt.Println("=== JSON VALIDATION ERROR RESPONSE ===")
	fmt.Println(string(jsonBytes))
}

Praktik dan kuis bab ini di kelas interaktif →

157. Studi Kasus: RESTful API DTO Request Validation Pipeline

Membangun middleware dan request pipeline terpadu untuk memvalidasi JSON DTO pada endpoint API.

Arsitektur Request Validation Pipeline

Dalam aplikasi profesional, proses validasi DTO dipisah dalam helper/middleware terpusat sebelum request diteruskan ke layer Controller atau Service.

📌 Flow Eksekusi:
  1. HTTP Handler menerima JSON Payload.
  2. Decode JSON ke Struct DTO via json.NewDecoder(r.Body).Decode(&dto).
  3. Jalankan validate.Struct(dto).
  4. Jika ada error, format menjadi JSON Error 400 Bad Request dan akhiri request.
  5. Jika valid, teruskan DTO ke Business Logic Service.

Contoh program

package main

import (
	"fmt"
)

type CreateProductDTO struct {
	SKU   string
	Name  string
	Price float64
	Stock int
}

func (dto CreateProductDTO) Validate() error {
	if dto.SKU == "" {
		return fmt.Errorf("SKU tidak boleh kosong")
	}
	if dto.Name == "" {
		return fmt.Errorf("Name produk wajib diisi")
	}
	if dto.Price <= 0 {
		return fmt.Errorf("Price harus lebih dari 0")
	}
	if dto.Stock < 0 {
		return fmt.Errorf("Stock tidak boleh negatif")
	}
	return nil
}

func ProductController(dto CreateProductDTO) {
	if err := dto.Validate(); err != nil {
		fmt.Println("[RESPONSE HTTP 400 Bad Request]:", err)
		return
	}
	fmt.Println("[RESPONSE HTTP 201 Created]: Produk berhasil disimpan -> SKU:", dto.SKU)
}

func main() {
	fmt.Println("=== DEMO PIPELINE REST API DTO ===")
	badReq := CreateProductDTO{SKU: "SKU-001", Name: "", Price: 0, Stock: -5}
	ProductController(badReq)

	goodReq := CreateProductDTO{SKU: "SKU-002", Name: "Mouse Wireless", Price: 150000, Stock: 20}
	ProductController(goodReq)
}

Praktik dan kuis bab ini di kelas interaktif →