# Go Guidance

Mempelajari pemrograman golang untuk pemula. Materi akan dibahas step by step dari basic sampai mahir. Ruang lingkup pembelajaran meliputi :

## [Basic](/golang-fundamental/01-basic)

* Install golang
* Hello world
* Package, type, constanta, variable, function
* Flow controll : if, else, switch, for, defer
* Array : array, slice, map

## [Struktur Data](/golang-fundamental/02-struktur-data)

* struct
* Method
* Interface
* Encapsulation, inheritance and polymorphism

## [Konkurensi](/golang-fundamental/03-konkurensi)

* Go routine
* Channel
* Channel dengan buffer
* Range dan close
* Select
* Select default
* Select timeout
* Sync Mutex
* Handling sync group routine

## [Concurrency Pattern](/concurrency-pattern/03-concurrency-pattern)

* [Worker Pool](/concurrency-pattern/04-worker-pool)
* [Future / Promise](/concurrency-pattern/08-future-promise)
* [Rate Limit Pattern](/concurrency-pattern/09-rate-limit)
* [Semaphore](/concurrency-pattern/10-semaphore)
* [Single Flight](/concurrency-pattern/12-single-flight)

## Build API Framework

Step by step membuat golang API framework, baik rest api maunpun grpc, baik monolit maupun microservices, baik monorepo maupun multirepo.

* [Start up](/build-api-framework/01-start-up)
* [Shutdown](/build-api-framework/02-shutdown)
* [Json](/build-api-framework/03-json)
* [Database](/build-api-framework/04-database)
* [Clean architecture](/build-api-framework/05-clean-architecture)
* [Configuration](/build-api-framework/06-configuration)
* [Fatal](/build-api-framework/07-fatal)
* [Bootstrap](/build-api-framework/08-bootstrap)
* [Logging](/build-api-framework/09-logging)
* [Routing](/build-api-framework/10-routing)
* [CRUD](/build-api-framework/11-crud)
* [Standard Response](/build-api-framework/12-standard-response)
* [Error handler](/build-api-framework/13-error-handler)
* [Context](/build-api-framework/14-context)
* [Validation](/build-api-framework/15-validation)
* [Middleware](/build-api-framework/16-middleware)
* [Token](/build-api-framework/17-token)
* [RBAC](/build-api-framework/18-rbac)
* [Pagination](/build-api-framework/19-pagination)
* [Unit testing](/build-api-framework/20-unit-testing)
* [API testing](/build-api-framework/21-api-testing)
* [Cache](/build-api-framework/22-cache)
* [Load Testing](/build-api-framework/load-testing)

## Referensi Tambahan

* [Buku "The Go Programing Language"](https://www.gopl.io/)
* [Dokumentasi Resmi Golang](https://golang.org/doc/)


# Basic Golang

Bab ini membahas dasar-dasar pemrograman golang

## Installasi

Anda dapat membaca [dokumentasi instalasi golang](https://golang.org/doc/install)

## Membuat Projek Baru

* Buat folder baru untuk memulai project
* go mod init golang-latihan
* Buat file main.go yang berisi kode berikut
* Jalankan `go run main.go`

```go
package main

func main(){
  println("Hello World")
}
```

## Fundamental

### Package

* Setiap program GO terdiri paket-paket.
* Program mulai berjalan dari paket utama (main package).
* Dalam satu project hanya boleh ada satu package main
* Selain paket main, nama paket harus sama dengan nama folder.

### Type

* Go is a statically typed programming language
* Setiap variabel, konstanta dan lain-lain memiliki type
* tipe dasar :

```
bool

string

int  int8  int16  int32  int64
uint uint8 uint16 uint32 uint64 uintptr

byte // alias untuk uint8

rune // alias untuk int32
     // merepresentasikan sebuah kode Unicode

float32 float64

complex64 complex128
```

* Ada tipe turunan seperti array, slice, map, interface, struct, dan function

### Konstanta

```go
const pi float64 = 22/7
const (
  alamat string = "Gandaria City, Jakarta Selatan"
  email string = "emailku@waresix.com"
)
```

### Variabel

```go
package repositories
// contoh variabel yang siklus hidupnya ada dalam satu paket. 
// Seluruh kode dalam paket repositories, biarpun berbeda file bisa mengakses variabel ini
var err error

func Satu() {
  // NOTE : siklus hidup variabel-variable ini hanya berlaku dalam fungsi Satu

  // variabel harus dideklarasikan terlebih dahulu
  var a int

  // variable yang telah dideklarasikan bisa diisi dengan nilai yang sesuai
  a = 1

  // variabel bisa dideklarasikan secara implisit dan sekaligus langsung diberi nilai. 
  // tipe akan disematkan secara implisit pada variabel ini
  b := 2 

  // siklus hidup variabel bisa hanya dalam blok yang membatasi. 
  // blok bisa berupa blok if, for, fungsi atau bahkan hanya notasi blok saja. 
  {
    var c string
    c = "variabel di dalam blok"
    println(c)
  }
}
```

### Siklus hidup variabel

```go
package repositories
// contoh variabel yang siklus hidupnya ada dalam satu paket. 
// Seluruh kode dalam paket repositories, biarpun berbeda file bisa mengakses variabel ini
var err error

func Satu() {
  // siklus hidup variabel-variable ini hanya berlaku dalam fungsi Satu

  var a int
  a = 1

  b := 2 

  // siklus hidup variabel bisa hanya dalam blok yang membatasi. 
  // blok bisa berupa blok if, for, fungsi atau bahkan hanya notasi blok saja. 
  {
    var c string
    c = "variabel di dalam blok"
    println(c)
  }
}
```

### Pengoptimalan penggunaan memori dalam siklus hidup variabel

* Jika ingin membuat variabel global dalam satu paket, sebaiknya pertimbangkan kembali, karena siklus hidupnya ada di seluruh kode dalam paket tersebut
* Untuk menghemat memori, deklarasikan variabel sesuai dengan kebutuhan siklus hidupnya

```go
package main

func main() {
    // variabel i akan tetap hidup walaupun looping for sudah selesai
    i := 0
    for i < 10 {
        println(i)
        i++
    }

    // variabel i hanya hidup dalam blok for
    for i := 0; i < 10; i++ {
        println(i)
    }

    myMap := map[string]string{"Satu": "Ahad", "Dua": "Senin", "Tiga": "Selasa"}

    // variabel value dan ok tetap hidup walaupun blok if / if else sudah berakhir
    value, ok := myMap["Satu"]
    if ok {
        println(value)
    }

    // variabel value dan ok hanya hidup dalam blok if / if else
    if value, ok := myMap["Dua"]; ok {
        println(value)
    }

    myName := string("Jet Lee")
    {
        name := string("Jacky")
        println(name)
    }

    println(myName)
}
```

### Package, Export dan Import

* Semua type, var, const, func dalam suatu paket yang sama bisa dipanggil di manapun (meskipun berbeda file)
* Untuk bisa dipanggil di paket lain, type, var, const, func harus diexport terlebih dahulu
* Cara export dilakukan dengan memberi nama var, type, const, fungsi dll yang diawali dengan huruf besar
* Jika suatu paket ingin menggunakan kode dari paket lainnya, harus mengimport terlebih dahulu

```go
// file APP/latihan/satu.go
package latihan

// MyStr adalah type yang diexport
type MyStr string

// Salam adalah fungsi diexport
func Salam (m MyStr) {
    println(m)
    cetaknama()
}

// cetaknama adalah fungsi privat yang tidak diexport
func cetaknama () {
    println(nama)
}
```

```go
// file APP/latihan/dua.go
package latihan

// nama var tidak diexport, tapi bisa gunakan di seluruh program dalam paket latihan
var nama string = "Jacky"

// PrintNama merupakan fungsi yang diexport
func PrintNama() {
    println(nama)
}
```

```go
// file APP/main.go
package main

import "golang-latihan/latihan"

func main() {
  str := latihan.MyStr("Selamat Pagi")
    println(str)

    latihan.Salam(latihan.MyStr("Selamat Sore"))

    //latihan.cetaknama()
    latihan.PrintNama()
}
```

### Type Casting

* Untuk mengubah suatu tipe menjadi tipe lain, bisa melalui fungsi bawaan golang maupun menggunakan paket strconv.
* Lebih jauh tentang strconv bisa melihat langsung ke paket strconv

```go
package main

func main() {
    var myInt int
    myInt = 1

    var myUint uint
    myUint = uint(myInt)
    println(myUint)

    myUint32 := uint32(1)
    myUint64 := uint64(myUint32)
    println(myUint64)

    str := string("1")
    myInt, err := strconv.Atoi(str)
    if err != nil {
        panic(err)
    }

    println(myInt)

    str = strconv.Itoa(myInt)
    println(str)
}
```

### Pointer

```go
func main() {
    i := 10
    p := &i       // menunjuk ke i
    println(*p)     // baca i lewat pointer
    *p = 20       // set i lewat pointer
    println(i)      // lihat nilai terbaru dari i
}
```

### Function

* fungsi juga merupakan sebuah tipe

```go
// contoh membuat type berupa fungsi 
type Handler func(http.ResponseWriter, *http.Request)
type CustomeHandler func(http.ResponseWriter, *http.Request) error
```

* Format fungsi : func NAMA (argument type) type\_return

```go
// Contoh membuat suatu fungsi
func Jumlah (a int, b int) int {
  return a+b
}
```

```go
type operasi func(a int, b int) int
func main() {
  // Lambda
  println(func() string {
    return "lambda"
  }())

  // Closure
  var GetClosure = func() string {
    return "closure"
  }

  var closure string
  closure = GetClosure()
  println(closure)

  // Callback dengan lambda :: double square
  println(square(func(i int) int {
      return i * i
  }, 2))

  // Callback dengan closure, dengan tipe data implisit
  var Jumlah = func(a int, b int) int {
      return a + b
  }

  // Callback dengan closure, dengan tipe data explisit
  var Kurang operasi = func(a int, b int) int {
      return a - b
  }

  println("Operasi Jumlah : ", Hitung(Jumlah, 5, 3))
  println("Operasi Kurang : ", Hitung(Kurang, 5, 3))
}

func square(f func(int) int, x int) int {
    return f(x * x)
}

func Hitung(o operasi, x int, y int) int {
    return o(x, y)
}
```

### Variadic Function

* Merupakan fungsi dengan jumlah argumen yang dinamis.
* Bisa dipanggil dengan cara biasa dengan argumen individual
* Bisa dipanggil secara dinamis dengan melempar argumen slice...

```go
func sum(nums ...int) {
    total := 0
    for _, num := range nums {
        total += num
    }
    println(total)
}

func main() {
    sum(1)
    sum(1, 2)
    sum(2, 3, 4)

    nums := []int{1, 2, 3, 4}
    sum(nums...)
}
```

## Flow Control

### if

```go
if err != nil {
    return err
}

if err := run(); err != nil {
    return err
}

if b := 1; b < 10 {
    println("blok if", b)    
} else {
    println("blok else", b)
}
```

### switch

```go
func main() {
    fmt.Print("Go berjalan pada ")
    switch os := runtime.GOOS; os {
    case "darwin":
        fmt.Println("OS X.")
    case "linux":
        fmt.Println("Linux.")
    default:
        // freebsd, openbsd,
        // plan9, windows...
        fmt.Printf("%s.\n", os)
    }

    t := time.Now()
    switch {
    case t.Hour() < 12:
        fmt.Println("Selamat pagi!")
    case t.Hour() < 17:
        fmt.Println("Selamat sore.")
    default:
        fmt.Println("Selamat malam.")
    }
}
```

### for

```go
func main() {
    // standard for
    for i:=0; i<=10; i++ {
        println(i)
    }

    // while
    i := 0
    for i<=10 {
        println(i)
        i++
    }

    // infinite loop
    i = 0
    for {
        println(i)
        if i == 10 {
            break
        }
        i++
    }

    // foreach
    array := []uint{0,1,2,3,4,5,6,7,8,9,10}
    for index, value := range array {
        println(index, value)
    }
}
```

### defer

* Perintah defer menunda eksekusi dari sebuah fungsi sampai fungsi yang melingkupinya selesai.
* Argumen untuk pemanggilan defer dievaluasi langsung, tapi pemanggilan fungsi tidak dieksekusi sampai fungsi yang melingkupinya selesai.

```go
func main() {
    defer println("datang")
    println("selamat")
}
```

* Jika ada tumpukan perintah defer, maka akan dieksekusi secara LIFO (last In First Out)

```go
func main() {
    defer println("pertama")
    for i:=0; i<= 10; i++ {
        defer println(i)
    }
    defer println("terakhir")
    println("normal")
}
```

## Array, Slice dan Map

### Array

```go
var salam [2]string
salam[0] = "selamat"
salam[1] = "pagi"
fmt.Println(salam)

greeting := []string{"Good", "Morning"}
fmt.Println(greeting)
```

### Slice

* Merupakan potongan dari sebuah array

```go
var musim [3]string
musim[0] = "panas"
musim[1] = "panas-sekali"
musim[2] = "super-duper-panas"
fmt.Println(musim)
slice := musim[1:2]
fmt.Println(slice)
slice = musim[:2]
fmt.Println(slice)
slice = musim[1:]
fmt.Println(slice)
```

### Map

* kalau di PHP ini seperti assosiatif array.
* index otomatis disort secara alpabet

```go
hari := map[string]int{"Senin":1, "Selasa":2, "Rabu":3}
fmt.Println(hari)
```

### Common Operation

* mengunakan potongan array (slice) sehingga tidak dideklarasikan kapasitasnya
* untuk menambahkan anggota dengan menggunakan fungsi append

```go
var salam []string
salam = append(salam, "selamat")
salam = append(salam, "pagi")
fmt.Println(salam)
```

```go
func main() {
    buah := []string{"rambutan", "durian", "salak"}
    exist, index := InArray("duku", buah)
    println(exist, index)

    buah = Remove("durian", buah)
    fmt.Println(buah)
    buah = append(buah, "mangga")
    fmt.Println(buah)
    exist, index = InArray("salak", buah)
    if exist {
        buah = RemoveByIndex(index, buah)
    }
    fmt.Println(buah)    
}

func InArray(val string, array []string) (bool, int) {
    for i, s := range array {
        if s == val {
            return true, i    
        }
    }

    return false, -1
}

func Remove(val string, array []string) []string {
    isExist, index := InArray(val, array)
    if isExist {
        if index == 0 {
            array = array[1:]
        } else {
            array = append(array[:index], array[(index+1):]...)
        }
    }

    return array
}

func RemoveByIndex(index int, array []string) []string {
    if index == 0 {
        return array[1:]
    } else {
        return append(array[:index], array[(index+1):]...)
    }
}
```


# Struktur Data

Golang bukan merupakan bahasa pemrograman yang berorientasi objek. Tapi golang memiliki fitur seperti type, struct, method, reference dan interface yang memungkinkan untuk melakukan pemrograman yang mirip dengan OOP.

## struct

* sebuah tipe data abstract
* berisi dari kumpulan dari berbagai type
* struct bisa digunakan dalam konsep class

```go
type User struct {
    ID uint64
    Name string    
}

func main() {
    var user User
    user.ID = 1
    user.Name = "Jacky"
    fmt.Printf("%v\n", user)
    println(user.Name)

    user2 := User{ID: 2, Name: "JetLee"}
    fmt.Printf("%v\n", user2)
    println(user2.Name)
}
```

## Method

* Kita bisa mendefiniskan suatu method pada sebuah type.
* Method adalah fungsi yang mempunyai argumen khusus receiver berupa type.

```go
package main

type MyStr string

func (m MyStr) Salam() {
    m = "Selamat Pagi"
    println(m)
}

func main() {
    var str MyStr
    str.Salam()
}
```

* Type yang bisa dibuatkan method adalah type local, yaitu type yang ada dalam paket yang sama dengan method yang dibuat.

```go
package main

// ini error karena string bukan type local dalam paket main
func (m string) Salam() {
    m = "Selamat Pagi"
    println(m)
}

func main() {
    var str string
    str.Salam()
}
```

* Receiver bisa berupa pointer

```go
package main

type myStr string

func (m *myStr) Change() {
    *m = myStr("Selamat Sore")
}

func (m *myStr) Print() {
    println(*m)
}

func main() {
    str := myStr("Selamat Pagi")
    str.Print()
    str.Change()
    str.Print()
}
```

## Interface

* Interface berisi kumpulan yang berisi method yang abstract

```go
type i interface{
    method()
}
```

* Type lain akan mengimplementasikan method dalam interface
* Tidak ada perintah implement, suatu interface akan dipenuhi secara implisit begitu ada yang mengimplementasikannya

```go
package main

type i interface {
    method()
}

type myStr string

func (m *myStr) method() {
    println(*m)
}

func main() {
    var i i
    str := myStr("Hello")
    i = &str
    i.method()
}
```

* Jika suatu interface diinisiasi tapi tidak ada yang mengimplementasikannya akan terjadi error nil pointer dereference

```go
package main

type i interface {
    method()
}

func main() {
    var i i
    i.method()
}
```

* Isi interface dapat dibayangkan sebagai sebuah pasangan nilai dan sebuah tipe: `(nilai, type)`

```go
package main

type i interface{
    method()
}

type myStr string

func (m *myStr) method() {
    println(*m)
}

func main() {
    var i i
    str := myStr("Hello")
    i = &str
    i.method()
        describe(i)
}

func describe(i I) {
    fmt.Printf("(%v, %T)\n", i, i)
}
```

## Interface Kosong

* Interface kosong merupakan interface yang tidak memiliki method
* Untuk mengklaim nilai interface harus dilakukan type asserting

```go
var a interface{}
a = "string"
println(a.(string))

a = false
println(a.(bool))
if value, ok := a.(bool); ok {
    println(value)
}

myMap := map[string]interface{}{"Satu": true, "Dua": "string", "Tiga": uint(3)}
println(myMap["Satu"].(bool))
println(myMap["Dua"].(string))
println(myMap["Tiga"].(uint))
```

* Penggunaan switch type dalam melakukan asserting

```go
package main

type myStr string

func main() {
    var a interface{}
    a = myStr("Jacky")

    switch t := a.(type) {
        case string :
            println("type string", t)
        case bool :
            println("type bool", t)
        case myStr :
            println("type myStr", t)
        default :
            println("type lainnya", t)
    }
}
```

## Pseudo Object

* tidak ada class dalam go, tapi kita bisa menggunakan type
* variable class diganti dengan type struct
* method class diganti dengan method dengan pointer reference
* gunakan kata kunci new() untuk membuat object

```go
package main

import (
    "fmt"
)

type becak struct {
    roda  int
    warna string
}

func (o *becak) caraJalan() string {
    return "dikayuh"
}

func main() {
    becak1 := becak{roda: 3, warna: "biru"}
    fmt.Printf("%v, %T\n", becak1, becak1)
    println("cara jalan:", becak1.caraJalan())

    becak2 := &becak1
    fmt.Printf("%v, %T\n", becak2, becak2)
    println("cara jalan:", becak2.caraJalan())

    becak3 := new(becak)
    becak3.roda = 3
    becak3.warna = "merah"
    fmt.Printf("%v, %T\n", becak3, becak3)
    println("cara jalan:", becak3.caraJalan())
}
```

### Method Overloading

* Method overloading dimungkinkan dengan reference yang berbeda

```go
package main

import (
    "fmt"
)

type becak struct {
    roda  int
    warna string
}

type gerobak struct {
        roda int
        warna string
} 

func (o *becak) caraJalan() string {
    return "dikayuh"
}

func (o *gerobak) caraJalan() string {
    return "didorong"
}

func main() {
    becak := new(becak)
    println("becak", "cara jalan:", becak.caraJalan())

        gerobak := new(gerobak)
        println("gerobak", "cara jalan:", gerobak.caraJalan())
}
```

### Encapsulation

* Encapsulasi terjadi di level paket.
* Kita bisa memilih kode (type, variabel, fungsi dll) yang hendak diexport ke luar paket dan mana yang hanya bisa diakses dalam paket yang sama.
* Penamaan kode yang bersifat publik diawali dengan huruf besar.
* Penamaan kode yang bersifat privat diawali dengan huruf kecil.

```go
// file APP/latihan/kendaraan.go
package latihan

// Kendaraan interface
type Kendaraan interface {
    CaraJalan() string
    SetWarna(string)
    GetWarna() string
    GetRoda() int
}

type becak struct {
    roda  int
    warna string
}

func (o *becak) SetWarna(s string) {
    o.warna = s
}

func (o *becak) GetWarna() string {
    return o.warna
}

func (o *becak) GetRoda() int {
    return 3
}

func (o *becak) CaraJalan() string {
    return "dikayuh"
}

// NewBecak function untuk membuat objek becak
func NewBecak() Kendaraan {
    return &becak{}
}
```

```go
package main

import (
    "golang-essentials/latihan"
)

func main() {
    becak := latihan.NewBecak()
    becak.SetWarna("Biru")
    println(becak.CaraJalan())
    println("jumlah roda:", becak.GetRoda())
    println("warna:", becak.GetWarna())
}
```

### Inheritance

* Go memungkinkan inheritance melalui embedded berupa field anonim

```go
package main

import (
    "fmt"
)

type User struct {
    Name string
    Gender string
    Address
}

type Address struct {
    Street string
    Number string
    City string
    Zipcode string
}

func main () {
    user := new(User)
    user.Name = "Wiro"
    user.Gender = "Male"
    user.Street = "Marlioboro"
    user.Number = "212"
    user.City = "Jogja"

    fmt.Printf("%v", user)
}
```

* Tapi banyak programmer golang yang tidak menyarankan untuk melakukan inheritance. Melainkan melakukan pendekatan object composition.

### Object Composition

* Daripada melakukan pseudo inheritance melalui embedded, disarankan untuk melakukan object composition

```go
package main

import (
    "fmt"
)

type User struct {
    Name string
    Gender string
    Address Address
}

type Address struct {
    Street string
    Number string
    City string
    Zipcode string
}

func main () {
    user := new(User)
    user.Name = "Wiro"
    user.Gender = "Male"
    user.Address.Street = "Marlioboro"
    user.Address.Number = "212"
    user.Address.City = "Jogja"

    fmt.Printf("%v", user)
}
```

### Polymorphism

```go
package main

import "fmt"

type Hewan struct {
    Nama  string
    Nyata bool
}

func (c *Hewan) Cetak() {
    fmt.Printf("Nama: '%s', Nyata: %t\n", c.Nama, c.Nyata)
}

type HewanTerbang struct {
    Hewan
    PanjangSayap int
}

func (c HewanTerbang) Cetak() {
    fmt.Printf("Nama: '%s', Nyata: %t, PanjangSayap: %d\n", c.Nama, c.Nyata, c.PanjangSayap)
}

type Unicorn struct {
    Hewan
}

type Naga struct {
    HewanTerbang
}

type Pterodactilus struct {
    HewanTerbang
}

func NewPterodactyl(panjangSayap int) *Pterodactilus {
    p := new(Pterodactilus)
    p.Nama = "Pterodactilus"
    p.Nyata = true
    p.PanjangSayap = panjangSayap

    return p
}

func main() {
    hewan := new(Hewan)
    hewan.Nama = "Sembarang hewan"
    hewan.Nyata = false

    naga := new(Naga)
    naga.Nama = "Naga"
    naga.Nyata = false

    uni := new(Unicorn)
    uni.Nama = "Unicorn"
    uni.Nyata = false

    p1 := new(Pterodactilus)
    p1.Nama = "Pterodactilus"
    p1.Nyata = true
    p1.PanjangSayap = 5

    p2 := NewPterodactyl(8)

    hewan.Cetak()
    naga.Cetak()
    uni.Cetak()
    p1.Cetak()
    p2.Cetak()

    animals := []*Hewan{
        hewan,
        &naga.Hewan,
        &uni.Hewan,
        &p1.Hewan,
        &p2.Hewan,
    }
    fmt.Println("Cetak() melalui  embedded type Hewan")
    for _, c := range animals {
        c.Cetak()
    }
}
```


# Konkurensi

Konkurensi adalah komposisi / struktur dari berbagai proses yang berjalan secara bersamaan. Fitur untuk melakukan konkurensi dalam golang adalah Go Routine.

## Go Routine

* Sebuah thread yang ringan, hanya dibutuhkan 2kB memori untuk menjalankan sebuah go routine
* Aksi go routine bersifat asynchronous, jadi tidak saling menunggu dengan go routine yang lain.
* Proses yang hendak dieksekusi sebagai go routine harus berupa fungsi tanpa return yang dipanggil dengan kata kunci go

```go
package main

import "time"

func Salam(s string) {
    for i := 0; i <= 10; i++ {
    println(s)
    time.Sleep(1000 * time.Millisecond)
    }
}

func main() {
    // salam tidak pernah tercetak karena dijalankan secara konkuren. 
    // Sehingga tidak akan ditunggu oleh func main, dan langsung exit.  
    go Salam("Selamat Pagi")
}
```

```go
package main

import "time"

func Salam(s string) {
    for i := 0; i <= 10; i++ {
    println(s)
    time.Sleep(1000 * time.Millisecond)
    }
}

func main() {
    // salam tidak pernah tercetak karena dijalankan secara konkuren. 
    // Sehingga tidak akan ditunggu oleh func main, dan langsung exit.
    go Salam("Selamat Pagi")
    println("Halo")
}
```

```go
package main

import "time"

func Salam(s string) {
    for i := 0; i <= 10; i++ {
    println(s)
    time.Sleep(1000 * time.Millisecond)
    }
}

func main() {
    go Salam("Selamat Pagi")
    Salam("Selamat Malam")
}
```

* Go routine jalan di multi core processor, dan bisa diset mau jalan di berapa core.

```go
package main

import "time"

func Salam(s string) {
    for i := 0; i <= 10; i++ {
    println(s)
    time.Sleep(1000 * time.Millisecond)
    }
}

func main() {
    runtime.GOMAXPROCS(1)

    go Salam("Selamat Pagi")
    Salam("Selamat Malam")
}
```

## Channel

* Untuk mengsinkronkan satu go routine dengan go routine lainnya, diperlukan channel
* Channel digunakan untuk menerima dan mengirim data antar go routine.
* Channel bersifat blocking / synchronous. Pengiriman dan penerimaan ditahan sampai sisi yang lain siap.
* Channel harus dibuat sebelum digunakan, dengan kombinasi kata kunci make dan chan
* Aliran untuk menerima / mengirim data ditunjukkan dengan arah panah

```go
package main

import "runtime"

func main() {
    var pesan = make(chan string)
    println("kirim data", "Jacky")
    pesan <- "Jacky"

    // akan error karena tidak ada go routine lain yang menangkap channel
    println("terima data", <-pesan)    
}
```

```go
package main

func main() {
    var pesan = make(chan string)

    println("kirim data", "Jacky")
    pesan <- "Jacky"

    // error karena go routine yang menangkap channel belum dieksekusi ketika exit program 
    go func() {
    println("terima data", <-pesan)
    }()

}
```

```go
package main

func main() {
    var pesan = make(chan string)

    go func() {
    println("terima data", <-pesan)
    }()

    println("kirim data", "Jacky")
    pesan <- "Jacky"

}
```

```go
package main

func main() {
    a := []string{"Jacky", "Jet Lee", "Bruce Lee", "Samo Hung"}

    var pesan = make(chan string)

    // error karena go routine yang melakukan penerimaan data hanya sekali, sementara pengiriman dilakukan 4 kali 
    go func() {
    println("terima data", <-pesan)
    }()

    for _, s := range a {
    println("kirim data", s)
    pesan <- s
    }
}
```

```go
package main

func main() {
    a := []string{"Jacky", "Jet Lee", "Bruce Lee", "Samo Hung"}

    var pesan = make(chan string)

    go func() {
    // catatan: looping tanpa henti termasuk boros cpu, 
    // di materi selanjutnya ada cara tanpa menggunakan for{}
    // baik melalui for range maupun for break 
        for {
        println("terima data", <-pesan)
        }
    }()

    for _, s := range a {
    println("kirim data", s)
    pesan <- s
    }
}
```

## Channel dengan buffer

* Panjang buffer ditambahkan pada fungsi make sebagai argumen kedua
* Buffering menyebabkan pengiriman dan penerimaan data berlangsung secara asynchronous
* Pengiriman ke kanal buffer akan ditahan bila buffer telah penuh. Penerimaan akan ditahan saat buffer kosong.
* Jika pengiriman data melebihi panjang buffer, maka akan diperlakukan secara synchronous.

```go
package main

func main() {
    a := []string{"Jacky", "Jet Lee", "Bruce Lee", "Samo Hung"}

    var pesan = make(chan string, 3)

    go func() {
    for {
        println("terima data", <-pesan)
    }
    }()

    for _, s := range a {
    println("kirim data", s)
    pesan <- s
    }
}
```

```go
package main

func main() {
    a := []string{"Jacky", "Jet Lee", "Bruce Lee", "Samo Hung"}

    var pesan = make(chan string, len(a))

    go func() {
    println("terima data", <-pesan)
    }()

    for _, s := range a {
    println("kirim data", s)
    pesan <- s
    }
}
```

```go
package main

func main() {
    a := []string{"Jacky", "Jet Lee", "Bruce Lee", "Samo Hung"}
    var pesan = make(chan string, len(a))
    for _, s := range a {
    println("kirim data", s)
    pesan <- s
    }
}
```

## Range dan Close

* Range merupakan perulangan dari sebuah channel

```go
package main

func main() {
    a := []string{"Jacky", "Jet Lee", "Bruce Lee", "Samo Hung"}

    var pesan = make(chan string, len(a)-1)

    go func() {
    for i := range pesan {
        println("terima data", i)
    }
    }()

    for _, s := range a {
    println("kirim data", s)
    pesan <- s
    }

}
```

* Pengirim bisa menutup sebuah channel untuk menandai sudah tidak ada data yang dikirim lagi.
* Penutupan ini hanya optional. Artinya pengirim boleh melakukan close maupun tidak.
* Yang melakukan close hanya pengirim. Karena jika yang melakukan close adalah penerima, dan ada routine yang melakukan pengrimana akan menyebabkan panic.
* Penerima bisa menambahkan pengecekan, jika masih ada data yang dikirim maka akan diterima.

```go
package main

func main() {
    a := []string{"Jacky", "Jet Lee", "Bruce Lee", "Samo Hung"}

    var pesan = make(chan string, len(a)-1)

    go func() {
    for {
        println("terima data", <-pesan)
        close(pesan)
    }
    }()

    for _, s := range a {
    println("kirim data", s)
    pesan <- s
    }
}
```

```go
package main

func main() {
    a := []string{"Jacky", "Jet Lee", "Bruce Lee", "Samo Hung"}

    var pesan = make(chan string, len(a)-1)

    go func() {
    for {
        println("terima data", <-pesan)
    }
    }()

    for _, s := range a {
    println("kirim data", s)
    pesan <- s
    }
    close(pesan)
}
```

```go
package main

func main() {
    a := []string{"Jacky", "Jet Lee", "Bruce Lee", "Samo Hung"}

    var pesan = make(chan string, len(a))

    for _, s := range a {
    println("kirim data", s)
    pesan <- s
    }
    close(pesan)
}
```

```go
package main

func main() {
    a := []string{"Jacky", "Jet Lee", "Bruce Lee", "Samo Hung"}

    var pesan = make(chan string, len(a))

    for _, s := range a {
    println("kirim data", s)
    pesan <- s
    }
    close(pesan)

    for {
    println("terima data", <-pesan)
    }
}
```

```go
package main

func main() {
    a := []string{"Jacky", "Jet Lee", "Bruce Lee", "Samo Hung"}

    var pesan = make(chan string, len(a))

    for _, s := range a {
    println("kirim data", s)
    pesan <- s
    }
    close(pesan)

    for {
        // dilakukan pengecekan agar tidak looping forefer
    if v, ok := <-pesan; ok {
        println("terima data", v)
    } else {
        break
    }
    }
}
```

```go
package main

func main() {
    a := []string{"Jacky", "Jet Lee", "Bruce Lee", "Samo Hung"}

    var pesan = make(chan string, len(a))

    // menggunakan range jauh lebih simple
    for _, s := range a {
    println("kirim data", s)
    pesan <- s
    }
    close(pesan)

    for i := range pesan {
    println("terima data", i)
    }
}
```

## Select

* Channel diperlukan untuk pertukaran data antar go routine
* Jika melibatkan lebih dari satu go routine, diperlukan fungsi kontrol melalui select
* Select akan menerima secara acak mana data yang terlebih dahulu tersedia

```go
package main

func main() {
    var pesan = make(chan string)
    var c = make(chan int)

    go func() {
    for _, s := range []string{"Jacky", "Jet Lee", "Bruce Lee", "Samo Hung"} {
        pesan <- s
    }
    }()

    go func() {
    c <- 5
    }()

    select {
    case i := <-c:
    println("terima data", i)
    case s := <-pesan:
    println("terima data", s)
    }
}
```

```go
package main

func main() {
    var pesan = make(chan string)
    var c = make(chan int)

    go func() {
    for _, s := range []string{"Jacky", "Jet Lee", "Bruce Lee", "Samo Hung"} {
        pesan <- s
    }
    }()

    go func() {
    c <- 5
    }()

    for a := 0; a <= 4; a++ {
    select {
    case i := <-c:
        println("terima data", i)
    case s := <-pesan:
        println("terima data", s)
    }
    }
}
```

```go
package main

func main() {
    var pesan = make(chan string)
    var c = make(chan int)

    go func() {
    for _, s := range []string{"Jacky", "Jet Lee", "Bruce Lee", "Samo Hung"} {
        pesan <- s
    }
    }()

    go func() {
    c <- 5
    }()

    for a := 0; a <= 5; a++ {
    select {
    case i := <-c:
        println("terima data", i)
    case s := <-pesan:
        println("terima data", s)
    }
    }
}
```

## Select Default

* Jika saat select tidak ada channel yang siap diterima maka akan dijalankan baris kode default

```go
package main

func main() {
    var pesan = make(chan string)
    var c = make(chan int)

    go func() {
    for _, s := range []string{"Jacky", "Jet Lee", "Bruce Lee", "Samo Hung"} {
        pesan <- s
    }
    }()

    go func() {
    c <- 5
    }()

    for a := 0; a <= 5; a++ {
    select {
    case i := <-c:
        println("terima data", i)
    case s := <-pesan:
        println("terima data", s)
        default :
            println("tidak ada penerimaan data")
    }
    }
}
```

## Select Timeout

* Teknik tambahan untuk mengakhiri select jika tidak ada penerimaan data

```go
package main

import (
    "fmt"
    "time"
)

func main() {
    var pesan = make(chan string)
    var c = make(chan int)

    go func() {
    for _, s := range []string{"Jacky", "Jet Lee", "Bruce Lee", "Samo Hung"} {
        pesan <- s
    }
    }()

    go func() {
    c <- 5
    }()

loop:
    for {
    select {
    case i := <-c:
        println("terima data", i)
    case s := <-pesan:
        println("terima data", s)
    case <-time.After(time.Second * 5):
        fmt.Println("timeout. tidak ada aktivitas selama 5 detik")
        break loop
    }
    }
}
```

Hati-hati jika ingin menggabungkan antara select timeout dengan default, karena bisa terjadi looping forever.

```go
package main

import (
    "fmt"
    "time"
)

func main() {
    var pesan = make(chan string)
    var c = make(chan int)

    go func() {
    for _, s := range []string{"Jacky", "Jet Lee", "Bruce Lee", "Samo Hung"} {
        pesan <- s
    }
    }()

    go func() {
    c <- 5
    }()

loop:
    for {
    select {
    case i := <-c:
        println("terima data", i)
    case s := <-pesan:
        println("terima data", s)
    case <-time.After(time.Second * 5):
        fmt.Println("timeout. tidak ada aktivitas selama 5 detik")
        break loop
    default:
        println("tidak ada data diterima")
    }
    }
}
```

Ini disebabkan time.After(time.Second \* 5) selalu dibuat setiap looping seleksi, untuk mengatasinya, buat variable untuk menampung timeout agar timeout dikenali disetiap looping.

```go
package main

import (
    "fmt"
    "time"
)

func main() {
    var pesan = make(chan string)
    var c = make(chan int)

    go func() {
    for _, s := range []string{"Jacky", "Jet Lee", "Bruce Lee", "Samo Hung"} {
        pesan <- s
    }
    }()

    go func() {
    c <- 5
    }()

    timeout := time.After(time.Second * 5)

loop:
    for {
    select {
    case i := <-c:
        println("terima data", i)
    case s := <-pesan:
        println("terima data", s)
    case <-timeout:
        fmt.Println("timeout. tidak ada aktivitas selama 5 detik")
        break loop
    default:
        println("tidak ada data diterima")
    }
    }
}
```

## Sync Mutex

* Channel dipakai untuk komunikasi antar go routine
* Jika tidak ingin berkomunikasi karena ngin memastikan hanya satu goroutine yang dapat mengakses suatu variabel pada satu waktu untuk menghindari konflik, digunakan sync mutex
* mutex adalah mutual exclusion dengan fungsi `Lock` dan `Unlock`

```go
package main

import (
    "fmt"
    "sync"
    "time"
)

// SafeCounter aman digunakan secara konkuren.
type SafeCounter struct {
    v   map[string]int
    mux sync.Mutex
}

// Inc meningkatkan nilai dari key.
func (c *SafeCounter) Inc(key string) {
    c.mux.Lock()
    // Lock sehingga hanya satu goroutine pada satu waktu yang dapat
    // mengakses map c.v.
    c.v[key]++
    c.mux.Unlock()
}

// Value mengembalikan nilai dari key.
func (c *SafeCounter) Value(key string) int {
    c.mux.Lock()
    // Lock sehingga hanya satu gorouting pada satu waktu yang dapat
    // mengakses map c.v.
    defer c.mux.Unlock()
    return c.v[key]
}

func main() {
    c := SafeCounter{v: make(map[string]int)}
    for i := 0; i < 1000; i++ {
    go c.Inc("key")
    }

    time.Sleep(time.Second)
    fmt.Println(c.Value("key"))
}
```

## Sync.WaitGroup

* Kadang kita perlu menjalankan satu group routine yang terdiri dari beberapa go routine.
* Kita ingin mengontrol group rutin tersebut dengan melakukan sinkronisasi (synchronous).
* Fitur sync.WaitGroup memungkinkan kita untuk menunggu semua group routine selesai.
* Untuk mencegah suatu routine berlangsung lama dibanding routine lainnya, dipasang context dengan deadline.
* Jika ada satu error di salah satu routine, maka seluruh routine yang sedang jalan akan dicancel.

### Group Routine

```go
package main

import "fmt"

func main() {
    for i := 0; i < 10; i++ {
        go fmt.Printf("Routine ke: %d\n", i)
    }
}
```

* Jika dijalankan kemungkinan tidak ada hasil yang diprint, atau mungkin cuma ada 1x print.
* Tidak ada garansi apakah suatu routine bisa selesai dieksekusi.
* Go menjalankan fungsi main, dan ketika fungsi main berakhir, maka berakhir juga seluruh program.
* Kode di atas menjalankan sekelompok goroutine dan kemudian keluar sebelum mereka punya waktu untuk eksekusi.

### Wait Group

* Solusi untuk kasus di atas adalah dengan menggunakan standar library sync.WaitGroup

```go
package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func (id int) {
            defer wg.Done()
            fmt.Printf("Routine dengan id: %d\n", id)
        }(i)
    }
    wg.Wait()
}
```

* wg.Add() untuk counter berapa goroutine yang sudah ditambahkan. Setiap kali hendak menjalankan gouroutine, tambahkan counter dengan perintah wg,Add(1).
* wg.Done() untuk menandai suatu routine sudah selesai
* wg.Wait() untuk menunggu seluruh counter routine sudah nol (semua goroutine telah selesai).
* Perhatikan saya mengenalkan variabel local id sebagai id sebuah goroutine. Ini adalah mekanisme aman menggunakan variabel local. Karena jika menggunakan varibel luar i, akan terjadi konflik karena menjalankan potensi race condition.
* Di bawah ini adalah contoh kode yang salah karena tidak menggunakan variabel local.

```go
package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Printf("Routine dengan id: %d\n", i)
        }()
    }
    wg.Wait()
}
```

### Handling Error

* Kode di atas sederhana dan optimis tidak terjadi error, padahal aplikasi riil pasti ada penanganan error.
* Library golang.org/x/sync/errgroup digunakan untuk handling error.
* Ingat untuk menggunakan variabel local sebagai id
* Error yang ditangkap adalah error pertama yang dihasilkan oleh routine.

```go
package main

import (
    "fmt"

    "golang.org/x/sync/errgroup"
)

func main() {
    var eg errgroup.Group
    for i := 0; i < 10; i++ {
        id := i
        eg.Go(func() error {
            return routine(id)
        })
    }

    if err := eg.Wait(); err != nil {
        fmt.Println("terjadi error: ", err)
        return
    }

    fmt.Println("sukses")
}

func routine(id int) error {
    fmt.Printf("Routine dengan id: %d\n", id)

    if id == 9 || id == 6 {
        return fmt.Errorf("simulasi error %d", id)
    }

    return nil
}
```

### Context

* context berguna untuk menjaga agar context dari client bisa diiikuti.
* context bisa digunakan untuk menyimpan variabel yang siklus hiduonya sesuai context.
* context bisa digunakan untuk melakukan deadline maupun cancell ation suatu fungsi.
* context bisa digunakan untuk melakukan cancellation kode saat context sdh berakhir.

```go
package main

import (
    "context"
    "fmt"

    "golang.org/x/sync/errgroup"
)

func main() {
    eg, ctx := errgroup.WithContext(context.Background())
    for i := 0; i < 10; i++ {
        id := i
        eg.Go(func() error {
            return routineContext(ctx, id)
        })
    }

    if err := eg.Wait(); err != nil {
        fmt.Println("terjadi error: ", err)
        return
    }

    fmt.Println("sukses")
}

func routineContext(ctx context.Context, id int) error {
    select {
    case <-ctx.Done():
        fmt.Printf("context cancelled job %v terminting\n", id)
        return ctx.Err()
    default:
    }

    fmt.Printf("Routine dengan id: %d\n", id)

    if id == 9 || id == 6 {
        return fmt.Errorf("simulasi error %d", id)
    }

    return nil
}
```

Kita bisa menambahkan deadline suatu context

```go
package main

import (
    "context"
    "fmt"
    "time"

    "golang.org/x/sync/errgroup"
)

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), time.Microsecond)
    defer cancel()

    eg, ctx := errgroup.WithContext(ctx)
    for i := 0; i < 10; i++ {
        id := i
        eg.Go(func() error {
            return routineContext(ctx, id)
        })
    }

    if err := eg.Wait(); err != nil {
        fmt.Println("terjadi error: ", err)
        return
    }

    fmt.Println("sukses")
}

func routineContext(ctx context.Context, id int) error {
    select {
    case <-ctx.Done():
        fmt.Printf("context cancelled job %v terminting\n", id)
        return ctx.Err()
    default:
    }

    fmt.Printf("Routine dengan id: %d\n", id)

    if id == 9 || id == 6 {
        return fmt.Errorf("simulasi error %d", id)
    }

    return nil
}
```

Bandingkan jika kita tidak handle context, maka cancellation jadi tidak berfungsi.

```go
package main

import (
    "context"
    "fmt"
    "time"

    "golang.org/x/sync/errgroup"
)

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), time.Microsecond)
    defer cancel()

    eg, ctx := errgroup.WithContext(ctx)
    for i := 0; i < 10; i++ {
        id := i
        eg.Go(func() error {
            return routineContext(ctx, id)
        })
    }

    if err := eg.Wait(); err != nil {
        fmt.Println("terjadi error: ", err)
        return
    }

    fmt.Println("sukses")
}

func routineContext(ctx context.Context, id int) error {
    /*select {
    case <-ctx.Done():
        fmt.Printf("context cancelled job %v terminting\n", id)
        return ctx.Err()
    default:
    } */

    fmt.Printf("Routine dengan id: %d\n", id)

    if id == 9 || id == 6 {
        return fmt.Errorf("simulasi error %d", id)
    }

    return nil
}
```


# Concurrency Pattern

Untuk alasan performance, kita sering mengeksploitasi fitur konkurensi di golang. Ada beberapa pattern yang terkenal terkait konkurensi ini, beberapa diantaranya adalah :

## 1. [Worker Pool](https://github.com/jacky-htg/golang-essentials/blob/master/02-design-pattern/worker-pool.md)

* Membuat sejumlah worker (goroutine) tetap yang mengambil tugas dari sebuah job queue.
* Digunakan untuk membatasi jumlah goroutine agar tidak membebani CPU/memori.

Contoh Kasus: Pemrosesan antrian tugas di backend, seperti pemrosesan gambar atau request API.

## 2. Fan-Out, Fan-In

* Fan-Out: Banyak goroutine dibuat untuk memproses data dari satu sumber.
* Fan-In: Beberapa goroutine mengirimkan hasilnya ke satu channel untuk digabungkan.

Contoh Kasus: Memproses banyak permintaan HTTP secara paralel, lalu menggabungkan hasilnya.

## 3. Publish-Subscribe (Pub-Sub)

* Satu publisher mengirimkan pesan ke banyak subscriber.
* Bisa dilakukan dengan channel atau message broker seperti Redis Pub/Sub atau Kafka.

Contoh Kasus: Notifikasi real-time, event-driven architecture.

## 4. Pipeline

* Data mengalir melalui beberapa tahap pemrosesan, di mana setiap tahap dilakukan oleh goroutine berbeda.
* Setiap tahap beroperasi secara independen dengan channel sebagai perantara.

Contoh Kasus: ETL (Extract, Transform, Load), pemrosesan data bertingkat.

## 5. [Future / Promise](https://github.com/jacky-htg/golang-essentials/blob/master/02-design-pattern/future-promise.md)

* Menggunakan goroutine untuk menjalankan tugas async dan mengembalikan hasilnya melalui channel atau struct yang menampung nilai dan status.

Contoh Kasus: Menjalankan beberapa query database secara paralel dan menunggu hasilnya.

## 6. [Rate Limiting / Token Bucket](https://github.com/jacky-htg/golang-essentials/blob/master/02-design-pattern/rate-limit.md)

* Mengontrol jumlah goroutine atau request dalam periode waktu tertentu untuk mencegah overload.

Contoh Kasus: Membatasi jumlah request API ke layanan eksternal.

## 7. [Semaphore](https://github.com/jacky-htg/golang-essentials/blob/master/02-design-pattern/semaphore.md)

* Menggunakan semaphoric channel untuk membatasi jumlah goroutine yang berjalan bersamaan.

Contoh Kasus: Mengontrol akses ke sumber daya yang terbatas seperti koneksi database.

## 8. Balking Pattern

* Jika suatu goroutine menemukan kondisi tertentu (misalnya, resource sedang dipakai), maka ia membatalkan tugasnya tanpa menunggu.

Contoh Kasus: Cache warming, di mana hanya satu goroutine yang boleh memperbarui cache.

## 9. [Single Flight Pattern](https://github.com/jacky-htg/golang-essentials/blob/master/02-design-pattern/single-flight.md)

* Jika dalam waktu bersamaan ada beberapa permintaan identik yang masuk, maka hanya ada satu permintaan yang diteruskan, yang lainnya akan menunggu. Setelah permintaan yang diteruskan mendapatakan response, maka semua permintaan yang masuk akan menerima response yang sama.
* Banyak digunakan untuk mengelola permintaan ke sebuah proses yang lambat/berat.

Contoh Kasus: request reporting, request ke heavy database, request ke proses yang latency tinggi dan consume banyak resource (memory/cpu), call api third party yang lambat.

## 10. Circuit Breaker

* Jika ada kegagalan berturut-turut, sistem akan berhenti mencoba untuk sementara waktu.
* Bisa dikombinasikan dengan timeout atau retry pattern.

Contoh Kasus: Mencegah request berulang ke layanan eksternal yang sedang down.


# Worker Pool

Worker Pool adalah pola konkurensi di mana sejumlah tetap goroutine (worker) dijalankan untuk menangani tugas dari antrian pekerjaan (job queue). Dengan pendekatan ini, kita dapat menghindari overhead akibat terlalu banyak goroutine yang berjalan secara bersamaan.

## Konsep Utama:

* Job Queue: Tempat di mana pekerjaan ditampung sebelum diproses oleh worker.
* Workers (Goroutines): Sejumlah tetap goroutine yang mengambil dan memproses pekerjaan dari job queue.
* Result Channel (Opsional): Jika pekerjaannya menghasilkan output, hasilnya bisa dikirim melalui channel.

## Kapan Menggunakan Worker Pool?

* Jika ada banyak tugas independen yang bisa dieksekusi secara paralel.
* Jika jumlah goroutine perlu dibatasi untuk menghindari konsumsi resource berlebih.
* Jika ingin meningkatkan efisiensi pemrosesan dengan menghindari overhead pembuatan goroutine yang berlebihan.

## Implementasi Worker Pool di Golang

Berikut contoh implementasi Worker Pool sederhana di Golang:

```go
package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

// Struktur untuk mewakili tugas (job)
type Job struct {
	ID int
}

// Fungsi worker yang mengambil job dari channel dan memprosesnya
func worker(id int, jobs <-chan Job, wg *sync.WaitGroup) {
	defer wg.Done()
	for job := range jobs {
		fmt.Printf("Worker %d memproses job %d\n", id, job.ID)
		time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond) // Simulasi pekerjaan
	}
}

func main() {
	const numWorkers = 3  // Jumlah worker
	const numJobs = 10     // Jumlah pekerjaan

	jobs := make(chan Job, numJobs) // Channel untuk menyimpan jobs
	var wg sync.WaitGroup

	// Memulai worker
	for i := 1; i <= numWorkers; i++ {
		wg.Add(1)
		go worker(i, jobs, &wg)
	}

	// Mengirimkan jobs ke dalam channel
	for j := 1; j <= numJobs; j++ {
		jobs <- Job{ID: j}
	}

	close(jobs) // Menutup channel jobs agar worker tahu tidak ada job baru
	wg.Wait()   // Menunggu semua worker selesai
	fmt.Println("Semua pekerjaan telah selesai!")
}
```

## Penjelasan Kode:

1. Channel jobs digunakan sebagai job queue.
2. Worker (worker function) membaca dari jobs dan memproses pekerjaan.
3. Loop utama membuat numWorkers goroutine untuk worker.
4. Jobs dimasukkan ke dalam channel.
5. Channel jobs ditutup untuk memberi sinyal bahwa tidak ada job baru.
6. WaitGroup digunakan untuk menunggu semua worker menyelesaikan tugasnya.

## Keuntungan Worker Pool

* Membatasi jumlah goroutine → Menghindari overhead dari terlalu banyak goroutine.
* Efisiensi pemrosesan → Tugas didistribusikan ke worker secara merata.
* Lebih scalable → Bisa dengan mudah menyesuaikan jumlah worker.

## Kapan Tidak Menggunakan Worker Pool?

* Jika jumlah tugas kecil dan overhead goroutine tidak menjadi masalah.
* Jika setiap pekerjaan membutuhkan sumber daya unik dan tidak bisa dibagikan antar worker.

## Best Practise Menentukan Jumlah Jobs dan Worker

Menentukan jumlah jobs dalam Worker Pool sangat bergantung pada beberapa faktor seperti jumlah worker, kapasitas CPU, I/O, dan sifat pekerjaan itu sendiri.

### 1. Berdasarkan Jumlah Worker dan Sifat Pekerjaan

```
Jumlah Jobs ≥ Jumlah Worker
```

Mengapa? Jika jumlah jobs lebih kecil dari jumlah worker, ada worker yang idle (menganggur), yang berarti resource tidak digunakan secara optimal.

Namun, jumlah jobs tidak boleh terlalu besar tanpa mempertimbangkan beban kerja karena bisa menyebabkan bottleneck.

### 2. Berdasarkan Tipe Pekerjaan (CPU-Bound vs. I/O-Bound)

Pekerjaan yang dilakukan dalam worker menentukan jumlah jobs yang ideal.

#### A. CPU-Bound (Butuh Banyak Perhitungan)

* Contoh: Enkripsi, kompresi, machine learning inference, hashing, perhitungan matematis intensif.
* Worker biasanya dibatasi oleh jumlah CPU core.
* Formula Optimal:

```
Jumlah Worker ≈ Jumlah Core CPU
```

atau sedikit lebih besar untuk mengakomodasi overhead switching.\
Misalnya:

* Jika CPU memiliki 8 core, maka worker bisa 8-12.
* Jumlah jobs bisa dibuat 2x dari worker untuk memastikan ada tugas yang selalu bisa diambil worker.

#### B. I/O-Bound (Sering Menunggu Respons)

* Contoh: HTTP requests, database queries, file I/O, network calls.
* Karena pekerjaan ini sering menunggu, jumlah worker bisa lebih besar dibanding CPU core.
* Formula Optimal:

```
Jumlah Worker ≈ (Jumlah Core CPU * 2) atau lebih tinggi
```

atau

```
Jumlah Worker ≈ (Jumlah Concurrent Requests / Waktu Tunggu Rata-rata)
```

Misalnya: Jika sistem menangani banyak API call dengan waktu respons 500ms, dan ingin menangani 1000 request per detik worker bisa sekitar (1000 / 0.5) = 2000.

### 3. Benchmark & Profiling

Cara terbaik menentukan jumlah jobs adalah dengan benchmarking dan profiling.\
Gunakan tools seperti:

* pprof (Golang built-in profiler)
* htop (monitor CPU usage)
* wrk (untuk load testing HTTP API)
* Apache JMeter (untuk uji beban)

Langkah Benchmarking:

* Mulai dengan jumlah worker = jumlah core CPU.
* Uji performa dengan jumlah jobs yang berbeda (misal, 1x, 2x, 4x dari worker).
* Pantau CPU, RAM, dan latensi untuk melihat titik optimal.
* Jika worker idle lama, bisa ditambah jobs.
* Jika CPU usage selalu 100% tanpa peningkatan throughput, jobs mungkin terlalu banyak.

### 4. Contoh Implementasi Adaptif

Jika ingin menyesuaikan jumlah worker secara otomatis, kita bisa mendeteksi jumlah core CPU dengan runtime.NumCPU():

```go
package main

import (
	"fmt"
	"runtime"
	"sync"
	"time"
)

func worker(id int, jobs <-chan int, wg *sync.WaitGroup) {
	defer wg.Done()
	for job := range jobs {
		fmt.Printf("Worker %d memproses job %d\n", id, job)
		time.Sleep(500 * time.Millisecond) // Simulasi pekerjaan
	}
}

func main() {
	numCPU := runtime.NumCPU() // Deteksi jumlah core CPU
	numWorkers := numCPU * 2   // Bisa dikalikan 2 untuk I/O-Bound
	numJobs := numWorkers * 2  // Jumlah jobs minimal 2x worker

	jobs := make(chan int, numJobs)
	var wg sync.WaitGroup

	// Memulai worker
	for i := 1; i <= numWorkers; i++ {
		wg.Add(1)
		go worker(i, jobs, &wg)
	}

	// Kirim jobs
	for j := 1; j <= numJobs; j++ {
		jobs <- j
	}

	close(jobs)
	wg.Wait()
	fmt.Println("Semua pekerjaan selesai!")
}
```

### Kesimpulan Best Practice

* CPU-Bound → Worker ≈ Jumlah Core CPU
* I/O-Bound → Worker bisa lebih banyak (Core CPU \* 2 atau lebih)
* Jumlah Jobs ≥ Jumlah Worker, tetapi tidak terlalu besar untuk menghindari bottleneck
* Gunakan Benchmarking & Profiling untuk menentukan jumlah optimal

## Jebakan Goroutine

Ya, kita sudah mengimplementasikan pattern worker pool untuk mencegah overhead, kita sudah memperkirakan jumlah worker dengan baik. Tapi bagaimana jika ada developer lain (tanpa kordinasi) membuat goroutine juga di fungsi lain? Ini mengakibatkan perhitungan jumlah worker yang kita buat menjadi tidak valid, dan berpotensi tinggi untuk mengalami overhead. Ini karena jumlah total goroutine bisa melampaui kapasitas optimal, yang dapat menyebabkan beberapa masalah seperti:

1. CPU Starvation

* Jika jumlah goroutine lebih banyak dari jumlah thread OS, CPU harus sering melakukan context switching, yang bisa mengurangi performa daripada meningkatkannya.
* Misalnya, jika ada 1000 goroutine aktif tetapi hanya ada 8 CPU core, maka setiap goroutine mendapat jatah waktu sangat kecil, yang bisa memperlambat eksekusi.

2. Konsumsi Memori Berlebih

* Setiap goroutine membutuhkan stack memory (\~2 KB awal, bisa berkembang). Jika jumlahnya terlalu banyak, RAM bisa cepat habis.

3. Deadlock & Goroutine Leaks

* Jika ada goroutine yang tidak dikontrol dengan baik (misalnya, tidak membaca dari channel atau tidak diberi timeout), ini bisa menyebabkan deadlock atau memory leaks.

## Global Worker Pool

Untuk memastikan setiap developer yang terlibat tidak membuat goroutine sendiri yang berpotensi membuat overhead, kita bisa mengimplementasikan global worker pool. Alih-alih membuat goroutine, developer cukup mengirimkan pekerjaan (job) ke worker pool yang sudah ada.

### Pendekatan

📌 Worker Pool sebagai Singleton

* Worker pool dibuat satu kali saat aplikasi berjalan.
* Developer lain cukup mengirimkan pekerjaan ke job queue, tanpa perlu membuat goroutine sendiri.

📌 Menggunakan Channel untuk Job Queue

* Developer cukup mengirimkan job ke channel.
* Pekerjaan akan diproses oleh worker pool yang ada.

📌 Thread-Safe dengan sync.Once

* Gunakan sync.Once untuk memastikan worker pool hanya dibuat satu kali.

### Implementasi

Berikut adalah contoh implementasi worker pool yang bisa digunakan oleh semua developer tanpa perlu membuat goroutine sendiri.

```go
package workerpool

import (
	"fmt"
	"log"
	"sync"
	"time"
)

// Job represents a task to be processed
type Job struct {
	ID     int
	Payload string
}

// WorkerPool struct
type WorkerPool struct {
	jobQueue   chan Job
	numWorkers int
	once       sync.Once
	wg         sync.WaitGroup
}

var pool *WorkerPool

// NewWorkerPool creates a singleton worker pool
func NewWorkerPool(numWorkers, jobQueueSize int) *WorkerPool {
	if pool == nil {
		pool = &WorkerPool{
			jobQueue:   make(chan Job, jobQueueSize),
			numWorkers: numWorkers,
		}
		pool.startWorkers()
	}
	return pool
}

// startWorkers initializes the worker pool
func (wp *WorkerPool) startWorkers() {
	wp.once.Do(func() {
		log.Println("Starting worker pool with", wp.numWorkers, "workers")
		for i := 0; i < wp.numWorkers; i++ {
			wp.wg.Add(1)
			go wp.worker(i)
		}
	})
}

// worker function processes jobs
func (wp *WorkerPool) worker(workerID int) {
	defer wp.wg.Done()
	for job := range wp.jobQueue {
		log.Printf("Worker %d processing job: %d with payload: %s\n", workerID, job.ID, job.Payload)
		time.Sleep(1 * time.Second) // Simulate processing time
	}
}

// SubmitJob allows developers to add a job to the pool
func (wp *WorkerPool) SubmitJob(job Job) {
	wp.jobQueue <- job
}

// Shutdown gracefully stops the worker pool
func (wp *WorkerPool) Shutdown() {
	close(wp.jobQueue)
	wp.wg.Wait()
	log.Println("Worker pool shut down")
}
```

Berikut adalah contoh cara menggunakan worker pool dalam aplikasi utama (package main).

```go
package main

import (
	"fmt"
	"myapp/workerpool"
	"time"
)

func main() {
	// Inisialisasi worker pool global dengan 5 workers dan queue size 10
	wp := workerpool.NewWorkerPool(5, 10)

	// Developer lain cukup memanggil SubmitJob tanpa membuat goroutine
	for i := 1; i <= 20; i++ {
		job := workerpool.Job{
			ID:      i,
			Payload: fmt.Sprintf("Job data %d", i),
		}
		wp.SubmitJob(job)
	}

	// Tunggu sebentar untuk melihat output
	time.Sleep(5 * time.Second)

	// Graceful shutdown
	wp.Shutdown()
}
```

### Bagaimana Ini Mengatasi Masalah Developer Lain Membuat Goroutine?

✅ Worker Pool Sudah Ada → Developer Tidak Perlu Buat Goroutine Sendiri

* Developer cukup memanggil wp.SubmitJob(job) untuk menambahkan pekerjaan ke queue.
* Semua pekerjaan akan diproses oleh worker pool yang ada, tanpa perlu goroutine tambahan.

✅ Job Queue Menjaga Batasan Beban

* Jika developer lain mengirim terlalu banyak job, worker pool hanya akan memproses sesuai kapasitas queue.

✅ Thread-Safe dan Singleton

* Worker pool dibuat sekali saja menggunakan sync.Once.
* Semua developer berbagi satu worker pool global.

✅ Graceful Shutdown

* Worker pool bisa dihentikan dengan aman menggunakan Shutdown().

## Kesiumpulan

🚀 Dengan implementasi ini:

* Developer tidak perlu membuat goroutine sendiri.
* Semua pekerjaan akan otomatis diproses oleh worker pool.
* Thread-safe dan efisien untuk menangani concurrent jobs.

Ini sudah siap dipakai untuk sistem skala besar seperti gRPC handler, HTTP request handler, atau background job processing! 😃


# Future / Promise

Future/Promise adalah pola pemrograman yang digunakan untuk menangani operasi asinkron dengan cara yang lebih terstruktur. Konsep ini mirip dengan async/await di JavaScript, di mana kita bisa menjalankan tugas secara non-blokir (non-blocking) dan mendapatkan hasilnya nanti setelah tugas selesai.

## Perbedaan Future/Promise dengan Goroutine & Channel di Go

Di JavaScript, Promise atau async/await digunakan untuk menangani operasi asinkron dengan mudah. Sementara itu, di Go, tidak ada Promise secara langsung, tetapi konsep Future bisa diimplementasikan menggunakan goroutine dan channel.

| Fitur          | Promise (JS)          | Future (Go - Custom)           |
| -------------- | --------------------- | ------------------------------ |
| Cara Kerja     | then(), await         | Channel atau sync.WaitGroup    |
| Eksekusi       | Non-blocking          | Non-blocking                   |
| Error Handling | catch() / try...catch | select { case <- errChan }     |
| Library Bawaan | Ya (Promise)          | Tidak ada, harus dibuat manual |

## Implementasi Future di Go (Mirip dengan Promise di JavaScript)

```go
package main

import (
	"fmt"
	"time"
)

// Future struct untuk menyimpan hasil async task
type Future struct {
	result chan string
}

// AsyncFunction menjalankan tugas secara asinkron dan mengembalikan Future
func AsyncFunction() *Future {
	f := &Future{result: make(chan string, 1)}

	go func() {
		time.Sleep(2 * time.Second) // Simulasi operasi yang butuh waktu lama
		f.result <- "Hasil dari Future!"
	}()

	return f
}

// Get akan mengambil hasil dari Future (mirip dengan await)
func (f *Future) Get() string {
	return <-f.result
}

func main() {
	fmt.Println("Mulai tugas...")
	future := AsyncFunction() // Memulai tugas async tanpa blocking

	// Kita bisa melakukan hal lain di sini sementara tugas async berjalan
	fmt.Println("Melakukan tugas lain...")

	// Ambil hasil dari Future (mirip await)
	result := future.Get()
	fmt.Println("Hasil Future:", result)
}
```

Output:

```
Mulai tugas...
Melakukan tugas lain...
(Hasil muncul setelah 2 detik)
Hasil Future: Hasil dari Future!
```

✅ Tidak blocking, tetap bisa menjalankan kode lain.

## Future dengan Error Handling (Mirip try...catch di Promise)

```go
package main

import (
	"errors"
	"fmt"
	"time"
)

// Future struct untuk menyimpan hasil dan error dari async task
type Future struct {
	result chan string
	err    chan error
}

// AsyncFunction menjalankan tugas secara asinkron dan mengembalikan Future
func AsyncFunction() *Future {
	f := &Future{
		result: make(chan string, 1),
		err:    make(chan error, 1),
	}

	go func() {
		time.Sleep(2 * time.Second) // Simulasi delay

		if time.Now().Unix()%2 == 0 {
			f.result <- "Data sukses!"
		} else {
			f.err <- errors.New("Terjadi kesalahan dalam Future")
		}
	}()

	return f
}

// Get akan mengambil hasil dari Future (mirip dengan await)
func (f *Future) Get() (string, error) {
	select {
	case res := <-f.result:
		return res, nil
	case err := <-f.err:
		return "", err
	}
}

func main() {
	fmt.Println("Mulai tugas...")
	future := AsyncFunction()

	fmt.Println("Melakukan tugas lain...")

	// Ambil hasil dari Future (mirip await)
	result, err := future.Get()
	if err != nil {
		fmt.Println("Error Future:", err)
	} else {
		fmt.Println("Hasil Future:", result)
	}
}
```

✅ Mirip try...catch di Promise, bisa menangani error dengan baik.

## Kapan Menggunakan Future/Promise di Go?

🚀 Future sangat berguna untuk:

* Memanggil API secara asinkron → Misalnya fetching data dari third-party API tanpa memblokir eksekusi lainnya.
* Mengurangi Blocking dalam Goroutine → Memungkinkan eksekusi tetap berjalan tanpa menunggu satu tugas selesai duluan.
* Meningkatkan Performa → Membantu menangani pekerjaan berat seperti query database atau proses perhitungan besar tanpa menghentikan alur program.

## Kesimpulan

* Future/Promise di Go bisa dibuat menggunakan goroutine + channel untuk menjalankan operasi asinkron.
* Mirip dengan JavaScript async/await, tetapi tidak built-in, harus dibuat manual.
* Future bisa menangani error dengan select { case result <- chan, case err <- chan }.
* Sangat bermanfaat untuk pemanggilan API, query database, dan tugas berat lainnya tanpa memblokir eksekusi.

🚀 Jika terbiasa dengan async/await di JavaScript, Future di Go adalah cara terbaik untuk menulis kode asinkron yang lebih bersih dan efisien!


# Rate Limit Pattern

Rate limiting adalah teknik untuk membatasi jumlah permintaan (requests) dalam periode waktu tertentu guna:

* Mencegah penyalahgunaan API (misalnya DDoS atau brute force).
* Melindungi performa server agar tidak overload.
* Membagi resource secara adil di antara pengguna.

Perbedaan Rate Limit dengan Semaphore:

| Fitur      | Rate Limit                           | Semaphore                                                    |
| ---------- | ------------------------------------ | ------------------------------------------------------------ |
| Membatasi  | Jumlah request dalam waktu tertentu  | Jumlah goroutine aktif                                       |
| Penerapan  | Berbasis waktu (misal: 10 req/detik) | Berbasis concurrency (misal: 5 goroutine berjalan bersamaan) |
| Penggunaan | API rate limiting                    | Kontrol parallelism                                          |

## Jenis-Jenis Rate Limiting

1. Fixed Window → Memeriksa jumlah request dalam interval tetap (misal: 10 request per menit).
2. Sliding Window → Menghitung request dalam periode berjalan agar lebih akurat.
3. Token Bucket → Menggunakan token yang diisi secara periodik (misalnya, 10 token per detik, 1 request = 1 token).
4. Leaky Bucket → Request masuk dalam antrian, diproses secara tetap untuk menghindari lonjakan tiba-tiba.

## Implementasi Simple Rate Limit

Berikut adalah implmentasi rate limit sederhana menggunakan token bucket.

```go
package rate_limiter

import (
	"sync"
	"time"
)

// RateLimiter menggunakan Token Bucket
type RateLimiter struct {
	mu          sync.Mutex
	rate        int       // Requests per second
	burst       int       // Maximum burst capacity
	tokens      int       // Available tokens
	lastChecked time.Time // Last refill time
}

// NewRateLimiter membuat RateLimiter baru
func NewRateLimiter(rate, burst int) *RateLimiter {
	return &RateLimiter{
		rate:        rate,
		burst:       burst,
		tokens:      burst,
		lastChecked: time.Now(),
	}
}

// Allow mengecek apakah request bisa diproses
func (rl *RateLimiter) Allow() bool {
	rl.mu.Lock()
	defer rl.mu.Unlock()

	now := time.Now()
	elapsed := now.Sub(rl.lastChecked).Seconds()
	rl.lastChecked = now

	// Tambah token berdasarkan waktu berlalu
	rl.tokens += int(elapsed * float64(rl.rate))
	if rl.tokens > rl.burst {
		rl.tokens = rl.burst
	}

	// Jika masih ada token, izinkan request
	if rl.tokens > 0 {
		rl.tokens--
		return true
	}

	return false
}
```

Di middleware bisa memanggil paket rate limiter

```go
package middleware

import (
	"log"
	"net/http"
	"myapp/rate_limiter" // Import dari package rate_limiter
)

// RateLimitMiddleware middleware untuk membatasi request
func RateLimitMiddleware(limiter *rate_limiter.RateLimiter) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if !limiter.Allow() {
				log.Println("Too many requests")
				http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}
```

```go
package main

import (
	"fmt"
	"myapp/middleware"
	"myapp/rate_limiter"
	"net/http"
	"time"
)

func main() {
	// Rate limiter: 2 request per detik, max burst 5
	limiter := rate_limiter.NewRateLimiter(2, 5)

	// Middleware rate limit
	middleware := middleware.RateLimitMiddleware(limiter)

	// Handler utama
	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "Request processed at %s\n", time.Now().Format(time.RFC3339))
	})

	// Pasang middleware di server
	http.Handle("/", middleware(handler))

	fmt.Println("Server running on port 8080")
	http.ListenAndServe(":8080", nil)
}
```

## Kesimpulan

✅ Rate Limiting berguna untuk:

* Mencegah abuse/DDoS dengan membatasi jumlah request per waktu tertentu.
* Menjaga performa server agar tidak overload.
* Mengontrol penggunaan API agar lebih adil untuk semua pengguna.

🚀 Gunakan rate limiting jika Anda ingin membatasi jumlah request dalam periode waktu tertentu!


# Semaphore

Semaphore pattern adalah teknik dalam concurrent programming yang digunakan untuk mengontrol jumlah goroutine atau thread yang berjalan secara bersamaan.

🛠 Cara Kerja:

* Semaphore memiliki batas maksimum (limit) untuk jumlah operasi yang berjalan bersamaan.
* Ketika limit tercapai, goroutine berikutnya harus menunggu sampai ada slot yang tersedia.
* Digunakan untuk mencegah overload atau resource starvation pada sistem.

## Contoh Dasar Implementasi Semaphore di Golang

```go
package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	const maxConcurrentJobs = 3 // Batas maksimal goroutine yang boleh berjalan
	semaphore := make(chan struct{}, maxConcurrentJobs)

	var wg sync.WaitGroup
	for i := 1; i <= 10; i++ {
		wg.Add(1)

		// Mengisi slot semaphore sebelum memulai pekerjaan
		semaphore <- struct{}{}

		go func(jobID int) {
			defer wg.Done()
			defer func() { <-semaphore }() // Melepaskan slot semaphore setelah selesai

			fmt.Printf("Processing job %d\n", jobID)
			time.Sleep(2 * time.Second) // Simulasi pekerjaan
		}(i)
	}

	wg.Wait()
	fmt.Println("All jobs completed")
}
```

Penjelasan:

* Membatasi jumlah goroutine aktif (dalam contoh ini, hanya 3 goroutine yang berjalan bersamaan).
* Menunggu slot kosong jika jumlah goroutine yang berjalan sudah mencapai batas.
* Mencegah aplikasi overload dengan terlalu banyak goroutine.

Pada prakteknya, seringkali semaphore digunakan di middleware untuk mengontrol banyaknya request yang bisa dilayani secara bersamaan.

## Implementasi Middleware Semaphore untuk gRPC

```go
package middleware

import (
	"context"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

// Semaphore struct untuk membatasi jumlah request
type Semaphore struct {
	sem chan struct{}
}

// NewSemaphore membuat middleware semaphore
func NewSemaphore(maxConcurrentRequests int) *Semaphore {
	return &Semaphore{
		sem: make(chan struct{}, maxConcurrentRequests), // Buffer menentukan batas maksimal request
	}
}

// UnaryInterceptor membatasi jumlah request secara global
func (s *Semaphore) UnaryInterceptor() grpc.UnaryServerInterceptor {
	return func(
		ctx context.Context,
		req interface{},
		info *grpc.UnaryServerInfo,
		handler grpc.UnaryHandler,
	) (interface{}, error) {
		// Coba memasukkan slot ke semaphore
		select {
		case s.sem <- struct{}{}:
			// Pastikan slot dilepas setelah selesai
			defer func() { <-s.sem }()
		default:
			return nil, status.Error(codes.ResourceExhausted, "Too many concurrent requests")
		}

		// Lanjutkan ke handler utama
		return handler(ctx, req)
	}
}
```

Cara Kerja Middleware gRPC Semaphore

* Membatasi jumlah request yang masuk berdasarkan maxConcurrentRequests.
* Jika slot penuh, request langsung ditolak dengan error ResourceExhausted.
* Menggunakan channel sebagai semaphore untuk tracking request yang berjalan.

## Implementasi Middleware Semaphore untuk REST API (HTTP)

```go
package middleware

import (
	"log"
	"net/http"
)

// Semaphore struct untuk REST API
type Semaphore struct {
	sem chan struct{}
}

// NewSemaphore membuat instance semaphore
func NewSemaphore(maxConcurrentRequests int) *Semaphore {
	return &Semaphore{
		sem: make(chan struct{}, maxConcurrentRequests),
	}
}

// Middleware membatasi jumlah request
func (s *Semaphore) Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		select {
		case s.sem <- struct{}{}: // Jika masih ada slot, lanjutkan
			defer func() { <-s.sem }() // Pastikan slot dilepas setelah selesai
		default:
			log.Println("Too many concurrent requests")
			http.Error(w, "Too many concurrent requests", http.StatusTooManyRequests)
			return
		}

		next.ServeHTTP(w, r)
	})
}
```

Cara Menggunakan Middleware di HTTP Server

```go
package main

import (
	"fmt"
	"myapp/middleware"
	"net/http"
	"time"
)

func main() {
	// Buat middleware semaphore dengan batas 3 request bersamaan
	sem := middleware.NewSemaphore(3)

	// Handler utama
	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "Processing request...\n")
		time.Sleep(2 * time.Second) // Simulasi proses
		fmt.Fprintf(w, "Request completed!\n")
	})

	// Pasang middleware
	http.Handle("/", sem.Middleware(handler))

	fmt.Println("Server running on port 8080")
	http.ListenAndServe(":8080", nil)
}
```

## Kesimpulan

✅ Semaphore cocok di Middleware

* gRPC: Gunakan UnaryInterceptor untuk batasi concurrent request di gRPC server.
* REST API: Gunakan http.Handler middleware untuk batasi HTTP request di web server.

✅ Mengatasi Overload

* Jika batas tercapai, request langsung ditolak dengan error Too many concurrent requests.

✅ Memastikan Performa Stabil

* Dengan semaphore, server tidak overload meskipun banyak request masuk.

🚀 Gunakan middleware ini untuk melindungi API dari lonjakan traffic dan menjaga stabilitas server!


# Single Flight

Single Flight adalah pola yang digunakan untuk mencegah redundant request dengan memastikan bahwa hanya satu goroutine yang menjalankan proses tertentu dalam satu waktu. Goroutine lain yang meminta hasil yang sama akan menunggu hasil dari goroutine pertama, bukan memproses ulang permintaan yang sama.

Pola ini sangat berguna untuk:

* Mengurangi load ke database atau API eksternal (misal: caching atau fetching data).
* Menghindari spam request ke third-party API, sehingga lebih efisien.
* Menghindari race condition saat banyak goroutine meminta data yang sama.
* Meningkatkan efisiensi dalam sistem dengan banyak request paralel.

## Kapan Teknik Ini Berguna?

* Ketika banyak request ke API yang sama dalam waktu bersamaan.
* Jika API third-party memiliki rate limit dan kita ingin menghindari throttling.
* Untuk mengurangi latensi dengan menghindari redundant request.
* Untuk menghemat biaya jika API third-party menggunakan sistem berbayar per request.

## Cara Menentukan Unique Key

Seperti yang kita lihat dari contoh kode implmentasi single flight patter, ada satu unique-key yang digunakan, sehingga request-request yang memiliki unique key yang sama, hanya akan diproses 1x. Unique-key bisa digenerate dengan berbagai logic, untuk kasus pemanggilan api third party, unique-key bisa menggunakan path url termasuk dengan parameter/query yang digunakan. Jika url, path dan parameter dirasa terlalu panjang, bisa menggunakan hashing agar lebih ringkas.

```go
key := fmt.Sprintf("%x", sha256.Sum256([]byte(urlWithParams)))
```

## Implementasi Single Flight Pattern

```go
package main

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"sync"
	"time"
)

// MySingleFlight adalah struktur untuk menangani request tunggal per key
type MySingleFlight struct {
	mu    sync.Mutex
	calls map[string]*call
}

// call menyimpan informasi tentang request yang sedang berlangsung
type call struct {
	wg  sync.WaitGroup
	res string
	err error
}

// NewMySingleFlight membuat instance MySingleFlight
func NewMySingleFlight() *MySingleFlight {
	return &MySingleFlight{
		calls: make(map[string]*call),
	}
}

// Do memastikan hanya satu request per key yang berjalan pada satu waktu
func (sf *MySingleFlight) Do(key string, fn func() (string, error)) (string, error) {
	sf.mu.Lock()
	if c, found := sf.calls[key]; found {
		sf.mu.Unlock()
		c.wg.Wait() // Tunggu hasil request yang sedang berjalan
		return c.res, c.err
	}

	// Jika belum ada request, buat yang baru
	c := &call{}
	c.wg.Add(1)
	sf.calls[key] = c
	sf.mu.Unlock()

	// Jalankan request
	c.res, c.err = fn()
	c.wg.Done()

	// Hapus dari map setelah selesai
	sf.mu.Lock()
	delete(sf.calls, key)
	sf.mu.Unlock()

	return c.res, c.err
}

// Hash URL dengan SHA-256 sebagai key
func hashURL(url string) string {
	hash := sha256.Sum256([]byte(url))
	return hex.EncodeToString(hash[:])
}

// Fetch API menggunakan MySingleFlight
func fetchAPI(sf *MySingleFlight, url string) (string, error) {
	key := hashURL(url)

	return sf.Do(key, func() (string, error) {
		fmt.Println("Fetching API:", url) // Indikasi request benar-benar terjadi
		resp, err := http.Get(url)
		if err != nil {
			return "", err
		}
		defer resp.Body.Close()

		body, err := io.ReadAll(resp.Body)
		if err != nil {
			return "", err
		}

		return string(body), nil
	})
}

func main() {
	url := "https://example.com/todos/1" // API contoh
	sf := NewMySingleFlight()

	var wg sync.WaitGroup
	numRequests := 3

	wg.Add(numRequests)
	for i := 0; i < numRequests; i++ {
		go func(id int) {
			defer wg.Done()
			data, err := fetchAPI(sf, url)
			if err != nil {
				fmt.Printf("Goroutine %d error: %v\n", id, err)
			} else {
				fmt.Printf("Goroutine %d result: %s\n", id, data)
			}
		}(i)
	}

	wg.Wait()
}
```

Penjelasan Kode :

* Membuat MySingleFlight
  * Menggunakan map\[string]\*call untuk menyimpan request yang sedang berjalan.
  * Menggunakan sync.Mutex agar hanya satu goroutine yang bisa memodifikasi map pada satu waktu.
  * Request kedua dan seterusnya akan menunggu hasil request pertama.
* Struktur call
  * wg sync.WaitGroup: Menunggu hasil request yang sedang berlangsung.
  * res string: Menyimpan hasil response.
  * err error: Menyimpan error jika terjadi.
* Mekanisme Do()
  * Jika request dengan key tertentu sudah berjalan, goroutine menunggu hasilnya (c.wg.Wait()).
  * belum ada request, membuat request baru dan menyimpannya di map.
  * Setelah request selesai, hapus entri dari map agar request baru bisa dilakukan.
* Memanggil API dengan Hashing
  * Menggunakan SHA-256 hash dari URL sebagai key untuk menghindari duplikasi request.
* Menjalankan fetchAPI() dengan Beberapa Goroutine
  * Tiga goroutine menjalankan request bersamaan.
  * Hanya satu request yang benar-benar dikirim, sisanya menunggu hasilnya.

## Output yang diharapkan

```
Fetching API: https://example.com/todos/1
Goroutine 0 result: {"userId":1,"id":1,"title":"lorem ipsum delectus aut autem","completed":false}
Goroutine 1 result: {"userId":1,"id":1,"title":"lorem ipsum delectus aut autem","completed":false}
Goroutine 2 result: {"userId":1,"id":1,"title":"lorem ipsum delectus aut autem","completed":false}
```

* Fetching API: hanya muncul sekali, menandakan hanya satu request yang benar-benar dikirim.
* Semua goroutine mendapatkan hasil yang sama tanpa harus request ulang.

## Implementasi Menggunakan Library "golang.org/x/sync/singleflight"

Saat ini, sudah ada library single-flight pattern yang cukup populer di golang. Pertimbangkan untuk menggunakan library ini agar kita tidak perlu membuatnya from scratch.

```go
package main

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"sync"
	"time"

	"golang.org/x/sync/singleflight"
)

var sf singleflight.Group

// Hash URL dengan SHA-256 untuk digunakan sebagai key
func hashURL(url string) string {
	hash := sha256.Sum256([]byte(url))
	return hex.EncodeToString(hash[:])
}

// Fetch data dari API third-party menggunakan SingleFlight
func fetchAPI(url string) (string, error) {
	key := hashURL(url) // Gunakan hash URL sebagai key

	// Gunakan SingleFlight untuk mencegah duplikasi request
	result, err, _ := sf.Do(key, func() (interface{}, error) {
		fmt.Println("Fetching API:", url) // Indikasi request benar-benar terjadi
		resp, err := http.Get(url)
		if err != nil {
			return "", err
		}
		defer resp.Body.Close()

		body, err := io.ReadAll(resp.Body)
		if err != nil {
			return "", err
		}

		return string(body), nil
	})

	if err != nil {
		return "", err
	}
	return result.(string), nil
}

func main() {
	url := "https://example.com/todos/1" // API contoh

	var wg sync.WaitGroup
	numRequests := 3

	wg.Add(numRequests)
	for i := 0; i < numRequests; i++ {
		go func(id int) {
			defer wg.Done()
			data, err := fetchAPI(url)
			if err != nil {
				fmt.Printf("Goroutine %d error: %v\n", id, err)
			} else {
				fmt.Printf("Goroutine %d result: %s\n", id, data)
			}
		}(i)
	}

	wg.Wait()
}
```

## Kesimpulan

SingleFlight adalah solusi yang efisien dan sederhana untuk menghindari eksekusi berulang dari tugas yang sama dalam lingkungan konkuren. Jika ingin kontrol penuh, kita bisa membuat SingleFlight buatan sendiri. Jika ingin implementasi cepat dan stabil, cukup gunakan sync/singleflight.

🚀 Dengan menggunakan SingleFlight, kita bisa meningkatkan performa aplikasi secara signifikan dan menghindari pemborosan resource!


# Start up

Setiap aplikasi backend API dimulai dari proses startup — saat server mendengarkan permintaan masuk dari pengguna. Di Go, paket [net/http](https://golang.org/pkg/net/http) menyediakan semua yang kita butuhkan untuk membangun server HTTP tanpa bantuan framework eksternal.

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/01-startup](https://github.com/jacky-htg/workshop/tree/main/01-startup)

## 1.1 Server HTTP Pertama

Cara paling sederhana untuk menjalankan server adalah dengan menggunakan `http.ListenAndServe`. Fungsi ini menerima dua parameter: alamat listen (domain dan port) serta handler yang akan memproses setiap permintaan.

Berikut contoh minimal server yang merespons "Hello World!" di port 9000:

```go
package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {

    handler := http.HandlerFunc(helloworld)

    if err := http.ListenAndServe("0.0.0.0:9000", handler); err != nil {
        log.Fatalf("error: listening and serving: %s", err)
    }
}

func helloworld(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, "Hello World!")
}
```

Penjelasan kode :

* `http.HandlerFunc(helloworld)` mengkonversi fungsi `helloworld` menjadi tipe `http.Handler`
* `ListenAndServe` memblokir eksekusi program hingga server berhenti atau terjadi error

## 1.2 Konfigurasi Server dengan Struct http.Server

Pada aplikasi nyata, kita biasanya butuh kontrol lebih atas perilaku server, seperti batas waktu baca (read timeout) dan tulis (write timeout). Go menyediakan struct [http.Server](https://golang.org/pkg/net/http/#Server) untuk keperluan ini.

```go
package main

import (
    "fmt"
    "log"
    "net/http"
    "time"
)

func main() {

    server := http.Server{
        Addr:         "0.0.0.0:9000",
        Handler:      http.HandlerFunc(helloworld),
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 5 * time.Second,
    }

    if err := server.ListenAndServe(); err != nil {
        log.Fatalf("error: listening and serving: %s", err)
    }
}

func helloworld(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, "Hello World!")
}
```

Dengan pendekatan ini, kita bisa menambah berbagai parameter seperti MaxHeaderBytes, TLSConfig, atau ConnContext nantinya.

## 1.3 Menjalankan Server Secara Asinkron

Pada aplikasi yang lebih kompleks, proses startup tidak hanya menyalakan server HTTP, tetapi juga menghubungkan database, memuat konfigurasi, atau menjalankan background worker. Jika server berjalan secara blocking, tugas-tugas tersebut tidak akan pernah tereksekusi.

Solusinya adalah menjalankan server di dalam goroutine dan menangkap error yang mungkin terjadi melalui channel:

```go
package main

import (
    "fmt"
    "log"
    "net/http"
    "time"
)

func main() {

    server := http.Server{
        Addr:         "0.0.0.0:9000",
        Handler:      http.HandlerFunc(helloworld),
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 5 * time.Second,
    }

    serverErrors := make(chan error, 1)
    
    go func() {
        log.Println("server listening on", server.Addr)
        serverErrors <- server.ListenAndServe()
    }()

    if err, ok := <-serverErrors; ok && err != nil {
        log.Fatalf("error: listening and serving: %s", err)
    }
}

func helloworld(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, "Hello World!")
}
```

Pola ini menjadi fondasi penting karena:

* Server tidak memblokir `main()`, sehingga kita bisa menambahkan logika inisialisasi lain
* Channel `serverErrors` memungkinkan kita mendeteksi kegagalan startup (misal port sudah digunakan)

## Ringkasan Bab 1

Di bab ini kita telah belajar:

1. Membuat server HTTP minimal dengan http.ListenAndServe
2. Menggunakan http.Server untuk konfigurasi timeout dan parameter lainnya
3. Menjalankan server secara asinkron menggunakan goroutine + channel sebagai fondasi untuk graceful shutdown nantinya

Pada bab berikutnya, kita akan membahas bagaimana mematikan server dengan aman (graceful shutdown) tanpa memutus koneksi aktif.


# Shutdown

Setelah kita bisa menyalakan server, pertanyaan selanjutnya adalah: bagaimana mematikannya dengan aman?

Dalam lingkungan produksi, server tidak boleh berhenti secara tiba-tiba. Ada permintaan yang sedang diproses, koneksi database yang terbuka, atau task latar belakang yang belum selesai. Mematikan server secara paksa (hard shutdown) dapat menyebabkan:

* Response terputus di tengah jalan (client mendapat error)
* Data tidak tersimpan dengan benar
* State aplikasi menjadi korup

Go menyediakan mekanisme graceful shutdown untuk mengatasi masalah ini.

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/02-shutdown](https://github.com/jacky-htg/workshop/tree/main/02-shutdown)

## 2.1 Mendengarkan Sinyal dari OS

Langkah pertama adalah mengetahui kapan sistem operasi ingin mematikan aplikasi kita. Di Linux/Unix, proses menerima sinyal seperti:

* `SIGINT` – dikirim saat user menekan `Ctrl+C`
* `SIGTERM` – dikirim oleh `kill`, `systemctl stop`, atau `docker stop`

Kita bisa mendengarkan sinyal-sinyal ini menggunakan `signal.Notify`:

```go
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
```

**Catatan:** Channel harus buffered karena paket signal tidak akan memblokir saat mengirim sinyal.

## 2.2 Menggabungkan Dua Sumber Event dengan `select`

Sekarang aplikasi kita memiliki dua sumber event asinkron:

1. Server error – terjadi jika server gagal berjalan (misal port sudah digunakan)
2. Shutdown signal – terjadi jika OS meminta aplikasi berhenti

Kita menggunakan select untuk menangani keduanya secara bersamaan:

```go
select {
case err, ok := <-serverErrors:
    if ok && err != nil {
        log.Fatalf("error: listening and serving: %s", err)
    }

case <-shutdown:
    // lakukan graceful shutdown
}
```

## 2.3 Implementasi Graceful Shutdown Lengkap

Berikut implementasi lengkap dengan strategi fallback: jika graceful shutdown gagal, kita paksa tutup dengan `server.Close()`.

```go
package main

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {

    server := http.Server{
        Addr:         "0.0.0.0:9000",
        Handler:      http.HandlerFunc(helloworld),
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 5 * time.Second,
    }

    serverErrors := make(chan error, 1)

    go func() {
        log.Println("server listening on", server.Addr)
        serverErrors <- server.ListenAndServe()
    }()

    shutdown := make(chan os.Signal, 1)
    signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)

    select {
    case err, ok := <-serverErrors:
        if ok && err != nil {
			log.Fatalf("error: listening and serving: %s", err)
		}

    case <-shutdown:
        log.Printf("received shutdown signal: %s", sig)

		// Beri waktu 30 detik untuk menyelesaikan request yang sedang berjalan
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()

		// Coba graceful shutdown
		if err := server.Shutdown(ctx); err != nil {
			log.Printf("error during graceful shutdown: %v", err)
            log.Printf("attempting force close due to graceful shutdown failure")

			// Paksa tutup jika graceful gagal
			if err := server.Close(); err != nil && err != http.ErrServerClosed {
				log.Printf("error during force close: %v", err)
			} else {
                log.Printf("server close complete")
            }
		} else {
			log.Printf("server gracefully shutdown complete")
		}
    }

    log.Println("done")
}

func helloworld(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, "Hello World!")
}
```

## 2.4 Keterbatasan Graceful Shutdown

Penting untuk memahami bahwa tidak semua skenario bisa ditangani dengan graceful shutdown:

Perbandingan Berbagai Skenario Shutdown:

| Skenario                            | Graceful Shutdown        | `server.Close()` | Data Loss Risk |
| ----------------------------------- | ------------------------ | ---------------- | -------------- |
| **Listrik mati**                    | ❌ Tidak jalan            | ❌ Tidak jalan    | 🔴 Tinggi      |
| **Kill -9 (SIGKILL)**               | ❌ Tidak jalan            | ❌ Tidak jalan    | 🔴 Tinggi      |
| **Ctrl+C (SIGINT)**                 | ✅ Jalan (jika di-handle) | Bisa dipanggil   | 🟢 Rendah      |
| **Kill / systemctl stop (SIGTERM)** | ✅ Jalan (jika di-handle) | Bisa dipanggil   | 🟢 Rendah      |
| **Docker stop (SIGTERM)**           | ✅ Jalan (jika di-handle) | Bisa dipanggil   | 🟢 Rendah      |

Pesan penting: Tidak ada kode Go yang bisa berjalan saat listrik mati atau proses di-kill -9. Graceful shutdown hanya melindungi dari shutdown normal yang dikirim melalui sinyal OS.

## 2.5 Memahami Dua Metode Penutupan Server

Go menyediakan dua metode berbeda untuk menutup server. Pilih berdasarkan kebutuhan:

### `server.Close()` – Penutupan Paksa

* Menutup listener dan semua koneksi aktif seketika
* Request yang sedang berjalan terputus, client mendapat connection reset
* Non-blocking, langsung return

### `server.Shutdown(ctx)` – Penutupan Bertahap

* Menutup listener → tidak menerima request baru
* Menunggu semua request yang sedang berjalan selesai
* Koneksi idle ditutup dengan normal
* Blocking sampai selesai atau timeout

| Aspek                      | `server.Close()`                                        | `server.Shutdown(ctx)`                               |
| -------------------------- | ------------------------------------------------------- | ---------------------------------------------------- |
| **Menutup listener**       | ✅ Langsung                                              | ✅ Setelah graceful                                   |
| **Koneksi aktif**          | ❌ Diputus paksa (reset)                                 | ✅ Ditunggu selesai                                   |
| **Request dalam proses**   | ❌ Terputus, client dapat error                          | ✅ Diberi waktu selesai                               |
| **Keep-Alive connections** | ❌ Ditutup paksa                                         | ✅ Ditutup setelah idle                               |
| **HTTP/2 streams**         | ❌ Diputus                                               | ✅ Ditunggu selesai                                   |
| **Menerima request baru**  | ✅ Langsung ditolak                                      | ✅ Langsung ditolak                                   |
| **Idle connections**       | ❌ Diputus paksa                                         | ✅ Ditutup normal                                     |
| **Context support**        | ❌ Tidak ada timeout                                     | ✅ Bisa pakai timeout                                 |
| **Error return**           | ✅ Selalu return error (biasanya `http.ErrServerClosed`) | ✅ Return error jika timeout atau gagal               |
| **Blocking behavior**      | ✅ Non-blocking, langsung return                         | ✅ Blocking sampai semua koneksi selesai atau timeout |
| **Use case**               | Force shutdown, testing, atau saat graceful gagal       | Production graceful shutdown                         |
| **Client experience**      | 🔴 Connection reset / EOF                               | 🟢 Mendapat response lengkap                         |
| **Risk**                   | ⚠️ Data loss, corrupted state                           | ✅ Aman untuk data integrity                          |

**Praktik terbaik:** Gunakan Shutdown sebagai cara utama, dan simpan Close sebagai fallback jika graceful gagal (seperti pada contoh kode di atas).

## Ringkasan Bab 2

Di bab ini kita telah belajar:

1. Mendengarkan sinyal OS (SIGINT, SIGTERM) menggunakan signal.Notify
2. Menggabungkan multiple channel events dengan select
3. Implementasi graceful shutdown dengan server.Shutdown
4. Fallback mekanisme dengan server.Close
5. Memahami keterbatasan graceful shutdown pada skenario ekstrem

Pada bab berikutnya, kita akan membahas bagaimana menangani data dalam format JSON — tulang punggung komunikasi API modern.


# Json

Setelah server bisa menyala dan mati dengan aman, sekarang saatnya membangun isi komunikasi API itu sendiri. Hampir semua API modern menggunakan JSON (JavaScript Object Notation) sebagai format pertukaran data karena ringan, mudah dibaca manusia, dan didukung oleh hampir semua bahasa pemrograman.

Di bab ini kita akan mengubah handler HelloWorld menjadi endpoint API yang sesungguhnya: **mengembalikan daftar pengguna dalam format JSON**.

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/03-json](https://github.com/jacky-htg/workshop/tree/main/03-json)

## 3.1 Menyiapkan Proyek

Sebelum mulai, kita buat proyek Go dengan module system:

```bash
go mod init workshop
```

Kita akan membutuhkan library [uuid7](https://github.com/jacky-htg/go-libs/uuid7) untuk menghasilkan ID unik. Tambahkan dependency-nya:

```bash
go get github.com/jacky-htg/go-libs/uuid7
go mod tidy
```

**Struktur proyek:** Saat ini semua kode masih dalam satu file main.go. Seiring berkembangnya framework, kita akan memisahkan ke package-package terpisah.

## 3.2 Mendefinisikan Struct Data

Di Go, JSON direpresentasikan melalui struct tags. Tag `json:"nama_field"` menentukan bagaimana field dalam struct dipetakan ke JSON.

```go
type User struct {
    ID       string `json:"id"`
    Name     string `json:"name"`
    Username string `json:"username"`
    Password string `json:"password"`
    Email    string `json:"email"`
    IsActive bool   `json:"is_active"`
}
```

**Konvensi penamaan:** Di Go kita menggunakan PascalCase untuk field struct (karena bersifat publik), tapi di JSON kita menggunakan snake\_case — ini praktik umum dalam API REST.

## 3.3 Membuat Handler JSON Pertama

Handler `ListUsers` akan:

1. Menyiapkan data contoh (biasanya dari database, nanti akan kita bahas)
2. Mengonversi (marshal) data Go ke JSON
3. Mengatur header Content-Type
4. Menulis response ke client

```go
func ListUsers(w http.ResponseWriter, r *http.Request) {
    users := []User{
        {
            ID:       uuid7.New(),
            Name:     "John Doe",
            Username: "johndoe",
            Password: "secret",
            Email:    "john.doe@example.com",
            IsActive: true,
        },
        {
            ID:       uuid7.New(),
            Name:     "Jane Smith",
            Username: "janesmith",
            Password: "secret",
            Email:    "jane.smith@example.com",
            IsActive: false,
        },
    }

    data, err := json.Marshal(users)
    if err != nil {
        log.Printf("error: marshaling users to JSON: %s", err)
        http.Error(w, "Internal Server Error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    if _, err := w.Write(data); err != nil {
        log.Printf("error: writing response: %s", err)
    }
}
```

## 3.4 Kode Lengkap main.go

Berikut kode lengkap yang menggabungkan startup, graceful shutdown, dan JSON response:

```go
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/jacky-htg/go-libs/uuid7"
)

func main() {

	server := &http.Server{
		Addr:         "0.0.0.0:9000",
		Handler:      http.HandlerFunc(ListUsers),
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 5 * time.Second,
	}

	serverErrChan := make(chan error, 1)

	go func() {
		log.Printf("starting server on %s", server.Addr)
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			serverErrChan <- fmt.Errorf("error: listening and serving: %s", err)
		}
		close(serverErrChan)
	}()

	shutdownChan := make(chan os.Signal, 1)
	signal.Notify(shutdownChan, os.Interrupt, syscall.SIGTERM)

	select {
	case err, ok := <-serverErrChan:
		if ok && err != nil {
			log.Fatalf("error: server error: %s", err)
		}
	case sig := <-shutdownChan:
		log.Printf("received shutdown signal: %s", sig)

		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()

		if err := server.Shutdown(ctx); err != nil {
			log.Printf("error during graceful shutdown: %v", err)
			log.Printf("attempting force close due to graceful shutdown failure")

			if err := server.Close(); err != nil && err != http.ErrServerClosed {
				log.Printf("error during force close: %v", err)
			}
		} else {
			log.Printf("server gracefully shutdown complete")
		}
	}
}

type User struct {
	ID       string `json:"id"`
	Name     string `json:"name"`
	Username string `json:"username"`
	Password string `json:"password"`
	Email    string `json:"email"`
	IsActive bool   `json:"is_active"`
}

func ListUsers(w http.ResponseWriter, r *http.Request) {
	users := []User{
		{ID: uuid7.New(), Name: "John Doe", Username: "johndoe", Password: "secret", Email: "john.doe@example.com", IsActive: true},
		{ID: uuid7.New(), Name: "Jane Smith", Username: "janesmith", Password: "secret", Email: "jane.smith@example.com", IsActive: false},
	}

	data, err := json.Marshal(users)
	if err != nil {
		log.Printf("error: marshaling users to JSON: %s", err)
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	if _, err := w.Write(data); err != nil {
		log.Printf("error: writing response: %s", err)
	}
}
```

## 3.5 Menjalankan dan Menguji

Jalankan server:

```bash
go run main.go
```

Uji endpoint menggunakan `curl`:

```bash
curl http://localhost:9000/
```

Response yang diharapkan:

```json
[
    {
        "id": "019eab40-97e2-7ea4-9707-210c937ca432",
        "name": "John Doe",
        "username": "johndoe",
        "password": "secret",
        "email": "john.doe@example.com",
        "is_active": true
    },
    {
        "id": "019eab40-97e2-74a2-a6e9-0a64df6d3415",
        "name": "Jane Smith",
        "username": "janesmith",
        "password": "secret",
        "email": "jane.smith@example.com",
        "is_active": false
    }
]
```

## 3.6 Catatan Penting tentang Password

**Peringatan keamanan:** Dalam contoh di atas, password dikirim dalam bentuk plain text! Ini TIDAK boleh dilakukan di aplikasi nyata. Nanti kita akan membahas:

* Hashing password (bcrypt)
* Tidak mengirim field password dalam response API
* Menggunakan struct tags seperti json:"password,omitempty" atau DTO (Data Transfer Object)

## Ringkasan Bab 3

Di bab ini kita telah belajar:

1. Membuat struct dengan JSON tags untuk memetakan data Go ke JSON
2. Menggunakan json.Marshal untuk mengonversi data menjadi JSON
3. Mengatur Content-Type: application/json di response header
4. Menulis JSON response ke http.ResponseWriter
5. Menambahkan dependency management dengan go mod

Apa yang sudah bisa dilakukan API kita:

* ✅ Menyalakan server dengan konfigurasi timeout
* ✅ Mematikan server secara graceful
* ✅ Mengembalikan response dalam format JSON

Yang masih perlu ditambahkan:

* ❌ Menerima data dari client (request body)
* ❌ Validasi data
* ❌ Menyimpan data ke database
* ❌ Status HTTP yang tepat (200, 400, 404, dsb)

Pada bab berikutnya, kita akan menghubungkan API dengan database agar data tidak lagi statis/hardcoded.


# Database

API tanpa database hanyalah kumpulan data statis. Di bab ini kita akan menghubungkan API dengan **PostgreSQL** sebagai penyimpanan data permanen. Kita akan menggunakan paket `database/sql` bawaan Go — sederhana, performa tinggi, dan tanpa ORM yang kompleks.

Pembahasan dibagi menjadi tiga bagian:

1. Migration – Membuat struktur tabel
2. Seed – Mengisi data awal
3. ListUsers – Mengambil data dari database (bukan hardcode)

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/04-database](https://github.com/jacky-htg/workshop/tree/main/04-database)

## 4.1 Menyiapkan Database

Pertama, buat database di PostgreSQL:

```sql
CREATE DATABASE workshop;
```

Kemudian buat fungsi koneksi. Sesuaikan user dan password dengan konfigurasi PostgreSQL Anda:

```go
func openDB() (*sql.DB, error) {
    return sql.Open("postgres", "postgres://user:password@localhost:5432/workshop?sslmode=disable")
}
```

**Catatan:** Koneksi string di atas menggunakan format URL. Pastikan menginstal driver PostgreSQL terlebih dahulu:

```bash
go get github.com/lib/pq
```

## 4.2 Migration

Migration adalah cara version-controlled untuk membuat dan mengubah skema database. Kita akan menggunakan library [go-libs/migration](https://github.com/jacky-htg/go-libs/migration) karena cukup sederhana.

### Langkah 1: Membuat Folder dan File Migration

Buat folder `migration/` di root proyek, lalu buat file `1_0001_users.sql` dengan struktur penamaan:

| Prefix | Kegunaan     |
| ------ | ------------ |
| `1_`   | Create Table |
| `2_`   | Alter Table  |
| `3_`   | Seed Data    |

### Langkah 2: Isi File Migration

```sql
-- Enable extension yang diperlukan
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";  -- Untuk pencarian nana

-- Membuat tabel users
CREATE TABLE IF NOT EXISTS users (
    id          UUID PRIMARY KEY,
    name        VARCHAR(255) NOT NULL,
    username    VARCHAR(100) NOT NULL,
    password    VARCHAR(255) NOT NULL,
    email       VARCHAR(255) NOT NULL,
    is_active   BOOLEAN NOT NULL DEFAULT true,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()),
    deleted_at  TIMESTAMPTZ NULL
);

-- =====================================================
-- INDEX SET (Minimum Viable Index untuk awal project)
-- =====================================================

-- 1. Unique partial index untuk username (wajib, untuk login/auth)
CREATE UNIQUE INDEX idx_users_username_unique 
ON users(username) 
WHERE deleted_at IS NULL;

-- 2. Unique partial index untuk email (wajib, untuk komunikasi)
CREATE UNIQUE INDEX idx_users_email_unique 
ON users(email) 
WHERE deleted_at IS NULL;

-- 3. Index untuk pagination/sorting
CREATE INDEX idx_users_created_at_active 
ON users(created_at DESC) 
WHERE deleted_at IS NULL;

-- 4. Partial index untuk filter is_active
CREATE INDEX idx_users_is_active 
ON users(is_active) 
WHERE deleted_at IS NULL AND is_active = true;

-- 5. Trigram index untuk pencarian name (buat hanya jika fitur search diperlukan)
CREATE INDEX idx_users_name_trgm 
ON users USING gin(name gin_trgm_ops) 
WHERE deleted_at IS NULL;

-- =====================================================
-- TRIGGER untuk auto-update updated_at
-- =====================================================

CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = timezone('utc', now());
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_update_users_updated_at
    BEFORE UPDATE ON users
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();
```

### Langkah 3: Integrasi Migration ke main.go

Kita perlu menambahkan kemampuan menjalankan migration dari command line:

```go
func main() {
    db, err := openDB()
    if err != nil {
        log.Fatalf("error: opening database: %s", err)
    }
    defer db.Close()

    flag.Parse()

    // Jika argumen = "migrate", jalankan migration lalu exit
    if len(flag.Args()) > 0 && flag.Arg(0) == "migrate" {
        if err := migration.Migrate(db, "migration"); err != nil {
            log.Fatalf("error: running migrations: %s", err)
        }
        log.Printf("migrations completed successfully")
        return
    }

    // ... lanjut ke server HTTP ...
}
```

Jalankan migration:

```bash
go run main.go migrate
```

## 4.3 Seed Data

Seed adalah proses mengisi data awal ke database. Buat file `3_0001_users.sql` di folder `migration/`:

```sql
INSERT INTO users (id, name, username, password, email, is_active) VALUES
(uuid_generate_v4(), 'John Doe', 'johndoe', 'secret', 'john.doe@example.com', true),
(uuid_generate_v4(), 'Jane Smith', 'janesmith', 'secret', 'jane.smith@example.com', false);
```

**Peringatan:** Password masih dalam bentuk plain text! Di bab selanjutnya kita akan membahas hashing dengan bcrypt.

Jalankan migration lagi untuk mengisi seed:

```bash
go run main.go migrate
```

Perintah yang sama akan mengeksekusi semua file migration yang belum dijalankan (termasuk seed).

## 4.4 Mengambil Data dari Database

Sekarang kita ubah handler `ListUsers` untuk mengambil data langsung dari database, bukan hardcode.

### Membuat Struct dengan Dependency Injection

Pertama, buat struct `Users` yang menerima koneksi database melalui *dependency injection* (DI). DI membuat kode lebih mudah di-test karena kita bisa mengganti dependency kapan saja.

```go
type Users struct {
    Db *sql.DB
}

func NewUsers(db *sql.DB) *Users {
    return &Users{Db: db}
}
```

### Method List yang Terintegrasi Database

```go
func (u Users) List(w http.ResponseWriter, r *http.Request) {
    query := `SELECT id, name, username, password, email, is_active FROM users`
    rows, err := u.Db.Query(query)
    if err != nil {
        log.Printf("error: querying users: %s", err)
        http.Error(w, "Internal Server Error", http.StatusInternalServerError)
        return
    }
    defer rows.Close()

    var users []User
    for rows.Next() {
        var user User
        if err := rows.Scan(&user.ID, &user.Name, &user.Username, &user.Password, &user.Email, &user.IsActive); err != nil {
            log.Printf("error: scanning user row: %s", err)
            http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            return
        }
        users = append(users, user)
    }

    if err := rows.Err(); err != nil {
        log.Printf("error: iterating user rows: %s", err)
        http.Error(w, "Internal Server Error", http.StatusInternalServerError)
        return
    }

    data, err := json.Marshal(users)
    if err != nil {
        log.Printf("error: marshaling users to JSON: %s", err)
        http.Error(w, "Internal Server Error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    if _, err := w.Write(data); err != nil {
        log.Printf("error: writing response: %s", err)
    }
}
```

### Menghubungkan ke Server

Update fungsi main untuk menggunakan `userService.List` sebagai handler:

```go
userService := NewUsers(db)

server := &http.Server{
    Addr:         "0.0.0.0:9000",
    Handler:      http.HandlerFunc(userService.List), // ← berubah
    ReadTimeout:  5 * time.Second,
    WriteTimeout: 5 * time.Second,
}
```

## 4.5 Kode Lengkap main.go

```go
package main

import (
	"context"
	"database/sql"
	"encoding/json"
	"flag"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/jacky-htg/go-libs/migration"
	_ "github.com/lib/pq"
)

func main() {

	db, err := openDB()
	if err != nil {
		log.Fatalf("error: opening database: %s", err)
	}
	defer db.Close()

	flag.Parse()

	if len(flag.Args()) > 0 && flag.Arg(0) == "migrate" {
		if err := migration.Migrate(db, "migration"); err != nil {
			log.Fatalf("error: running migrations: %s", err)
		}
		log.Printf("migrations completed successfully")
		return
	}

	userService := NewUsers(db)
	server := &http.Server{
		Addr:         "0.0.0.0:9000",
		Handler:      http.HandlerFunc(userService.List),
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 5 * time.Second,
	}

	serverErrChan := make(chan error, 1)

	go func() {
		log.Printf("starting server on %s", server.Addr)
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			serverErrChan <- fmt.Errorf("error: listening and serving: %s", err)
		}
		close(serverErrChan)
	}()

	shutdownChan := make(chan os.Signal, 1)
	signal.Notify(shutdownChan, os.Interrupt, syscall.SIGTERM)

	select {
	case err, ok := <-serverErrChan:
		if ok && err != nil {
			log.Fatalf("error: server error: %s", err)
		}
	case sig := <-shutdownChan:
		log.Printf("received shutdown signal: %s", sig)

		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()

		if err := server.Shutdown(ctx); err != nil {
			log.Printf("error during graceful shutdown: %v", err)
			log.Printf("attempting force close due to graceful shutdown failure")

			if err := server.Close(); err != nil && err != http.ErrServerClosed {
				log.Printf("error during force close: %v", err)
			}
		} else {
			log.Printf("server gracefully shutdown complete")
		}
	}
}

type Users struct {
	Db *sql.DB
}

func NewUsers(db *sql.DB) *Users {
	return &Users{Db: db}
}

type User struct {
	ID       string `json:"id"`
	Name     string `json:"name"`
	Username string `json:"username"`
	Password string `json:"password"`
	Email    string `json:"email"`
	IsActive bool   `json:"is_active"`
}

func (u Users) List(w http.ResponseWriter, r *http.Request) {
	query := `SELECT id, name, username, password, email, is_active FROM users`
	rows, err := u.Db.Query(query)
	if err != nil {
		log.Printf("error: querying users: %s", err)
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}
	defer rows.Close()

	var users []User
	for rows.Next() {
		var user User
		if err := rows.Scan(&user.ID, &user.Name, &user.Username, &user.Password, &user.Email, &user.IsActive); err != nil {
			log.Printf("error: scanning user row: %s", err)
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
			return
		}
		users = append(users, user)
	}

	if err := rows.Err(); err != nil {
		log.Printf("error: iterating user rows: %s", err)
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	data, err := json.Marshal(users)
	if err != nil {
		log.Printf("error: marshaling users to JSON: %s", err)
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	if _, err := w.Write(data); err != nil {
		log.Printf("error: writing response: %s", err)
	}
}

func openDB() (*sql.DB, error) {
	return sql.Open("postgres", "postgres://postgres:1234@localhost:5432/workshop?sslmode=disable")
}
```

## 4.6 Menjalankan dan Menguji

```bash
# Jalankan migration dan seed
go run main.go migrate

# Jalankan server
go run main.go

# Uji endpoint
curl http://localhost:9000/
```

Response akan berisi data dari database PostgreSQL:

```json
[
    {
        "id": "019eab40-97e2-7ea4-9707-210c937ca432",
        "name": "John Doe",
        "username": "johndoe",
        "password": "secret",
        "email": "john.doe@example.com",
        "is_active": true
    },
    {
        "id": "019eab40-97e2-74a2-a6e9-0a64df6d3415",
        "name": "Jane Smith",
        "username": "janesmith",
        "password": "secret",
        "email": "jane.smith@example.com",
        "is_active": false
    }
]
```

## Ringkasan Bab 4

Di bab ini kita telah belajar:

1. Migration – Mengelola skema database dengan file SQL version-controlled
2. Seed – Mengisi data awal untuk development/testing
3. Dependency Injection – Menyuntikkan koneksi database ke handler
4. Database Query – Menggunakan database/sql untuk mengambil data
5. CLI Pattern – Menambahkan perintah migrate tanpa mengganggu server HTTP

Pola penting yang diperkenalkan:

* File migration dengan prefix numerik (urutan eksekusi jelas)
* Struct Users sebagai receiver method (bukan fungsi global)
* Database connection dibuat sekali di main dan di-inject

Yang akan datang:

* ❌ Masih ada password plain text
* ❌ Belum ada validasi input
* ❌ Semua kode dalam satu file (akan dipisah sesuai Clean Architecture)

Pada bab berikutnya, kita akan membahas Clean Architecture — memisahkan kode ke layer-layer yang bertanggung jawab agar framework kita mudah di-maintain dan di-test.


# Clean architecture

Setelah kita memiliki API yang terhubung ke database, sekarang saatnya memikirkan struktur kode. Tanpa arsitektur yang jelas, kode akan sulit di-maintain, sulit di-test, dan sulit dikembangkan oleh tim.

Clean Architecture (diperkenalkan oleh Robert C. Martin) adalah pendekatan yang memisahkan kode ke dalam lapisan-lapisan (layer) berdasarkan tanggung jawabnya.

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/05-clean-architecture](https://github.com/jacky-htg/workshop/tree/main/05-clean-architecture)

## 5.1 Tiga Layer Utama

![](https://1500694864-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-MaN3VdVswXpcCva69TG%2Fuploads%2Fgit-blob-0a1335bdbe1f9e8d9d4829ed664fcd6ec45b28d4%2Fclean-architecture.jpg?alt=media)

PRESENTATION LAYER (HTTP Handler, Routing, Request/Response DTO) :

* Menerima input dari user
* Memformat output ke user
* TIDAK boleh mengandung logika bisnis

DOMAIN LAYER (Service / UseCase) :

* Logika bisnis aplikasi
* Aturan bisnis & validasi
* Tidak peduli dari mana data berasal

DATA LAYER (Repository, Model) :

* Akses database / API eksternal
* Mapping data dari storage ke struct
* Hanya operasi CRUD sederhana

**Prinsip utama:** Ketergantungan hanya mengarah ke dalam (inward). Layer dalam (Domain) tidak boleh tahu tentang layer luar (Presentation/Data).

## 5.2 Struktur Direktori

Berikut struktur direktori yang akan kita bangun:

```
workshop/
├── cmd/
│   ├── cli/
│   │   └── main.go          # Perintah CLI (migrate, seed, dll)
│   └── server/
│       └── main.go          # Entry point server HTTP
├── internal/                 # Kode inti (tidak boleh diimport dari luar)
│   ├── dto/                  # Data Transfer Object (response)
│   │   └── user_response.go
│   ├── handler/              # Presentation layer
│   │   └── user_handler.go
│   ├── model/                # Data layer - entity
│   │   └── user.go
│   ├── repository/           # Data layer - akses database
│   │   └── user_repository.go
│   └── service/              # Domain layer - logika bisnis
│       └── users.go
├── migration/                # File SQL migration
│   ├── 1_0001_users.sql
│   └── 3_0001_users.sql
├── pkg/                      # Library publik (bisa diimport proyek lain)
│   └── database/
│       └── postgre.go
├── go.mod
└── go.sum
```

**Catatan tentang folder `internal` dan `pkg`:**

* `internal/` – Kode yang hanya boleh digunakan oleh proyek ini. Go compiler akan melarang import dari luar.
* `pkg/` – Kode yang boleh diimport oleh proyek lain (library publik).

## 5.3 Memisahkan Entry Points (Server vs CLI)

Sebelumnya, kita mencampur logika server HTTP dan migration dalam satu main.go. Sekarang kita pisahkan:

### `cmd/server/main.go` – Entry Point untuk API Server

Menjalankan HTTP server, graceful shutdown, dan injeksi dependency.

### `cmd/cli/main.go` – Entry Point untuk Command Line

Menjalankan perintah administrasi seperti migration.

```go
// cmd/cli/main.go
package main

import (
	"flag"
	"log"
	"workshop/pkg/database"

	"github.com/jacky-htg/go-libs/migration"
	_ "github.com/lib/pq"
)

func main() {

	db, err := database.OpenDB()
	if err != nil {
		log.Fatalf("error: opening database: %s", err)
	}
	defer db.Close()

	flag.Parse()

	if len(flag.Args()) > 0 && flag.Arg(0) == "migrate" {
		if err := migration.Migrate(db, "migration"); err != nil {
			log.Fatalf("error: running migrations: %s", err)
		}
		log.Printf("migrations completed successfully")
		return
	}
}
```

## 5.4 Dependency Injection dengan Interface

Dependency Injection (DI) memastikan setiap object hanya dibuat sekali (singleton) dan disuntikkan ke komponen yang membutuhkan. Kita sudah mempraktekkannya sebelumnya dengan `NewUsers(db)`.

Sekarang kita tingkatkan dengan interface. Interface mendefinisikan kontrak behavior — apa yang bisa dilakukan, bukan bagaimana cara melakukannya.

```go
type UserRepository interface {
    List() ([]model.User, error)
    FindByID(ctx context.Context, id string) (*model.User, error)
    Create(user *model.User) error
}
```

Konvensi penamaan interface:

* Jika berisi satu behavior → akhiri dengan `-er` (contoh: `Reader`, `Writer`)
* Jika berisi banyak behavior → `PascalCase` (contoh: `UserRepository`)

Mengapa interface penting untuk DI?

* Memungkinkan kita mengganti implementasi (misal: dari PostgreSQL ke MongoDB) tanpa mengubah kode lain
* Memudahkan unit testing dengan mock object

## 5.5 Implementasi Layer per Layer

### Layer Data – Model (`internal/model/user.go`)

Model adalah representasi struktur data dari database. Tidak mengandung tag JSON karena ini murni untuk layer data.

```go
package model

type User struct {
	ID       string
	Name     string
	Username string
	Password string
	Email    string
	IsActive bool
}
```

### Layer Data – Repository (`internal/repository/user_repository.go`)

Repository bertanggung jawab untuk operasi database. Hanya berisi query sederhana — tanpa logika bisnis.

```go
package repository

import (
	"database/sql"
	"log"
	"workshop/internal/model"
)

type UserRepository interface {
	List() ([]model.User, error)
}

type userRepository struct {
	db *sql.DB
}

func NewUserRepository(db *sql.DB) UserRepository {
	return &userRepository{db: db}
}

// List : http handler for returning list of users
func (u *userRepository) List() ([]model.User, error) {
	query := `SELECT id, name, username, password, email, is_active FROM users`
	rows, err := u.db.Query(query)
	if err != nil {
		log.Printf("error: querying users: %s", err)
		return nil, err
	}
	defer rows.Close()

	var users []model.User
	for rows.Next() {
		var user model.User
		if err := rows.Scan(&user.ID, &user.Name, &user.Username, &user.Password, &user.Email, &user.IsActive); err != nil {
			log.Printf("error: scanning user row: %s", err)
			return nil, err
		}
		users = append(users, user)
	}

	if err := rows.Err(); err != nil {
		log.Printf("error: iterating user rows: %s", err)
		return nil, err
	}

	return users, nil
}
```

* Berikut isi dari file `pkg/database/postgre.go`

### Layer Domain – Service (`internal/service/users.go`)

Service berisi logika bisnis. Di contoh sederhana ini, service hanya meneruskan ke repository. Namun nanti di sinilah validasi, perhitungan, dan aturan bisnis lainnya berada.

```go
package service

import (
	"workshop/internal/model"
	"workshop/internal/repository"
)

type Users interface {
	List() ([]model.User, error)
}

type users struct {
	repo repository.UserRepository
}

func NewUsers(repo repository.UserRepository) Users {
	return &users{repo: repo}
}

func (u *users) List() ([]model.User, error) {
	// Logika bisnis bisa ditambahkan di sini
    // Contoh: filter, sorting, validasi, dll.
	return u.repo.List()
}
```

### Layer Presentation – DTO (`internal/dto/user_response.go`)

DTO (Data Transfer Object) adalah representasi data yang dikirim ke client. Tidak semua field dari model harus diekspos — misalnya, field Password tidak boleh dikirim ke response.

```go
package dto

import "workshop/internal/model"

type UserResponse struct {
	ID       string `json:"id"`
	Name     string `json:"name"`
	Username string `json:"username"`
	Email    string `json:"email"`
	IsActive bool   `json:"is_active"`
}

func (u *UserResponse) Transform(user model.User) {
	u.ID = user.ID
	u.Name = user.Name
	u.Username = user.Username
	u.Email = user.Email
	u.IsActive = user.IsActive
}
```

**Perhatikan:** Field Password tidak ada di UserResponse — ini sengaja agar password tidak bocor ke client.

### Layer Presentation – Handler (`internal/handler/user_handler.go`)

Handler menerima HTTP request, memanggil service, lalu mengubah hasil menjadi JSON response.

```go
package handler

import (
	"encoding/json"
	"log"
	"net/http"
	"workshop/internal/dto"
	"workshop/internal/service"
)

type UserHanlder interface {
	List(w http.ResponseWriter, r *http.Request)
}

type userHandler struct {
	service service.Users
}

func NewUserHandler(service service.Users) UserHanlder {
	return &userHandler{service: service}
}

// List : http handler for returning list of users
func (u *userHandler) List(w http.ResponseWriter, r *http.Request) {
	users, err := u.service.List()
	if err != nil {
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	var response []dto.UserResponse
	for _, user := range users {
		var ur dto.UserResponse
		ur.Transform(user)
		response = append(response, ur)
	}

	data, err := json.Marshal(response)
	if err != nil {
		log.Printf("error: marshaling users to JSON: %s", err)
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	if _, err := w.Write(data); err != nil {
		log.Printf("error: writing response: %s", err)
	}
}
```

### Library Pendukung – Database (`pkg/database/postgre.go`)

Kode ini bisa dijadikan library karena tidak spesifik untuk proyek ini.

```go
package database

import "database/sql"

func OpenDB() (*sql.DB, error) {
	return sql.Open("postgres", "postgres://postgres:1234@localhost:5432/workshop?sslmode=disable")
}
```

### Entry Point Server (`cmd/server/main.go`)

Ini adalah tempat perakitan semua komponen (dependency injection). Urutan inisialisasi: database → repository → service → handler.

```go
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
	"workshop/internal/handler"
	"workshop/internal/repository"
	"workshop/internal/service"
	"workshop/pkg/database"

	_ "github.com/lib/pq"
)

func main() {

	db, err := database.OpenDB()
	if err != nil {
		log.Fatalf("error: opening database: %s", err)
	}
	defer db.Close()

	userRepository := repository.NewUserRepository(db)
	userService := service.NewUsers(userRepository)
	userHandler := handler.NewUserHandler(userService)

	// server
	server := &http.Server{
		Addr:         "0.0.0.0:9000",
		Handler:      http.HandlerFunc(userHandler.List),
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 5 * time.Second,
	}

	serverErrChan := make(chan error, 1)

	// start server in a goroutine
	go func() {
		log.Printf("starting server on %s", server.Addr)
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			serverErrChan <- fmt.Errorf("error: listening and serving: %s", err)
		}
		close(serverErrChan)
	}()

	shutdownChan := make(chan os.Signal, 1)
	signal.Notify(shutdownChan, os.Interrupt, syscall.SIGTERM)

	select {
	case err, ok := <-serverErrChan:
		if ok && err != nil {
			log.Fatalf("error: server error: %s", err)
		}
	case sig := <-shutdownChan:
		log.Printf("received shutdown signal: %s", sig)

		// Give more time for graceful shutdown
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()

		// Attempt graceful shutdown
		if err := server.Shutdown(ctx); err != nil {
			log.Printf("error during graceful shutdown: %v", err)
			log.Printf("attempting force close due to graceful shutdown failure")

			// Force close if graceful shutdown fails
			if err := server.Close(); err != nil && err != http.ErrServerClosed {
				log.Printf("error during force close: %v", err)
			}
		} else {
			log.Printf("server gracefully shutdown complete")
		}
	}
}

```

## 5.6 Menjalankan Aplikasi

```bash
# Migration
go run cmd/cli/main.go migrate

# Jalankan server
go run cmd/server/main.go

# Uji endpoint
curl http://localhost:9000/
```

## Ringkasan Bab 5

Di bab ini kita telah belajar:

| Komponen        | Folder                | Tanggung Jawab                           |
| --------------- | --------------------- | ---------------------------------------- |
| **Model**       | `internal/model`      | Struktur data dari database              |
| **Repository**  | `internal/repository` | Operasi database (CRUD)                  |
| **Service**     | `internal/service`    | Logika bisnis                            |
| **DTO**         | `internal/dto`        | Format response ke client                |
| **Handler**     | `internal/handler`    | Menerima request, mengembalikan response |
| **Library**     | `pkg`                 | Kode yang bisa digunakan ulang           |
| **Entry point** | `cmd`                 | Server HTTP dan CLI tools                |

Manfaat Clean Architecture yang sudah kita rasakan:

* ✅ Pemisahan tanggung jawab yang jelas
* ✅ Model data tidak terikat dengan format JSON
* ✅ Password tidak bocor ke response (karena dipisah di DTO)
* ✅ Repository bisa diganti tanpa mengubah service/handler
* ✅ CLI dan server berbagi kode yang sama

Yang akan datang:

* ❌ Belum ada konfigurasi (database URL masih hardcoded)
* ❌ Belum ada error handling yang terstruktur
* ❌ Belum ada validasi input

Pada bab berikutnya, kita akan membahas Configuration — bagaimana mengelola konfigurasi aplikasi (database URL, port, timeout) tanpa hardcode.


# Configuration

Selama ini kita menulis konfigurasi seperti port server dan koneksi database secara hardcoded — langsung ditulis dalam kode. Ini masalah besar karena:

* Berbeda antara laptop developer (development) dengan server produksi
* Konfigurasi rahasia (password database) tidak boleh masuk ke repository Git
* Mengganti konfigurasi memerlukan compile ulang kode

Solusinya adalah membaca konfigurasi dari environment variable dan file `.env`.

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/06-configuration](https://github.com/jacky-htg/workshop/tree/main/06-configuration)

## 6.1 Strategi Konfigurasi

| Lingkungan             | Sumber Konfigurasi   | Contoh                 |
| ---------------------- | -------------------- | ---------------------- |
| **Development**        | File `.env`          | `APP_PORT=9000`        |
| **Production**         | Environment variable | `export APP_PORT=8080` |
| **Container (Docker)** | Environment variable | `-e APP_PORT=8080`     |

Pola yang akan kita terapkan:

1. Baca file `.env` jika ada (untuk development)
2. Jika variabel yang sama diset di environment, nilai environment lebih prioritas
3. Setiap konfigurasi memiliki nilai default (fallback)

## 6.2 Library yang Digunakan

Kita akan menggunakan dua library:

* `godotenv` – membaca file `.env`
* `go-libs/env` – wrapper yang memberi prioritas ke environment variable

Install dependency:

```bash
go get github.com/jacky-htg/go-libs/env
go get github.com/joho/godotenv
```

## 6.3 File .env

Buat file `.env` di root proyek:

```
APP_PORT=9000
SERVER_WRITE_TIMEOUT=15s
SERVER_READ_TIMEOUT=15s
SERVER_IDLE_TIMEOUT=30s
SERVER_GRACEFUL_SHUTDOWN_TIMEOUT=30s

DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=1234
DB_DATABASE=workshop
DB_SSLMODE=disable
DB_SCHEMA=public
DB_APPLICATION_NAME=workshop
DB_MAX_OPEN_CONNS=25
DB_MAX_IDLE_CONNS=25
DB_CONN_MAX_LIFETIME=5m
DB_CONN_MAX_IDLE_TIME=5m
```

**Keamanan:** Jangan commit file `.env` ke Git! Tambahkan `.env` ke `.gitignore`.

## 6.4 Struct Konfigurasi

Buat folder `config/` dan file `config/config.go`. Struct ini akan mengelompokkan konfigurasi berdasarkan domainnya:

```go
package config

import (
	"time"

	"github.com/jacky-htg/go-libs/env"
)

type Config struct {
	Server   ServerConfig
	Database DatabaseConfig
}

type ServerConfig struct {
	AppPort 				int
	WriteTimeout            time.Duration
	ReadTimeout             time.Duration
	IdleTimeout             time.Duration
	GracefulShutdownTimeout time.Duration
}

type DatabaseConfig struct {
	Host            string
	Port            string
	Username        string
	Password        string
	Database        string
	SslMode         string
	Schema          string
	ApplicationName string
	MaxOpenConns    int
	MaxIdleConns    int
	ConnMaxLifetime time.Duration
	ConnMaxIdleTime time.Duration
}
```

**Pola ini penting:** Dengan mengelompokkan konfigurasi, kita bisa dengan mudah melewatkan cfg.Server atau cfg.Database ke fungsi yang membutuhkan, bukan seluruh Config.

## 6.5 Fungsi LoadConfig

Fungsi `LoadConfig` akan membaca environment (dan file `.env` jika ada), lalu mengembalikan struct Config yang sudah terisi:

```go
func LoadConfig() (Config, error) {
    err := env.InitEnv()
    if err != nil {
        return Config{}, err
    }

    server := ServerConfig{
        AppPort:                 env.EnvInt("APP_PORT", 9000),
        WriteTimeout:            env.EnvDuration("SERVER_WRITE_TIMEOUT", 5*time.Second),
        ReadTimeout:             env.EnvDuration("SERVER_READ_TIMEOUT", 5*time.Second),
        IdleTimeout:             env.EnvDuration("SERVER_IDLE_TIMEOUT", 30*time.Second),
        GracefulShutdownTimeout: env.EnvDuration("SERVER_GRACEFUL_SHUTDOWN_TIMEOUT", 30*time.Second),
    }

    databaseConfig := DatabaseConfig{
        Host:            env.Env("DB_HOST", "localhost"),
        Port:            env.Env("DB_PORT", "5432"),
        Username:        env.Env("DB_USERNAME", "postgres"),
        Password:        env.Env("DB_PASSWORD", "1234"),
        Database:        env.Env("DB_DATABASE", "workshop"),
        SslMode:         env.Env("DB_SSLMODE", "disable"),
        Schema:          env.Env("DB_SCHEMA", "public"),
        ApplicationName: env.Env("DB_APPLICATION_NAME", "workshop"),
        MaxOpenConns:    env.EnvInt("DB_MAX_OPEN_CONNS", 25),
        MaxIdleConns:    env.EnvInt("DB_MAX_IDLE_CONNS", 25),
        ConnMaxLifetime: env.EnvDuration("DB_CONN_MAX_LIFETIME", 5*time.Minute),
        ConnMaxIdleTime: env.EnvDuration("DB_CONN_MAX_IDLE_TIME", 5*time.Minute),
    }

    return Config{
        Server:   server,
        Database: databaseConfig,
    }, nil
}
```

Perhatikan setiap nilai memiliki **fallback default** — parameter kedua di `env.EnvInt`, `env.EnvDuration`, dll.

## 6.6 Update Package Database

Ubah `pkg/database/postgre.go` untuk menerima konfigurasi:

```go
package database

import (
	"database/sql"
	"fmt"

	"workshop/config"
)

func OpenDB(cfg config.Config) (*sql.DB, error) {
	db, err := sql.Open(
		"postgres",
		fmt.Sprintf(
			"host=%s port=%s user=%s password=%s dbname=%s sslmode=%s search_path=%s application_name=%s",
			cfg.Database.Host,
			cfg.Database.Port,
			cfg.Database.Username,
			cfg.Database.Password,
			cfg.Database.Database,
			cfg.Database.SslMode,
			cfg.Database.Schema,
			cfg.Database.ApplicationName,
		),
	)

	db.SetMaxOpenConns(cfg.Database.MaxOpenConns)
	db.SetMaxIdleConns(cfg.Database.MaxIdleConns)
	db.SetConnMaxLifetime(cfg.Database.ConnMaxLifetime)
	db.SetConnMaxIdleTime(cfg.Database.ConnMaxIdleTime)

	err = db.Ping()
	if err != nil {
		return nil, fmt.Errorf("Failed to ping %s DB: %v", cfg.Database.Database, err)
	}
	return db, nil
}
```

Penjelasan tambahan tentang connection pool:

* MaxOpenConns – maksimal koneksi aktif ke database (25 adalah nilai yang baik untuk aplikasi skala sedang)
* MaxIdleConns – koneksi idle yang disimpan untuk dipakai ulang
* ConnMaxLifetime – maksimal umur koneksi (mencegah koneksi stale)
* ConnMaxIdleTime – waktu maksimal koneksi idle sebelum ditutup

## 6.7 Update CLI dan Server

### `cmd/cli/main.go`

```go
package main

import (
	"flag"
	"log"
	"workshop/config"
	"workshop/pkg/database"

	"github.com/jacky-htg/go-libs/migration"
	_ "github.com/lib/pq"
)

func main() {

	cfg, err := config.LoadConfig()
	if err != nil {
		log.Fatalf("error: loading config: %s", err)
	}

	db, err := database.OpenDB(cfg)
	if err != nil {
		log.Fatalf("error: opening database: %s", err)
	}
	defer db.Close()

	flag.Parse()

	if len(flag.Args()) > 0 && flag.Arg(0) == "migrate" {
		if err := migration.Migrate(db, "migration"); err != nil {
			log.Fatalf("error: running migrations: %s", err)
		}
		log.Printf("migrations completed successfully")
		return
	}
}
```

### `cmd/server/main.go`

```go
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
	"workshop/config"
	"workshop/internal/handler"
	"workshop/internal/repository"
	"workshop/internal/service"
	"workshop/pkg/database"

	_ "github.com/lib/pq"
)

func main() {
	cfg, err := config.LoadConfig()
	if err != nil {
		log.Fatalf("error: loading config: %s", err)
	}

	db, err := database.OpenDB(cfg)
	if err != nil {
		log.Fatalf("error: opening database: %s", err)
	}
	defer db.Close()

	userRepository := repository.NewUserRepository(db)
	userService := service.NewUsers(userRepository)
	userHandler := handler.NewUserHandler(userService)

	// server
	server := &http.Server{
		Addr:         fmt.Sprintf("0.0.0.0:%d", cfg.Server.AppPort),
		Handler:      http.HandlerFunc(userHandler.List),
		ReadTimeout:  cfg.Server.ReadTimeout,
		WriteTimeout: cfg.Server.WriteTimeout,
	}

	serverErrChan := make(chan error, 1)

	// start server in a goroutine
	go func() {
		log.Printf("starting server on %s", server.Addr)
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			serverErrChan <- fmt.Errorf("error: listening and serving: %s", err)
		}
		close(serverErrChan)
	}()

	shutdownChan := make(chan os.Signal, 1)
	signal.Notify(shutdownChan, os.Interrupt, syscall.SIGTERM)

	select {
	case err, ok := <-serverErrChan:
		if ok && err != nil {
			log.Fatalf("error: server error: %s", err)
		}
	case sig := <-shutdownChan:
		log.Printf("received shutdown signal: %s", sig)

		// Give more time for graceful shutdown
		ctx, cancel := context.WithTimeout(context.Background(), cfg.Server.GracefulShutdownTimeout)
		defer cancel()

		// Attempt graceful shutdown
		if err := server.Shutdown(ctx); err != nil {
			log.Printf("error during graceful shutdown: %v", err)
			log.Printf("attempting force close due to graceful shutdown failure")

			// Force close if graceful shutdown fails
			if err := server.Close(); err != nil && err != http.ErrServerClosed {
				log.Printf("error during force close: %v", err)
			}
		} else {
			log.Printf("server gracefully shutdown complete")
		}
	}
}
```

Perhatikan bahwa `GracefulShutdownTimeout` sekarang juga dibaca dari konfigurasi (tidak hardcoded 30 detik lagi).

## 6.8 Menjalankan Aplikasi

```bash
# Jalankan dengan konfigurasi default dari .env
go run cmd/server/main.go

# Override port via environment variable
APP_PORT=8080 go run cmd/server/main.go

# Migration juga membaca konfigurasi
go run cmd/cli/main.go migrate
```

## Ringkasan Bab 6

Di bab ini kita telah belajar:

| Konsep               | Implementasi                                        |
| -------------------- | --------------------------------------------------- |
| Environment variable | Prioritas tertinggi, aman untuk production          |
| File .env            | Untuk development, tidak di-commit                  |
| Default values       | Fallback jika variabel tidak diset                  |
| Struct grouping      | `ServerConfig`, `DatabaseConfig` – mudah di-passing |
| Connection pool      | `SetMaxOpenConns`, `SetMaxIdleConns`, dll           |

Manfaat yang kita peroleh:

* ✅ Tidak ada lagi hardcoded configuration
* ✅ Password database bisa disimpan di environment (aman)
* ✅ Port, timeout, dan koneksi database bisa diubah tanpa recompile
* ✅ Connection pool database bisa diatur sesuai beban
* ✅ Satu kode berjalan di development dan production

Yang akan datang:

* ❌ Belum ada log yang terstruktur (masih pakai log.Printf)
* ❌ Belum ada error handling yang konsisten

Pada bab berikutnya, kita akan membahas pola penggunaan log.Fatal yang disiplin dan terpusat.


# Fatal

Dalam pengembangan aplikasi Go, kita sering melihat log.Fatal digunakan untuk menghentikan program ketika terjadi error. Namun, penggunaan log.Fatal yang tersebar di berbagai tempat dapat membuat kode sulit diuji dan dikelola. Bab ini akan membahas pola penggunaan log.Fatal yang disiplin dan terpusat.

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/07-fatal](https://github.com/jacky-htg/workshop/tree/main/07-fatal)

## 7.1 Memahami log.Fatal

Fungsi log.Fatal di Go melakukan dua hal sekaligus:

1. Mencetak pesan error ke log
2. Memanggil `os.Exit(1)` untuk menghentikan program secara paksa

```go
log.Fatal("something went wrong")
// Sama seperti:
// log.Print("something went wrong")
// os.Exit(1)
```

Karakteristik penting:

* `defer` tidak akan dieksekusi setelah `log.Fatal`
* Kode setelah `log.Fatal` tidak akan pernah berjalan
* Tidak ada kesempatan untuk melakukan cleanup (menutup koneksi database, dll)

## 7.2 Kapan Menggunakan log.Fatal

`log.Fatal` sebaiknya hanya digunakan untuk error yang:

* Terjadi di awal program (belum ada resource yang perlu dibersihkan)
* Tidak mungkin dipulihkan (unrecoverable)
* Membuat state program tidak valid untuk melanjutkan eksekusi

Contoh penggunaan yang tepat:

* File konfigurasi tidak ditemukan
* Port server sudah digunakan oleh proses lain
* Koneksi database gagal sama sekali

Lokasi yang tepat untuk log.Fatal:

* `func init()` – inisialisasi package-level variable
* `func main()` – entry point aplikasi

## 7.3 Masalah dengan log.Fatal yang Tersebar

Pada bab-bab sebelumnya, kita memiliki log.Fatal di berbagai tempat:

```go
// Di dalam server goroutine
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
    serverErrChan <- fmt.Errorf("error: listening and serving: %s", err)
}
// Lalu di select:
case err, ok := <-serverErrChan:
    if ok && err != nil {
        log.Fatalf("error: server error: %s", err)  // ← log.Fatal di luar main?
    }
```

Masalah dengan pendekatan ini:

* Sulit diuji – `log.Fatal` akan menghentikan test
* Cleanup tidak berjalan – `defer db.Close()` tidak terpanggil
* Tidak jelas aliran error – bercampur antara return error dan fatal

## 7.4 Pola: Memisahkan logika dari eksekusi

Solusi yang direkomendasikan adalah memindahkan semua logika ke fungsi run() error, lalu hanya main() yang memanggil log.Fatal jika run() mengembalikan error.

Pola ini memiliki keuntungan:

* Semua error dikembalikan sebagai nilai biasa (`return error`)
* `defer` tetap berjalan dengan benar
* Fungsi `run()` bisa diuji secara unit
* Hanya satu `log.Fatal` di seluruh program (di `main`)

```
┌─────────────────────────────────────────────────────────┐
│                       func main()                       │
│                                                         │
│   if err := run(); err != nil {                         │
│       log.Fatalf("error: %s", err)  ← SATU-SATUNYA      │
│   }                                                     │
└─────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────┐
│                    func run() error                     │
│                                                         │
│   - Load config (return error jika gagal)               │
│   - Open database (return error jika gagal)             │
│   - Start server (return error jika gagal)              │
│   - Wait for signal (return nil jika normal)            │
└─────────────────────────────────────────────────────────┘
```

## 7.5 Implementasi: CLI

Berikut implementasi pola `run() error` pada `cmd/cli/main.go`:

```go
package main

import (
	"flag"
	"fmt"
	"log"
	"workshop/config"
	"workshop/pkg/database"

	"github.com/jacky-htg/go-libs/migration"
	_ "github.com/lib/pq"
)

func main() {
	if err := run(); err != nil {
		log.Fatalf("error: running application: %s", err)
	}
}

func run() error {

	cfg, err := config.LoadConfig()
	if err != nil {
		return fmt.Errorf("error: loading config: %w", err)
	}

	db, err := database.OpenDB(cfg)
	if err != nil {
		return fmt.Errorf("error: opening database: %w", err)
	}
	defer db.Close()

	flag.Parse()

	if len(flag.Args()) > 0 && flag.Arg(0) == "migrate" {
		if err := migration.Migrate(db, "migration"); err != nil {
			return fmt.Errorf("error: running migrations: %w", err)
		}
		log.Printf("migrations completed successfully")
	}

	return nil
}
```

Perhatikan perubahan:

* `log.Fatalf` dihapus dari dalam `run()`, diganti dengan `return fmt.Errorf(...)`
* Hanya `main()` yang memiliki `log.Fatalf`
* Menggunakan `%w` untuk wrapping error (mempertahankan rantai error)

## 7.6 Implementasi: Server

`cmd/server/main.go` juga mengikuti pola yang sama:

```go
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"workshop/config"
	"workshop/internal/handler"
	"workshop/internal/repository"
	"workshop/internal/service"
	"workshop/pkg/database"

	_ "github.com/lib/pq"
)

func main() {
	if err := run(); err != nil {
		log.Fatalf("error: running application: %s", err)
	}
}

func run() error {
	cfg, err := config.LoadConfig()
	if err != nil {
		return fmt.Errorf("error: loading config: %w", err)
	}

	db, err := database.OpenDB(cfg)
	if err != nil {
		return fmt.Errorf("error: opening database: %w", err)
	}
	defer db.Close()

	userRepository := repository.NewUserRepository(db)
	userService := service.NewUsers(userRepository)
	userHandler := handler.NewUserHandler(userService)

	// server
	server := &http.Server{
		Addr:         fmt.Sprintf("0.0.0.0:%d", cfg.Server.AppPort),
		Handler:      http.HandlerFunc(userHandler.List),
		ReadTimeout:  cfg.Server.ReadTimeout,
		WriteTimeout: cfg.Server.WriteTimeout,
	}

	serverErrChan := make(chan error, 1)

	// start server in a goroutine
	go func() {
		log.Printf("starting server on %s", server.Addr)
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			serverErrChan <- fmt.Errorf("error: listening and serving: %w", err)
		}
		close(serverErrChan)
	}()

	shutdownChan := make(chan os.Signal, 1)
	signal.Notify(shutdownChan, os.Interrupt, syscall.SIGTERM)

	select {
	case err, ok := <-serverErrChan:
		if ok && err != nil {
			return fmt.Errorf("server error: %w", err)
		}
	case sig := <-shutdownChan:
		log.Printf("received shutdown signal: %s", sig)

		// Give more time for graceful shutdown
		ctx, cancel := context.WithTimeout(context.Background(), cfg.Server.GracefulShutdownTimeout)
		defer cancel()

		// Attempt graceful shutdown
		if err := server.Shutdown(ctx); err != nil {
			log.Printf("error during graceful shutdown: %v", err)
			log.Printf("attempting force close due to graceful shutdown failure")

			// Force close if graceful shutdown fails
			if err := server.Close(); err != nil && err != http.ErrServerClosed {
				return fmt.Errorf("error during force close: %w", err)
			}
		} else {
			log.Printf("server gracefully shutdown complete")
		}
	}

	return nil
}
```

Perhatikan perubahan penting:

* `log.Fatalf` di dalam `select` diubah menjadi `return fmt.Errorf(...)`
* Server error sekarang dikembalikan sebagai nilai error dari `run()`
* `main()` tetap hanya memiliki SATU `log.Fatalf`

## 7.7 Perbandingan Sebelum dan Sesudah

| Aspek              | Sebelum                     | Sesudah                      |
| ------------------ | --------------------------- | ---------------------------- |
| Jumlah `log.Fatal` | 2+ (tersebar)               | 1 (hanya di main())          |
| Error handling     | Campuran (return & fatal)   | Konsisten (return error)     |
| Testability        | Sulit (fatal hentikan test) | Mudah (`run()` bisa di-test) |
| Cleanup (`defer`)  | Tidak jalan setelah fatal   | Jalan selalu                 |
| Error wrapping     | Tidak konsisten             | Menggunakan `%w`             |

## 7.8 Error Wrapping dengan `%w`

Perhatikan penggunaan `%w` (bukan `%v`) saat membungkus error:

```go
// Sebelum (kehilangan informasi error asli)
return fmt.Errorf("loading config: %s", err)

// Sesudah (mempertahankan rantai error)
return fmt.Errorf("loading config: %w", err)
```

Dengan `%w`, kita bisa menggunakan `errors.Is()` dan `errors.As()` nantinya untuk memeriksa tipe error tertentu.

## Ringkasan Bab 7

Di bab ini kita telah belajar:

1. Apa itu `log.Fatal` – Mencetak log + `os.Exit(1)`
2. Kapan menggunakannya – Hanya di `init()` atau `main()`, untuk error yang tidak bisa dipulihkan
3. Pola `run() error` – Memisahkan logika dari eksekusi
4. Satu `log.Fatal` – Hanya di `main()`, memanggil `run()` dan handle error
5. Error wrapping – Menggunakan `%w` untuk mempertahankan rantai error

Manfaat yang kita peroleh:

* ✅ Semua error ditangani secara konsisten (`return error`)
* ✅ `defer` selalu berjalan (koneksi database tertutup dengan benar)
* ✅ Fungsi `run()` bisa diuji secara unit
* ✅ Aliran kode lebih jelas dan mudah dilacak

Yang akan datang:

* Saat ini `run()` sudah cukup rapi, tapi masih bisa dikelompokkan lagi
* Bab selanjutnya: Bootstrap – mengorganisir inisialisasi aplikasi (config, database, dependency injection) dalam satu tempat yang terstruktur


# Bootstrap

Seiring berkembangnya framework, kita akan menambahkan berbagai komponen: Redis, OpenTelemetry, HTTP client, message queue, dan lain-lain. Jika semua inisialisasi dilakukan langsung di `run()`, fungsi tersebut akan menjadi sangat panjang dan sulit dikelola.

**Bootstrap** adalah pola untuk memusatkan semua inisialisasi aplikasi dalam satu tempat, sehingga `run()` hanya fokus pada **orchestration** (mengatur alur) bukan **construction** (membangun komponen).

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/08-bootstrap](https://github.com/jacky-htg/workshop/tree/main/08-bootstrap)

## 8.1 Masalah dengan Inisialisasi Langsung

Saat ini, `run()` melakukan dua tanggung jawab sekaligus:

```go
func run() error {
    // Tanggung jawab 1: Inisialisasi komponen
    cfg, err := config.LoadConfig()     // ← inisialisasi
    db, err := database.OpenDB(cfg)     // ← inisialisasi
    
    // Tanggung jawab 2: Orchestration
    userRepository := repository.NewUserRepository(db)
    userService := service.NewUsers(userRepository)
    userHandler := handler.NewUserHandler(userService)
    // ... server setup dan graceful shutdown
}
```

**Masalah ke depan:**

* Setiap komponen baru (Redis, cache, telemetry) akan menambah panjang `run()`
* Inisialisasi dan cleanup tersebar (database di sini, nanti Redis di sana)
* Sulit mengatur urutan inisialisasi yang benar (misal: logger harus sebelum yang lain)
* Testing jadi sulit karena harus menginisialisasi semua komponen

## 8.2 Solusi: Struct App sebagai Container

Kita buat struct App yang menjadi container untuk semua dependency aplikasi:

```go
type App struct {
    Config   config.Config
    Database *sql.DB
    // Redis    *redis.Client   (nanti)
    // Logger   *slog.Logger    (nanti)
    // Tracer   trace.Tracer    (nanti)
    
    Cleanup func()  // fungsi untuk membersihkan resource
}
```

**Prinsip:**

* Semua inisialisasi terjadi di fungsi `NewApp()`
* `App` berisi semua komponen yang sudah siap pakai
* `Cleanup` berisi fungsi untuk menutup resource (database, koneksi, dll)
* `run()` cukup memanggil `NewApp()` dan `defer app.Cleanup()`

## 8.3 Implementasi Bootstrap

Buat file `internal/bootstrap/app.go`:

```go
package bootstrap

import (
	"database/sql"
	"fmt"
	"workshop/config"
	"workshop/pkg/database"

	_ "github.com/lib/pq"
)

type App struct {
	Config   config.Config
	Database *sql.DB

	Cleanup func()
}

func NewApp() (App, error) {
	cfg, err := config.LoadConfig()
	if err != nil {
		return App{}, fmt.Errorf("error: loading config: %w", err)
	}

	db, err := database.OpenDB(cfg)
	if err != nil {
		return App{}, fmt.Errorf("error: opening database: %w", err)
	}

	return App{
		Config:   cfg,
		Database: db,
		Cleanup: func() {
			if err := db.Close(); err != nil {
				fmt.Printf("error: closing database: %s\n", err)
			}
		},
	}, nil
}
```

**Catatan**: Cleanup function memastikan resource dibersihkan dalam urutan yang benar (LIFO - Last In First Out) ketika dipanggil di `defer`.

## 8.4 Update Server Main

Sekarang `cmd/server/main.go` menjadi lebih bersih. Semua inisialisasi komponen diambil alih oleh `bootstrap.NewApp()`:

```go
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
	"workshop/internal/bootstrap"
	"workshop/internal/handler"
	"workshop/internal/repository"
	"workshop/internal/service"
)

func main() {
	if err := run(); err != nil {
		log.Fatalf("error: running application: %s", err)
	}
}

func run() error {
	app, err := bootstrap.NewApp()
	if err != nil {
		return fmt.Errorf("error: initializing app: %w", err)
	}
	defer app.Cleanup()

	userRepository := repository.NewUserRepository(app.Database)
	userService := service.NewUsers(userRepository)
	userHandler := handler.NewUserHandler(userService)

	// server
	server := &http.Server{
		Addr:         fmt.Sprintf("0.0.0.0:%d", app.Config.Server.AppPort),
		Handler:      http.HandlerFunc(userHandler.List),
		ReadTimeout:  app.Config.Server.ReadTimeout,
		WriteTimeout: app.Config.Server.WriteTimeout,
	}

	serverErrChan := make(chan error, 1)

	// start server in a goroutine
	go func() {
		log.Printf("starting server on %s", server.Addr)
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			serverErrChan <- fmt.Errorf("error: listening and serving: %w", err)
		}
		close(serverErrChan)
	}()

	shutdownChan := make(chan os.Signal, 1)
	signal.Notify(shutdownChan, os.Interrupt, syscall.SIGTERM)

	select {
	case err, ok := <-serverErrChan:
		if ok && err != nil {
			return fmt.Errorf("server error: %w", err)
		}
	case sig := <-shutdownChan:
		log.Printf("received shutdown signal: %s", sig)

		// Give more time for graceful shutdown
		ctx, cancel := context.WithTimeout(context.Background(), app.Config.Server.GracefulShutdownTimeout)
		defer cancel()

		// Attempt graceful shutdown
		if err := server.Shutdown(ctx); err != nil {
			log.Printf("error during graceful shutdown: %v", err)
			log.Printf("attempting force close due to graceful shutdown failure")

			// Force close if graceful shutdown fails
			if err := server.Close(); err != nil && err != http.ErrServerClosed {
				return fmt.Errorf("error during force close: %w", err)
			}
		} else {
			log.Printf("server gracefully shutdown complete")
		}
	}

	return nil
}
```

## 8.5 Perbandingan Sebelum dan Sesudah

| Aspek                    | Sebelum Bootstrap          | Sesudah Bootstrap                         |
| ------------------------ | -------------------------- | ----------------------------------------- |
| Lokasi inisialisasi      | Tersebar di `run()`        | Terpusat di `bootstrap.NewApp()`          |
| Penambahan komponen baru | Mengubah `run()`           | Mengubah `bootstrap.NewApp()`             |
| Cleanup resource         | Tersebar (hanya database)  | Terpusat di `Cleanup()`                   |
| Testability              | Sulit mock komponen        | Mudah (bisa buat App terpisah untuk test) |
| Tanggung jawab `run()`   | Inisialisasi + orchestrasi | Hanya orchestrasi                         |

## 8.6 Diagram Alur Bootstrap

```
┌─────────────────────────────────────────────────────────────────┐
│                         main()                                  │
│                    if err := run()...                           │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                         run()                                   │
│  1. app, err := bootstrap.NewApp()                              │
│  2. defer app.Cleanup()                                         │
│  3. Setup handler, server, graceful shutdown                    │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌────────────────────────────────────────────────────────────────┐
│                bootstrap.NewApp()                              │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ config.LoadConfig()                                     │   │
│  │         ↓                                                   │
│  │ database.OpenDB(cfg)                                    │   │
│  │         ↓                                               │   │
│  │ redis.NewClient(cfg)             (nanti)                │   │
│  │         ↓                                               │   │
│  │ logger.New(cfg)                  (nanti)                │   │
│  │         ↓                                               │   │
│  │ return App{Config, Database, Cleanup}                   │   │
│  └─────────────────────────────────────────────────────────┘   │
└────────────────────────────────────────────────────────────────┘
```

## 8.7 Update CLI Main (Opsional)

Untuk konsistensi, `cmd/cli/main.go` juga bisa menggunakan bootstrap:

```go
package main

import (
    "flag"
    "fmt"
    "log"
    "workshop/internal/bootstrap"

    "github.com/jacky-htg/go-libs/migration"
)

func main() {
    if err := run(); err != nil {
        log.Fatalf("error: running application: %s", err)
    }
}

func run() error {
    app, err := bootstrap.NewApp()
    if err != nil {
        return fmt.Errorf("initializing app: %w", err)
    }
    defer app.Cleanup()

    flag.Parse()

    if len(flag.Args()) > 0 && flag.Arg(0) == "migrate" {
        if err := migration.Migrate(app.Database, "migration"); err != nil {
            return fmt.Errorf("running migrations: %w", err)
        }
        log.Printf("migrations completed successfully")
    }

    return nil
}
```

## 8.8 Manfaat untuk Testing (Preview)

Testing memang bleum dibahas di bab ini, tapi sebagai preview saja untuk menjelaskan keuntungan pemakaian bootstrap. Dengan pola bootstrap, testing menjadi lebih mudah karena kita bisa membuat App terpisah untuk test:

```go
func TestUserHandler(t *testing.T) {
    // Setup test database
    testDB := setupTestDB(t)
    
    // Buat App untuk testing
    app := bootstrap.App{
        Config:   testConfig,
        Database: testDB,
        Cleanup:  func() { testDB.Close() },
    }
    
    // Test dengan app yang sudah siap
    // ...
}
```

## Ringkasan Bab 8

Di bab ini kita telah belajar:

1. Masalah – Inisialisasi komponen yang tersebar membuat `run()` panjang dan sulit dikelola
2. Solusi – Bootstrap pattern dengan struct App sebagai container dependency
3. Implementasi – Fungsi `NewApp()` yang menginisialisasi semua komponen sekaligus
4. Cleanup – Fungsi Cleanup untuk membersihkan resource dalam urutan yang benar
5. Testability – Bootstrap memudahkan pembuatan test dengan dependency yang bisa diganti

Manfaat yang kita peroleh:

* ✅ `run()` sekarang hanya fokus pada orchestrasi (setup server, graceful shutdown)
* ✅ Penambahan komponen baru (Redis, logger, dll) hanya mengubah `bootstrap.NewApp()`
* ✅ Cleanup resource terpusat dan terjamin
* ✅ Lebih mudah di-test karena dependency bisa diganti
* ✅ CLI dan server berbagi inisialisasi yang sama

Yang akan datang:

* Saat ini logging masih menggunakan `log.Printf` standar
* Bab selanjutnya: Logging – membangun logging terstruktur untuk memudahkan observabilitas


# Logging

Logging adalah mata dan telinga aplikasi kita di production. Tanpa log yang baik, kita buta terhadap apa yang terjadi: error tidak terdeteksi, performa tidak terukur, dan debugging menjadi mimpi buruk.

Di bab ini kita akan meningkatkan sistem logging dari `log.Printf` biasa menjadi structured logging dengan `slog.Logger`.

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/09-logging](https://github.com/jacky-htg/workshop/tree/main/09-logging)

## 9.1 Masalah dengan Logging Saat Ini

Sejauh ini kita menggunakan `log.Printf` dan `log.Fatalf` dari package log standar. Pendekatan ini memiliki dua kelemahan utama:

| Masalah         | Penjelasan                              | Dampak                                                       |
| --------------- | --------------------------------------- | ------------------------------------------------------------ |
| Variabel global | `log` package menggunakan logger global | Sulit di-test, tidak bisa disuntikkan (dependency injection) |
| Hanya teks      | Output berupa string tidak terstruktur  | Tool monitoring (Loki, Elasticsearch) sulit memparsing       |

Paket log standard bisa secara mudah dibuat menjadi variabel lokal, namun sulit untuk mendukung format struktured log.

Contoh log teks (sulit diparsing):

```
2024/01/15 10:30:45 error: querying users: connection refused
```

Contoh log terstruktur (mudah diparsing):

```json
{"time":"2024-01-15T10:30:45Z","level":"ERROR","msg":"error: querying users","error":"connection refused","component":"repository"}
```

## 9.2 Mengenal `slog.Logger`

Go 1.21+ memperkenalkan package `log/slog` (Structured Logging) yang menjadi standar bawaan. Keunggulannya:

* Multiple output formats – Teks (human-friendly) atau JSON (machine-friendly)
* Log levels – Debug, Info, Warn, Error
* Structured fields – Menambahkan key-value pairs ke setiap log
* Context-aware – Bisa mengambil nilai dari context.Context

Kita akan menggunakan library wrapper dari [go-libs/logger](https://github.com/jacky-htg/go-libs/blob/main/logger/logger.go) untuk kemudahan:

```go
// Inisialisasi logger
log := logger.InitLogger(nil)

// Log dengan level dan field terstruktur
log.Info(ctx, "user created", slog.String("user_id", user.ID))
log.Error(ctx, "database error", slog.Any("error", err))
```

## 9.3 Dependency Injection untuk Logger

Prinsip penting: Logger juga harus di-inject, bukan global. Mengapa?

* Testing bisa menggunakan logger mock
* Setiap komponen bisa memiliki konteks sendiri (misal: menambahkan component field)
* Tidak ada hidden dependency

### Langkah 1: Update Bootstrap

Tambahkan Log field ke struct App dan inisialisasi di `NewApp()`:

```go
package bootstrap

import (
	"database/sql"
	"fmt"
	"workshop/config"
	"workshop/pkg/database"

	"github.com/jacky-htg/go-libs/logger"
	_ "github.com/lib/pq"
)

type App struct {
	Config   config.Config
	Database *sql.DB
	Log      logger.Logger

	Cleanup func()
}

func NewApp() (App, error) {
	cfg, err := config.LoadConfig()
	if err != nil {
		return App{}, fmt.Errorf("error: loading config: %w", err)
	}

	db, err := database.OpenDB(cfg)
	if err != nil {
		return App{}, fmt.Errorf("error: opening database: %w", err)
	}

	log := logger.InitLogger(nil)

	return App{
		Config:   cfg,
		Database: db,
		Log:      log,
		Cleanup: func() {
			if err := db.Close(); err != nil {
				fmt.Printf("error: closing database: %s\n", err)
			}
		},
	}, nil
}
```

### Langkah 2: Propagasikan Logger ke Semua Layer

Logger harus di-passing dari `main()` → bootstrap → handler → service → repository.

Update `cmd/server/main.go`:

```go
package main

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
	"workshop/internal/bootstrap"
	"workshop/internal/handler"
	"workshop/internal/repository"
	"workshop/internal/service"
)

func main() {
	if err := run(); err != nil {
		fmt.Printf("error: running application: %s\n", err)
		os.Exit(1)
	}
}

func run() error {
	app, err := bootstrap.NewApp()
	if err != nil {
		return fmt.Errorf("error: initializing app: %w", err)
	}
	defer app.Cleanup()

	userRepository := repository.NewUserRepository(app.Database, app.Log)
	userService := service.NewUsers(userRepository, app.Log)
	userHandler := handler.NewUserHandler(userService, app.Log)

	// server
	server := &http.Server{
		Addr:         fmt.Sprintf("0.0.0.0:%d", app.Config.Server.AppPort),
		Handler:      http.HandlerFunc(userHandler.List),
		ReadTimeout:  app.Config.Server.ReadTimeout,
		WriteTimeout: app.Config.Server.WriteTimeout,
	}

	serverErrChan := make(chan error, 1)

	// start server in a goroutine
	go func() {
		app.Log.Info(context.Background(), "starting server", slog.String("addr", server.Addr))
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			serverErrChan <- fmt.Errorf("error: listening and serving: %w", err)
		}
		close(serverErrChan)
	}()

	shutdownChan := make(chan os.Signal, 1)
	signal.Notify(shutdownChan, os.Interrupt, syscall.SIGTERM)

	select {
	case err, ok := <-serverErrChan:
		if ok && err != nil {
			app.Log.Error(context.Background(), "error: server error", slog.Any("error", err))
			return err
		}
	case sig := <-shutdownChan:
		app.Log.Info(context.Background(), "received shutdown signal", slog.String("signal", sig.String()))

		// Give more time for graceful shutdown
		ctx, cancel := context.WithTimeout(context.Background(), app.Config.Server.GracefulShutdownTimeout)
		defer cancel()

		// Attempt graceful shutdown
		if err := server.Shutdown(ctx); err != nil {
			app.Log.Error(context.Background(), "error during graceful shutdown", slog.Any("error", err))
			app.Log.Info(context.Background(), "attempting force close due to graceful shutdown failure")

			// Force close if graceful shutdown fails
			if err := server.Close(); err != nil && err != http.ErrServerClosed {
				app.Log.Error(context.Background(), "error during force close", slog.Any("error", err))
				return err
			}
		} else {
			app.Log.Info(context.Background(), "server gracefully shutdown complete")
		}
	}

	return nil
}
```

### Langkah 3: Update Repository Layer

```go
package repository

import (
	"context"
	"database/sql"
	"log/slog"
	"workshop/internal/model"

	"github.com/jacky-htg/go-libs/logger"
)

type UserRepository interface {
	List() ([]model.User, error)
}

type userRepository struct {
	db  *sql.DB
	log logger.Logger
}

func NewUserRepository(db *sql.DB, log logger.Logger) UserRepository {
	return &userRepository{db: db, log: log}
}

// List : http handler for returning list of users
func (u *userRepository) List() ([]model.User, error) {
	query := `SELECT id, name, username, password, email, is_active FROM users`
	rows, err := u.db.Query(query)
	if err != nil {
		u.log.Error(context.Background(), "error: querying users", slog.Any("error", err))
		return nil, err
	}
	defer rows.Close()

	var users []model.User
	for rows.Next() {
		var user model.User
		if err := rows.Scan(&user.ID, &user.Name, &user.Username, &user.Password, &user.Email, &user.IsActive); err != nil {

			u.log.Error(context.Background(), "error: scanning user row", slog.Any("error", err))
			return nil, err
		}
		users = append(users, user)
	}

	if err := rows.Err(); err != nil {
		u.log.Error(context.Background(), "error: iterating user rows", slog.Any("error", err))
		return nil, err
	}

	return users, nil
}
```

### Langkah 4: Update Service Layer

```go
package service

import (
	"workshop/internal/model"
	"workshop/internal/repository"

	"github.com/jacky-htg/go-libs/logger"
)

type Users interface {
	List() ([]model.User, error)
}

type users struct {
	log  logger.Logger
	repo repository.UserRepository
}

func NewUsers(repo repository.UserRepository, log logger.Logger) Users {
	return &users{repo: repo, log: log}
}

func (u *users) List() ([]model.User, error) {
	return u.repo.List()
}
```

### Langkah 5: Update Handler Layer

```go
package handler

import (
	"context"
	"encoding/json"
	"log/slog"
	"net/http"
	"workshop/internal/dto"
	"workshop/internal/service"

	"github.com/jacky-htg/go-libs/logger"
)

type UserHanlder interface {
	List(w http.ResponseWriter, r *http.Request)
}

type userHandler struct {
	log     logger.Logger
	service service.Users
}

func NewUserHandler(service service.Users, log logger.Logger) UserHanlder {
	return &userHandler{service: service, log: log}
}

// List : http handler for returning list of users
func (u *userHandler) List(w http.ResponseWriter, r *http.Request) {
	users, err := u.service.List()
	if err != nil {
		u.log.Error(context.Background(), "error: listing users", slog.Any("error", err))
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	var response []dto.UserResponse
	for _, user := range users {
		var ur dto.UserResponse
		ur.Transform(user)
		response = append(response, ur)
	}

	data, err := json.Marshal(response)
	if err != nil {
		u.log.Error(context.Background(), "error: marshaling users to JSON", slog.Any("error", err))
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	if _, err := w.Write(data); err != nil {
		u.log.Error(context.Background(), "error: writing response", slog.Any("error", err))
	}
}
```

### Langkah 6: Update CLI Main

```go
package main

import (
	"context"
	"flag"
	"fmt"
	"log/slog"
	"os"
	"workshop/config"
	"workshop/pkg/database"

	"github.com/jacky-htg/go-libs/logger"
	"github.com/jacky-htg/go-libs/migration"
	_ "github.com/lib/pq"
)

func main() {
	log := logger.InitLogger(nil)
	if err := run(log); err != nil {
		log.Debug(context.Background(), "application error", slog.Any("error", err))
		os.Exit(1)
	}
}

func run(log logger.Logger) error {

	cfg, err := config.LoadConfig()
	if err != nil {
		return fmt.Errorf("error: loading config: %w", err)
	}

	db, err := database.OpenDB(cfg)
	if err != nil {
		return fmt.Errorf("error: opening database: %w", err)
	}
	defer db.Close()

	flag.Parse()

	if len(flag.Args()) > 0 && flag.Arg(0) == "migrate" {
		if err := migration.Migrate(db, "migration"); err != nil {
			return fmt.Errorf("error: running migrations: %w", err)
		}
		log.Info(context.Background(), "migrations completed successfully")
	}

	return nil
}
```

### 9.4 Format Output: Teks vs JSON

`logger.InitLogger(nil)` secara default menghasilkan output teks yang mudah dibaca manusia. Untuk production, kita bisa mengubah ke format JSON:

```go
// Untuk production (JSON)
log := logger.InitLogger(&logger.Config{
    Format: "json",
    Level:  "info",
})

// Untuk development (teks dengan warna)
log := logger.InitLogger(&logger.Config{
    Format: "text",
    Level:  "debug",
})
```

Contoh output JSON:

```json
{"time":"2024-01-15T10:30:45Z","level":"INFO","msg":"starting server","addr":"0.0.0.0:9000"}
{"time":"2024-01-15T10:30:46Z","level":"DEBUG","msg":"listing users"}
{"time":"2024-01-15T10:30:46Z","level":"INFO","msg":"users listed successfully","count":2}
```

## 9.5 Hierarki Log Levels

`slog.Logger` mendukung level logging yang bisa dikonfigurasi:

| Level | Penggunaan                                              |
| ----- | ------------------------------------------------------- |
| Debug | Informasi detail untuk debugging (hanya di development) |
| Info  | Informasi normal (server started, user created, dll)    |
| Warn  | Kejadian tidak normal tapi tidak fatal (retry, timeout) |
| Error | Error yang perlu diinvestigasi                          |

### Ringkasan Bab 9

Di bab ini kita telah belajar:

| Konsep                | Implementasi                                 |
| --------------------- | -------------------------------------------- |
| Structured logging    | Menggunakan `slog.Logger` dengan format JSON |
| Dependency injection  | Logger di-passing dari main() ke semua layer |
| Log levels            | Debug, Info, Warn, Error                     |
| Context fields        | Menambahkan field terstruktur ke setiap log  |
| Bootstrap integration | Logger menjadi bagian dari struct App        |

Manfaat yang kita peroleh:

* ✅ Log terstruktur (JSON) → mudah diintegrasikan dengan tool monitoring
* ✅ Logger di-inject → mudah di-test dan diganti implementasinya
* ✅ Log levels → bisa filter sesuai kebutuhan (debug di dev, info/warn di prod)
* ✅ Konteks yang kaya → setiap log bisa membawa field tambahan (user\_id, request\_id, dll)

Yang akan datang:

* Saat ini semua log menggunakan context.Background()
* Bab selanjutnya: Routing – membangun router HTTP sendiri untuk mendukung multiple endpoints dengan method (GET, POST, PUT, DELETE) dan path parameters


# Routing

Sejauh ini API kita hanya memiliki satu endpoint: `GET /users`. Aplikasi nyata membutuhkan banyak endpoint dengan berbagai method HTTP (GET, POST, PUT, DELETE) dan parameter di URL (seperti `/users/{id}`). Di sinilah routing berperan.

Bab ini akan membangun dua jenis router:

1. CLI Router – untuk perintah command line (migrate, seed, scheduler dll)
2. HTTP Router – untuk REST API endpoints

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/10-routing](https://github.com/jacky-htg/workshop/tree/main/10-routing)

## 10.1 Routing di Go 1.22+

Sejak Go 1.22, package `net/http` memiliki kemampuan routing yang jauh lebih baik. Kita tidak perlu lagi library eksternal seperti `gorilla/mux` atau `chi` untuk kebutuhan dasar.

Fitur baru yang tersedia:

* Method-based routing: `mux.HandleFunc("GET /users", handler)`
* Path parameters: `mux.HandleFunc("GET /users/{id}", handler)`
* Wildcard: `mux.HandleFunc("GET /files/{path...}", handler)`

## 10.2 CLI Router

CLI router bertugas memetakan perintah dari terminal ke fungsi yang sesuai. Pola yang umum digunakan adalah switch-case berdasarkan argumen pertama.

### Struktur Router CLI

Buat file `internal/router/cli.go:`

```go
package router

import (
	"context"
	"database/sql"
	"fmt"

	"github.com/jacky-htg/go-libs/logger"
	"github.com/jacky-htg/go-libs/migration"
)

func Cli(
	db *sql.DB,
	log logger.Logger,
	command string,
	args []string) error {

	switch command {
	case "migrate":
		err := migration.Migrate(db, "migration")
		if err != nil {
			log.Error(context.Background(), "Migration failed", "error", err)
			return err
		}
		log.Info(context.Background(), "Migration completed successfully")
	default:
		return fmt.Errorf("unknown command: %s", command)
	}

	return nil
}
```

### Update CLI Main

`cmd/cli/main.go` sekarang lebih sederhana — hanya bertugas parsing argumen dan memanggil router:

```go
package main

import (
	"context"
	"flag"
	"fmt"
	"log/slog"
	"os"
	"workshop/config"
	"workshop/internal/router"
	"workshop/pkg/database"

	"github.com/jacky-htg/go-libs/logger"
	_ "github.com/lib/pq"
)

func main() {
	log := logger.InitLogger(nil)
	if err := run(log); err != nil {
		log.Debug(context.Background(), "application error", slog.Any("error", err))
		os.Exit(1)
	}
}

func run(log logger.Logger) error {

	cfg, err := config.LoadConfig()
	if err != nil {
		return fmt.Errorf("error: loading config: %w", err)
	}

	db, err := database.OpenDB(cfg)
	if err != nil {
		return fmt.Errorf("error: opening database: %w", err)
	}
	defer db.Close()

	flag.Parse()
	args := flag.Args()
	if len(args) == 0 {
		return fmt.Errorf("Usage: program <command> [arguments...]")
	}

	command := args[0]
	commandArgs := args[1:]

	if err := router.Cli(db, log, command, commandArgs); err != nil {
		return fmt.Errorf("error: executing command: %w", err)
	}

	return nil
}
```

Penggunaan CLI sekarang:

```bash
# Menjalankan migration
go run cmd/cli/main.go migrate

# Output: "Migration completed successfully"
```

## 10.3 HTTP Router (REST API)

HTTP router memetakan method HTTP + path ke handler function. Dengan Go 1.22+, kita bisa menulis routing yang ekspresif dan aman tipe.

### Struktur Router API

Buat file `internal/router/api.go`:

```go
package router

import (
	"database/sql"
	"net/http"
	"workshop/config"
	"workshop/internal/handler"
	"workshop/internal/repository"
	"workshop/internal/service"

	"github.com/jacky-htg/go-libs/logger"
)

func Api(
	cfg config.Config,
	db *sql.DB,
	log logger.Logger,
) http.Handler {
	mux := http.NewServeMux()

	userRepository := repository.NewUserRepository(db, log)
	userService := service.NewUsers(userRepository, log)
	userHandler := handler.NewUserHandler(userService, log)
	mux.HandleFunc("GET /users", userHandler.List)

	return mux
}
```

**Sintaks Method + Path**: "GET /users" adalah format baru di Go 1.22+. Jika method tidak cocok, otomatis mengembalikan 405 Method Not Allowed.

### Update Server Main

`cmd/server/main.go` sekarang lebih bersih — cukup memanggil `router.Api()` sebagai handler:

```go
package main

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
	"workshop/internal/bootstrap"
	"workshop/internal/router"
)

func main() {
	if err := run(); err != nil {
		fmt.Printf("error: running application: %s\n", err)
		os.Exit(1)
	}
}

func run() error {
	app, err := bootstrap.NewApp()
	if err != nil {
		return fmt.Errorf("error: initializing app: %w", err)
	}
	defer app.Cleanup()

	server := &http.Server{
		Addr:         fmt.Sprintf("0.0.0.0:%d", app.Config.Server.AppPort),
		Handler:      router.Api(app.Config, app.Database, app.Log),
		ReadTimeout:  app.Config.Server.ReadTimeout,
		WriteTimeout: app.Config.Server.WriteTimeout,
	}

	serverErrChan := make(chan error, 1)

	// start server in a goroutine
	go func() {
		app.Log.Info(context.Background(), "starting server", slog.String("addr", server.Addr))
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			serverErrChan <- fmt.Errorf("error: listening and serving: %w", err)
		}
		close(serverErrChan)
	}()

	shutdownChan := make(chan os.Signal, 1)
	signal.Notify(shutdownChan, os.Interrupt, syscall.SIGTERM)

	select {
	case err, ok := <-serverErrChan:
		if ok && err != nil {
			app.Log.Error(context.Background(), "error: server error", slog.Any("error", err))
			return err
		}
	case sig := <-shutdownChan:
		app.Log.Info(context.Background(), "received shutdown signal", slog.String("signal", sig.String()))

		// Give more time for graceful shutdown
		ctx, cancel := context.WithTimeout(context.Background(), app.Config.Server.GracefulShutdownTimeout)
		defer cancel()

		// Attempt graceful shutdown
		if err := server.Shutdown(ctx); err != nil {
			app.Log.Error(context.Background(), "error during graceful shutdown", slog.Any("error", err))
			app.Log.Info(context.Background(), "attempting force close due to graceful shutdown failure")

			// Force close if graceful shutdown fails
			if err := server.Close(); err != nil && err != http.ErrServerClosed {
				app.Log.Error(context.Background(), "error during force close", slog.Any("error", err))
				return err
			}
		} else {
			app.Log.Info(context.Background(), "server gracefully shutdown complete")
		}
	}

	return nil
}
```

## 10.4 Menambahkan Endpoint Baru (Contoh)

Untuk menambahkan endpoint baru, cukup tambahkan di `router/api.go`:

```go
// GET /users/{id} - mengambil satu user berdasarkan ID
mux.HandleFunc("GET /users/{id}", userHandler.Get)

// POST /users - membuat user baru
mux.HandleFunc("POST /users", userHandler.Create)

// PUT /users/{id} - mengupdate user
mux.HandleFunc("PUT /users/{id}", userHandler.Update)

// DELETE /users/{id} - menghapus user
mux.HandleFunc("DELETE /users/{id}", userHandler.Delete)
```

Dan implementasikan method handler yang sesuai di `user_handler.go`.

## 10.5 Struktur Direktori Setelah Routing

```
workshop/
├── cmd/
│   ├── cli/
│   │   └── main.go          # Memanggil router.Cli()
│   └── server/
│       └── main.go          # Memanggil router.Api()
├── internal/
│   ├── bootstrap/
│   │   └── app.go
│   ├── dto/
│   │   └── user_response.go
│   ├── handler/
│   │   └── user_handler.go
│   ├── model/
│   │   └── user.go
│   ├── repository/
│   │   └── user_repository.go
│   ├── router/
│   │   ├── api.go           ← baru (HTTP router)
│   │   └── cli.go           ← baru (CLI router)
│   └── service/
│       └── users.go
├── migration/
├── pkg/
└── config/
```

## 10.6 Perbandingan Sebelum dan Sesudah

| Aspek               | Sebelum                                 | Sesudah                           |
| ------------------- | --------------------------------------- | --------------------------------- |
| CLI handling        | Langsung di `main()`                    | `router.Cli()` dengan switch-case |
| HTTP routing        | Hardcoded di `main()`                   | `router.Api()` terpusat           |
| Method support      | Semua method masuk ke handler yang sama | Method-specific routing           |
| Path parameters     | Tidak ada (parsing manual)              | Dukungan bawaan `/{id}`           |
| Penambahan endpoint | Mengubah `main()`                       | Cukup tambah di `router.Api()`    |

## Ringkasan Bab 10

Di bab ini kita telah belajar:

1. CLI Router – Memetakan perintah terminal ke fungsi menggunakan switch-case
2. HTTP Router (Go 1.22+) – Routing dengan method + path dalam satu baris
3. Pemisahan tanggung jawab – Router bertugas memetakan, handler tetap fokus pada logika bisnis
4. Struktur yang scalable – Endpoint baru mudah ditambahkan tanpa mengganggu main()

Manfaat yang kita peroleh:

* ✅ CLI dan HTTP routing terpisah dan terorganisir
* ✅ Sintaks routing Go 1.22+ lebih bersih dan tidak perlu library eksternal
* ✅ main() menjadi sangat ramping — hanya orchestrasi
* ✅ Persiapan untuk CRUD lengkap di bab berikutnya

Yang akan datang:

* Saat ini kita hanya memiliki endpoint GET /users (READ)
* Bab selanjutnya: CRUD – menambahkan Create, Read (by ID), Update, dan Delete untuk resource User


# CRUD

Setelah memiliki routing yang rapi, kini saatnya melengkapi operasi CRUD (Create, Read, Update, Delete) untuk resource User. Bab ini akan membangun kelima endpoint REST API yang umum:

| Method | Endpoint    | Deskripsi                    |
| ------ | ----------- | ---------------------------- |
| GET    | /users\`    | Mendaftar semua user         |
| POST   | /users      | Membuat user baru            |
| GET    | /users/{id} | Membaca detail user          |
| PUT    | /users/{id} | Mengupdate user              |
| DELETE | /users/{id} | Menghapus user (soft delete) |

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/11-crud](https://github.com/jacky-htg/workshop/tree/main/11-crud)

## 11.1 Create User

Operasi Create adalah yang paling kompleks karena melibatkan:

1. Hashing password – menggunakan bcrypt (tidak boleh disimpan plain text)
2. Generate ID – menggunakan UUID v7 (terurut berdasarkan waktu)
3. Validasi input – akan dibahas di bab terpisah

### 11.1.1 Repository Layer – Create

Tambahkan method Create ke UserRepository interface dan implementasinya:

```go
// internal/repository/user_repository.go
type UserRepository interface {
    List() ([]model.User, error)
    Create(*model.User) error  // ← tambahan
    // ... method lainnya
}

func (u *userRepository) Create(user *model.User) error {
    query := `INSERT INTO users (id, name, username, password, email, is_active) 
              VALUES ($1, $2, $3, $4, $5, $6)`
    _, err := u.db.Exec(query, user.ID, user.Name, user.Username, 
                        user.Password, user.Email, user.IsActive)
    if err != nil {
        u.log.Error(context.Background(), "error: inserting user", slog.Any("error", err))
        return err
    }
    return nil
}
```

### 11.1.2 Service Layer – Create dengan Business Logic

Service layer bertanggung jawab untuk hashing password dan generate ID:

```go
// internal/service/users.go
package service

import (
	"github.com/jacky-htg/go-libs/uuid7"
	"golang.org/x/crypto/bcrypt"
)

func (u *users) Create(user *model.User) error {

	pass, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
	if err != nil {
		u.log.Error(context.Background(), "error generate password", slog.Any("error", err))
		return err
	}

	user.ID = uuid7.New()
	user.Password = string(pass)

	if err := u.repo.Create(user); err != nil {
		return err
	}

	return nil
}
```

**Catatan:** `bcrypt.DefaultCost` adalah nilai 10. Untuk keamanan lebih tinggi, bisa ditingkatkan (dengan konsekuensi performa lebih lambat).

### 11.1.3 DTO – UserRequest (Input)

Buat DTO khusus untuk menerima input dari client. Ini memisahkan struktur request dari model database:

```go
// internal/dto/user_request.go
package dto

import "workshop/internal/model"

type UserRequest struct {
	Name     string `json:"name"`
	Username string `json:"username"`
	Password string `json:"password"`
	Email    string `json:"email"`
	IsActive bool   `json:"is_active"`
}

func (u *UserRequest) Transform(user *model.User) {
	user.Name = u.Name
	user.Username = u.Username
	user.Password = u.Password
	user.Email = u.Email
	user.IsActive = u.IsActive
}
```

### 11.1.4 Handler Layer – Create

Handler bertugas menerima request, memparsing JSON, memanggil service, dan mengembalikan response:

```go
// internal/handler/user_handler.go
func (u *userHandler) Create(w http.ResponseWriter, r *http.Request) {
	var req dto.UserRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		u.log.Error(context.Background(), "error: decoding user request", slog.Any("error", err))
		http.Error(w, "Bad Request", http.StatusBadRequest)
		return
	}

	user := model.User{}
	req.Transform(&user)
	err := u.service.Create(&user)
	if err != nil {
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	var response dto.UserResponse
	response.Transform(user)

	data, err := json.Marshal(response)
	if err != nil {
		u.log.Error(context.Background(), "error: marshaling user to JSON", slog.Any("error", err))
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(http.StatusCreated)
	if _, err := w.Write(data); err != nil {
		u.log.Error(context.Background(), "error: writing response", slog.Any("error", err))
	}
}
```

**Status code:** 201 Created adalah status yang tepat untuk operasi Create.

## 11.2 Read (Get by ID)

### 11.2.1 Repository – FindById

```go
// internal/repository/user_repository.go
func (u *userRepository) FindById(id string) (*model.User, error) {
	query := `SELECT id, name, username, password, email, is_active FROM users WHERE id = $1`
	row := u.db.QueryRow(query, id)

	var user model.User
	if err := row.Scan(&user.ID, &user.Name, &user.Username, &user.Password, &user.Email, &user.IsActive); err != nil {
		if err == sql.ErrNoRows {
			return nil, nil
		}
		u.log.Error(context.Background(), "error: scanning user row", slog.Any("error", err))
		return nil, err
	}

	return &user, nil
}
```

### 11.2.2 Service – FindById

```go
// internal/service/users.go
func (u *users) FindById(id string) (*model.User, error) {
	return u.repo.FindById(id)
}
```

### 11.2.3 Handler – FindById dengan Path Parameter

Go 1.22+ menyediakan `r.PathValue("id")` untuk mengambil parameter dari URL:

* Ubah file `internal/handler/user_handler.go` untuk menambhakan method FindById

```go
// internal/handler/user_handler.go
func (u *userHandler) FindById(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if id == "" {
		http.Error(w, "Bad Request: missing id parameter", http.StatusBadRequest)
		return
	}

	user, err := u.service.FindById(id)
	if err != nil {
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}
	if user == nil {
		http.Error(w, "Not Found", http.StatusNotFound)
		return
	}

	var response dto.UserResponse
	response.Transform(*user)

	data, err := json.Marshal(response)
	if err != nil {
		u.log.Error(context.Background(), "error: marshaling user to JSON", slog.Any("error", err))
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	if _, err := w.Write(data); err != nil {
		u.log.Error(context.Background(), "error: writing response", slog.Any("error", err))
	}
}
```

## 11.3 Update

### 11.3.1 DTO – UserUpdateRequest

Untuk update, kita hanya mengizinkan field tertentu yang bisa diubah:

```go
// internal/dto/user_request.go
type UserUpdateRequest struct {
	Name     string `json:"name"`
	IsActive bool   `json:"is_active"`
}

func (u *UserUpdateRequest) Transform(user *model.User) {
	user.Name = u.Name
	user.IsActive = u.IsActive
}
```

### 11.3.2 Repository – Update dengan RETURNING

```go
// internal/repository/user_repository.go
func (u *userRepository) Update(user *model.User) error {
	query := `UPDATE users SET name = $1, is_active = $2 WHERE id = $3 RETURNING username, email`
	err := u.db.QueryRow(query, user.Name, user.IsActive, user.ID).Scan(&user.Username, &user.Email)
	if err != nil {
		u.log.Error(context.Background(), "error: updating user", slog.Any("error", err))
		return err
	}

	return nil
}
```

**RETURNING clause:** Mengambil field yang tidak diubah (username, email) untuk mempertahankan nilainya di struct user.

### 11.3.3 Service – Update dengan Validasi Keberadaan

```go
// internal/service/users.go
func (u *users) Update(user *model.User) error {
	existUser, err := u.repo.FindById(user.ID)
	if err != nil {
		return err
	}
	if existUser == nil {
		return fmt.Errorf("user not found")
	}
	return u.repo.Update(user)
}
```

### 11.3.4 Handler – Update

```go
// internal/handler/user_handler.go
func (u *userHandler) Update(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if id == "" {
		http.Error(w, "Bad Request: missing id parameter", http.StatusBadRequest)
		return
	}

	var req dto.UserUpdateRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		u.log.Error(context.Background(), "error: decoding user request", slog.Any("error", err))
		http.Error(w, "Bad Request", http.StatusBadRequest)
		return
	}
	user := model.User{ID: id}
	req.Transform(&user)
	err := u.service.Update(&user)
	if err != nil {
		if err.Error() == "user not found" {
			http.Error(w, "Not Found", http.StatusNotFound)
		} else {
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		}
		return
	}

	var response dto.UserResponse
	response.Transform(user)

	data, err := json.Marshal(response)
	if err != nil {
		u.log.Error(context.Background(), "error: marshaling user to JSON", slog.Any("error", err))
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	if _, err := w.Write(data); err != nil {
		u.log.Error(context.Background(), "error: writing response", slog.Any("error", err))
	}
}
```

## 11.4 Delete (Soft Delete)

### 11.4.1 Konsep Soft Delete

Soft delete berarti data tidak dihapus secara fisik, hanya ditandai sebagai terhapus dengan mengisi field deleted\_at. Keuntungan:

* Data bisa dipulihkan
* Audit trail (kapan dihapus)
* Referensi integritas tetap terjaga

Perubahan pada query `List` dan `FindById` (hanya menampilkan data yang belum terhapus):

```go
// List: tambah WHERE deleted_at IS NULL
query := `SELECT ... FROM users WHERE deleted_at IS NULL`

// FindById: tambah kondisi yang sama
query := `SELECT ... FROM users WHERE id = $1 AND deleted_at IS NULL`
```

### 11.4.2 Repository – Delete (Soft)

```go
// internal/repository/user_repository.go
func (u *userRepository) Delete(id string) error {
	query := `UPDATE users SET deleted_at = timezone('utc', now()) WHERE id = $1`
	_, err := u.db.Exec(query, id)
	if err != nil {
		u.log.Error(context.Background(), "error: deleting user", slog.Any("error", err))
		return err
	}

	return nil
}
```

### 11.4.3 Service – Delete

```go
// internal/service/users.go
func (u *users) Delete(id string) error {
	existUser, err := u.repo.FindById(id)
	if err != nil {
		return err
	}
	if existUser == nil {
		return fmt.Errorf("user not found")
	}
	return u.repo.Delete(id)
}
```

### 11.4.4 Handler – Delete (No Content)

```go
// internal/handler/user_handler.go
func (u *userHandler) Delete(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if id == "" {
		http.Error(w, "Bad Request: missing id parameter", http.StatusBadRequest)
		return
	}

	err := u.service.Delete(id)
	if err != nil {
		if err.Error() == "user not found" {
			http.Error(w, "Not Found", http.StatusNotFound)
		} else {
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		}
		return
	}

	w.WriteHeader(http.StatusNoContent)
}
```

**Status code:** 204 No Content adalah response yang tepat untuk DELETE karena tidak ada data yang dikembalikan.

## 11.5 Routing Lengkap

Update `internal/router/api.go` dengan semua endpoint:

```go
package router

import (
	"database/sql"
	"net/http"
	"workshop/config"
	"workshop/internal/handler"
	"workshop/internal/repository"
	"workshop/internal/service"

	"github.com/jacky-htg/go-libs/logger"
)

func Api(
	cfg config.Config,
	db *sql.DB,
	log logger.Logger,
) http.Handler {
	mux := http.NewServeMux()

	userRepository := repository.NewUserRepository(db, log)
	userService := service.NewUsers(userRepository, log)
	userHandler := handler.NewUserHandler(userService, log)
	mux.HandleFunc("GET /users", userHandler.List)
	mux.HandleFunc("POST /users", userHandler.Create)
	mux.HandleFunc("GET /users/{id}", userHandler.FindById)
	mux.HandleFunc("PUT /users/{id}", userHandler.Update)
	mux.HandleFunc("DELETE /users/{id}", userHandler.Delete)

	return mux
}
```

## 11.6 Testing CRUD dengan cURL

### Create User

```bash
curl -X POST localhost:9000/users \
  -H "Content-Type: application/json" \
  -d '{
    "name": "John Doe",
    "username": "johndoe",
    "email": "john@example.com",
    "password": "secret123",
    "is_active": true
  }'
```

**Response:** 201 Created + data user (tanpa password)

### List Users

```bash
curl localhost:9000/users
```

### Get User by ID

```bash
curl localhost:9000/users/{id}
```

### Update User

```bash
curl -X PUT localhost:9000/users/{id} \
  -H "Content-Type: application/json" \
  -d '{"name": "John Updated", "is_active": false}'
```

### Delete User

```bash
curl -X DELETE localhost:9000/users/{id}
```

## Ringkasan Bab 11

Di bab ini kita telah melengkapi semua operasi CRUD:

| Operasi | Method | Endpoint    | Status Code                    |
| ------- | ------ | ----------- | ------------------------------ |
| Create  | POST   | /users      | 201 Created                    |
| List    | GET    | /users      | 200 OK                         |
| Read    | GET    | /users/{id} | 200 OK / 404 Not Found         |
| Update  | PUT    | /users/{id} | 200 OK / 404 Not Found         |
| Delete  | DELETE | /users/{id} | 204 No Content / 404 Not Found |

Penting yang dipelajari:

* ✅ Hashing password dengan bcrypt
* ✅ Generate UUID v7 untuk ID terurut
* ✅ Soft delete pattern dengan deleted\_at
* ✅ Path parameter dengan r.PathValue()
* ✅ RETURNING clause untuk mengambil data setelah update
* ✅ DTO terpisah untuk Create (full) vs Update (partial)

Yang akan datang:

* Saat ini error handling masih sederhana (http.Error)
* Bab selanjutnya: Standard Response – membangun format response JSON yang konsisten di seluruh API


# Standard Response

Saat ini, API kita mengembalikan response yang tidak konsisten:

* Saat sukses → mengembalikan JSON data mentah (array user atau object user)
* Saat error → mengembalikan teks biasa melalui http.Error()

Hal ini menyulitkan client (mobile, frontend) dalam memproses response karena struktur yang selalu berubah. Bab ini akan membangun standard response yang seragam untuk semua endpoint.

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/12-standard-response](https://github.com/jacky-htg/workshop/tree/main/12-standard-response)

## 12.1 Masalah dengan Response Tidak Terstandar

Response sukses saat ini:

```json
// GET /users
[
    {"id": "123", "name": "John", ...}
]

// POST /users
{"id": "456", "name": "Jane", ...}
```

Response error saat ini:

```
Internal Server Error
```

| Masalah                         | Dampak                                              |
| ------------------------------- | --------------------------------------------------- |
| Tipe data berbeda               | Sukses → array/object, Error → string               |
| Tidak ada status business logic | Client tidak tahu apakah operasi benar-benar sukses |
| Tidak ada pesan yang konsisten  | Frontend sulit menampilkan error message            |

## 12.2 Desain Standard Response

Kita akan mendefinisikan format response JSON yang seragam:

```json
{
    "status": "B1",
    "message": "Success",
    "data": { ... }
}
```

| Field   | Deskripsi                                    | Contoh Nilai                        |
| ------- | -------------------------------------------- | ----------------------------------- |
| status  | Status business logic (bukan HTTP status)    | "B1" (sukses), "B0" (error)         |
| message | Pesan yang ramah untuk user                  | "User created successfully"         |
| data    | Payload data (bisa object, array, atau null) | {"id": "123", ...} atau \[] atau {} |

**Mengapa status business logic?** HTTP status code (200, 400, 500) untuk infrastruktur. Status "B1"/"B0" untuk logika bisnis. Contoh: login gagal karena password salah → HTTP 200 (request berhasil diproses) tapi status "B0" (business error).

## 12.3 Implementasi Response Helper

Buat file `pkg/response/response.go`:

```go
package response

import (
	"context"
	"encoding/json"
	"log/slog"
	"net/http"

	"github.com/jacky-htg/go-libs/logger"
)

const (
	AppBusinessStatusSuccess = "B1"
	AppBusinessStatusError   = "B0"
)

type StandardResponse struct {
	Status  string `json:"status"`
	Message string `json:"message"`
	Data    any    `json:"data"`
}

func SetResponse(log logger.Logger, w http.ResponseWriter, httpStatus int, appBusinessLogicStatus string, message string, data any) {
	standardResponse := StandardResponse{
		Status:  appBusinessLogicStatus,
		Message: message,
		Data:    data,
	}

	resp, err := json.Marshal(standardResponse)
	if err != nil {
		log.Error(context.Background(), "error: marshaling users to JSON", slog.Any("error", err))
		httpStatus = http.StatusInternalServerError
		appBusinessLogicStatus = AppBusinessStatusError
		message = "Internal Server Error"
	}

	w.Header().Set("Content-Type", "application/json")
	if httpStatus != http.StatusOK {
		w.WriteHeader(httpStatus)
	}

	if _, err = w.Write(resp); err != nil {
		log.Error(context.Background(), "error: writing response", slog.Any("error", err))
	}
}

func SetError(log logger.Logger, w http.ResponseWriter, httpStatus int, appBusinessLogicStatus string, err error, message string) {
	finalMessage := message
	if finalMessage == "" && err != nil {
		finalMessage = err.Error()
	}
	SetResponse(log, w, httpStatus, appBusinessLogicStatus, finalMessage, struct{}{})
}

func SetOk(log logger.Logger, w http.ResponseWriter, data any) {
	SetResponse(log, w, http.StatusOK, AppBusinessStatusSuccess, "Success", data)
}

func SetCreated(log logger.Logger, w http.ResponseWriter, data any) {
	SetResponse(log, w, http.StatusCreated, AppBusinessStatusSuccess, "Created", data)
}
```

## 12.4 Refactor Handler Menggunakan Response Helper

Sekarang semua handler diubah menggunakan helper di atas. Kode menjadi lebih bersih dan konsisten.

### UserHandler.List

Sebelum:

```go
data, err := json.Marshal(response)
w.Header().Set("Content-Type", "application/json")
w.Write(data)
```

Sesudah:

```go
response.SetOk(u.log, w, resp)
```

### UserHandler.Create

Sebelum:

```go
w.WriteHeader(http.StatusCreated)
w.Write(data)
```

Sesudah

```go
response.SetCreated(u.log, w, resp)
```

### UserHandler.FindById

Sebelum:

```go
if user == nil {
    http.Error(w, "Not Found", http.StatusNotFound)
    return
}
```

Sesudah :

```go
if user == nil {
    response.SetError(u.log, w, http.StatusNotFound, response.AppBusinessStatusError, 
                      nil, "User not found")
    return
}
```

## 12.5 Kode Lengkap Handler yang Direfactor

```go
// internal/handler/user_handler.go
package handler

import (
	"context"
	"encoding/json"
	"log/slog"
	"net/http"

	"workshop/internal/dto"
	"workshop/internal/model"
	"workshop/internal/service"
	"workshop/pkg/response"

	"github.com/jacky-htg/go-libs/logger"
)

type UserHandler interface {
	List(w http.ResponseWriter, r *http.Request)
	Create(w http.ResponseWriter, r *http.Request)
	FindById(w http.ResponseWriter, r *http.Request)
	Update(w http.ResponseWriter, r *http.Request)
	Delete(w http.ResponseWriter, r *http.Request)
}

type userHandler struct {
	log     logger.Logger
	service service.Users
}

func NewUserHandler(service service.Users, log logger.Logger) UserHandler {
	return &userHandler{service: service, log: log}
}

// List : http handler for returning list of users
func (u *userHandler) List(w http.ResponseWriter, r *http.Request) {
	users, err := u.service.List()
	if err != nil {
		u.log.Error(context.Background(), "error: listing users", slog.Any("error", err))
		response.SetError(u.log, w, http.StatusInternalServerError, response.AppBusinessStatusError, err, "Failed to list users")
		return
	}

	var resp []dto.UserResponse
	for _, user := range users {
		var ur dto.UserResponse
		ur.Transform(user)
		resp = append(resp, ur)
	}

	response.SetOk(u.log, w, resp)
}

// Create : http handler for creating a new user
func (u *userHandler) Create(w http.ResponseWriter, r *http.Request) {
	var req dto.UserRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		u.log.Error(context.Background(), "error: decoding user request", slog.Any("error", err))
		response.SetError(u.log, w, http.StatusBadRequest, response.AppBusinessStatusError, err, "Invalid request payload")
		return
	}
	user := model.User{}
	req.Transform(&user)
	err := u.service.Create(&user)
	if err != nil {
		response.SetError(u.log, w, http.StatusInternalServerError, response.AppBusinessStatusError, err, "Failed to create user")
		return
	}

	var resp dto.UserResponse
	resp.Transform(user)
	response.SetCreated(u.log, w, resp)
}

// FindById : http handler for finding a user by ID
func (u *userHandler) FindById(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if id == "" {
		response.SetError(u.log, w, http.StatusBadRequest, response.AppBusinessStatusError, nil, "Missing id parameter")
		return
	}

	user, err := u.service.FindById(id)
	if err != nil {
		response.SetError(u.log, w, http.StatusInternalServerError, response.AppBusinessStatusError, err, "Failed to find user")
		return
	}
	if user == nil {
		response.SetError(u.log, w, http.StatusNotFound, response.AppBusinessStatusError, nil, "User not found")
		return
	}

	var resp dto.UserResponse
	resp.Transform(*user)

	response.SetOk(u.log, w, resp)
}

func (u *userHandler) Update(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if id == "" {
		response.SetError(u.log, w, http.StatusBadRequest, response.AppBusinessStatusError, nil, "Missing id parameter")
		return
	}

	var req dto.UserUpdateRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		u.log.Error(context.Background(), "error: decoding user request", slog.Any("error", err))
		response.SetError(u.log, w, http.StatusBadRequest, response.AppBusinessStatusError, nil, "Invalid request payload")
		return
	}
	user := model.User{ID: id}
	req.Transform(&user)
	err := u.service.Update(&user)
	if err != nil {
		if err.Error() == "user not found" {
			response.SetError(u.log, w, http.StatusNotFound, response.AppBusinessStatusError, nil, "User not found")
		} else {
			response.SetError(u.log, w, http.StatusInternalServerError, response.AppBusinessStatusError, err, "Failed to update user")
		}
		return
	}

	var resp dto.UserResponse
	resp.Transform(user)

	response.SetOk(u.log, w, resp)
}

func (u *userHandler) Delete(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if id == "" {
		response.SetError(u.log, w, http.StatusBadRequest, response.AppBusinessStatusError, nil, "Missing id parameter")
		return
	}

	err := u.service.Delete(id)
	if err != nil {
		if err.Error() == "user not found" {
			response.SetError(u.log, w, http.StatusNotFound, response.AppBusinessStatusError, nil, "User not found")
		} else {
			response.SetError(u.log, w, http.StatusInternalServerError, response.AppBusinessStatusError, err, "Failed to delete user")
		}
		return
	}
	response.SetOk(u.log, w, struct{}{})
}
```

## 12.6 Contoh Response Setelah Standardisasi

### Response Sukses (GET /users)

```json
{
    "status": "B1",
    "message": "Success",
    "data": [
        {"id": "123", "name": "John Doe", ...},
        {"id": "456", "name": "Jane Smith", ...}
    ]
}
```

### Response Sukses (GET /users/{id})

```json
{
    "status": "B1",
    "message": "Success",
    "data": {"id": "123", "name": "John Doe", ...}
}
```

### Response Sukses (POST /users)

```json
{
    "status": "B1",
    "message": "Created",
    "data": {"id": "789", "name": "New User", ...}
}
```

### Response Sukses (DELETE /users/{id})

```json
{
    "status": "B1",
    "message": "Success",
    "data": {}
}
```

### Response Error (User Not Found)

```json
{
    "status": "B0",
    "message": "User not found",
    "data": {}
}
```

### Response Error (Invalid Request)

```json
{
    "status": "B0",
    "message": "Invalid request payload",
    "data": {}
}
```

## 12.7 Manfaat yang Diperoleh

| Sebelum                                    | Sesudah                                |
| ------------------------------------------ | -------------------------------------- |
| Response tidak konsisten                   | Semua response memiliki format seragam |
| Error berupa teks biasa                    | Error juga dalam format JSON           |
| Kode repetitive (Marshal + Header + Write) | Satu baris SetOk() atau SetError()     |
| Status business logic tidak ada            | Status B1/B0 untuk logika bisnis       |
| Perubahan format susah                     | Cukup ubah satu fungsi SetResponse()   |

## Ringkasan Bab 12

Di bab ini kita telah belajar:

1. Standard Response Pattern – Format JSON seragam untuk semua endpoint
2. Business Status Code – B1 (sukses) dan B0 (error)
3. Helper Functions – SetOk(), SetCreated(), SetError() untuk konsistensi
4. Refactoring Handler – Kode menjadi lebih bersih dan mudah dipelihara

Manfaat yang kita peroleh:

* ✅ Frontend bisa memproses semua response dengan cara yang sama
* ✅ Error message informatif dan terstruktur
* ✅ Mudah menambahkan field baru ke semua response (misal: request\_id)
* ✅ Mengurangi duplikasi kode (DRY principle)

Yang akan datang:

* Saat ini error handling masih sederhana (semua error balik ke client dengan status B0)
* Bab selanjutnya: Error Handler – membangun sistem error handling yang lebih canggih dengan custom error types dan proper error wrapping


# Error Handler

Setelah memiliki standard response, langkah selanjutnya adalah menyempurnakan cara kita menangani error. Tidak semua error adalah "Internal Server Error". Client perlu tahu apakah error terjadi karena:

* Input tidak valid (Bad Request)
* Data tidak ditemukan (Not Found)
* Tidak punya akses (Forbidden)
* Belum login (Unauthorized)

Bab ini akan membangun sistem error handling yang konsisten dan informatif.

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/13-error-handler](https://github.com/jacky-htg/workshop/tree/main/13-error-handler)

## 13.1 Masalah dengan Error Handling Saat Ini

Saat ini, semua error dari service langsung dibungkus menjadi InternalServerError:

```go
// Sebelumnya
if err != nil {
    response.SetError(u.log, w, http.StatusInternalServerError, 
        response.AppBusinessStatusError, err, "Failed to list users")
    return
}
```

Masalah:

* Error "user not found" juga dianggap Internal Server Error (seharusnya 404)
* Client tidak bisa membedakan jenis error
* Tidak ada kode error standar untuk frontend

## 13.2 Desain Custom Error

Kita akan membuat custom error type yang mengimplementasikan interface error bawaan Go:

```go
type BusinessError struct {
	Err        error
	Code       string
	Message    string
	HTTPStatus int
}
```

Contoh error yang akan dihasilkan:

| Skenario             | Code | HTTP Status | Message                 |
| -------------------- | ---- | ----------- | ----------------------- |
| Input tidak valid    | E001 | 400         | "Invalid input"         |
| Data tidak ditemukan | E002 | 404         | "Resource not found"    |
| Akses ditolak        | E003 | 403         | "Forbidden"             |
| Belum login          | E004 | 401         | "Unauthorized"          |
| Error internal       | E000 | 500         | "Internal Server Error" |

## 13.3 Implementasi Custom Error

Buat file `pkg/errors/errors.go`:

```go
package errors

import (
	"errors"
	"fmt"
	"net/http"
)

// Business error codes
const (
	InternalServerErrorCode = "E000"
	InvalidInputCode        = "E001"
	NotFoundCode            = "E002"
	ForbiddenCode           = "E003"
	UnauthorizedCode        = "E004"
)

// Default messages
const (
	InternalServerErrorMessage = "Internal Server Error"
	InvalidInputMessage        = "Invalid input"
	NotFoundMessage            = "Resource not found"
	ForbiddenMessage           = "Forbidden"
	UnauthorizedMessage        = "Unauthorized"
)

// BusinessError adalah custom error untuk business logic
type BusinessError struct {
	Err        error
	Code       string
	Message    string
	HTTPStatus int
}

// Error implements error interface
func (err *BusinessError) Error() string {
	if err.Err != nil {
		return fmt.Sprintf("[%s] %s: %v", err.Code, err.Message, err.Err)
	}
	return fmt.Sprintf("[%s] %s", err.Code, err.Message)
}

// Unwrap untuk error wrapping (Go 1.13+)
func (err *BusinessError) Unwrap() error {
	return err.Err
}

// Helper functions untuk create error
func ErrNew(code string, message string, httpStatus int) *BusinessError {
	return &BusinessError{
		Err:        fmt.Errorf("%s", message),
		Code:       code,
		Message:    message,
		HTTPStatus: httpStatus,
	}
}

func ErrWrap(err error, bErr *BusinessError) {
	bErr.Err = err
}


// ============= Constructor tanpa wrapping =============

func InvalidInput(message ...string) *BusinessError {
	finalMessage := InvalidInputMessage
	if len(message) > 0 && len(message[0]) > 0 {
		finalMessage = message[0]
	}
	return ErrNew(InvalidInputCode, finalMessage, http.StatusBadRequest)
}

func NotFound(message ...string) *BusinessError {
	finalMessage := NotFoundMessage
	if len(message) > 0 && len(message[0]) > 0 {
		finalMessage = message[0]
	}
	return ErrNew(NotFoundCode, finalMessage, http.StatusNotFound)
}

func Forbidden(message ...string) *BusinessError {
	finalMessage := ForbiddenMessage
	if len(message) > 0 && len(message[0]) > 0 {
		finalMessage = message[0]
	}
	return ErrNew(ForbiddenCode, finalMessage, http.StatusForbidden)
}

func Unauthorized(message ...string) *BusinessError {
	finalMessage := UnauthorizedMessage
	if len(message) > 0 && len(message[0]) > 0 {
		finalMessage = message[0]
	}
	return ErrNew(UnauthorizedCode, finalMessage, http.StatusUnauthorized)
}

func InternalServerError(message ...string) *BusinessError {
	finalMessage := InternalServerErrorMessage
	if len(message) > 0 && len(message[0]) > 0 {
		finalMessage = message[0]
	}
	return ErrNew(InternalServerErrorCode, finalMessage, http.StatusInternalServerError)
}


// ============= Constructor dengan wrapping =============

func InvalidInputWrap(err error, message ...string) *BusinessError {
	bErr := InvalidInput(message...)
	ErrWrap(err, bErr)
	return bErr
}

func NotFoundWrap(err error, message ...string) *BusinessError {
	bErr := NotFound(message...)
	ErrWrap(err, bErr)
	return bErr
}

func ForbiddenWrap(err error, message ...string) *BusinessError {
	bErr := Forbidden(message...)
	ErrWrap(err, bErr)
	return bErr
}

func UnauthorizedWrap(err error, message ...string) *BusinessError {
	bErr := Unauthorized(message...)
	ErrWrap(err, bErr)
	return bErr
}

func InternalServerErrorWrap(err error, message ...string) *BusinessError {
	bErr := InternalServerError(message...)
	ErrWrap(err, bErr)
	return bErr
}

// ============= Utility untuk ekstraksi error =============

// GetBusinessError extracts BusinessError from error chain
func GetBusinessError(err error) (*BusinessError, bool) {
	var bizErr *BusinessError
	if errors.As(err, &bizErr) {
		return bizErr, true
	}
	return nil, false
}
```

## 13.4 Update Response Helper

Sederhanakan SetError untuk menerima \*BusinessError langsung:

```go
// pkg/response/response.go
package response

import (
	"context"
	"encoding/json"
	"log/slog"
	"net/http"
	"workshop/pkg/errors"

	"github.com/jacky-htg/go-libs/logger"
)

const AppBusinessStatusSuccess = "B1"

type StandardResponse struct {
	Status  string `json:"status"`
	Message string `json:"message"`
	Data    any    `json:"data"`
}

func SetResponse(log logger.Logger, w http.ResponseWriter, httpStatus int, appBusinessLogicStatus string, message string, data any) {
	standardResponse := StandardResponse{
		Status:  appBusinessLogicStatus,
		Message: message,
		Data:    data,
	}

	resp, err := json.Marshal(standardResponse)
	if err != nil {
		log.Error(context.Background(), "error: marshaling users to JSON", slog.Any("error", err))
		httpStatus = http.StatusInternalServerError
		appBusinessLogicStatus = errors.InternalServerErrorCode
		message = "Internal Server Error"
	}

	w.Header().Set("Content-Type", "application/json")
	if httpStatus != http.StatusOK {
		w.WriteHeader(httpStatus)
	}

	if _, err = w.Write(resp); err != nil {
		log.Error(context.Background(), "error: writing response", slog.Any("error", err))
	}
}

func SetError(log logger.Logger, w http.ResponseWriter, err *errors.BusinessError, message ...string) {
	finalMessage := ""
	if len(message) > 0 && len(message[0]) > 0 {
		finalMessage = message[0]
	}

	if finalMessage == "" && err != nil {
		finalMessage = err.Message
	}
	SetResponse(log, w, err.HTTPStatus, err.Code, finalMessage, struct{}{})
}

func SetOk(log logger.Logger, w http.ResponseWriter, data any) {
	SetResponse(log, w, http.StatusOK, AppBusinessStatusSuccess, "Success", data)
}

func SetCreated(log logger.Logger, w http.ResponseWriter, data any) {
	SetResponse(log, w, http.StatusCreated, AppBusinessStatusSuccess, "Created", data)
}
```

## 13.5 Update Service Layer dengan BusinessError

Setiap error di repository bisa dipertahankan karena error di repository tidak akan ditampilkan sebagai reponse dan hanya disimpan dalam log. Namun error di level service dan handler harus diubah mengikuti bisnis error yang telah ditentukan.

Service layer sekarang mengembalikan \*errors.BusinessError:

```go
// internal/service/users.go
package service

import (
	"context"
	"log/slog"
	"workshop/internal/model"
	"workshop/internal/repository"
	"workshop/pkg/errors"

	"github.com/jacky-htg/go-libs/logger"
	"github.com/jacky-htg/go-libs/uuid7"
	"golang.org/x/crypto/bcrypt"
)

type Users interface {
	List() ([]model.User, *errors.BusinessError)
	Create(*model.User) *errors.BusinessError
	FindById(id string) (*model.User, *errors.BusinessError)
	Update(*model.User) *errors.BusinessError
	Delete(id string) *errors.BusinessError
}

type users struct {
	log  logger.Logger
	repo repository.UserRepository
}

func NewUsers(repo repository.UserRepository, log logger.Logger) Users {
	return &users{repo: repo, log: log}
}

func (u *users) List() ([]model.User, *errors.BusinessError) {
	users, err := u.repo.List()
	if err != nil {
		return nil, errors.InternalServerErrorWrap(err, "error listing users")
	}
	return users, nil
}

func (u *users) Create(user *model.User) *errors.BusinessError {

	pass, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
	if err != nil {
		u.log.Error(context.Background(), "error generate password", slog.Any("error", err))
		return errors.InternalServerErrorWrap(err, "error generating password")
	}

	user.ID = uuid7.New()
	user.Password = string(pass)

	if err := u.repo.Create(user); err != nil {
		return errors.InternalServerErrorWrap(err, "error creating user")
	}

	return nil
}

func (u *users) FindById(id string) (*model.User, *errors.BusinessError) {
	user, err := u.repo.FindById(id)
	if err != nil {
		return nil, errors.InternalServerErrorWrap(err, "error finding user")
	}
	if user == nil {
		return nil, errors.NotFound("user not found")
	}
	return user, nil
}

func (u *users) Update(user *model.User) *errors.BusinessError {
	existUser, err := u.repo.FindById(user.ID)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error finding user")
	}
	if existUser == nil {
		return errors.NotFound("user not found")
	}
	err = u.repo.Update(user)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error updating user")
	}
	return nil
}

func (u *users) Delete(id string) *errors.BusinessError {
	existUser, err := u.repo.FindById(id)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error finding user")
	}
	if existUser == nil {
		return errors.NotFound("user not found")
	}
	err = u.repo.Delete(id)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error deleting user")
	}
	return nil
}
```

## 13.6 Update Handler Layer

Handler sekarang jauh lebih bersih:

```go
// internal/handler/user_handler.go
package handler

import (
	"context"
	"encoding/json"
	"log/slog"
	"net/http"

	"workshop/internal/dto"
	"workshop/internal/model"
	"workshop/internal/service"
	"workshop/pkg/errors"
	"workshop/pkg/response"

	"github.com/jacky-htg/go-libs/logger"
)

type UserHandler interface {
	List(w http.ResponseWriter, r *http.Request)
	Create(w http.ResponseWriter, r *http.Request)
	FindById(w http.ResponseWriter, r *http.Request)
	Update(w http.ResponseWriter, r *http.Request)
	Delete(w http.ResponseWriter, r *http.Request)
}

type userHandler struct {
	log     logger.Logger
	service service.Users
}

func NewUserHandler(service service.Users, log logger.Logger) UserHandler {
	return &userHandler{service: service, log: log}
}

// List : http handler for returning list of users
func (u *userHandler) List(w http.ResponseWriter, r *http.Request) {
	users, err := u.service.List()
	if err != nil {
		u.log.Error(context.Background(), "error: listing users", slog.Any("error", err))
		response.SetError(u.log, w, err)
		return
	}

	var resp []dto.UserResponse
	for _, user := range users {
		var ur dto.UserResponse
		ur.Transform(user)
		resp = append(resp, ur)
	}

	response.SetOk(u.log, w, resp)
}

// Create : http handler for creating a new user
func (u *userHandler) Create(w http.ResponseWriter, r *http.Request) {
	var req dto.UserRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		u.log.Error(context.Background(), "error: decoding user request", slog.Any("error", err))
		response.SetError(u.log, w, errors.InvalidInputWrap(err))
		return
	}
	user := model.User{}
	req.Transform(&user)
	err := u.service.Create(&user)
	if err != nil {
		response.SetError(u.log, w, err)
		return
	}

	var resp dto.UserResponse
	resp.Transform(user)
	response.SetCreated(u.log, w, resp)
}

// FindById : http handler for finding a user by ID
func (u *userHandler) FindById(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if id == "" {
		response.SetError(u.log, w, errors.InvalidInput("Missing id parameter"))
		return
	}

	user, err := u.service.FindById(id)
	if err != nil {
		response.SetError(u.log, w, err)
		return
	}

	var resp dto.UserResponse
	resp.Transform(*user)

	response.SetOk(u.log, w, resp)
}

func (u *userHandler) Update(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if id == "" {
		response.SetError(u.log, w, errors.InvalidInput("Missing id parameter"))
		return
	}

	var req dto.UserUpdateRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		u.log.Error(context.Background(), "error: decoding user request", slog.Any("error", err))
		response.SetError(u.log, w, errors.InvalidInputWrap(err))
		return
	}
	user := model.User{ID: id}
	req.Transform(&user)
	err := u.service.Update(&user)
	if err != nil {
		response.SetError(u.log, w, err)
		return
	}

	var resp dto.UserResponse
	resp.Transform(user)

	response.SetOk(u.log, w, resp)
}

func (u *userHandler) Delete(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if id == "" {
		response.SetError(u.log, w, errors.InvalidInput("Missing id parameter"))
		return
	}

	err := u.service.Delete(id)
	if err != nil {
		response.SetError(u.log, w, err)
		return
	}
	response.SetOk(u.log, w, struct{}{})
}
```

## 13.7 Aliran Error dari Repository ke Client

Berikut diagram aliran error:

![](https://1500694864-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-MaN3VdVswXpcCva69TG%2Fuploads%2Fgit-blob-4a90bedb52e8e1f90d026653a3953bd6cdace6d9%2Ferror-flow-diagram.svg?alt=media)

## 13.8 Contoh Response Error Setelah Implementasi

### Error Not Found (404)

```json
{
    "status": "E002",
    "message": "user not found",
    "data": {}
}
```

### Error Invalid Input (400)

```json
{
    "status": "E001",
    "message": "Invalid input",
    "data": {}
}
```

### Error Internal Server (500)

```json
{
    "status": "E000",
    "message": "Internal Server Error",
    "data": {}
}
```

## Ringkasan Bab 13

Di bab ini kita telah belajar:

| Konsep                 | Implementasi                                       |
| ---------------------- | -------------------------------------------------- |
| Custom Error Type      | BusinessError dengan Code, Message, HTTPStatus     |
| Error Wrapping         | Menggunakan `%w` dan `Unwrap()`                    |
| Error Constructors     | `InvalidInput()`, `NotFound()`, `Forbidden()`, dll |
| Error Extraction       | `GetBusinessError()` dengan `errors.As()`          |
| Handler Simplification | Handler hanya panggil `SetError(w, err)`           |

Manfaat yang kita peroleh:

* ✅ Client bisa membedakan jenis error berdasarkan status code dan kode error
* ✅ Pesan error konsisten dan user-friendly
* ✅ Error handling logic terpusat (tidak tersebar)
* ✅ Mudah menambahkan jenis error baru
* ✅ Error original tetap terjaga untuk logging (via Unwrap())

Yang akan datang:

* Saat ini context hanya menggunakan `context.Background()`
* Bab selanjutnya: Context – memanfaatkan context untuk request-scoped values, timeout, dan cancellation


# Context

Dalam aplikasi web, setiap request memiliki siklus hidupnya sendiri. Jika suatu request memakan waktu terlalu lama (misal karena database lambat atau koneksi jaringan bermasalah), kita perlu kemampuan untuk membatalkan proses tersebut.

Package `context` di Go menyediakan mekanisme standar untuk:

* Mengirim sinyal pembatalan (cancellation)
* Memberikan batas waktu (deadline)
* Menyimpan nilai yang spesifik untuk request (request-scoped values)

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/14-context](https://github.com/jacky-htg/workshop/tree/main/14-context)

## 14.1 Mengapa Context Penting?

Bayangkan skenario berikut:

1. Client melakukan request `GET /users`
2. Database sedang sibuk, query butuh 30 detik
3. Client sudah timeout setelah 10 detik dan menutup koneksi

**Tanpa context:** Server tetap menjalankan query selama 30 detik, membuang resource.

**Dengan context:** Begitu koneksi client tertutup, context otomatis dibatalkan, dan query database bisa dihentikan.

## 14.2 Prinsip Dasar Context

```go
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}
```

| Method       | Kegunaan                                                             |
| ------------ | -------------------------------------------------------------------- |
| `Done()`     | Mengembalikan channel yang ditutup saat context selesai/batal        |
| `Err()`      | Mengembalikan alasan pembatalan (`Canceled` atau `DeadlineExceeded`) |
| `Deadline()` | Waktu kapan context akan otomatis batal                              |
| `Value()`    | Mengambil nilai yang tersimpan di context                            |

## 14.3 Aturan Emas Penggunaan Context

1. Context adalah parameter pertama setiap fungsi yang melakukan operasi I/O atau berpotensi lama
2. Jangan menyimpan context di struct – context harus di-passing sebagai parameter
3. Gunakan `context.Background()` hanya di level tertinggi (main, test, init)
4. Jangan gunakan nil context – gunakan `context.TODO()` jika belum tahu
5. Context di HTTP handler berasal dari `r.Context()`

## 14.4 Implementasi Context di Repository

Semua method database harus menerima `context.Context` dan menggunakan method ...Context-nya:

```go
// internal/repository/user_repository.go
func (u *userRepository) List(ctx context.Context) ([]model.User, error) {
    query := `SELECT ... FROM users WHERE deleted_at IS NULL`
    
    // Gunakan QueryContext, bukan Query
    rows, err := u.db.QueryContext(ctx, query)
    if err != nil {
        u.log.Error(ctx, "error: querying users", slog.Any("error", err))
        return nil, err
    }
    defer rows.Close()
    
    // ... scan rows ...
}

func (u *userRepository) FindById(ctx context.Context, id string) (*model.User, error) {
    // Gunakan QueryRowContext
    row := u.db.QueryRowContext(ctx, query, id)
    // ...
}

func (u *userRepository) Create(ctx context.Context, user *model.User) error {
    // Gunakan ExecContext
    _, err := u.db.ExecContext(ctx, query, ...)
    // ...
}
```

Method database yang mendukung context:

* `QueryContext()`
* `QueryRowContext()`
* `ExecContext()`
* `PrepareContext()`

## 14.5 Propagasi Context ke Semua Layer

### Service Layer

```go
// internal/service/users.go
type Users interface {
    List(ctx context.Context) ([]model.User, *errors.BusinessError)
    Create(ctx context.Context, user *model.User) *errors.BusinessError
    FindById(ctx context.Context, id string) (*model.User, *errors.BusinessError)
    Update(ctx context.Context, user *model.User) *errors.BusinessError
    Delete(ctx context.Context, id string) *errors.BusinessError
}

func (u *users) List(ctx context.Context) ([]model.User, *errors.BusinessError) {
    // Context diteruskan ke repository
    users, err := u.repo.List(ctx)
    if err != nil {
        return nil, errors.InternalServerErrorWrap(err, "error listing users")
    }
    return users, nil
}
```

### Handler Layer

```go
// internal/handler/user_handler.go
func (u *userHandler) List(w http.ResponseWriter, r *http.Request) {
    // Context diambil dari HTTP request
    ctx := r.Context()
    
    users, err := u.service.List(ctx)
    if err != nil {
        u.log.Error(ctx, "error: listing users", slog.Any("error", err))
        response.SetError(ctx, u.log, w, err)
        return
    }
    
    // ...
}
```

## 14.6 Context di Logger

Logger juga perlu menerima context agar bisa menambahkan informasi request-specific (seperti request\_id nantinya):

```go
// Sebelumnya
u.log.Error(context.Background(), "error", slog.Any("error", err))

// Sesudah
u.log.Error(ctx, "error", slog.Any("error", err))
```

## 14.7 Context Cancellation Pattern (Referensi)

Untuk operasi yang benar-benar berat dan ingin mendukung pembatalan manual, gunakan pattern select dengan `ctx.Done()`:

```go
func (u *userRepository) HeavyOperation(ctx context.Context) error {
    select {
    case <-ctx.Done():
        // Context dibatalkan (timeout atau manual cancel)
        return ctx.Err()
    default:
        // Lanjutkan eksekusi
    }
    
    // Lakukan operasi berat di sini
    // ...
}
```

Contoh error yang mungkin dihasilkan:

* `context.Canceled` – ketika `cancel()` dipanggil
* `context.DeadlineExceeded` – ketika melebihi deadline

## 14.8 Handling Context Error dengan Gateway Timeout

Untuk operasi yang membutuhkan `select` dengan `ctx.Done()`, kita perlu mengembalikan error yang sesuai:

```go
// pkg/errors/errors.go (tambahan)
const GatewayTimeoutCode = "E005"
const GatewayTimeoutMessage = "Gateway Timeout"

func GatewayTimeout(message ...string) *BusinessError {
    finalMessage := GatewayTimeoutMessage
    if len(message) > 0 && message[0] != "" {
        finalMessage = message[0]
    }
    return ErrNew(GatewayTimeoutCode, finalMessage, http.StatusGatewayTimeout)
}
```

## 14.9 Aliran Context dalam Satu Request

```
Client Request
    │
    ▼
┌─────────────────────────────────────────────────────────────┐
│  Handler: ctx := r.Context()                                │
│  - Context berasal dari HTTP server                         │
│  - Otomatis dibatalkan jika client menutup koneksi          │
└─────────────────────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────────────────────┐
│  Service: u.repo.List(ctx)                                  │
│  - Meneruskan context tanpa perubahan                       │
└─────────────────────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────────────────────┐
│  Repository: u.db.QueryContext(ctx, query)                  │
│  - Database driver mendeteksi pembatalan                    │
│  - Query dihentikan jika context dibatalkan                 │
└─────────────────────────────────────────────────────────────┘
```

## 14.10 Kode Lengkap yang Diubah

### `internal/repository/user_repository.go`

```go
package repository

import (
	"context"
	"database/sql"
	"log/slog"
	"workshop/internal/model"

	"github.com/jacky-htg/go-libs/logger"
)

type UserRepository interface {
	List(ctx context.Context) ([]model.User, error)
	Create(ctx context.Context, user *model.User) error
	FindById(ctx context.Context, id string) (*model.User, error)
	Update(ctx context.Context, user *model.User) error
	Delete(ctx context.Context, id string) error
}

type userRepository struct {
	db  *sql.DB
	log logger.Logger
}

func NewUserRepository(db *sql.DB, log logger.Logger) UserRepository {
	return &userRepository{db: db, log: log}
}

// List : http handler for returning list of users
func (u *userRepository) List(ctx context.Context) ([]model.User, error) {
	query := `SELECT id, name, username, password, email, is_active FROM users WHERE deleted_at IS NULL`
	rows, err := u.db.QueryContext(ctx, query)
	if err != nil {
		u.log.Error(ctx, "error: querying users", slog.Any("error", err))
		return nil, err
	}
	defer rows.Close()

	var users []model.User
	for rows.Next() {
		var user model.User
		if err := rows.Scan(&user.ID, &user.Name, &user.Username, &user.Password, &user.Email, &user.IsActive); err != nil {

			u.log.Error(ctx, "error: scanning user row", slog.Any("error", err))
			return nil, err
		}
		users = append(users, user)
	}

	if err := rows.Err(); err != nil {
		u.log.Error(ctx, "error: iterating user rows", slog.Any("error", err))
		return nil, err
	}

	return users, nil
}

func (u *userRepository) Create(ctx context.Context, user *model.User) error {
	query := `INSERT INTO users (id, name, username, password, email, is_active) VALUES ($1, $2, $3, $4, $5, $6)`
	_, err := u.db.ExecContext(ctx, query, user.ID, user.Name, user.Username, user.Password, user.Email, user.IsActive)
	if err != nil {
		u.log.Error(ctx, "error: inserting user", slog.Any("error", err))
		return err
	}

	return nil
}

func (u *userRepository) FindById(ctx context.Context, id string) (*model.User, error) {
	query := `SELECT id, name, username, password, email, is_active FROM users WHERE id = $1 AND deleted_at IS NULL`
	row := u.db.QueryRowContext(ctx, query, id)

	var user model.User
	if err := row.Scan(&user.ID, &user.Name, &user.Username, &user.Password, &user.Email, &user.IsActive); err != nil {
		if err == sql.ErrNoRows {
			return nil, nil
		}
		u.log.Error(ctx, "error: scanning user row", slog.Any("error", err))
		return nil, err
	}

	return &user, nil
}

func (u *userRepository) Update(ctx context.Context, user *model.User) error {
	query := `UPDATE users SET name = $1, is_active = $2 WHERE id = $3 RETURNING username, email`
	err := u.db.QueryRowContext(ctx, query, user.Name, user.IsActive, user.ID).Scan(&user.Username, &user.Email)
	if err != nil {
		u.log.Error(ctx, "error: updating user", slog.Any("error", err))
		return err
	}

	return nil
}

func (u *userRepository) Delete(ctx context.Context, id string) error {
	query := `UPDATE users SET deleted_at = timezone('utc', now()) WHERE id = $1`
	_, err := u.db.ExecContext(ctx, query, id)
	if err != nil {
		u.log.Error(ctx, "error: deleting user", slog.Any("error", err))
		return err
	}

	return nil
}
```

### `internal/service/users.go`

```go
package service

import (
	"context"
	"log/slog"
	"workshop/internal/model"
	"workshop/internal/repository"
	"workshop/pkg/errors"

	"github.com/jacky-htg/go-libs/logger"
	"github.com/jacky-htg/go-libs/uuid7"
	"golang.org/x/crypto/bcrypt"
)

type Users interface {
	List(ctx context.Context) ([]model.User, *errors.BusinessError)
	Create(ctx context.Context, user *model.User) *errors.BusinessError
	FindById(ctx context.Context, id string) (*model.User, *errors.BusinessError)
	Update(ctx context.Context, user *model.User) *errors.BusinessError
	Delete(ctx context.Context, id string) *errors.BusinessError
}

type users struct {
	log  logger.Logger
	repo repository.UserRepository
}

func NewUsers(repo repository.UserRepository, log logger.Logger) Users {
	return &users{repo: repo, log: log}
}

func (u *users) List(ctx context.Context) ([]model.User, *errors.BusinessError) {
	users, err := u.repo.List(ctx)
	if err != nil {
		return nil, errors.InternalServerErrorWrap(err, "error listing users")
	}
	return users, nil
}

func (u *users) Create(ctx context.Context, user *model.User) *errors.BusinessError {
	pass, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
	if err != nil {
		u.log.Error(ctx, "error generate password", slog.Any("error", err))
		return errors.InternalServerErrorWrap(err, "error generating password")
	}

	user.ID = uuid7.New()
	user.Password = string(pass)

	if err := u.repo.Create(ctx, user); err != nil {
		return errors.InternalServerErrorWrap(err, "error creating user")
	}

	return nil
}

func (u *users) FindById(ctx context.Context, id string) (*model.User, *errors.BusinessError) {
	user, err := u.repo.FindById(ctx, id)
	if err != nil {
		return nil, errors.InternalServerErrorWrap(err, "error finding user")
	}
	if user == nil {
		return nil, errors.NotFound("user not found")
	}
	return user, nil
}

func (u *users) Update(ctx context.Context, user *model.User) *errors.BusinessError {
	existUser, err := u.repo.FindById(ctx, user.ID)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error finding user")
	}
	if existUser == nil {
		return errors.NotFound("user not found")
	}
	err = u.repo.Update(ctx, user)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error updating user")
	}
	return nil
}

func (u *users) Delete(ctx context.Context, id string) *errors.BusinessError {
	existUser, err := u.repo.FindById(ctx, id)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error finding user")
	}
	if existUser == nil {
		return errors.NotFound("user not found")
	}
	err = u.repo.Delete(ctx, id)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error deleting user")
	}
	return nil
}
```

### `internal/handler/user_handler.go`

```go
package handler

import (
	"encoding/json"
	"log/slog"
	"net/http"

	"workshop/internal/dto"
	"workshop/internal/model"
	"workshop/internal/service"
	"workshop/pkg/errors"
	"workshop/pkg/response"

	"github.com/jacky-htg/go-libs/logger"
)

type UserHandler interface {
	List(w http.ResponseWriter, r *http.Request)
	Create(w http.ResponseWriter, r *http.Request)
	FindById(w http.ResponseWriter, r *http.Request)
	Update(w http.ResponseWriter, r *http.Request)
	Delete(w http.ResponseWriter, r *http.Request)
}

type userHandler struct {
	log     logger.Logger
	service service.Users
}

func NewUserHandler(service service.Users, log logger.Logger) UserHandler {
	return &userHandler{service: service, log: log}
}

// List : http handler for returning list of users
func (u *userHandler) List(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	users, err := u.service.List(ctx)
	if err != nil {
		u.log.Error(ctx, "error: listing users", slog.Any("error", err))
		response.SetError(ctx, u.log, w, err)
		return
	}

	var resp []dto.UserResponse
	for _, user := range users {
		var ur dto.UserResponse
		ur.Transform(user)
		resp = append(resp, ur)
	}

	response.SetOk(ctx, u.log, w, resp)
}

// Create : http handler for creating a new user
func (u *userHandler) Create(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	var req dto.UserRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		u.log.Error(ctx, "error: decoding user request", slog.Any("error", err))
		response.SetError(ctx, u.log, w, errors.InvalidInputWrap(err))
		return
	}
	user := model.User{}
	req.Transform(&user)
	err := u.service.Create(ctx, &user)
	if err != nil {
		response.SetError(ctx, u.log, w, err)
		return
	}

	var resp dto.UserResponse
	resp.Transform(user)
	response.SetCreated(ctx, u.log, w, resp)
}

// FindById : http handler for finding a user by ID
func (u *userHandler) FindById(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	id := r.PathValue("id")
	if id == "" {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Missing id parameter"))
		return
	}

	user, err := u.service.FindById(ctx, id)
	if err != nil {
		response.SetError(ctx, u.log, w, err)
		return
	}

	var resp dto.UserResponse
	resp.Transform(*user)

	response.SetOk(ctx, u.log, w, resp)
}

func (u *userHandler) Update(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	id := r.PathValue("id")
	if id == "" {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Missing id parameter"))
		return
	}

	var req dto.UserUpdateRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		u.log.Error(ctx, "error: decoding user request", slog.Any("error", err))
		response.SetError(ctx, u.log, w, errors.InvalidInputWrap(err))
		return
	}
	user := model.User{ID: id}
	req.Transform(&user)
	err := u.service.Update(ctx, &user)
	if err != nil {
		response.SetError(ctx, u.log, w, err)
		return
	}

	var resp dto.UserResponse
	resp.Transform(user)

	response.SetOk(ctx, u.log, w, resp)
}

func (u *userHandler) Delete(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	id := r.PathValue("id")
	if id == "" {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Missing id parameter"))
		return
	}

	err := u.service.Delete(ctx, id)
	if err != nil {
		response.SetError(ctx, u.log, w, err)
		return
	}
	response.SetOk(ctx, u.log, w, struct{}{})
}
```

### `pkg/response/response.go`

```go
package response

import (
	"context"
	"encoding/json"
	"log/slog"
	"net/http"
	"workshop/pkg/errors"

	"github.com/jacky-htg/go-libs/logger"
)

const AppBusinessStatusSuccess = "B1"

type StandardResponse struct {
	Status  string `json:"status"`
	Message string `json:"message"`
	Data    any    `json:"data"`
}

func SetResponse(ctx context.Context, log logger.Logger, w http.ResponseWriter, httpStatus int, appBusinessLogicStatus string, message string, data any) {
	standardResponse := StandardResponse{
		Status:  appBusinessLogicStatus,
		Message: message,
		Data:    data,
	}

	resp, err := json.Marshal(standardResponse)
	if err != nil {
		log.Error(ctx, "error: marshaling users to JSON", slog.Any("error", err))
		httpStatus = http.StatusInternalServerError
		appBusinessLogicStatus = errors.InternalServerErrorCode
		message = "Internal Server Error"
	}

	w.Header().Set("Content-Type", "application/json")
	if httpStatus != http.StatusOK {
		w.WriteHeader(httpStatus)
	}

	if _, err = w.Write(resp); err != nil {
		log.Error(ctx, "error: writing response", slog.Any("error", err))
	}
}

func SetError(ctx context.Context, log logger.Logger, w http.ResponseWriter, err *errors.BusinessError, message ...string) {
	finalMessage := ""
	if len(message) > 0 && len(message[0]) > 0 {
		finalMessage = message[0]
	}

	if finalMessage == "" && err != nil {
		finalMessage = err.Message
	}
	SetResponse(ctx, log, w, err.HTTPStatus, err.Code, finalMessage, struct{}{})
}

func SetOk(ctx context.Context, log logger.Logger, w http.ResponseWriter, data any) {
	SetResponse(ctx, log, w, http.StatusOK, AppBusinessStatusSuccess, "Success", data)
}

func SetCreated(ctx context.Context, log logger.Logger, w http.ResponseWriter, data any) {
	SetResponse(ctx, log, w, http.StatusCreated, AppBusinessStatusSuccess, "Created", data)
}
```

### `pkg/errors/error.go`

```go
package errors

import (
	"errors"
	"fmt"
	"net/http"
)

const (
	InternalServerErrorCode = "E000"
	InvalidInputCode        = "E001"
	NotFoundCode            = "E002"
	ForbiddenCode           = "E003"
	UnauthorizedCode        = "E004"
	GatewayTimeoutCode      = "E005"
)

// Default messages
const (
	InternalServerErrorMessage = "Internal Server Error"
	InvalidInputMessage        = "Invalid input"
	NotFoundMessage            = "Resource not found"
	ForbiddenMessage           = "Forbidden"
	UnauthorizedMessage        = "Unauthorized"
	GatewayTimeoutMessage      = "Gateway Timeout"
)

type BusinessError struct {
	Err        error
	Code       string
	Message    string
	HTTPStatus int
}

// Error implements error interface
func (err *BusinessError) Error() string {
	if err.Err != nil {
		return fmt.Sprintf("[%s] %s: %v", err.Code, err.Message, err.Err)
	}
	return fmt.Sprintf("[%s] %s", err.Code, err.Message)
}

// Unwrap untuk error wrapping (Go 1.13+)
func (err *BusinessError) Unwrap() error {
	return err.Err
}

// Helper functions untuk create error
func ErrNew(code string, message string, httpStatus int) *BusinessError {
	return &BusinessError{
		Err:        fmt.Errorf("%s", message),
		Code:       code,
		Message:    message,
		HTTPStatus: httpStatus,
	}
}

func ErrWrap(err error, bErr *BusinessError) {
	bErr.Err = err
}

// Quick constructors tanpa wrap
func InvalidInput(message ...string) *BusinessError {
	finalMessage := InvalidInputMessage
	if len(message) > 0 && len(message[0]) > 0 {
		finalMessage = message[0]
	}
	return ErrNew(InvalidInputCode, finalMessage, http.StatusBadRequest)
}

func NotFound(message ...string) *BusinessError {
	finalMessage := NotFoundMessage
	if len(message) > 0 && len(message[0]) > 0 {
		finalMessage = message[0]
	}
	return ErrNew(NotFoundCode, finalMessage, http.StatusNotFound)
}

func Forbidden(message ...string) *BusinessError {
	finalMessage := ForbiddenMessage
	if len(message) > 0 && len(message[0]) > 0 {
		finalMessage = message[0]
	}
	return ErrNew(ForbiddenCode, finalMessage, http.StatusForbidden)
}

func Unauthorized(message ...string) *BusinessError {
	finalMessage := UnauthorizedMessage
	if len(message) > 0 && len(message[0]) > 0 {
		finalMessage = message[0]
	}
	return ErrNew(UnauthorizedCode, finalMessage, http.StatusUnauthorized)
}

func InternalServerError(message ...string) *BusinessError {
	finalMessage := InternalServerErrorMessage
	if len(message) > 0 && len(message[0]) > 0 {
		finalMessage = message[0]
	}
	return ErrNew(InternalServerErrorCode, finalMessage, http.StatusInternalServerError)
}

func GatewayTimeout(message ...string) *BusinessError {
	finalMessage := GatewayTimeoutMessage
	if len(message) > 0 && len(message[0]) > 0 {
		finalMessage = message[0]
	}
	return ErrNew(GatewayTimeoutCode, finalMessage, http.StatusGatewayTimeout)
}

// Wrapped constructors
func InvalidInputWrap(err error, message ...string) *BusinessError {
	bErr := InvalidInput(message...)
	ErrWrap(err, bErr)
	return bErr
}

func NotFoundWrap(err error, message ...string) *BusinessError {
	bErr := NotFound(message...)
	ErrWrap(err, bErr)
	return bErr
}

func ForbiddenWrap(err error, message ...string) *BusinessError {
	bErr := Forbidden(message...)
	ErrWrap(err, bErr)
	return bErr
}

func UnauthorizedWrap(err error, message ...string) *BusinessError {
	bErr := Unauthorized(message...)
	ErrWrap(err, bErr)
	return bErr
}

func InternalServerErrorWrap(err error, message ...string) *BusinessError {
	bErr := InternalServerError(message...)
	ErrWrap(err, bErr)
	return bErr
}

func GatewayTimeoutWrap(err error, message ...string) *BusinessError {
	bErr := GatewayTimeout(message...)
	ErrWrap(err, bErr)
	return bErr
}

// GetBusinessError extracts BusinessError from error chain
func GetBusinessError(err error) (*BusinessError, bool) {
	var bizErr *BusinessError
	if errors.As(err, &bizErr) {
		return bizErr, true
	}
	return nil, false
}
```

## 14.11 Perbandingan Sebelum dan Sesudah

| Aspek                 | Sebelum              | Sesudah                        |
| --------------------- | -------------------- | ------------------------------ |
| Parameter pertama     | Tidak ada context    | ctx context.Context            |
| Database query        | Query() / Exec()     | QueryContext() / ExecContext() |
| Logger call           | context.Background() | Meneruskan ctx                 |
| Timeout handling      | Tidak ada (infinite) | Bisa dibatalkan via context    |
| Request-scoped values | Tidak ada            | Bisa pakai ctx.Value()         |

## 14.12 Catatan Penting

Untuk CRUD sederhana seperti di buku ini, kita tidak perlu menambahkan select dengan ctx.Done() secara manual karena:

* Database driver sudah mendukung context cancellation
* `QueryContext()` dan `ExecContext()` sudah internal mengecek `ctx.Done()`

Yang perlu diingat untuk masa depan:

* Operasi I/O yang tidak punya dukungan context bawaan (file I/O, channel, goroutine manual) perlu `select` dengan `ctx.Done()`
* Jika menemui kasus seperti itu, kembalikan `errors.GatewayTimeout()` agar client mendapat response 504

## Ringkasan Bab 14

Di bab ini kita telah belajar:

| Konsep              | Implementasi                                     |
| ------------------- | ------------------------------------------------ |
| Context parameter   | Parameter pertama di semua fungsi I/O            |
| HTTP context        | r.Context() sebagai sumber context               |
| Database context    | QueryContext(), ExecContext(), QueryRowContext() |
| Logger context      | Semua log menerima context                       |
| Context propagation | Handler → Service → Repository                   |

Manfaat yang kita peroleh:

* ✅ Request dapat dibatalkan jika client disconnected
* ✅ Database query berhenti ketika context batal (hemat resource)
* ✅ Timeout bisa diatur di level HTTP server
* ✅ Request-scoped values (seperti request\_id) bisa di-passing
* ✅ Siap untuk tracing (OpenTelemetry) yang membutuhkan context

Yang akan datang:

* Saat ini input user masih diterima mentah-mentah
* Bab selanjutnya: Validation – memvalidasi input sebelum diproses


# Validation

Salah satu prinsip keamanan paling dasar dalam pengembangan API adalah: jangan pernah percaya input dari client. Setiap data yang masuk harus divalidasi sebelum diproses.

Validasi terdiri dari dua jenis:

1. Input validation – Memeriksa format data (email, panjang string, required, dll)
2. Business validation – Memeriksa aturan bisnis (apakah user sudah exist, stok cukup, dll)

Bab ini akan fokus pada input validation menggunakan library `go-playground/validator/v10`.

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/15-validation](https://github.com/jacky-htg/workshop/tree/main/15-validation)

## 15.1 Jenis Validasi yang Dibutuhkan

Untuk endpoint User, kita memiliki aturan validasi berikut:

| Field    | Aturan                            | Pesan Error                                   |
| -------- | --------------------------------- | --------------------------------------------- |
| name     | Required, min 3 karakter, max 100 | "Name is required / too short / too long"     |
| username | Required, min 3 karakter, max 50  | "Username is required / too short / too long" |
| password | Required, min 10 karakter         | "Password is required / too short"            |
| email    | Required, format email            | "Email is required / must be valid"           |

## 15.2 Menambahkan Tag Validator ke DTO

Update DTO dengan menambahkan tag validate:

```go
// internal/dto/user_request.go
package dto

import (
	"workshop/internal/model"
)

type UserRequest struct {
	Name     string `json:"name" validate:"required,min=3,max=100"`
	Username string `json:"username" validate:"required,min=3,max=50"`
	Password string `json:"password" validate:"required,min=10"`
	Email    string `json:"email" validate:"required,email"`
	IsActive bool   `json:"is_active"`
}

func (u *UserRequest) Transform(user *model.User) {
	user.Name = u.Name
	user.Username = u.Username
	user.Password = u.Password
	user.Email = u.Email
	user.IsActive = u.IsActive
}

type UserUpdateRequest struct {
	Name     string `json:"name"  validate:"required,min=3,max=100"`
	IsActive bool   `json:"is_active"`
}

func (u *UserUpdateRequest) Transform(user *model.User) {
	user.Name = u.Name
	user.IsActive = u.IsActive
}
```

## 15.3 Validator Instance (Singleton via Dependency Injection)

Validator v10 memiliki cache untuk struktur yang divalidasi. Sebaiknya instance-nya dibuat sekali dan di-inject ke handler:

```go
// internal/bootstrap/app.go
package bootstrap

import (
	"database/sql"
	"fmt"
	"workshop/config"
	"workshop/pkg/database"

	"github.com/go-playground/validator/v10"
	"github.com/jacky-htg/go-libs/logger"
	_ "github.com/lib/pq"
)

type App struct {
	Config   config.Config
	Database *sql.DB
	Log      logger.Logger
	Validate *validator.Validate

	Cleanup func()
}

func NewApp() (App, error) {
	cfg, err := config.LoadConfig()
	if err != nil {
		return App{}, fmt.Errorf("error: loading config: %w", err)
	}

	db, err := database.OpenDB(cfg)
	if err != nil {
		return App{}, fmt.Errorf("error: opening database: %w", err)
	}

	log := logger.InitLogger(nil)
	validate := validator.New()

	return App{
		Config:   cfg,
		Database: db,
		Log:      log,
		Validate: validate,
		Cleanup: func() {
			if err := db.Close(); err != nil {
				fmt.Printf("error: closing database: %s\n", err)
			}
		},
	}, nil
}
```

## 15.4 Helper untuk Format Error Validation

Validator mengembalikan error dengan tipe `validator.ValidationErrors` yang berisi banyak field error. Kita buat helper untuk mengubahnya menjadi map yang lebih ramah client:

```go
// pkg/validation/validation.go
package validation

import (
	"strings"

	"github.com/go-playground/validator/v10"
)

func FormatValidationErrors(err error) map[string]string {
	errors := make(map[string]string)

	if validationErrors, ok := err.(validator.ValidationErrors); ok {
		for _, fieldErr := range validationErrors {
			field := fieldErr.Field()
			tag := fieldErr.Tag()

			message := generateValidationMessage(field, tag)
			errors[strings.ToLower(field)] = message
		}
	}

	return errors
}

func generateValidationMessage(field, tag string) string {
	switch tag {
	case "required":
		return field + " is required"
	case "email":
		return field + " must be a valid email address"
	case "min":
		return field + " is too short"
	case "max":
		return field + " is too long"
	default:
		return field + " is invalid"
	}
}
```

## 15.5 Update Response untuk Mendukung Multiple Error

Response perlu menampung multiple error messages. Ubah SetError untuk menerima parameter data any:

```go
// pkg/response/response.go
func SetError(ctx context.Context, log logger.Logger, w http.ResponseWriter, err *errors.BusinessError, data any, message ...string) {
	finalMessage := ""
	if len(message) > 0 && len(message[0]) > 0 {
		finalMessage = message[0]
	}

	if finalMessage == "" && err != nil {
		finalMessage = err.Message
	}

	if data == nil {
		data = struct{}{}
	}
	SetResponse(ctx, log, w, err.HTTPStatus, err.Code, finalMessage, data)
}
```

## 15.6 Implementasi Validasi di Handler

Validasi dilakukan di handler layer (bukan service) karena:

* Validasi format input tidak memerlukan logika bisnis
* Service tetap fokus pada aturan bisnis

```go
// internal/handler/user_handler.go
package handler

import (
	"encoding/json"
	"log/slog"
	"net/http"

	"workshop/internal/dto"
	"workshop/internal/model"
	"workshop/internal/service"
	"workshop/pkg/errors"
	"workshop/pkg/response"
	"workshop/pkg/validation"

	"github.com/go-playground/validator/v10"
	"github.com/jacky-htg/go-libs/logger"
)

type userHandler struct {
	log      logger.Logger
	service  service.Users
	validate *validator.Validate
}

func NewUserHandler(log logger.Logger, validate *validator.Validate, service service.Users) UserHandler {
	return &userHandler{log: log, validate: validate, service: service}
}

// Create : http handler for creating a new user
func (u *userHandler) Create(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	var req dto.UserRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		u.log.Error(ctx, "error: decoding user request", slog.Any("error", err))
		response.SetError(ctx, u.log, w, errors.InvalidInputWrap(err), nil)
		return
	}

	if err := u.validate.Struct(req); err != nil {
		u.log.Error(ctx, "error: decoding user request", slog.Any("error", err))
		response.SetError(ctx, u.log, w, errors.InvalidInputWrap(err), validation.FormatValidationErrors(err))
		return
	}

	user := model.User{}
	req.Transform(&user)
	err := u.service.Create(ctx, &user)
	if err != nil {
		response.SetError(ctx, u.log, w, err, nil)
		return
	}

	var resp dto.UserResponse
	resp.Transform(user)
	response.SetCreated(ctx, u.log, w, resp)
}

func (u *userHandler) Update(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	id := r.PathValue("id")
	if id == "" {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Missing id parameter"), nil)
		return
	}

	var req dto.UserUpdateRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		u.log.Error(ctx, "error: decoding user request", slog.Any("error", err))
		response.SetError(ctx, u.log, w, errors.InvalidInputWrap(err), nil)
		return
	}

    if err := u.validate.Struct(req); err != nil {
		u.log.Error(ctx, "error: decoding user request", slog.Any("error", err))
		response.SetError(ctx, u.log, w, errors.InvalidInputWrap(err), validation.FormatValidationErrors(err))
		return
	}

	user := model.User{ID: id}
	req.Transform(&user)
	err := u.service.Update(ctx, &user)
	if err != nil {
		response.SetError(ctx, u.log, w, err, nil)
		return
	}

	var resp dto.UserResponse
	resp.Transform(user)

	response.SetOk(ctx, u.log, w, resp)
}
```

## 15.7 Update Routing untuk Inject Validator

```go
package router

import (
	"database/sql"
	"net/http"
	"workshop/config"
	"workshop/internal/handler"
	"workshop/internal/repository"
	"workshop/internal/service"

	"github.com/go-playground/validator/v10"
	"github.com/jacky-htg/go-libs/logger"
)

func Api(
	cfg config.Config,
	db *sql.DB,
	log logger.Logger,
	validate *validator.Validate,
) http.Handler {
	mux := http.NewServeMux()

	userRepository := repository.NewUserRepository(db, log)
	userService := service.NewUsers(log, userRepository)
	userHandler := handler.NewUserHandler(log, validate, userService)
	mux.HandleFunc("GET /users", userHandler.List)
	mux.HandleFunc("POST /users", userHandler.Create)
	mux.HandleFunc("GET /users/{id}", userHandler.FindById)
	mux.HandleFunc("PUT /users/{id}", userHandler.Update)
	mux.HandleFunc("DELETE /users/{id}", userHandler.Delete)

	return mux
}
```

## 15.8 Update Server Main

```go
func run() error {
	app, err := bootstrap.NewApp()
	if err != nil {
		return fmt.Errorf("error: initializing app: %w", err)
	}
	defer app.Cleanup()

	server := &http.Server{
		Addr:         fmt.Sprintf("0.0.0.0:%d", app.Config.Server.AppPort),
		Handler:      router.Api(app.Config, app.Database, app.Log, app.Validate),
		ReadTimeout:  app.Config.Server.ReadTimeout,
		WriteTimeout: app.Config.Server.WriteTimeout,
	}
    // ... kode lainnya tetap sama
}
```

## 15.9 Contoh Response Validasi Error

Ketika client mengirim request dengan data tidak valid:

### Request (password terlalu pendek):

```bash
curl --location 'localhost:9000/users' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "jacky",
    "username": "jacky",
    "email": "jacky@example.com",
    "password": "1234",
    "is_active": false
}'
```

Response :

```json
{
    "status": "E001",
    "message": "Invalid input",
    "data": {
        "password": "Password is too short"
    }
}
```

Contoh error multiple field :

```json
{
    "status": "E001",
    "message": "Invalid input",
    "data": {
        "name": "Name is too short",
        "username": "Username is too short",
        "password": "Password is too short",
        "email": "Email must be a valid email address"
    }
}
```

## 15.10 Ringkasan Validasi Berdasarkan Layer

| Layer      | Jenis Validasi      | Contoh                                         |
| ---------- | ------------------- | ---------------------------------------------- |
| Handler    | Input validation    | Required, min, max, email format               |
| Service    | Business validation | User already exists, stock cukup, status aktif |
| Repository | Data integrity      | Tidak ada validasi di sini                     |

## Ringkasan Bab 15

Di bab ini kita telah belajar:

| Konsep                    | Implementasi                                          |
| ------------------------- | ----------------------------------------------------- |
| Validation library        | go-playground/validator/v10                           |
| Validation tags           | validate:"required,min=3,email"                       |
| Single validator instance | Di bootstrap, di-inject ke handler                    |
| Error formatter           | FormatValidationErrors() untuk multiple errors        |
| Response enhancement      | SetError sekarang support data any untuk detail error |

Manfaat yang kita peroleh:

* ✅ Input terjamin valid sebelum diproses
* ✅ Client mendapat feedback spesifik field mana yang error
* ✅ Multiple error dalam satu response
* ✅ Separation of concerns: handler urus format, service urus logika

Yang akan datang:

* Saat ini belum ada autentikasi
* Bab selanjutnya: Middleware – membangun pipeline untuk logging, auth, recovery, dll


# Middleware

Dalam pengembangan web, ada kebutuhan lintas endpoint yang harus dipenuhi: logging, autentikasi, recovery dari panic, timeout, CORS, dan lain-lain. Menulis kode yang sama di setiap handler akan melanggar prinsip DRY (Don't Repeat Yourself).

Middleware adalah solusi elegant: fungsi yang membungkus handler dan dieksekusi sebelum handler utama dipanggil.

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/16-middleware](https://github.com/jacky-htg/workshop/tree/main/16-middleware)

## 16.1 Konsep Middleware

Secara matematis, middleware adalah fungsi dengan tipe:

```go
type Middleware func(http.Handler) http.Handler
```

Ilustrasi aliran middleware:

```
Request → Recovery → Timeout → Auth → Handler → Response
              ↓          ↓        ↓
         (panic)    (timeout)  (unauth)
              ↓          ↓        ↓
            Error ← Error ← Error ←
```

Setiap middleware bisa:

1. Meneruskan request ke handler berikutnya (next.ServeHTTP())
2. Menghentikan request dan langsung mengembalikan response (error)
3. Memodifikasi request/response (menambah header, logging)

## 16.2 Library Middleware

Kita akan menggunakan library sederhana dari go-libs/middleware:

```go
// github.com/jacky-htg/go-libs/middleware
package middleware

import "net/http"

type Middleware func(http.Handler) http.Handler

func Chain(handler http.Handler, middlewares ...Middleware) http.Handler {
	for i := len(middlewares) - 1; i >= 0; i-- {
		handler = middlewares[i](handler)
	}
	return handler
}

type Stack []Middleware

// Method untuk menambah middleware
func (s Stack) With(mw ...Middleware) Stack {
	return append(s, mw...)
}

// Method untuk apply ke handler
func (s Stack) Then(handler http.HandlerFunc) http.Handler {
	if len(s) == 0 {
		return handler
	}
	return Chain(handler, s...)
}
```

## 16.3 Middleware yang Akan Dibangun

| Middleware | Fungsi                                 | Akan diaplikasikan ke |
| ---------- | -------------------------------------- | --------------------- |
| Recovery   | Menangkap panic, mencegah server crash | Semua route           |
| Timeout    | Membatalkan request yang terlalu lama  | Semua route           |
| Auth       | Memvalidasi token (sederhana dulu)     | Private routes        |

## 16.4 Recovery Middleware

Panic di Go (misal: index out of range, nil pointer dereference) akan menghentikan program. Middleware ini menangkap panic dan mengembalikan error 500:

```go
// pkg/middleware/recovery_middleware.go
package middleware

import (
	"log/slog"
	"net/http"
	"runtime/debug"
	"workshop/pkg/errors"
	"workshop/pkg/response"

	"github.com/jacky-htg/go-libs/logger"
	lib "github.com/jacky-htg/go-libs/middleware"
)

func Recovery(log logger.Logger) lib.Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				if err := recover(); err != nil {
					ctx := r.Context()
					log.Error(ctx, "panic recovered",
						slog.Any("error", err),
						slog.String("stack", string(debug.Stack())))
					response.SetError(ctx, log, w, errors.InternalServerError(), nil)
				}
			}()
			next.ServeHTTP(w, r)
		})
	}
}
```

## 16.5 Timeout Middleware

Middleware ini membungkus request dengan context timeout. Jika handler tidak selesai sebelum timeout, client mendapat 504 Gateway Timeout:

```go
// pkg/middleware/timeout_middleware.go
package middleware

import (
	"context"
	"net/http"
	"time"
	"workshop/pkg/errors"
	"workshop/pkg/response"

	"github.com/jacky-htg/go-libs/logger"
	lib "github.com/jacky-htg/go-libs/middleware"
)

func Timeout(log logger.Logger, timeout time.Duration) lib.Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			originalCtx := r.Context()
			ctx, cancel := context.WithTimeout(originalCtx, timeout)
			defer cancel()

			done := make(chan struct{})
			go func() {
				next.ServeHTTP(w, r.WithContext(ctx))
				close(done)
			}()

			select {
			case <-done:
				return
			case <-ctx.Done():
				response.SetError(originalCtx, log, w, errors.GatewayTimeout(), nil)
			}
		})
	}
}
```

## 16.6 Auth Middleware (Sederhana)

Untuk sementara, auth middleware hanya memeriksa bahwa token tidak kosong dan panjangnya > 10 karakter. Nanti di bab Token akan diperbaiki dengan JWT:

```go
// pkg/middleware/auth_middleware.go
package middleware

import (
	"database/sql"
	"log/slog"
	"net/http"
	"strings"
	"workshop/pkg/errors"
	"workshop/pkg/response"

	"github.com/jacky-htg/go-libs/logger"
	lib "github.com/jacky-htg/go-libs/middleware"
)

func Auth(db *sql.DB, log logger.Logger) lib.Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx := r.Context()
			authHeader := r.Header.Get("Authorization")
			if authHeader == "" {
				err := errors.Unauthorized()
				log.Error(ctx, "Unauthorized", slog.Any("error", err))
				response.SetError(ctx, log, w, err, nil)
				return
			}

			token := strings.TrimPrefix(authHeader, "Bearer ")
			if token == authHeader {
				err := errors.Unauthorized("Invalid authorization header")
				log.Error(ctx, "Unauthorized", slog.Any("error", err))
				response.SetError(ctx, log, w, err, nil)
				return
			}

			if len(token) <= 10 {
				err := errors.Unauthorized("Invalid token")
				log.Error(ctx, "Unauthorized", slog.Any("error", err))
				response.SetError(ctx, log, w, err, nil)
				return
			}

			next.ServeHTTP(w, r)
		})
	}
}
```

## 16.7 Menambahkan Konfigurasi Gateway Timeout

Tambahkan GatewayTimeout ke config:

* Kita menggunakan env config baru untuk gateway timeout

```go
// config/config.go
type ServerConfig struct {
    AppPort                 int
    WriteTimeout            time.Duration
    ReadTimeout             time.Duration
    IdleTimeout             time.Duration
    GracefulShutdownTimeout time.Duration
    GatewayTimeout          time.Duration  // ← baru
}

func LoadConfig() (Config, error) {
    // ...
    server := ServerConfig{
        // ... yang sudah ada ...
        GatewayTimeout: env.EnvDuration("SERVER_GATEWAY_TIMEOUT", 5*time.Second),
    }
    // ...
}
```

Update .env:

```env
SERVER_GATEWAY_TIMEOUT=5s
```

## 16.8 Mengaplikasikan Middleware ke Routes

Sekarang kita bagi routes menjadi:

* Public routes – tidak perlu auth (health check)
* Private routes – perlu auth (CRUD users)

```go
package router

import (
	"database/sql"
	"net/http"
	"workshop/config"
	"workshop/internal/handler"
	"workshop/internal/repository"
	"workshop/internal/service"
	mid "workshop/pkg/middleware"
	"workshop/pkg/response"

	"github.com/go-playground/validator/v10"
	"github.com/jacky-htg/go-libs/logger"
	"github.com/jacky-htg/go-libs/middleware"
)

func Api(
	cfg config.Config,
	db *sql.DB,
	log logger.Logger,
	validate *validator.Validate,
) http.Handler {
	mux := http.NewServeMux()

	base := middleware.Stack{
		mid.Recovery(log),
		mid.Timeout(log, cfg.Server.GatewayTimeout),
	}
	private := base.With(mid.Auth(db, log))

	userRepository := repository.NewUserRepository(db, log)
	userService := service.NewUsers(log, userRepository)
	userHandler := handler.NewUserHandler(log, validate, userService)

	mux.Handle("GET /health", base.Then(func(w http.ResponseWriter, r *http.Request) {
		response.SetOk(r.Context(), log, w, struct{}{})
	}))

	mux.Handle("GET /users", private.Then(userHandler.List))
	mux.Handle("POST /users", private.Then(userHandler.Create))
	mux.Handle("GET /users/{id}", private.Then(userHandler.FindById))
	mux.Handle("PUT /users/{id}", private.Then(userHandler.Update))
	mux.Handle("DELETE /users/{id}", private.Then(userHandler.Delete))

	return mux
}
```

## 16.9 Perbedaan Handle vs HandleFunc

Perhatikan perbedaan ini:

```go
// Sebelum (tanpa middleware)
mux.HandleFunc("GET /users", userHandler.List)

// Sesudah (dengan middleware)
mux.Handle("GET /users", private.Then(userHandler.List))
```

* HandleFunc – langsung menerima `func(http.ResponseWriter, *http.Request)`
* Handle – menerima `http.Handler` interface, yang merupakan hasil dari `Then()`

## 16.10 Menguji Middleware

### Uji Health Check (Public)

```bash
curl localhost:9000/health
```

Response :

```json
{
    "status": "B1",
    "message": "Success",
    "data": {}
}
```

### Uji Private Route Tanpa Token

```bash
curl 'localhost:9000/users'
```

Response :

```json
{
    "status": "E004",
    "message": "Unauthorized",
    "data": {}
}
```

### Uji Private Route Dengan Token

```bash
curl --location 'localhost:9000/users' \
--header 'Authorization: Bearer my-valid-token'
```

Response :

```json
{
    "status": "B1",
    "message": "Success",
    "data": [
        {
            "id": "019e9fd4-4cd0-7c7c-a88d-c40138fca6c8",
            "name": "John Doe",
            "username": "jacky",
            "email": "jacky@example.com",
            "is_active": false
        },
        {
            "id": "bc5d7cce-fca7-4392-8871-0ea83f2a101e",
            "name": "Jane Smith",
            "username": "janesmith",
            "email": "jane.smith@example.com",
            "is_active": false
        }
    ]
}
```

## 16.11 Diagram Alur Middleware

```
Request Masuk
      │
      ▼
┌─────────────────────────────────────────────────────────────┐
│                    Recovery Middleware                      │
│  defer recover() → menangkap panic, return 500 jika panic   │
└─────────────────────────────────────────────────────────────┘
      │
      ▼
┌─────────────────────────────────────────────────────────────┐
│                    Timeout Middleware                       │
│  Context with timeout → jika timeout, return 504            │
└─────────────────────────────────────────────────────────────┘
      │
      ▼
┌─────────────────────────────────────────────────────────────┐
│                     Auth Middleware                         │
│  Cek header Authorization → jika invalid, return 401        │
└─────────────────────────────────────────────────────────────┘
      │
      ▼
┌─────────────────────────────────────────────────────────────┐
│                   Actual Handler                            │
│  userHandler.List / Create / FindById / Update / Delete     │
└─────────────────────────────────────────────────────────────┘
      │
      ▼
Response
```

## 16.12 Middleware yang Akan Datang

| Middleware      | Bab           | Kegunaan                                      |
| --------------- | ------------- | --------------------------------------------- |
| Logging/Tracing | OpenTelemetry | Request logging, distributed tracing          |
| CORS            | Security      | Mengizinkan cross-origin request              |
| Rate Limiter    | Performance   | Mencegah abuse                                |
| Metrics         | OpenTelemetry | Mengumpulkan metrics (request count, latency) |

## Ringkasan Bab 16

Di bab ini kita telah belajar:

| Konsep                | Implementasi                         |
| --------------------- | ------------------------------------ |
| Middleware definition | func(http.Handler) http.Handler      |
| Chaining              | middleware.Chain() atau Stack.Then() |
| Recovery              | Menangkap panic, return 500          |
| Timeout               | Context timeout, return 504          |
| Auth                  | Validasi token (sementara)           |
| Public vs Private     | Base stack vs Private stack          |

Manfaat yang kita peroleh:

* ✅ Kode lintas endpoint tidak berulang (DRY)
* ✅ Server tidak crash saat panic
* ✅ Request yang terlalu lama dibatalkan (504)
* ✅ Proteksi sederhana untuk private routes

Yang akan datang:

* Saat ini auth hanya pengecekan panjang token (insecure)
* Bab selanjutnya: Token (Autentikasi) – implementasi JWT yang aman


# Token

Setelah memiliki middleware auth sederhana (hanya cek panjang token), sekarang saatnya mengimplementasikan autentikasi yang sesungguhnya menggunakan JWT (JSON Web Token).

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/17-token](https://github.com/jacky-htg/workshop/tree/main/17-token)

## 17.1 Mengapa JWT?

| Karakteristik   | JWT                                       | Session-based                       |
| --------------- | ----------------------------------------- | ----------------------------------- |
| State           | Stateless (tidak perlu session di server) | Stateful (perlu menyimpan session)  |
| Scalability     | Sangat mudah (horizontal scaling)         | Perlu session store bersama (Redis) |
| Mobile friendly | ✅ Ya                                      | ❌ Perlu cookie handling             |
| Expiration      | Built-in (exp claim)                      | Perlu implementasi sendiri          |

## 17.2 Alur Autentikasi dengan JWT

```
1. Client mengirim email + password ke /login
                    │
                    ▼
2. Server validasi credential, buat JWT
                    │
                    ▼
3. Server mengembalikan JWT ke client
                    │
                    ▼
4. Client menyimpan token (localStorage / secure cookie)
                    │
                    ▼
5. Client mengirim token di header Authorization: Bearer <token>
                    │
                    ▼
6. Middleware Auth memvalidasi token
                    │
                    ▼
7. Jika valid, request diteruskan ke handler
```

## 17.3 Konfigurasi Token

Tambahkan konfigurasi token di `.env`:

```env
TOKEN_SALT=secret-salt-key-change-in-production
TOKEN_EXP=5
```

| Variable    | Deskripsi                                                      |
| ----------- | -------------------------------------------------------------- |
| TOKEN\_SALT | Kunci rahasia untuk signing JWT (harus aman, jangan di-commit) |
| TOKEN\_EXP  | Masa berlaku token dalam jam (24 jam = 1 hari)                 |

## 17.4 Library Token

Kita akan menggunakan library dari `go-libs/token` yang membungkus `golang-jwt/jwt`:

```go
// github.com/jacky-htg/go-libs/token/jwt.go
package token

import (
	"os"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

// CustomClaims dengan interface{} untuk fleksibilitas tipe data
type CustomClaims struct {
	Data map[string]interface{} `json:"data"`
	jwt.RegisteredClaims
}

var mySigningKey = []byte(os.Getenv("TOKEN_SALT"))

// ValidateToken untuk mengembalikan map[string]interface{}
func ValidateToken(myToken string) (bool, map[string]interface{}) {
	token, err := jwt.ParseWithClaims(myToken, &CustomClaims{}, func(token *jwt.Token) (interface{}, error) {
		return mySigningKey, nil
	})

	if err != nil {
		return false, nil
	}

	claims, ok := token.Claims.(*CustomClaims)
	if !ok {
		return false, nil
	}

	return token.Valid, claims.Data
}

// ClaimToken untuk membuat token dengan data berbagai tipe
func ClaimToken(data map[string]interface{}, expirationHours int) (string, error) {
	if expirationHours <= 0 {
		expirationHours = 5
	}

	claims := CustomClaims{
		Data: data,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * time.Duration(expirationHours))),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(mySigningKey)
}

// Helper function untuk mengkonversi tipe data
func GetString(claims map[string]interface{}, key string) string {
	if val, ok := claims[key]; ok {
		if str, ok := val.(string); ok {
			return str
		}
	}
	return ""
}

func GetInt(claims map[string]interface{}, key string) int {
	if val, ok := claims[key]; ok {
		switch v := val.(type) {
		case int:
			return v
		case float64:
			return int(v)
		case float32:
			return int(v)
		}
	}
	return 0
}

func GetBool(claims map[string]interface{}, key string) bool {
	if val, ok := claims[key]; ok {
		if b, ok := val.(bool); ok {
			return b
		}
	}
	return false
}

func GetFloat64(claims map[string]interface{}, key string) float64 {
	if val, ok := claims[key]; ok {
		if f, ok := val.(float64); ok {
			return f
		}
	}
	return 0.0
}
```

## 17.5 Update Konfigurasi

Tambahkan TokenConfig ke struct Config:

```go
// config/config.go
type Config struct {
    Server   ServerConfig
    Database DatabaseConfig
    Token    TokenConfig  // ← baru
}

type TokenConfig struct {
    TokenSalt string
    TokenExp  int
}

func LoadConfig() (Config, error) {
    // ...
    tokenConfig := TokenConfig{
        TokenSalt: env.Env("TOKEN_SALT", ""),
        TokenExp:  env.EnvInt("TOKEN_EXP", 24),
    }
    // ...
}
```

## 17.6 Repository: FindByEmail

Kita perlu mencari user berdasarkan email untuk proses login:

```go
// internal/repository/user_repository.go
type UserRepository interface {
    // ... method existing
    FindByEmail(ctx context.Context, email string) (*model.User, error)  // ← baru
}

func (u *userRepository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
    query := `SELECT id, name, username, password, email, is_active 
              FROM users WHERE email = $1 AND deleted_at IS NULL`
    row := u.db.QueryRowContext(ctx, query, email)

    var user model.User
    if err := row.Scan(&user.ID, &user.Name, &user.Username, 
                       &user.Password, &user.Email, &user.IsActive); err != nil {
        if err == sql.ErrNoRows {
            return nil, nil
        }
        u.log.Error(ctx, "error: scanning user row", slog.Any("error", err))
        return nil, err
    }

    return &user, nil
}
```

## 17.7 Service: Auths

Service ini bertanggung jawab untuk logika autentikasi:

```go
// internal/service/auths.go
package service

import (
    "context"
    "workshop/config"
    "workshop/internal/repository"
    "workshop/pkg/errors"

    "github.com/jacky-htg/go-libs/logger"
    "github.com/jacky-htg/go-libs/token"
    "golang.org/x/crypto/bcrypt"
)

type Auths interface {
    Login(ctx context.Context, email, password string) (string, *errors.BusinessError)
}

type auths struct {
    log      logger.Logger
    repo     repository.UserRepository
    cfgToken config.TokenConfig
}

func NewAuths(log logger.Logger, cfgToken config.TokenConfig, repo repository.UserRepository) Auths {
    return &auths{
        log:      log,
        repo:     repo,
        cfgToken: cfgToken,
    }
}

func (a *auths) Login(ctx context.Context, email, password string) (string, *errors.BusinessError) {
    // 1. Cari user berdasarkan email
    user, err := a.repo.FindByEmail(ctx, email)
    if err != nil {
        return "", errors.InternalServerErrorWrap(err, "error finding user")
    }
    if user == nil {
        return "", errors.InvalidInput("Invalid email/password")
    }

    // 2. Verifikasi password dengan bcrypt
    if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
        a.log.Error(ctx, "Invalid password for user", slog.String("email", email))
        return "", errors.InvalidInput("Invalid email/password")
    }

    // 3. Cek apakah user aktif
    if !user.IsActive {
        return "", errors.Forbidden("User account is inactive")
    }

    // 4. Generate JWT token
    myToken, err := token.ClaimToken(map[string]any{
        "email": user.Email,
        "id":    user.ID,
    }, a.cfgToken.TokenExp)

    if err != nil {
        a.log.Error(ctx, "Failed to claim token", slog.Any("error", err))
        return "", errors.InternalServerErrorWrap(err, "failed to generate token")
    }

    return myToken, nil
}
```

## 17.8 DTO untuk Login

```go
// internal/dto/login_request.go
package dto

type LoginRequest struct {
    Username string `json:"username" validate:"required,email"` // username di sini adalah email
    Password string `json:"password" validate:"required"`
}
```

```go
// internal/dto/login_response.go
package dto

type LoginResponse struct {
    Token string `json:"token"`
}

func (l *LoginResponse) Transform(token string) {
    l.Token = token
}
```

## 17.9 Handler: AuthHandler

```go
// internal/handler/auth_handler.go
package handler

import (
    "encoding/json"
    "log/slog"
    "net/http"

    "workshop/internal/dto"
    "workshop/internal/service"
    "workshop/pkg/errors"
    "workshop/pkg/response"
    "workshop/pkg/validation"

    "github.com/go-playground/validator/v10"
    "github.com/jacky-htg/go-libs/logger"
)

type AuthHandler interface {
    Login(w http.ResponseWriter, r *http.Request)
}

type authHandler struct {
    log      logger.Logger
    service  service.Auths
    validate *validator.Validate
}

func NewAuthHandler(log logger.Logger, validate *validator.Validate, service service.Auths) AuthHandler {
    return &authHandler{
        log:      log,
        validate: validate,
        service:  service,
    }
}

func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    var req dto.LoginRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        h.log.Error(ctx, "error: decoding login request", slog.Any("error", err))
        response.SetError(ctx, h.log, w, errors.InvalidInputWrap(err), nil)
        return
    }

    if err := h.validate.Struct(req); err != nil {
        h.log.Error(ctx, "error: validating login request", slog.Any("error", err))
        response.SetError(ctx, h.log, w, errors.InvalidInputWrap(err), 
            validation.FormatValidationErrors(err))
        return
    }

    token, err := h.service.Login(ctx, req.Username, req.Password)
    if err != nil {
        response.SetError(ctx, h.log, w, err, nil)
        return
    }

    resp := dto.LoginResponse{Token: token}
    response.SetOk(ctx, h.log, w, resp)
}
```

## 17.10 Update Auth Middleware dengan Validasi JWT

Sekarang middleware auth yang sesungguhnya: memvalidasi JWT dan menyimpan claims ke context:

```go
// pkg/app/ctx.go (type custom untuk context key)
package app

type MyCtx string
```

```go
// pkg/middleware/auth_middleware.go
package middleware

import (
    "context"
    "database/sql"
    "log/slog"
    "net/http"
    "strings"
    "workshop/pkg/app"
    "workshop/pkg/errors"
    "workshop/pkg/response"

    "github.com/jacky-htg/go-libs/logger"
    lib "github.com/jacky-htg/go-libs/middleware"
    "github.com/jacky-htg/go-libs/token"
)

func Auth(db *sql.DB, log logger.Logger) lib.Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx := r.Context()
            authHeader := r.Header.Get("Authorization")

            // 1. Cek keberadaan header
            if authHeader == "" {
                response.SetError(ctx, log, w, errors.Unauthorized("Missing authorization header"), nil)
                return
            }

            // 2. Parse Bearer token
            mytoken := strings.TrimPrefix(authHeader, "Bearer ")
            if mytoken == authHeader {
                response.SetError(ctx, log, w, 
                    errors.Unauthorized("Invalid authorization header format, expected Bearer"), nil)
                return
            }

            // 3. Validasi JWT
            isValid, claim := token.ValidateToken(mytoken)
            if !isValid {
                response.SetError(ctx, log, w, errors.Unauthorized("Invalid or expired token"), nil)
                return
            }

            // 4. Simpan claims ke context untuk digunakan handler
            ctx = context.WithValue(ctx, app.MyCtx("email"), token.GetString(claim, "email"))
            ctx = context.WithValue(ctx, app.MyCtx("user_id"), token.GetString(claim, "id"))

            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}
```

## 17.11 Update Routing

Tambahkan endpoint /login dan gunakan cfg.Token untuk auth service:

```go
// internal/router/api.go
func Api(
    cfg config.Config,
    db *sql.DB,
    log logger.Logger,
    validate *validator.Validate,
) http.Handler {
    mux := http.NewServeMux()

    base := middleware.Stack{
        mid.Recovery(log),
        mid.Timeout(log, cfg.Server.GatewayTimeout),
    }
    private := base.With(mid.Auth(db, log))

    userRepository := repository.NewUserRepository(db, log)

    // Auth service membutuhkan token config
    authService := service.NewAuths(log, cfg.Token, userRepository)
    userService := service.NewUsers(log, userRepository)

    authHandler := handler.NewAuthHandler(log, validate, authService)
    userHandler := handler.NewUserHandler(log, validate, userService)

    // Public routes
    mux.Handle("GET /health", base.Then(func(w http.ResponseWriter, r *http.Request) {
        response.SetOk(r.Context(), log, w, struct{}{})
    }))
    mux.Handle("POST /login", base.Then(authHandler.Login))  // ← endpoint login

    // Private routes (memerlukan token)
    mux.Handle("GET /users", private.Then(userHandler.List))
    mux.Handle("POST /users", private.Then(userHandler.Create))
    mux.Handle("GET /users/{id}", private.Then(userHandler.FindById))
    mux.Handle("PUT /users/{id}", private.Then(userHandler.Update))
    mux.Handle("DELETE /users/{id}", private.Then(userHandler.Delete))

    return mux
}
```

## 17.12 Testing Autentikasi

### Login dengan credential salah

```bash
curl -X POST localhost:9000/login \
  -H "Content-Type: application/json" \
  -d '{"username":"jet@example.com","password":"wrongpassword"}'
```

Response:

```json
{
    "status": "E001",
    "message": "Invalid email/password",
    "data": {}
}
```

### Login dengan credential benar

```bash
curl -X POST localhost:9000/login \
  -H "Content-Type: application/json" \
  -d '{"username":"jet@example.com","password":"1234"}'
```

Response

```json
{
    "status": "B1",
    "message": "Success",
    "data": {
        "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7ImVtYWlsIjoiamV0QGV4YW1wbGUuY29tIiwiaWQiOiIwMTllOWRjNy0zYWI3LTc4OTktYmZhMC1jNTYzMjY5OGUxYzIifSwiZXhwIjoxNzQ5NjQ0MDAwLCJpYXQiOjE3NDk1NTc2MDB9..."
    }
}
```

### Akses private route dengan token valid

```bash
curl --location 'localhost:9000/users' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7ImVtYWlsIjoiamV0QGV4YW1wbGUuY29tIn0sImV4cCI6MTc4MTIwNjQ5NywiaWF0IjoxNzgxMTg4NDk3fQ.xr3vFDIUlnD8emvINA9zZdvvDmKC6eSeWDvXQG08uCs'
```

```json
{
    "status": "B1",
    "message": "Success",
    "data": [
        {
            "id": "019e9fd4-4cd0-7c7c-a88d-c40138fca6c8",
            "name": "John Doe",
            "username": "jacky",
            "email": "jacky@example.com",
            "is_active": false
        },
        {
            "id": "bc5d7cce-fca7-4392-8871-0ea83f2a101e",
            "name": "Jane Smith",
            "username": "janesmith",
            "email": "jane.smith@example.com",
            "is_active": false
        },
        {
            "id": "019e9dc7-3ab7-7899-bfa0-c5632698e1c2",
            "name": "jet",
            "username": "jet",
            "email": "jet@example.com",
            "is_active": true
        }
    ]
}
```

### Akses private route tanpa token

```bash
curl localhost:9000/users
```

Response

```json
{
    "status": "E004",
    "message": "Missing authorization header",
    "data": {}
}
```

### Akses private route dengan token expired/invalid

```bash
curl localhost:9000/users \
  -H "Authorization: Bearer invalid-token"
```

```json
{
    "status": "E004",
    "message": "Invalid token",
    "data": {}
}
```

## 17.13 Aliran Lengkap Autentikasi

```
┌──────────┐     POST /login      ┌──────────┐
│  Client  │ ───────────────────▶ │  Server  │
└──────────┘   email + password   └──────────┘
                                         │
                                         ▼
                              ┌─────────────────────┐
                              │ AuthService.Login()  │
                              │ 1. FindByEmail()     │
                              │ 2. bcrypt.Compare()  │
                              │ 3. Check IsActive    │
                              │ 4. Generate JWT      │
                              └─────────────────────┘
                                         │
                                         ▼
┌──────────┐      {token}        ┌──────────┐
│  Client  │ ◀─────────────────── │  Server  │
└──────────┘                      └──────────┘
     │
     │ (simpan token)
     │
     ▼
┌──────────┐   GET /users        ┌──────────┐
│  Client  │ ───────────────────▶ │  Server  │
│          │   Bearer <token>     │          │
└──────────┘                      └──────────┘
                                         │
                                         ▼
                              ┌─────────────────────┐
                              │  Auth Middleware    │
                              │ 1. Validate JWT     │
                              │ 2. Extract claims   │
                              │ 3. Store in context │
                              └─────────────────────┘
                                         │
                                         ▼
                              ┌─────────────────────┐
                              │   UserHandler.List  │
                              │   (dapat mengambil  │
                              │    user dari ctx)   │
                              └─────────────────────┘
```

## Ringkasan Bab 17

Di bab ini kita telah belajar:

| Komponen        | File                            | Fungsi                            |
| --------------- | ------------------------------- | --------------------------------- |
| JWT Library     | `go-libs/token`                 | Generate & validate token         |
| Token Config    | `config/config.go`              | Salt & expiration                 |
| Auth Service    | `service/auths.go`              | Login logic                       |
| Auth Handler    | `handler/auth_handler.go`       | HTTP endpoint /login              |
| Auth Middleware | `middleware/auth_middleware.go` | Validate token, inject to context |

Manfaat yang kita peroleh:

* ✅ Autentikasi stateless (tidak perlu session storage)
* ✅ Password tidak pernah dikirim dalam response
* ✅ Token memiliki masa berlaku (expiration)
* ✅ Claims dapat diakses di handler via context
* ✅ Middleware auth sekarang benar-benar memvalidasi JWT

Yang akan datang:

* Saat ini semua user dengan token valid bisa mengakses semua endpoint
* Bab selanjutnya: Role Based Access Controller (Otorisasi) – membatasi akses berdasarkan role user


# Role Based Access Controller

Setelah memiliki autentikasi (siapa pengguna), langkah selanjutnya adalah otorisasi (apa yang boleh dilakukan pengguna). RBAC adalah pendekatan standar untuk mengelola hak akses berdasarkan peran (role) yang dimiliki pengguna.

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/18-rbac](https://github.com/jacky-htg/workshop/tree/main/18-rbac)

## 18.1 Konsep RBAC

```
┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│    Users     │────▶│    Roles     │────▶│   Accesses   │
└──────────────┘     └──────────────┘     └──────────────┘
   (siapa)              (peran)              (apa yang
                                             boleh dilakukan)
```

| Komponen | Deskripsi       | Contoh                       |
| -------- | --------------- | ---------------------------- |
| User     | Pengguna sistem | <admin@example.com>          |
| Role     | Kumpulan akses  | superadmin, manager, staff   |
| Access   | Ijin spesifik   | users::create, users::delete |

**Prinsip:** User memiliki Roles, Roles memiliki Accesses. Seorang user bisa memiliki multiple roles. Dam satu role bisa memiliki banyak akses.

## 18.2 Design

### Database

* Gambarkan ERD

```
┌─────────┐     ┌────────────┐     ┌─────────┐
│  users  │────▶│ roles_users│◀────│  roles  │
└─────────┘     └────────────┘     └─────────┘
                       │                  │
                       │                  │
                       │           ┌─────────────┐
                       │           │ access_roles│
                       │           └─────────────┘
                       │                  │
                       │                  │
                       │           ┌──────────┐
                       └──────────▶│  access  │
                                   └──────────┘
```

**Cardinality :**

* users : roles → Many-to-Many (via roles\_users)
* roles : access → Many-to-Many (via access\_roles)
* access : access → One-to-Many (self, untuk hierarki)

### REST API

* GET /access
* GET /roles
* GET /roles/{id}
* POST /roles
* PUT /roles/{id}
* DELETE /roles/{id}
* POST /roles/{id}/access/{access\_id}
* DELETE /roles/{id}/access/{access\_id}
* GET /users
* POST /users
* GET /users/{id}
* PUT /users/{id}
* DELETE /users/{id}
* POST /login

### CLI

Menambahkan routing cli `scan-access`, untuk menambahkan accesss setiap kali ada pembuatan routing baru.

### Handler

```go
type AccessHandler interface {
	List(w http.ResponseWriter, r *http.Request)
}

type RoleHandler interface {
	List(w http.ResponseWriter, r *http.Request)
	Create(w http.ResponseWriter, r *http.Request)
	FindByID(w http.ResponseWriter, r *http.Request)
	Update(w http.ResponseWriter, r *http.Request)
	Delete(w http.ResponseWriter, r *http.Request)
	Grant(w http.ResponseWriter, r *http.Request)
	Revoke(w http.ResponseWriter, r *http.Request)
}

type UserHandler interface {
	List(w http.ResponseWriter, r *http.Request)
	Create(w http.ResponseWriter, r *http.Request)
	FindByID(w http.ResponseWriter, r *http.Request)
	Update(w http.ResponseWriter, r *http.Request)
	Delete(w http.ResponseWriter, r *http.Request)
}
```

### Service

```go
type Accesses interface {
	List(ctx context.Context) (map[int]*model.AccessTree, *errors.BusinessError)
	ScanAccess(ctx context.Context) error
}

type Auths interface {
	Login(ctx context.Context, email, password string) (string, *model.User, []string, *errors.BusinessError)
}

type Roles interface {
	List(ctx context.Context) ([]model.Role, *errors.BusinessError)
	FindByID(ctx context.Context, id int) (*model.Role, *errors.BusinessError)
	Create(ctx context.Context, role *model.Role) *errors.BusinessError
	Update(ctx context.Context, role *model.Role) *errors.BusinessError
	Delete(ctx context.Context, id int) *errors.BusinessError
	Grant(ctx context.Context, roleID, accessID int) *errors.BusinessError
	Revoke(ctx context.Context, roleID, accessID int) *errors.BusinessError
}

type Users interface {
	List(ctx context.Context) ([]model.User, *errors.BusinessError)
	Create(ctx context.Context, user *model.User) *errors.BusinessError
	FindByID(ctx context.Context, id string) (*model.User, *errors.BusinessError)
	Update(ctx context.Context, user *model.User) *errors.BusinessError
	Delete(ctx context.Context, id string) *errors.BusinessError
}
```

### Repository

```go
type AccessRepository interface {
	List(ctx context.Context) ([]model.Access, error)
	Create(ctx context.Context, tx *sql.Tx, access *model.Access) error
}

type RoleRepository interface {
	// Basic CRUD
	Create(ctx context.Context, role *model.Role) error
	FindByID(ctx context.Context, id int) (*model.Role, error)
	List(ctx context.Context) ([]model.Role, error)
	Update(ctx context.Context, role *model.Role) error
	Delete(ctx context.Context, id int) error

	// Many-to-many dengan Access
	GrantAccess(ctx context.Context, roleID, accessID int) error
	RevokeAccess(ctx context.Context, roleID, accessID int) error
	GetAccessesByRoles(ctx context.Context, roleIDs []int) ([]model.Access, error)

	// Helper
	HasAccess(ctx context.Context, roleID, accessID int) (bool, error)
}

type UserRepository interface {
	List(ctx context.Context) ([]model.User, error)
	Create(ctx context.Context, tx *sql.Tx, user *model.User) error
	FindByID(ctx context.Context, id string) (*model.User, error)
	FindByEmail(ctx context.Context, email string) (*model.User, error)
	Update(ctx context.Context, tx *sql.Tx, user *model.User) error
	Delete(ctx context.Context, id string) error

	// Manage roles untuk user
	AssignRole(ctx context.Context, tx *sql.Tx, userID string, roleID int64) error
	RemoveRole(ctx context.Context, tx *sql.Tx, userID string, roleID int64) error

	// Check permission
	HasPermission(ctx context.Context, email, routePath, routeGroup string) bool
}
```

### Middleware

Middleware `Auth` diberi kemampuan untuk melakukan otorisasi berdasarkan role-role yang dimiliki oleh user.

## 18.3 Implementasi Database

### ERD

![](https://1500694864-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-MaN3VdVswXpcCva69TG%2Fuploads%2Fgit-blob-42d990ad95f388275ade483bc9c95d7066f780d0%2Ferd.svg?alt=media)

### Migration Tabel

Buat file `migration/1_0002_rbac.sql`

```sql
-- =====================================================
-- TABEL access
-- =====================================================
CREATE TABLE IF NOT EXISTS access (
    id          SERIAL PRIMARY KEY,
    parent_id   INTEGER,
    name        VARCHAR(255) NOT NULL UNIQUE,
    alias       VARCHAR(255) NOT NULL UNIQUE,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now())
);

-- Index untuk parent_id (hierarchical queries)
CREATE INDEX idx_access_parent_id ON access(parent_id);

-- =====================================================
-- TABEL roles
-- =====================================================
CREATE TABLE IF NOT EXISTS roles (
    id          SERIAL PRIMARY KEY,
    name        VARCHAR(255) NOT NULL UNIQUE,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now())
);

-- =====================================================
-- TABEL access_roles (many-to-many)
-- =====================================================
CREATE TABLE IF NOT EXISTS access_roles (
    id          SERIAL PRIMARY KEY,
    access_id   INTEGER NOT NULL,
    role_id     INTEGER NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()),
    
    -- Constraint unique composite
    CONSTRAINT access_roles_unique UNIQUE (access_id, role_id),
    
    -- Foreign keys
    CONSTRAINT fk_access_roles_to_access 
        FOREIGN KEY (access_id) 
        REFERENCES access(id) 
        ON DELETE CASCADE 
        ON UPDATE CASCADE,
    
    CONSTRAINT fk_access_roles_to_roles 
        FOREIGN KEY (role_id) 
        REFERENCES roles(id) 
        ON DELETE CASCADE 
        ON UPDATE CASCADE
);

-- Index untuk performance
CREATE INDEX idx_access_roles_access_id ON access_roles(access_id);
CREATE INDEX idx_access_roles_role_id ON access_roles(role_id);

-- =====================================================
-- TABEL roles_users (many-to-many)
-- =====================================================
CREATE TABLE IF NOT EXISTS roles_users (
    id          BIGSERIAL PRIMARY KEY,
    role_id     INTEGER NOT NULL,
    user_id     UUID NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT timezone('utc', now()),
    
    -- Constraint unique composite
    CONSTRAINT roles_users_unique UNIQUE (role_id, user_id),
    
    -- Foreign keys
    CONSTRAINT fk_roles_users_to_roles 
        FOREIGN KEY (role_id) 
        REFERENCES roles(id) 
        ON DELETE CASCADE 
        ON UPDATE CASCADE,
    
    CONSTRAINT fk_roles_users_to_users 
        FOREIGN KEY (user_id) 
        REFERENCES users(id) 
        ON DELETE CASCADE 
        ON UPDATE CASCADE
);

-- Index untuk performance
CREATE INDEX idx_roles_users_role_id ON roles_users(role_id);
CREATE INDEX idx_roles_users_user_id ON roles_users(user_id);

-- =====================================================
-- INDEX TAMBAHAN UNTUK PERFORMANCE (Opsional)
-- =====================================================

-- Index composite untuk query permission checking yang umum
CREATE INDEX idx_access_roles_composite_lookup 
    ON access_roles(role_id, access_id);

-- Index untuk roles_users jika sering join dengan users
CREATE INDEX idx_roles_users_user_role 
    ON roles_users(user_id, role_id);
```

### Seed Data Awal

Buat file migration/3\_0002\_rbac.sql

```sql
INSERT INTO users (id, name, username, password, email, is_active) VALUES
('019eb960-a27d-73c8-9703-b23a9f50dc83', 'Admin', 'admin', '$2a$10$D7UJmo0/bnXUyvsvRNKmc.cLeiLPNGQ8TfBnQHc2hkQV.oSFBh.qO', 'admin@example.com', true);

INSERT INTO access (id, name, alias) VALUES (1, 'root', 'root');

INSERT INTO roles (id, name) VALUES (1, 'superadmin');

INSERT INTO access_roles (access_id, role_id) VALUES (1, 1);

INSERT INTO roles_users (role_id, user_id) VALUES (1, '019eb960-a27d-73c8-9703-b23a9f50dc83');

```

## 18.4 Implementasi Model

```go
// internal/model/access.go
package model

type Access struct {
	ID       int
	ParentID *int
	Name     string
	Alias    string
}

type AccessTree struct {
	ID        int
	Name      string
	Alias     string
	Childrens []Access
}
```

```go
// internal/model/role.go
package model

type Role struct {
	ID   int
	Name string

	Accesses []Access
}
```

```go
// internal/model/user.go
package model

type User struct {
	ID       string
	Name     string
	Username string
	Password string
	Email    string
	IsActive bool

	Roles []Role
}
```

## 18.5 Implementasi DTO (Reques/Response)

### Login Response (diperluas)

```go
// internal/dto/login_response.go
package dto

import "workshop/internal/model"

type LoginResponse struct {
	Token    string       `json:"token"`
	User     UserResponse `json:"user"`
	Accesses []string     `json:"permissions"`
}

func (u *LoginResponse) Transform(token string, user model.User, accesses []string) {
	u.Token = token
	u.Accesses = accesses

	userResp := UserResponse{}
	userResp.Transform(user)
	u.User = userResp
}
```

### Access Response (Tree Structure)

```go
// internal/dto/access_response.go
package dto

import "workshop/internal/model"

type AccessTreeResponse struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Alias string `json:"alias"`

	Childrens []AccessResponse `json:"childrens"`
}

func (u *AccessTreeResponse) Transform(access model.AccessTree) {
	u.ID = access.ID
	u.Name = access.Name
	u.Alias = access.Alias

	for _, val := range access.Childrens {
		var child AccessResponse
		child.Transform(val)
		u.Childrens = append(u.Childrens, child)
	}
}

type AccessResponse struct {
	ID       int    `json:"id"`
	ParentID *int   `json:"parent_id"`
	Name     string `json:"name"`
	Alias    string `json:"alias"`
}

func (u *AccessResponse) Transform(access model.Access) {
	u.ID = access.ID
	u.ParentID = access.ParentID
	u.Name = access.Name
	u.Alias = access.Alias
}
```

### Role Request & Response

```go
// internal/dto/role_request.go
package dto

import "workshop/internal/model"

type RoleRequest struct {
	Name string `json:"name" validate:"required,min=3,max=25"`
}

func (u *RoleRequest) Transform(role *model.Role) {
	role.Name = u.Name
}
```

```go
// internal/dto/role_response.go
package dto

import "workshop/internal/model"

type RoleResponse struct {
	ID       int              `json:"id"`
	Name     string           `json:"name,omitempty"`
	Accesses []AccessResponse `json:"accesses,omitempty"`
}

func (u *RoleResponse) Transform(role model.Role) {
	u.ID = role.ID
	u.Name = role.Name
	u.Accesses = make([]AccessResponse, 0)

	for _, a := range role.Accesses {
		var access AccessResponse
		access.Transform(a)
		u.Accesses = append(u.Accesses, access)
	}
}
```

### Users Request & Response (dengan Roles)

```go
// internal/dto/user_request.go
package dto

import (
	"workshop/internal/model"
)

type UserRequest struct {
	Name     string `json:"name" validate:"required,min=3,max=100"`
	Username string `json:"username" validate:"required,min=3,max=50"`
	Password string `json:"password" validate:"required,min=10"`
	Email    string `json:"email" validate:"required,email"`
	IsActive bool   `json:"is_active"`

	Roles []int `json:"roles"`
}

func (u *UserRequest) Transform(user *model.User) {
	user.Name = u.Name
	user.Username = u.Username
	user.Password = u.Password
	user.Email = u.Email
	user.IsActive = u.IsActive

	user.Roles = make([]model.Role, 0)

	for _, v := range u.Roles {
		user.Roles = append(user.Roles, model.Role{ID: v})
	}
}

type UserUpdateRequest struct {
	Name     string `json:"name" validate:"required,min=3,max=100"`
	IsActive bool   `json:"is_active"`

	Roles []int `json:"roles"`
}

func (u *UserUpdateRequest) Transform(user *model.User) {
	user.Name = u.Name
	user.IsActive = u.IsActive

	user.Roles = make([]model.Role, 0)

	for _, v := range u.Roles {
		user.Roles = append(user.Roles, model.Role{ID: v})
	}
}
```

```go
// internal/dto/role_response.go
package dto

import "workshop/internal/model"

type UserResponse struct {
	ID       string `json:"id"`
	Name     string `json:"name"`
	Username string `json:"username"`
	Email    string `json:"email"`
	IsActive bool   `json:"is_active"`

	Roles []RoleResponse `json:"roles,omitempty"`
}

func (u *UserResponse) Transform(user model.User) {
	u.ID = user.ID
	u.Name = user.Name
	u.Username = user.Username
	u.Email = user.Email
	u.IsActive = user.IsActive

	u.Roles = make([]RoleResponse, 0)
	for _, r := range user.Roles {
		var role RoleResponse
		role.Transform(r)
		u.Roles = append(u.Roles, role)
	}
}
```

## 18.6 Implementasi Repository

### Access Repository

```go
// internal/repository/access_repository.go
package repository

import (
	"context"
	"database/sql"
	"fmt"
	"log/slog"
	"workshop/internal/model"

	"github.com/jacky-htg/go-libs/logger"
)

type AccessRepository interface {
	List(ctx context.Context) ([]model.Access, error)
	Create(ctx context.Context, tx *sql.Tx, access *model.Access) error
}

type accessRepository struct {
	db  *sql.DB
	log logger.Logger
}

func NewAccessRepository(db *sql.DB, log logger.Logger) AccessRepository {
	return &accessRepository{db: db, log: log}
}

func (u *accessRepository) List(ctx context.Context) ([]model.Access, error) {
	query := `SELECT id, parent_id, name, alias FROM access WHERE alias != 'root' ORDER BY parent_id, name`
	rows, err := u.db.QueryContext(ctx, query)
	if err != nil {
		u.log.Error(ctx, "error: querying access", slog.Any("error", err))
		return nil, err
	}
	defer rows.Close()

	var list []model.Access = make([]model.Access, 0)
	for rows.Next() {
		var obj model.Access
		if err := rows.Scan(&obj.ID, &obj.ParentID, &obj.Name, &obj.Alias); err != nil {
			u.log.Error(ctx, "error: scanning access row", slog.Any("error", err))
			return nil, err
		}
		list = append(list, obj)
	}

	if err := rows.Err(); err != nil {
		u.log.Error(ctx, "error: iterating access rows", slog.Any("error", err))
		return nil, err
	}

	return list, nil
}

func (u *accessRepository) Create(ctx context.Context, tx *sql.Tx, access *model.Access) error {
	query := `
		WITH inserted AS (
            INSERT INTO access (parent_id, name, alias) 
            VALUES ($1, $2, $3)
            ON CONFLICT (name) DO NOTHING
            RETURNING id
        )
        SELECT id FROM inserted
        UNION ALL
        SELECT id FROM access WHERE name = $2
        LIMIT 1`
	err := tx.QueryRowContext(ctx, query, access.ParentID, access.Name, access.Alias).Scan(&access.ID)
	if err != nil {
		fmt.Println(query, query, *access.ParentID, access.Name, access.Alias)
		u.log.Error(ctx, "error: inserting access", slog.Any("error", err))
		return err
	}

	return nil
}
```

Perhatikan untuk query create, tampak kompleks karena desain operasi insert hanya melalui scan file routing, sehingga jika ada duplikat data akan diabaikan, serta selalu mengembalikan id untuk keperluan mendapatkan id jika access tersebut adalah access parent.

### Role Repository

```go
// internal/repository/role_repository.go
package repository

import (
	"context"
	"database/sql"
	"encoding/json"
	"log/slog"
	"workshop/internal/model"

	"github.com/jacky-htg/go-libs/logger"
	"github.com/lib/pq"
)

type RoleRepository interface {
	// Basic CRUD
	Create(ctx context.Context, role *model.Role) error
	FindByID(ctx context.Context, id int) (*model.Role, error)
	List(ctx context.Context) ([]model.Role, error)
	Update(ctx context.Context, role *model.Role) error
	Delete(ctx context.Context, id int) error

	// Many-to-many dengan Access
	GrantAccess(ctx context.Context, roleID, accessID int) error
	RevokeAccess(ctx context.Context, roleID, accessID int) error
	GetAccessesByRoles(ctx context.Context, roleIDs []int) ([]model.Access, error)

	// Helper
	HasAccess(ctx context.Context, roleID, accessID int) (bool, error)
}

type roleRepository struct {
	db  *sql.DB
	log logger.Logger
}

func NewRoleRepository(db *sql.DB, log logger.Logger) RoleRepository {
	return &roleRepository{db: db, log: log}
}

func (u *roleRepository) Create(ctx context.Context, role *model.Role) error {
	query := `INSERT INTO roles (name) VALUES ($1) RETURNING id`
	err := u.db.QueryRowContext(ctx, query, role.Name).Scan(&role.ID)
	if err != nil {
		u.log.Error(ctx, "error: inserting role", slog.Any("error", err))
		return err
	}

	return nil
}

func (u *roleRepository) FindByID(ctx context.Context, id int) (*model.Role, error) {
	query := `
		SELECT r.id, r.name, 
			    COALESCE(
					json_agg(
						json_build_object(
							'id', a.id,
							'name', a.name,
							'alias', a.alias
						)
					) FILTER (WHERE a.id IS NOT NULL),
					'[]'::json
				)  AS accesses
		FROM roles r
		LEFT JOIN access_roles ar ON (r.id = ar.role_id)
		LEFT JOIN access a ON (ar.access_id = a.id) 
		WHERE r.id = $1 GROUP BY r.id, r.name`

	row := u.db.QueryRowContext(ctx, query, id)

	var obj model.Role
	var accessesJSON []byte
	if err := row.Scan(&obj.ID, &obj.Name, &accessesJSON); err != nil {
		if err == sql.ErrNoRows {
			return nil, nil
		}
		u.log.Error(ctx, "error: scanning role row", slog.Any("error", err))
		return nil, err
	}

	err := json.Unmarshal(accessesJSON, &obj.Accesses)
	if err != nil {
		u.log.Error(ctx, "error: unmarshall accesses", slog.Any("error", err))
		return nil, err
	}

	return &obj, nil
}

func (u *roleRepository) List(ctx context.Context) ([]model.Role, error) {
	query := `SELECT id, name FROM roles ORDER BY name`
	rows, err := u.db.QueryContext(ctx, query)
	if err != nil {
		u.log.Error(ctx, "error: querying roles", slog.Any("error", err))
		return nil, err
	}
	defer rows.Close()

	var list []model.Role = make([]model.Role, 0)
	for rows.Next() {
		var obj model.Role
		if err := rows.Scan(&obj.ID, &obj.Name); err != nil {
			u.log.Error(ctx, "error: scanning roles row", slog.Any("error", err))
			return nil, err
		}
		list = append(list, obj)
	}

	if err := rows.Err(); err != nil {
		u.log.Error(ctx, "error: iterating roles rows", slog.Any("error", err))
		return nil, err
	}

	return list, nil
}

func (u *roleRepository) Update(ctx context.Context, role *model.Role) error {
	query := `UPDATE roles SET name = $1 WHERE id = $2`
	_, err := u.db.ExecContext(ctx, query, role.Name, role.ID)
	if err != nil {
		u.log.Error(ctx, "error: updating role", slog.Any("error", err))
		return err
	}

	return nil
}

func (u *roleRepository) Delete(ctx context.Context, id int) error {
	query := `DELETE FROM roles WHERE id = $1`
	_, err := u.db.ExecContext(ctx, query, id)
	if err != nil {
		u.log.Error(ctx, "error: delete role", slog.Any("error", err))
		return err
	}

	return nil
}

func (u *roleRepository) GrantAccess(ctx context.Context, roleID, accessID int) error {
	query := `INSERT INTO access_roles (access_id, role_id) VALUES ($1, $2)`
	_, err := u.db.ExecContext(ctx, query, accessID, roleID)
	if err != nil {
		u.log.Error(ctx, "error: grant access", slog.Any("error", err))
		return err
	}

	return nil
}

func (u *roleRepository) RevokeAccess(ctx context.Context, roleID, accessID int) error {
	query := `DELETE FROM access_roles WHERE access_id = $1 AND role_id = $2`
	_, err := u.db.ExecContext(ctx, query, accessID, roleID)
	if err != nil {
		u.log.Error(ctx, "error: grant access", slog.Any("error", err))
		return err
	}

	return nil
}

func (u *roleRepository) GetAccessesByRoles(ctx context.Context, roleIDs []int) ([]model.Access, error) {
	var list []model.Access = make([]model.Access, 0)

	if len(roleIDs) == 0 {
		return list, nil
	}
	query := `
		SELECT DISTINCT a.id, a.parent_id, a.alias 
		FROM roles r
		JOIN access_roles ar ON (r.id = ar.role_id)
		JOIN access a ON (ar.access_id = a.id) 
		WHERE r.id = ANY($1)
		ORDER BY a.parent_id, a.alias`

	rows, err := u.db.QueryContext(ctx, query, pq.Array(roleIDs))
	if err != nil {
		u.log.Error(ctx, "error: querying get access by role", slog.Any("error", err))
		return nil, err
	}
	defer rows.Close()

	for rows.Next() {
		var obj model.Access
		if err := rows.Scan(&obj.ID, &obj.ParentID, &obj.Alias); err != nil {
			u.log.Error(ctx, "error: scanning access row", slog.Any("error", err))
			return nil, err
		}
		list = append(list, obj)
	}

	if err := rows.Err(); err != nil {
		u.log.Error(ctx, "error: iterating access rows", slog.Any("error", err))
		return nil, err
	}

	return list, nil
}

func (u *roleRepository) HasAccess(ctx context.Context, roleID, accessID int) (bool, error) {
	query := `SELECT true FROM access_roles WHERE role_id = $1 AND access_id = $2`
	row := u.db.QueryRowContext(ctx, query, roleID, accessID)

	var hasAccess bool
	if err := row.Scan(&hasAccess); err != nil {
		if err == sql.ErrNoRows {
			return false, nil
		}
		u.log.Error(ctx, "error: scanning role row", slog.Any("error", err))
		return false, err
	}

	return hasAccess, nil
}
```

Pada FindByID query termasuk mendapatkan relasi access

### User Repository (dengan Roles)

```go
// internal/repository/user_repository.go
package repository

import (
	"context"
	"database/sql"
	"encoding/json"
	"log/slog"
	"workshop/internal/model"

	"github.com/jacky-htg/go-libs/logger"
)

type UserRepository interface {
	List(ctx context.Context) ([]model.User, error)
	Create(ctx context.Context, tx *sql.Tx, user *model.User) error
	FindByID(ctx context.Context, id string) (*model.User, error)
	FindByEmail(ctx context.Context, email string) (*model.User, error)
	Update(ctx context.Context, tx *sql.Tx, user *model.User) error
	Delete(ctx context.Context, id string) error

	// Manage roles untuk user
	AssignRole(ctx context.Context, tx *sql.Tx, userID string, roleID int64) error
	RemoveRole(ctx context.Context, tx *sql.Tx, userID string, roleID int64) error

	// Check permission
	HasPermission(ctx context.Context, email, routePath, routeGroup string) bool
}

type userRepository struct {
	db  *sql.DB
	log logger.Logger
}

func NewUserRepository(db *sql.DB, log logger.Logger) UserRepository {
	return &userRepository{db: db, log: log}
}

// List : http handler for returning list of users
func (u *userRepository) List(ctx context.Context) ([]model.User, error) {
	query := `SELECT id, name, username, password, email, is_active FROM users WHERE deleted_at IS NULL`
	rows, err := u.db.QueryContext(ctx, query)
	if err != nil {
		u.log.Error(ctx, "error: querying users", slog.Any("error", err))
		return nil, err
	}
	defer rows.Close()

	var users []model.User = make([]model.User, 0)
	for rows.Next() {
		var user model.User
		if err := rows.Scan(&user.ID, &user.Name, &user.Username, &user.Password, &user.Email, &user.IsActive); err != nil {
			u.log.Error(ctx, "error: scanning user row", slog.Any("error", err))
			return nil, err
		}
		users = append(users, user)
	}

	if err := rows.Err(); err != nil {
		u.log.Error(ctx, "error: iterating user rows", slog.Any("error", err))
		return nil, err
	}

	return users, nil
}

func (u *userRepository) Create(ctx context.Context, tx *sql.Tx, user *model.User) error {
	query := `INSERT INTO users (id, name, username, password, email, is_active) VALUES ($1, $2, $3, $4, $5, $6)`
	_, err := tx.ExecContext(ctx, query, user.ID, user.Name, user.Username, user.Password, user.Email, user.IsActive)
	if err != nil {
		u.log.Error(ctx, "error: inserting user", slog.Any("error", err))
		return err
	}

	return nil
}

func (u *userRepository) FindByID(ctx context.Context, id string) (*model.User, error) {
	query := `
			SELECT u.id, u.name, u.username, u.password, u.email, u.is_active, 
					COALESCE(
						json_agg(
							json_build_object(
								'id', r.id,
								'name', r.name
							)
						) FILTER (WHERE r.id IS NOT NULL),
						'[]'::json
					)  AS roles
			FROM users u
			LEFT JOIN roles_users ru ON (u.id = ru.user_id)
			LEFT JOIN roles r ON (ru.role_id = r.id) 
			WHERE u.id = $1 AND u.deleted_at IS NULL 
			GROUP BY u.id, u.name, u.username, u.password, u.email, u.is_active`
	row := u.db.QueryRowContext(ctx, query, id)

	var user model.User
	var rolesJSON []byte
	if err := row.Scan(&user.ID, &user.Name, &user.Username, &user.Password, &user.Email, &user.IsActive, &rolesJSON); err != nil {
		if err == sql.ErrNoRows {
			return nil, nil
		}
		u.log.Error(ctx, "error: scanning user row", slog.Any("error", err))
		return nil, err
	}

	if err := json.Unmarshal(rolesJSON, &user.Roles); err != nil {
		u.log.Error(ctx, "error: unmarshall roles", slog.Any("error", err))
		return nil, err
	}

	return &user, nil
}

func (u *userRepository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
	query := `
			SELECT u.id, u.name, u.username, u.password, u.email, u.is_active, 
					COALESCE(
						json_agg(
							json_build_object(
								'id', r.id,
								'name', r.name
							)
						) FILTER (WHERE r.id IS NOT NULL),
						'[]'::json
					)  AS roles
			FROM users u
			LEFT JOIN roles_users ru ON (u.id = ru.user_id)
			LEFT JOIN roles r ON (ru.role_id = r.id) 
			WHERE u.email = $1 AND u.deleted_at IS NULL 
			GROUP BY u.id, u.name, u.username, u.password, u.email, u.is_active`
	row := u.db.QueryRowContext(ctx, query, email)

	var user model.User
	var rolesJSON []byte
	if err := row.Scan(&user.ID, &user.Name, &user.Username, &user.Password, &user.Email, &user.IsActive, &rolesJSON); err != nil {
		if err == sql.ErrNoRows {
			return nil, nil
		}
		u.log.Error(ctx, "error: scanning user row", slog.Any("error", err))
		return nil, err
	}

	if err := json.Unmarshal(rolesJSON, &user.Roles); err != nil {
		u.log.Error(ctx, "error: unmarshall roles", slog.Any("error", err))
		return nil, err
	}

	return &user, nil
}

func (u *userRepository) Update(ctx context.Context, tx *sql.Tx, user *model.User) error {
	query := `UPDATE users SET name = $1, is_active = $2 WHERE id = $3 RETURNING username, email`
	err := tx.QueryRowContext(ctx, query, user.Name, user.IsActive, user.ID).Scan(&user.Username, &user.Email)
	if err != nil {
		u.log.Error(ctx, "error: updating user", slog.Any("error", err))
		return err
	}

	return nil
}

func (u *userRepository) Delete(ctx context.Context, id string) error {
	query := `UPDATE users SET deleted_at = timezone('utc', now()) WHERE id = $1`
	_, err := u.db.ExecContext(ctx, query, id)
	if err != nil {
		u.log.Error(ctx, "error: deleting user", slog.Any("error", err))
		return err
	}

	return nil
}

func (u *userRepository) AssignRole(ctx context.Context, tx *sql.Tx, userID string, roleID int64) error {
	query := `INSERT INTO roles_users (role_id, user_id) VALUES ($1, $2)`
	_, err := tx.ExecContext(ctx, query, roleID, userID)
	if err != nil {
		u.log.Error(ctx, "error: assign role", slog.Any("error", err))
		return err
	}

	return nil
}

func (u *userRepository) RemoveRole(ctx context.Context, tx *sql.Tx, userID string, roleID int64) error {
	query := `DELETE FROM roles_users WHERE role_id = $1 AND user_id = $2`
	_, err := tx.ExecContext(ctx, query, roleID, userID)
	if err != nil {
		u.log.Error(ctx, "error: remove role", slog.Any("error", err))
		return err
	}

	return nil
}

func (u *userRepository) HasPermission(ctx context.Context, email, routePath, routeGroup string) bool {

	query := `
			SELECT true 
			FROM users u
			JOIN roles_users ru ON (u.id = ru.user_id)
			JOIN roles r ON (ru.role_id = r.id)
			JOIN access_roles ar ON (r.id = ar.role_id) 
			JOIN access a ON (ar.access_id = a.id)
			WHERE u.email = $1 AND (a.name = $2 OR a.name = $3 OR a.name = 'root') `

	var hasPermission bool = false
	err := u.db.QueryRowContext(ctx, query, email, routePath, routeGroup).Scan(&hasPermission)
	if err != nil {
		u.log.Error(ctx, "error: has permission", slog.Any("error", err))
		return false
	}
	return hasPermission
}
```

## 18.7 Implementasi Service

### Access Service

```go
// internal/service/accesses.go
package service

import (
	"context"
	"database/sql"
	"fmt"
	"go/ast"
	"go/parser"
	"go/token"
	"log/slog"
	"strings"
	"workshop/internal/model"
	"workshop/internal/repository"
	"workshop/pkg/app"
	"workshop/pkg/errors"

	"github.com/jacky-htg/go-libs/logger"
)

type Accesses interface {
	List(ctx context.Context) (map[int]*model.AccessTree, *errors.BusinessError)
	ScanAccess(ctx context.Context) error
}

type accesses struct {
	db   *sql.DB
	log  logger.Logger
	repo repository.AccessRepository
}

func NewAccesses(db *sql.DB, log logger.Logger, repo repository.AccessRepository) Accesses {
	return &accesses{db: db, log: log, repo: repo}
}

func (u *accesses) List(ctx context.Context) (map[int]*model.AccessTree, *errors.BusinessError) {
	list, err := u.repo.List(ctx)
	if err != nil {
		return nil, errors.InternalServerErrorWrap(err, "error listing access")
	}

	results := make(map[int]*model.AccessTree)
	for _, val := range list {
		if val.ParentID != nil && *val.ParentID == 1 {
			results[val.ID] = &model.AccessTree{
				ID:        val.ID,
				Name:      val.Name,
				Alias:     val.Alias,
				Childrens: []model.Access{},
			}
		} else if val.ParentID != nil {
			if parent, exists := results[*val.ParentID]; exists {
				parent.Childrens = append(parent.Childrens, val)
			}
		}
	}

	fmt.Printf("result : %v", results)
	return results, nil
}

func (u *accesses) ScanAccess(ctx context.Context) error {
	// Parse route definitions from router file
	routes, err := parseRouteDefinitions("internal/router/api.go")
	if err != nil {
		return fmt.Errorf("failed to parse route definitions: %w", err)
	}

	rootID := 1

	mapGroups := make(map[string]*model.Access)
	for _, route := range routes {
		if _, exists := mapGroups[route.Group]; !exists {
			mapGroups[route.Group] = &model.Access{
				ParentID: &rootID,
				Name:     route.Group,
				Alias:    route.Group,
			}
		}
	}

	tx, err := u.db.BeginTx(ctx, nil)
	if err != nil {
		u.log.Error(ctx, "error begin tx", slog.Any("error", err))
		return errors.InternalServerErrorWrap(err)
	}
	defer tx.Rollback()

	for _, access := range mapGroups {
		err := u.repo.Create(ctx, tx, access)
		if err != nil {
			return err
		}
	}

	list := make([]model.Access, 0)
	for _, route := range routes {
		groupAccess := mapGroups[route.Group]
		if groupAccess == nil {
			return fmt.Errorf("group %s not found for route %s", route.Group, route.Alias)
		}

		list = append(list, model.Access{
			ParentID: &groupAccess.ID,
			Name:     fmt.Sprintf("%s %s", route.Method, route.Path),
			Alias:    route.Alias,
		})
	}

	for _, access := range list {
		err := u.repo.Create(ctx, tx, &access)
		if err != nil {
			return err
		}
	}

	if err = tx.Commit(); err != nil {
		return errors.InternalServerErrorWrap(err)
	}

	return nil
}

func parseRouteDefinitions(filePath string) ([]app.RouteDefinition, error) {
	fset := token.NewFileSet()
	node, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments)
	if err != nil {
		return nil, fmt.Errorf("failed to parse file: %w", err)
	}

	var routes []app.RouteDefinition

	ast.Inspect(node, func(n ast.Node) bool {
		// Look for composite literal
		compLit, ok := n.(*ast.CompositeLit)
		if !ok {
			return true
		}

		// Check if it's a slice
		if compLit.Type == nil {
			return true
		}

		// Try to match array type: []app.RouteDefinition
		if arrayType, ok := compLit.Type.(*ast.ArrayType); ok {
			// Get the element type
			if selectorExpr, ok := arrayType.Elt.(*ast.SelectorExpr); ok {
				// Check if it's app.RouteDefinition
				if ident, ok := selectorExpr.X.(*ast.Ident); ok {
					if ident.Name == "app" && selectorExpr.Sel.Name == "RouteDefinition" {
						// Extract each route from the composite literal
						for _, elt := range compLit.Elts {
							if route, err := parseRouteFromCompositeLit(elt); err == nil {
								routes = append(routes, route)
							}
						}
					}
				}
			}

			// Alternative: check for direct ident (if type is just RouteDefinition)
			if ident, ok := arrayType.Elt.(*ast.Ident); ok {
				if ident.Name == "RouteDefinition" {
					for _, elt := range compLit.Elts {
						if route, err := parseRouteFromCompositeLit(elt); err == nil {
							routes = append(routes, route)
						}
					}
				}
			}
		}

		return true
	})

	if len(routes) == 0 {
		return nil, fmt.Errorf("no route definitions found in %s", filePath)
	}

	return routes, nil
}

func parseRouteFromCompositeLit(expr ast.Expr) (app.RouteDefinition, error) {
	compLit, ok := expr.(*ast.CompositeLit)
	if !ok {
		return app.RouteDefinition{}, fmt.Errorf("not a composite literal")
	}

	route := app.RouteDefinition{}

	for _, elt := range compLit.Elts {
		kv, ok := elt.(*ast.KeyValueExpr)
		if !ok {
			continue
		}

		key, ok := kv.Key.(*ast.Ident)
		if !ok {
			continue
		}

		switch key.Name {
		case "Method":
			if value, ok := kv.Value.(*ast.BasicLit); ok {
				route.Method = strings.Trim(value.Value, `"`)
			}
		case "Path":
			if value, ok := kv.Value.(*ast.BasicLit); ok {
				route.Path = strings.Trim(value.Value, `"`)
			}
		case "Group":
			if value, ok := kv.Value.(*ast.BasicLit); ok {
				route.Group = strings.Trim(value.Value, `"`)
			}
		case "Alias":
			if value, ok := kv.Value.(*ast.BasicLit); ok {
				route.Alias = strings.Trim(value.Value, `"`)
			}
		}
	}

	if route.Method == "" || route.Path == "" || route.Group == "" || route.Alias == "" {
		return app.RouteDefinition{}, fmt.Errorf("incomplete route definition: method=%s, path=%s, group=%s, alias=%s",
			route.Method, route.Path, route.Group, route.Alias)
	}

	return route, nil
}
```

**Catatan:** Fungsi ScanAccess akan dipanggil dari routing cli

### Auths Service (diperluas)

```go
// internal/service/auths.go
package service

import (
	"context"
	"workshop/config"
	"workshop/internal/model"
	"workshop/internal/repository"
	"workshop/pkg/errors"

	"github.com/jacky-htg/go-libs/logger"
	"github.com/jacky-htg/go-libs/token"
	"golang.org/x/crypto/bcrypt"
)

type Auths interface {
	Login(ctx context.Context, email, password string) (string, *model.User, []string, *errors.BusinessError)
}

type auths struct {
	log      logger.Logger
	repo     repository.UserRepository
	roleRepo repository.RoleRepository
	cfgToken config.TokenConfig
}

func NewAuths(log logger.Logger, cfgToken config.TokenConfig, repo repository.UserRepository, roleRepo repository.RoleRepository) Auths {
	return &auths{log: log, repo: repo, roleRepo: roleRepo}
}

func (u *auths) Login(ctx context.Context, email, password string) (string, *model.User, []string, *errors.BusinessError) {
	list := make([]string, 0)

	user, err := u.repo.FindByEmail(ctx, email)
	if err != nil {
		return "", nil, list, errors.InternalServerErrorWrap(err, "error finding user")
	}
	if user == nil {
		return "", nil, list, errors.InvalidInput("Invalid username/password")
	}

	var roleIDs []int = make([]int, 0)
	for _, val := range user.Roles {
		roleIDs = append(roleIDs, val.ID)
	}

	accesses, err := u.roleRepo.GetAccessesByRoles(ctx, roleIDs)
	if err != nil {
		return "", nil, list, errors.InternalServerErrorWrap(err, "error finding user")
	}

	for _, val := range accesses {
		list = append(list, val.Alias)
	}

	err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
	if err != nil {
		u.log.Error(ctx, "Invalid username/password")
		return "", nil, list, errors.InvalidInput("Invalid username/password")
	}

	if !user.IsActive {
		return "", nil, list, errors.Forbidden("user inavtive")
	}

	myToken, err := token.ClaimToken(map[string]any{
		"email": user.Email,
		"id":    user.ID,
	}, u.cfgToken.TokenExp)

	if err != nil {
		u.log.Error(ctx, "claim token")
		return "", nil, list, errors.InternalServerErrorWrap(err)
	}

	return myToken, user, list, nil
}
```

### Role Service

```go
// internal/service/roles.go
package service

import (
	"context"
	"workshop/internal/model"
	"workshop/internal/repository"
	"workshop/pkg/errors"

	"github.com/jacky-htg/go-libs/logger"
)

type Roles interface {
	List(ctx context.Context) ([]model.Role, *errors.BusinessError)
	FindByID(ctx context.Context, id int) (*model.Role, *errors.BusinessError)
	Create(ctx context.Context, role *model.Role) *errors.BusinessError
	Update(ctx context.Context, role *model.Role) *errors.BusinessError
	Delete(ctx context.Context, id int) *errors.BusinessError
	Grant(ctx context.Context, roleID, accessID int) *errors.BusinessError
	Revoke(ctx context.Context, roleID, accessID int) *errors.BusinessError
}

type roles struct {
	log  logger.Logger
	repo repository.RoleRepository
}

func NewRoles(log logger.Logger, repo repository.RoleRepository) Roles {
	return &roles{log: log, repo: repo}
}

func (u *roles) List(ctx context.Context) ([]model.Role, *errors.BusinessError) {
	list, err := u.repo.List(ctx)
	if err != nil {
		return nil, errors.InternalServerErrorWrap(err, "error listing roles")
	}
	return list, nil
}

func (u *roles) FindByID(ctx context.Context, id int) (*model.Role, *errors.BusinessError) {
	obj, err := u.repo.FindByID(ctx, id)
	if err != nil {
		return nil, errors.InternalServerErrorWrap(err, "error finding role")
	}
	if obj == nil {
		return nil, errors.NotFound("role not found")
	}
	return obj, nil
}

func (u *roles) Create(ctx context.Context, role *model.Role) *errors.BusinessError {
	if err := u.repo.Create(ctx, role); err != nil {
		return errors.InternalServerErrorWrap(err, "error creating role")
	}

	return nil
}

func (u *roles) Update(ctx context.Context, role *model.Role) *errors.BusinessError {
	existObj, err := u.repo.FindByID(ctx, role.ID)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error finding role")
	}
	if existObj == nil {
		return errors.NotFound("role not found")
	}
	err = u.repo.Update(ctx, role)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error updating role")
	}
	return nil
}

func (u *roles) Delete(ctx context.Context, id int) *errors.BusinessError {
	existObj, err := u.repo.FindByID(ctx, id)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error finding role")
	}
	if existObj == nil {
		return errors.NotFound("role not found")
	}
	err = u.repo.Delete(ctx, id)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error delete role")
	}
	return nil
}

func (u *roles) Grant(ctx context.Context, roleID, accessID int) *errors.BusinessError {
	hasAccess, err := u.repo.HasAccess(ctx, roleID, accessID)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error grant access")
	}

	if !hasAccess {
		err = u.repo.GrantAccess(ctx, roleID, accessID)
		if err != nil {
			return errors.InternalServerErrorWrap(err, "error grant access")
		}
	}
	return nil
}

func (u *roles) Revoke(ctx context.Context, roleID, accessID int) *errors.BusinessError {
	hasAccess, err := u.repo.HasAccess(ctx, roleID, accessID)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error grant access")
	}

	if hasAccess {
		err = u.repo.RevokeAccess(ctx, roleID, accessID)
		if err != nil {
			return errors.InternalServerErrorWrap(err, "error grant access")
		}
	}
	return nil
}
```

### User Service

```go
// internal/service/users.go
package service

import (
	"context"
	"database/sql"
	"log/slog"
	"workshop/internal/model"
	"workshop/internal/repository"
	"workshop/pkg/errors"

	"github.com/jacky-htg/go-libs/logger"
	"github.com/jacky-htg/go-libs/uuid7"
	"golang.org/x/crypto/bcrypt"
)

type Users interface {
	List(ctx context.Context) ([]model.User, *errors.BusinessError)
	Create(ctx context.Context, user *model.User) *errors.BusinessError
	FindByID(ctx context.Context, id string) (*model.User, *errors.BusinessError)
	Update(ctx context.Context, user *model.User) *errors.BusinessError
	Delete(ctx context.Context, id string) *errors.BusinessError
}

type users struct {
	db   *sql.DB
	log  logger.Logger
	repo repository.UserRepository
}

func NewUsers(db *sql.DB, log logger.Logger, repo repository.UserRepository) Users {
	return &users{db: db, log: log, repo: repo}
}

func (u *users) List(ctx context.Context) ([]model.User, *errors.BusinessError) {
	users, err := u.repo.List(ctx)
	if err != nil {
		return nil, errors.InternalServerErrorWrap(err, "error listing users")
	}
	return users, nil
}

func (u *users) Create(ctx context.Context, user *model.User) *errors.BusinessError {
	pass, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
	if err != nil {
		u.log.Error(ctx, "error generate password", slog.Any("error", err))
		return errors.InternalServerErrorWrap(err, "error generating password")
	}

	user.ID = uuid7.New()
	user.Password = string(pass)

	tx, err := u.db.BeginTx(ctx, nil)
	if err != nil {
		u.log.Error(ctx, "error begin tx", slog.Any("error", err))
		return errors.InternalServerErrorWrap(err)
	}
	defer tx.Rollback()

	if err := u.repo.Create(ctx, tx, user); err != nil {
		return errors.InternalServerErrorWrap(err, "error creating user")
	}

	for _, v := range user.Roles {
		if err := u.repo.AssignRole(ctx, tx, user.ID, int64(v.ID)); err != nil {
			return errors.InternalServerErrorWrap(err, "error assign role")
		}
	}

	if err = tx.Commit(); err != nil {
		return errors.InternalServerErrorWrap(err)
	}

	return nil
}

func (u *users) FindByID(ctx context.Context, id string) (*model.User, *errors.BusinessError) {
	user, err := u.repo.FindByID(ctx, id)
	if err != nil {
		return nil, errors.InternalServerErrorWrap(err, "error finding user")
	}
	if user == nil {
		return nil, errors.NotFound("user not found")
	}
	return user, nil
}

func (u *users) Update(ctx context.Context, user *model.User) *errors.BusinessError {
	existUser, err := u.repo.FindByID(ctx, user.ID)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error finding user")
	}
	if existUser == nil {
		return errors.NotFound("user not found")
	}

	tx, err := u.db.BeginTx(ctx, nil)
	if err != nil {
		u.log.Error(ctx, "error begin tx", slog.Any("error", err))
		return errors.InternalServerErrorWrap(err)
	}
	defer tx.Rollback()

	err = u.repo.Update(ctx, tx, user)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error updating user")
	}

	mapExistingRoles := make(map[int]model.Role)
	mapNewRoles := make(map[int]model.Role)

	for _, v := range existUser.Roles {
		mapExistingRoles[v.ID] = v
	}

	for _, w := range user.Roles {
		if _, ok := mapExistingRoles[w.ID]; ok {
			delete(mapExistingRoles, w.ID)
		} else {
			mapNewRoles[w.ID] = w
		}
	}

	for _, val := range mapNewRoles {
		if err := u.repo.AssignRole(ctx, tx, user.ID, int64(val.ID)); err != nil {
			return errors.InternalServerErrorWrap(err, "error update assign role")
		}
	}

	for _, val := range mapExistingRoles {
		if err := u.repo.RemoveRole(ctx, tx, user.ID, int64(val.ID)); err != nil {
			return errors.InternalServerErrorWrap(err, "error update assign role")
		}
	}

	if err = tx.Commit(); err != nil {
		return errors.InternalServerErrorWrap(err)
	}

	return nil
}

func (u *users) Delete(ctx context.Context, id string) *errors.BusinessError {
	existUser, err := u.repo.FindByID(ctx, id)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error finding user")
	}
	if existUser == nil {
		return errors.NotFound("user not found")
	}
	err = u.repo.Delete(ctx, id)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error deleting user")
	}
	return nil
}
```

## 18.8 Implementasi Handler

### Accesss Handler

```go
// internal/handler/access_handler.go
package handler

import (
	"log/slog"
	"net/http"

	"workshop/internal/dto"
	"workshop/internal/service"
	"workshop/pkg/response"

	"github.com/go-playground/validator/v10"
	"github.com/jacky-htg/go-libs/logger"
)

type AccessHandler interface {
	List(w http.ResponseWriter, r *http.Request)
}

type accessHandler struct {
	log      logger.Logger
	service  service.Accesses
	validate *validator.Validate
}

func NewAccessHandler(log logger.Logger, validate *validator.Validate, service service.Accesses) AccessHandler {
	return &accessHandler{log: log, validate: validate, service: service}
}

func (u *accessHandler) List(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	list, err := u.service.List(ctx)
	if err != nil {
		u.log.Error(ctx, "error: listing access", slog.Any("error", err))
		response.SetError(ctx, u.log, w, err, nil)
		return
	}

	var resp []dto.AccessTreeResponse = make([]dto.AccessTreeResponse, 0)
	for _, val := range list {
		var obj dto.AccessTreeResponse
		obj.Transform(*val)
		resp = append(resp, obj)
	}

	response.SetOk(ctx, u.log, w, resp)
}
```

### Auth Handler

```go
// internal/handler/auth_handler.go
package handler

import (
	"encoding/json"
	"log/slog"
	"net/http"

	"workshop/internal/dto"
	"workshop/internal/service"
	"workshop/pkg/errors"
	"workshop/pkg/response"
	"workshop/pkg/validation"

	"github.com/go-playground/validator/v10"
	"github.com/jacky-htg/go-libs/logger"
)

type AuthHandler interface {
	Login(w http.ResponseWriter, r *http.Request)
}

type authHandler struct {
	log      logger.Logger
	service  service.Auths
	validate *validator.Validate
}

func NewAuthHandler(log logger.Logger, validate *validator.Validate, service service.Auths) AuthHandler {
	return &authHandler{log: log, validate: validate, service: service}
}

func (u *authHandler) Login(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	var req dto.LoginRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		u.log.Error(ctx, "error: decoding login request", slog.Any("error", err))
		response.SetError(ctx, u.log, w, errors.InvalidInputWrap(err), nil)
		return
	}

	if err := u.validate.Struct(req); err != nil {
		u.log.Error(ctx, "error: decoding login request", slog.Any("error", err))
		response.SetError(ctx, u.log, w, errors.InvalidInputWrap(err), validation.FormatValidationErrors(err))
		return
	}

	token, user, accesses, err := u.service.Login(ctx, req.Username, req.Password)
	if err != nil {
		response.SetError(ctx, u.log, w, err, nil)
		return
	}

	resp := dto.LoginResponse{Token: token}
	resp.Transform(token, *user, accesses)
	response.SetOk(ctx, u.log, w, resp)
}
```

### Role Handler

```go
// internal/handler/role_handler.go
package handler

import (
	"encoding/json"
	"log/slog"
	"net/http"
	"strconv"

	"workshop/internal/dto"
	"workshop/internal/model"
	"workshop/internal/service"
	"workshop/pkg/errors"
	"workshop/pkg/response"
	"workshop/pkg/validation"

	"github.com/go-playground/validator/v10"
	"github.com/jacky-htg/go-libs/logger"
)

type RoleHandler interface {
	List(w http.ResponseWriter, r *http.Request)
	Create(w http.ResponseWriter, r *http.Request)
	FindByID(w http.ResponseWriter, r *http.Request)
	Update(w http.ResponseWriter, r *http.Request)
	Delete(w http.ResponseWriter, r *http.Request)
	Grant(w http.ResponseWriter, r *http.Request)
	Revoke(w http.ResponseWriter, r *http.Request)
}

type roleHandler struct {
	log      logger.Logger
	service  service.Roles
	validate *validator.Validate
}

func NewRoleHandler(log logger.Logger, validate *validator.Validate, service service.Roles) RoleHandler {
	return &roleHandler{log: log, validate: validate, service: service}
}

func (u *roleHandler) List(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	list, err := u.service.List(ctx)
	if err != nil {
		u.log.Error(ctx, "error: listing roles", slog.Any("error", err))
		response.SetError(ctx, u.log, w, err, nil)
		return
	}

	var resp []dto.RoleResponse = make([]dto.RoleResponse, 0)
	for _, val := range list {
		var obj dto.RoleResponse
		obj.Transform(val)
		resp = append(resp, obj)
	}

	response.SetOk(ctx, u.log, w, resp)
}

func (u *roleHandler) Create(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	var req dto.RoleRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		u.log.Error(ctx, "error: decoding role request", slog.Any("error", err))
		response.SetError(ctx, u.log, w, errors.InvalidInputWrap(err), nil)
		return
	}

	if err := u.validate.Struct(req); err != nil {
		u.log.Error(ctx, "error: decoding role request", slog.Any("error", err))
		response.SetError(ctx, u.log, w, errors.InvalidInputWrap(err), validation.FormatValidationErrors(err))
		return
	}

	obj := model.Role{}
	req.Transform(&obj)
	err := u.service.Create(ctx, &obj)
	if err != nil {
		response.SetError(ctx, u.log, w, err, nil)
		return
	}

	var resp dto.RoleResponse
	resp.Transform(obj)
	response.SetCreated(ctx, u.log, w, resp)
}

func (u *roleHandler) FindByID(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	id := r.PathValue("id")
	if id == "" {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Missing id parameter"), nil)
		return
	}

	roleID, err := strconv.Atoi(id)
	if err != nil {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Invalid id"), nil)
		return
	}

	role, bizErr := u.service.FindByID(ctx, roleID)
	if bizErr != nil {
		response.SetError(ctx, u.log, w, bizErr, nil)
		return
	}

	var resp dto.RoleResponse
	resp.Transform(*role)

	response.SetOk(ctx, u.log, w, resp)
}

func (u *roleHandler) Update(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	id := r.PathValue("id")
	if id == "" {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Missing id parameter"), nil)
		return
	}

	roleID, err := strconv.Atoi(id)
	if err != nil {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Invalid id"), nil)
		return
	}

	var req dto.RoleRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		u.log.Error(ctx, "error: decoding user request", slog.Any("error", err))
		response.SetError(ctx, u.log, w, errors.InvalidInputWrap(err), nil)
		return
	}

	if err := u.validate.Struct(req); err != nil {
		u.log.Error(ctx, "error: decoding user request", slog.Any("error", err))
		response.SetError(ctx, u.log, w, errors.InvalidInputWrap(err), validation.FormatValidationErrors(err))
		return
	}

	obj := model.Role{ID: roleID}
	req.Transform(&obj)
	if err := u.service.Update(ctx, &obj); err != nil {
		response.SetError(ctx, u.log, w, err, nil)
		return
	}

	var resp dto.RoleResponse
	resp.Transform(obj)

	response.SetOk(ctx, u.log, w, resp)
}

func (u *roleHandler) Delete(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	id := r.PathValue("id")
	if id == "" {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Missing id parameter"), nil)
		return
	}

	roleID, err := strconv.Atoi(id)
	if err != nil {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Invalid id"), nil)
		return
	}

	if err := u.service.Delete(ctx, roleID); err != nil {
		response.SetError(ctx, u.log, w, err, nil)
		return
	}
	response.SetOk(ctx, u.log, w, struct{}{})
}

func (u *roleHandler) Grant(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	id := r.PathValue("id")
	if id == "" {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Missing id parameter"), nil)
		return
	}

	idAccess := r.PathValue("access_id")
	if idAccess == "" {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Missing access_id parameter"), nil)
		return
	}

	roleID, err := strconv.Atoi(id)
	if err != nil {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Invalid id"), nil)
		return
	}

	accessID, err := strconv.Atoi(idAccess)
	if err != nil {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Invalid access_id"), nil)
		return
	}

	if err := u.service.Grant(ctx, roleID, accessID); err != nil {
		response.SetError(ctx, u.log, w, err, nil)
		return
	}
	response.SetOk(ctx, u.log, w, struct{}{})
}

func (u *roleHandler) Revoke(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	id := r.PathValue("id")
	if id == "" {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Missing id parameter"), nil)
		return
	}

	idAccess := r.PathValue("access_id")
	if idAccess == "" {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Missing access_id parameter"), nil)
		return
	}

	roleID, err := strconv.Atoi(id)
	if err != nil {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Invalid id"), nil)
		return
	}

	accessID, err := strconv.Atoi(idAccess)
	if err != nil {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Invalid access_id"), nil)
		return
	}

	if err := u.service.Revoke(ctx, roleID, accessID); err != nil {
		response.SetError(ctx, u.log, w, err, nil)
		return
	}
	response.SetOk(ctx, u.log, w, struct{}{})
}
```

### User Handler

```go
// internal/handler/user_handler.go
package handler

import (
	"encoding/json"
	"log/slog"
	"net/http"

	"workshop/internal/dto"
	"workshop/internal/model"
	"workshop/internal/service"
	"workshop/pkg/errors"
	"workshop/pkg/response"
	"workshop/pkg/validation"

	"github.com/go-playground/validator/v10"
	"github.com/jacky-htg/go-libs/logger"
)

type UserHandler interface {
	List(w http.ResponseWriter, r *http.Request)
	Create(w http.ResponseWriter, r *http.Request)
	FindByID(w http.ResponseWriter, r *http.Request)
	Update(w http.ResponseWriter, r *http.Request)
	Delete(w http.ResponseWriter, r *http.Request)
}

type userHandler struct {
	log      logger.Logger
	service  service.Users
	validate *validator.Validate
}

func NewUserHandler(log logger.Logger, validate *validator.Validate, service service.Users) UserHandler {
	return &userHandler{log: log, validate: validate, service: service}
}

// List : http handler for returning list of users
func (u *userHandler) List(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	users, err := u.service.List(ctx)
	if err != nil {
		u.log.Error(ctx, "error: listing users", slog.Any("error", err))
		response.SetError(ctx, u.log, w, err, nil)
		return
	}

	var resp []dto.UserResponse = make([]dto.UserResponse, 0)
	for _, user := range users {
		var ur dto.UserResponse
		ur.Transform(user)
		resp = append(resp, ur)
	}

	response.SetOk(ctx, u.log, w, resp)
}

// Create : http handler for creating a new user
func (u *userHandler) Create(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	var req dto.UserRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		u.log.Error(ctx, "error: decoding user request", slog.Any("error", err))
		response.SetError(ctx, u.log, w, errors.InvalidInputWrap(err), nil)
		return
	}

	if err := u.validate.Struct(req); err != nil {
		u.log.Error(ctx, "error: decoding user request", slog.Any("error", err))
		response.SetError(ctx, u.log, w, errors.InvalidInputWrap(err), validation.FormatValidationErrors(err))
		return
	}

	user := model.User{}
	req.Transform(&user)
	err := u.service.Create(ctx, &user)
	if err != nil {
		response.SetError(ctx, u.log, w, err, nil)
		return
	}

	var resp dto.UserResponse
	resp.Transform(user)
	response.SetCreated(ctx, u.log, w, resp)
}

// FindById : http handler for finding a user by ID
func (u *userHandler) FindByID(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	id := r.PathValue("id")
	if id == "" {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Missing id parameter"), nil)
		return
	}

	user, err := u.service.FindByID(ctx, id)
	if err != nil {
		response.SetError(ctx, u.log, w, err, nil)
		return
	}

	var resp dto.UserResponse
	resp.Transform(*user)

	response.SetOk(ctx, u.log, w, resp)
}

func (u *userHandler) Update(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	id := r.PathValue("id")
	if id == "" {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Missing id parameter"), nil)
		return
	}

	var req dto.UserUpdateRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		u.log.Error(ctx, "error: decoding user request", slog.Any("error", err))
		response.SetError(ctx, u.log, w, errors.InvalidInputWrap(err), nil)
		return
	}

	if err := u.validate.Struct(req); err != nil {
		u.log.Error(ctx, "error: decoding user request", slog.Any("error", err))
		response.SetError(ctx, u.log, w, errors.InvalidInputWrap(err), validation.FormatValidationErrors(err))
		return
	}

	user := model.User{ID: id}
	req.Transform(&user)
	err := u.service.Update(ctx, &user)
	if err != nil {
		response.SetError(ctx, u.log, w, err, nil)
		return
	}

	var resp dto.UserResponse
	resp.Transform(user)

	response.SetOk(ctx, u.log, w, resp)
}

func (u *userHandler) Delete(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	id := r.PathValue("id")
	if id == "" {
		response.SetError(ctx, u.log, w, errors.InvalidInput("Missing id parameter"), nil)
		return
	}

	err := u.service.Delete(ctx, id)
	if err != nil {
		response.SetError(ctx, u.log, w, err, nil)
		return
	}
	response.SetOk(ctx, u.log, w, struct{}{})
}
```

## 18.9 Route Definition untuk Scan Access

Karena kita perlu mendaftarkan semua route ke database untuk keperluan otorisasi, buat struct untuk mendefinisikan route:

```go
// pkg/app/route_definition.go
package app

import "net/http"

type RouteDefinition struct {
	Method      string
	Path        string
	Group       string
	Alias       string
	HandlerFunc http.HandlerFunc
}
```

## 18.10 Router dengan Route Definitions

```go
// internal/router/api.go
package router

import (
	"context"
	"database/sql"
	"fmt"
	"net/http"
	"workshop/config"
	"workshop/internal/handler"
	"workshop/internal/repository"
	"workshop/internal/service"
	"workshop/pkg/app"
	mid "workshop/pkg/middleware"
	"workshop/pkg/response"

	"github.com/go-playground/validator/v10"
	"github.com/jacky-htg/go-libs/logger"
	"github.com/jacky-htg/go-libs/middleware"
)

func Api(
	cfg config.Config,
	db *sql.DB,
	log logger.Logger,
	validate *validator.Validate,
) http.Handler {
	mux := http.NewServeMux()

	base := middleware.Stack{
		mid.Recovery(log),
		mid.Timeout(log, cfg.Server.GatewayTimeout),
	}
	private := base.With(mid.Auth(db, log))

	accessRepository := repository.NewAccessRepository(db, log)
	roleRepository := repository.NewRoleRepository(db, log)
	userRepository := repository.NewUserRepository(db, log)

	accessService := service.NewAccesses(db, log, accessRepository)
	authService := service.NewAuths(log, cfg.Token, userRepository, roleRepository)
	roleService := service.NewRoles(log, roleRepository)
	userService := service.NewUsers(db, log, userRepository)

	accessHandler := handler.NewAccessHandler(log, validate, accessService)
	authHandler := handler.NewAuthHandler(log, validate, authService)
	roleHandler := handler.NewRoleHandler(log, validate, roleService)
	userHandler := handler.NewUserHandler(log, validate, userService)

	mux.Handle("GET /health", base.Then(func(w http.ResponseWriter, r *http.Request) {
		response.SetOk(r.Context(), log, w, struct{}{})
	}))

	mux.Handle("POST /login", base.Then(authHandler.Login))

	privateRoutes := []app.RouteDefinition{
		{Method: "GET", Path: "/accesses", Group: "accesses", Alias: "accesses::list", HandlerFunc: accessHandler.List},

		{Method: "GET", Path: "/roles", Group: "roles", Alias: "roles::list", HandlerFunc: roleHandler.List},
		{Method: "POST", Path: "/roles", Group: "roles", Alias: "roles::create", HandlerFunc: roleHandler.Create},
		{Method: "GET", Path: "/roles/{id}", Group: "roles", Alias: "roles::view", HandlerFunc: roleHandler.FindByID},
		{Method: "PUT", Path: "/roles/{id}", Group: "roles", Alias: "roles::update", HandlerFunc: roleHandler.Update},
		{Method: "DELETE", Path: "/roles/{id}", Group: "roles", Alias: "roles::delete", HandlerFunc: roleHandler.Delete},
		{Method: "POST", Path: "/roles/{id}/access/{access_id}", Group: "roles", Alias: "roles::grant", HandlerFunc: roleHandler.Grant},
		{Method: "DELETE", Path: "/roles/{id}/access/{access_id}", Group: "roles", Alias: "roles::revoke", HandlerFunc: roleHandler.Revoke},

		{Method: "GET", Path: "/users", Group: "users", Alias: "users::list", HandlerFunc: userHandler.List},
		{Method: "POST", Path: "/users", Group: "users", Alias: "users::create", HandlerFunc: userHandler.Create},
		{Method: "GET", Path: "/users/{id}", Group: "users", Alias: "users::view", HandlerFunc: userHandler.FindByID},
		{Method: "PUT", Path: "/users/{id}", Group: "users", Alias: "users::update", HandlerFunc: userHandler.Update},
		{Method: "DELETE", Path: "/users/{id}", Group: "users", Alias: "users::delete", HandlerFunc: userHandler.Delete},
	}

	for _, route := range privateRoutes {
		pattern := fmt.Sprintf("%s %s", route.Method, route.Path)
		wrappedHandler := wrapWithRoutePattern(pattern, route.Group, private.Then(route.HandlerFunc))
		mux.Handle(pattern, wrappedHandler)
	}

	return mux
}

func wrapWithRoutePattern(pattern, group string, handler http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := context.WithValue(r.Context(), app.MyCtx("route-path"), pattern)
		ctx = context.WithValue(ctx, app.MyCtx("route-group"), group)
		handler.ServeHTTP(w, r.WithContext(ctx))
	})
}
```

Perhatikan ada fungsi baru: wrapWithRoutePattern, sebelum masuk ke middleware, kita sisipkan informasi terkait pattern dan group ke dalam context value.

## 18.11 CLI untuk Scan Access

Tambahkan command scan-access untuk mendaftarkan semua route ke database:

```go
// internal/router/cli.go
package router

import (
	"context"
	"database/sql"
	"fmt"
	"workshop/internal/repository"
	"workshop/internal/service"

	"github.com/jacky-htg/go-libs/logger"
	"github.com/jacky-htg/go-libs/migration"
)

func Cli(
	db *sql.DB,
	log logger.Logger,
	command string,
	args []string) error {

	accessRepository := repository.NewAccessRepository(db, log)
	accessService := service.NewAccesses(db, log, accessRepository)

	switch command {
	case "migrate":
		err := migration.Migrate(db, "migration")
		if err != nil {
			log.Error(context.Background(), "Migration failed", "error", err)
			return err
		}
		log.Info(context.Background(), "Migration completed successfully")
	case "scan-access":
		err := accessService.ScanAccess(context.Background())
		if err != nil {
			log.Error(context.Background(), "Scan Access failed", "error", err)
			return err
		}
		log.Info(context.Background(), "Scan access completed successfully")
	default:
		return fmt.Errorf("Error: perintah tidak valid")
	}

	return nil
}
```

**Penggunaan:**

```bash
go run cmd/cli/main.go scan-access
```

## 18.12 Middleware Auth dengan Otorisasi

Middleware sekarang memeriksa apakah user memiliki permission untuk mengakses route:

```go
// pkg/middleware/auth_middleware.go
package middleware

import (
	"context"
	"database/sql"
	"log/slog"
	"net/http"
	"strings"
	"workshop/internal/repository"
	"workshop/pkg/app"
	"workshop/pkg/errors"
	"workshop/pkg/response"

	"github.com/jacky-htg/go-libs/logger"
	lib "github.com/jacky-htg/go-libs/middleware"
	"github.com/jacky-htg/go-libs/token"
)

func Auth(db *sql.DB, log logger.Logger) lib.Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ctx := r.Context()
			authHeader := r.Header.Get("Authorization")
			if authHeader == "" {
				err := errors.Unauthorized()
				log.Error(ctx, "Unauthorized", slog.Any("error", err))
				response.SetError(ctx, log, w, err, nil)
				return
			}

			mytoken := strings.TrimPrefix(authHeader, "Bearer ")
			if mytoken == authHeader {
				err := errors.Unauthorized("Invalid authorization header")
				log.Error(ctx, "Unauthorized", slog.Any("error", err))
				response.SetError(ctx, log, w, err, nil)
				return
			}

			isValid, claim := token.ValidateToken(mytoken)
			if !isValid {
				err := errors.Unauthorized("Invalid token")
				log.Error(ctx, "Unauthorized", slog.Any("error", err))
				response.SetError(ctx, log, w, err, nil)
				return
			}

			email := token.GetString(claim, "email")
			repo := repository.NewUserRepository(db, log)
			hasPermission := repo.HasPermission(
				ctx,
				email,
				ctx.Value(app.MyCtx("route-path")).(string),
				ctx.Value(app.MyCtx("route-group")).(string),
			)
			if !hasPermission {
				response.SetError(ctx, log, w, errors.Forbidden(), nil)
				return
			}

			ctx = context.WithValue(ctx, app.MyCtx("email"), email)
			ctx = context.WithValue(ctx, app.MyCtx("userID"), token.GetString(claim, "id"))

			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}
```

## 18.13 REST API Endpoints yang Tersedia

| Method | Endpoint                         | Deskripsi                          |
| ------ | -------------------------------- | ---------------------------------- |
| GET    | `/accesses`                      | List semua access (tree structure) |
| GET    | `/roles`                         | List semua role                    |
| POST   | `/roles`                         | Buat role baru                     |
| GET    | `/roles/{id}`                    | Detail role (dengan accesses)      |
| PUT    | `/roles/{id}`                    | Update role                        |
| DELETE | `/roles/{id}`                    | Hapus role                         |
| POST   | `/roles/{id}/access/{access_id}` | Grant access ke role               |
| DELETE | `/roles/{id}/access/{access_id}` | Revoke access dari role            |
| GET    | `/users`                         | List semua user                    |
| POST   | `/users`                         | Buat user baru (dengan roles)      |
| GET    | `/users/{id}`                    | Detail user (dengan roles)         |
| PUT    | `/users/{id}`                    | Update user (dengan roles)         |
| DELETE | `/users/{id}`                    | Hapus user                         |
| POST   | `/login`                         | Mendapatakn token dan permissions  |

## 18.14 Testing RBAC

### Login sebagai Admin

```bash
curl -X POST localhost:9000/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin@example.com","password":"admin123"}'
```

**Response**

```json
{
    "status": "B1",
    "message": "Success",
    "data": {
        "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7ImVtYWlsIjoiYWRtaW5AZXhhbXBsZS5jb20iLCJpZCI6IjAxOWViOTYwLWEyN2QtNzNjOC05NzAzLWIyM2E5ZjUwZGM4MyJ9LCJleHAiOjE3ODEzMzA3MjIsImlhdCI6MTc4MTMxMjcyMn0.kcA15jvQyDfOKHH6M3jPIKQV-uYRHRQ7Jhzunl5QtKg",
        "user": {
            "id": "019eb960-a27d-73c8-9703-b23a9f50dc83",
            "name": "Admin",
            "username": "admin",
            "email": "admin@example.com",
            "is_active": true,
            "roles": [
                {
                    "id": 1,
                    "name": "superadmin"
                }
            ]
        },
        "permissions": [
            "root"
        ]
    }
}
```

### List Roles (dengan token)

```bash
curl localhost:9000/accesses \
  -H "Authorization: Bearer <token>"
```

**Response**

```json
{
    "status": "B1",
    "message": "Success",
    "data": [
        {
            "id": 4,
            "name": "accesses",
            "alias": "accesses",
            "childrens": [
                {
                    "id": 6,
                    "parent_id": 4,
                    "name": "GET /accesses",
                    "alias": "accesses::list"
                }
            ]
        },
        {
            "id": 5,
            "name": "roles",
            "alias": "roles",
            "childrens": [
                {
                    "id": 11,
                    "parent_id": 5,
                    "name": "DELETE /roles/{id}",
                    "alias": "roles::delete"
                },
                {
                    "id": 13,
                    "parent_id": 5,
                    "name": "DELETE /roles/{id}/access/{access_id}",
                    "alias": "roles::revoke"
                },
                {
                    "id": 7,
                    "parent_id": 5,
                    "name": "GET /roles",
                    "alias": "roles::list"
                },
                {
                    "id": 9,
                    "parent_id": 5,
                    "name": "GET /roles/{id}",
                    "alias": "roles::view"
                },
                {
                    "id": 8,
                    "parent_id": 5,
                    "name": "POST /roles",
                    "alias": "roles::create"
                },
                {
                    "id": 12,
                    "parent_id": 5,
                    "name": "POST /roles/{id}/access/{access_id}",
                    "alias": "roles::grant"
                },
                {
                    "id": 10,
                    "parent_id": 5,
                    "name": "PUT /roles/{id}",
                    "alias": "roles::update"
                }
            ]
        },
        {
            "id": 16,
            "name": "users",
            "alias": "users",
            "childrens": [
                {
                    "id": 41,
                    "parent_id": 16,
                    "name": "DELETE /users/{id}",
                    "alias": "users::delete"
                },
                {
                    "id": 18,
                    "parent_id": 16,
                    "name": "GET /users",
                    "alias": "users::list"
                },
                {
                    "id": 39,
                    "parent_id": 16,
                    "name": "GET /users/{id}",
                    "alias": "users::view"
                },
                {
                    "id": 38,
                    "parent_id": 16,
                    "name": "POST /users",
                    "alias": "users::create"
                },
                {
                    "id": 40,
                    "parent_id": 16,
                    "name": "PUT /users/{id}",
                    "alias": "users::update"
                }
            ]
        }
    ]
}
```

### Akses tanpa permission (Forbidden)

```json
{
    "status": "E003",
    "message": "Forbidden",
    "data": {}
}
```

## Ringkasan Bab 18

Di bab ini kita telah belajar:

| Komponen   | File                                                            | Fungsi                                                               |
| ---------- | --------------------------------------------------------------- | -------------------------------------------------------------------- |
| Database   | migration/\*\_rbac.sql                                          | Tabel access, roles, relasi many-to-many                             |
| Model      | model/access.go, role.go, user.go                               | Struct dengan relasi                                                 |
| Repository | access\_repository.go, role\_repository.go, user\_repository.go | Query dengan JSON aggregation                                        |
| Service    | accesses.go, roles.go, auths.go                                 | Logika bisnis, permission checking dan handling database transaction |
| Handler    | access\_handler.go, role\_handler.go                            | HTTP handlers                                                        |
| Middleware | auth\_middleware.go                                             | JWT + Permission check                                               |
| CLI        | cli.go (scan-access)                                            | Auto-register routes ke database                                     |

Manfaat yang kita peroleh:

* ✅ Otorisasi berbasis peran (Role-Based Access Control)
* ✅ Akses hierarkis (root → group → endpoint)
* ✅ Auto-scan routing untuk registrasi access
* ✅ Permission checking di middleware
* ✅ Login response mencakup user info dan permissions

Yang akan datang:

* Saat ini list users belum mendukung pagination
* Bab selanjutnya: Pagination – membatasi jumlah data per response


# Pagination

Saat jumlah data dalam database membesar (ratusan, ribuan, atau jutaan), mengembalikan semua data dalam satu response adalah ide yang buruk karena:

* Response menjadi sangat besar (berat di bandwidth)
* Client lambat memproses data
* Database terbebani oleh query besar
* Pengalaman pengguna menurun

Pagination adalah solusi untuk memecah data menjadi halaman-halaman kecil.

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/19-pagination](https://github.com/jacky-htg/workshop/tree/main/19-pagination)

## 19.1 Desain Pagination

Query Parameters yang Didukung

| Parameter | Contoh     | Deskripsi                                  |
| --------- | ---------- | ------------------------------------------ |
| page      | page=2     | Halaman yang diminta (default: 1)          |
| limit     | limit=20   | Jumlah data per halaman (default: 10)      |
| order     | order=name | Field untuk sorting                        |
| sort      | sort=desc  | Arah sorting (asc atau desc, default: asc) |
| search    | search=joh | Pencarian teks (case-insensitive)          |

**Contoh Request**

```bash
GET /users?page=2&limit=5&order=name&sort=asc&search=admin
```

**Struktur Response dengan Meta**

```json
{
    "status": "B1",
    "message": "Success",
    "data": [...],
    "meta": {
        "order": "name",
        "sort": "asc",
        "search": "admin",
        "pagination": {
            "page": 2,
            "limit": 5,
            "total": 25,
            "total_pages": 5,
            "has_next": true,
            "has_prev": true
        }
    }
}
```

## 19.2 Update Standard Response untuk Meta

Tambahkan field `Meta` ke `StandardResponse`:

```go
// pkg/response/response.go
package response

import (
	"context"
	"encoding/json"
	"log/slog"
	"net/http"
	"workshop/pkg/errors"

	"github.com/jacky-htg/go-libs/logger"
)

const AppBusinessStatusSuccess = "B1"

type StandardResponse struct {
	Status  string `json:"status"`
	Message string `json:"message"`
	Data    any    `json:"data"`
	Meta    any    `json:"meta,omitempty"`
}

func SetResponse(ctx context.Context, log logger.Logger, w http.ResponseWriter, httpStatus int, appBusinessLogicStatus string, message string, data ...any) {
	var dataResp any
	dataResp = struct{}{}

	if len(data) > 0 {
		dataResp = data[0]
	}
	standardResponse := StandardResponse{
		Status:  appBusinessLogicStatus,
		Message: message,
		Data:    dataResp,
	}

	if len(data) > 1 {
		standardResponse.Meta = data[1]
	}

	resp, err := json.Marshal(standardResponse)
	if err != nil {
		log.Error(ctx, "error: marshaling users to JSON", slog.Any("error", err))
		httpStatus = http.StatusInternalServerError
		appBusinessLogicStatus = errors.InternalServerErrorCode
		message = "Internal Server Error"
	}

	w.Header().Set("Content-Type", "application/json")
	if httpStatus != http.StatusOK {
		w.WriteHeader(httpStatus)
	}

	if _, err = w.Write(resp); err != nil {
		log.Error(ctx, "error: writing response", slog.Any("error", err))
	}
}

func SetError(ctx context.Context, log logger.Logger, w http.ResponseWriter, err *errors.BusinessError, data any, message ...string) {
	finalMessage := ""
	if len(message) > 0 && len(message[0]) > 0 {
		finalMessage = message[0]
	}

	if finalMessage == "" && err != nil {
		finalMessage = err.Message
	}

	if data == nil {
		data = struct{}{}
	}
	SetResponse(ctx, log, w, err.HTTPStatus, err.Code, finalMessage, data)
}

func SetOk(ctx context.Context, log logger.Logger, w http.ResponseWriter, data ...any) {
	SetResponse(ctx, log, w, http.StatusOK, AppBusinessStatusSuccess, "Success", data...)
}

func SetCreated(ctx context.Context, log logger.Logger, w http.ResponseWriter, data ...any) {
	SetResponse(ctx, log, w, http.StatusCreated, AppBusinessStatusSuccess, "Created", data...)
}
```

## 19.3 Helper Pagination

Buat package pagination untuk mengekstrak parameter dari URL dan membuat meta response:

```go
// pkg/pagination/pagination.go
package pagination

import (
	"net/http"
	"strconv"
	"strings"
	"workshop/internal/dto"
	"workshop/internal/model"
)

func ExtractPaginationFromURL(r *http.Request, defaultOrder ...string) (page, limit int, order, sort, search string) {
	page, _ = strconv.Atoi(r.URL.Query().Get("page"))
	limit, _ = strconv.Atoi(r.URL.Query().Get("limit"))
	order = strings.ToLower(r.URL.Query().Get("order"))
	sort = strings.ToLower(r.URL.Query().Get("sort"))
	search = strings.ToLower(r.URL.Query().Get("search"))

	if page < 1 {
		page = 1
	}

	if limit < 1 {
		limit = 10
	}

	if len(order) == 0 {
		if len(defaultOrder) > 0 {
			order = defaultOrder[0]
		} else {
			order = "id"
		}
	}

	if len(sort) == 0 || !(sort == "asc" || sort == "desc") {
		sort = "asc"
	}

	return
}

func GetMeta(search, order, sort string, pagination model.Pagination) dto.MetaResponse {
	meta := dto.MetaResponse{
		Order: order,
		Sort:  sort,
	}

	if len(search) > 0 {
		meta.Search = search
	}

	var paginationResp dto.PaginationResponse
	paginationResp.Transform(pagination)
	meta.Pagination = paginationResp
	return meta
}
```

## 19.4 DTO untuk Pagination

```go
// internal/dto/pagination_response
package dto

import "workshop/internal/model"

type MetaResponse struct {
	Order      string             `json:"order,omitempty"`
	Sort       string             `json:"sort,omitempty"`
	Search     string             `json:"search,omitempty"`
	Filter     any                `json:"filter,omitempty"`
	Pagination PaginationResponse `json:"pagination,omitempty"`
}

type PaginationResponse struct {
	Page       int  `json:"page"`
	Limit      int  `json:"limit"`
	Total      int  `json:"total"`
	TotalPages int  `json:"total_pages"`
	HasNext    bool `json:"has_next"`
	HasPrev    bool `json:"has_prev"`
}

func (u *PaginationResponse) Transform(pagination model.Pagination) {
	u.Page = pagination.Page
	u.Limit = pagination.Limit
	u.Total = pagination.Count
	u.TotalPages = (u.Total + u.Limit - 1) / u.Limit
	u.HasNext = u.Page < u.TotalPages
	u.HasPrev = u.Page > 1
}
```

## 19.5 Model Pagination

```go
package model

type Pagination struct {
	Page  int
	Limit int
	Count int
}
```

## 19.6 Repository dengan Pagination

Update repository untuk mendukung search, order, sort, limit, dan offset:

```go
// internal/repository/user_repository.go
type UserRepository interface {
	List(ctx context.Context, search, order, sort string, limit, offset int) ([]model.User, int, error)
	// ... kode lainnya tetap sama
}

func (u *userRepository) List(ctx context.Context, search, order, sort string, limit, offset int) ([]model.User, int, error) {
	conditions := []string{"deleted_at IS NULL"}
	args := []any{}

	if len(search) > 0 {
		conditions = append(conditions, fmt.Sprintf(`(name ILIKE $%d)`, len(args)+1))
		args = append(args, "%"+search+"%")
	}

	conditionStr := strings.Join(conditions, " AND ")

	var count int
	err := u.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE `+conditionStr, args...).Scan(&count)
	if err != nil {
		u.log.Error(ctx, "error: querying count users", slog.Any("error", err))
		return nil, count, err
	}

	orderByMap := map[string]string{
		"id":         "id",
		"name":       "LOWER(name)",     // Case-insensitive
		"username":   "LOWER(username)", // Case-insensitive
		"email":      "LOWER(email)",    // Case-insensitive
		"is_active":  "is_active",
		"created_at": "created_at",
	}

	order, ok := orderByMap[order]
	if !ok {
		order = "id"
	}

	query := `SELECT id, name, username, password, email, is_active FROM users WHERE ` + conditionStr
	query += fmt.Sprintf(` ORDER BY %s %s LIMIT %d OFFSET %d`, order, sort, limit, offset)

	rows, err := u.db.QueryContext(ctx, query, args...)
	if err != nil {
		u.log.Error(ctx, "error: querying users", slog.Any("error", err))
		return nil, count, err
	}
	defer rows.Close()

	var users []model.User = make([]model.User, 0)
	for rows.Next() {
		var user model.User
		if err := rows.Scan(&user.ID, &user.Name, &user.Username, &user.Password, &user.Email, &user.IsActive); err != nil {
			u.log.Error(ctx, "error: scanning user row", slog.Any("error", err))
			return nil, count, err
		}
		users = append(users, user)
	}

	if err := rows.Err(); err != nil {
		u.log.Error(ctx, "error: iterating user rows", slog.Any("error", err))
		return nil, count, err
	}

	return users, count, nil
}
```

## 19.7 Service dengan Pagination

```go
// internal/service/users.go
type Users interface {
	List(ctx context.Context, search, order, sort string, limit, page int) ([]model.User, model.Pagination, *errors.BusinessError)
    // .... kode lainnya tetap sama
}

func (u *users) List(ctx context.Context, search, order, sort string, limit, page int) ([]model.User, model.Pagination, *errors.BusinessError) {
	pagination := model.Pagination{Page: page, Limit: limit}
	offset := (pagination.Page - 1) * pagination.Limit

	users, count, err := u.repo.List(ctx, search, order, sort, pagination.Limit, offset)
	if err != nil {
		return nil, pagination, errors.InternalServerErrorWrap(err, "error listing users")
	}
	pagination.Count = count
	return users, pagination, nil
}
```

## 19.8 Handler dengan Pagination

```go
// internal/handler/user_handler.go
func (u *userHandler) List(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	page, limit, order, sort, search := paginationLib.ExtractPaginationFromURL(r, "name")
	users, pagination, err := u.service.List(ctx, search, order, sort, limit, page)
	if err != nil {
		u.log.Error(ctx, "error: listing users", slog.Any("error", err))
		response.SetError(ctx, u.log, w, err, nil)
		return
	}

	var resp []dto.UserResponse = make([]dto.UserResponse, 0)
	for _, user := range users {
		var ur dto.UserResponse
		ur.Transform(user)
		resp = append(resp, ur)
	}

	meta := paginationLib.GetMeta(search, order, sort, pagination)
	response.SetOk(ctx, u.log, w, resp, meta)
}
```

## 19.9 Contoh Request dan Response

```bash
curl 'localhost:9000/users?page=1&limit=10&order=name&sort=asc&search=admin' \
  -H 'Authorization: Bearer <token>'
```

**Response:**

```json
{
    "status": "B1",
    "message": "Success",
    "data": [
        {
            "id": "019eb960-a27d-73c8-9703-b23a9f50dc83",
            "name": "Admin",
            "username": "admin",
            "email": "admin@example.com",
            "is_active": true
        },
        {
            "id": "019ebbc3-fc6e-70f1-81ff-fa93d0bd2e64",
            "name": "admin3",
            "username": "admin3",
            "email": "admin3@example.com",
            "is_active": false
        },
        {
            "id": "019ebbc5-2735-7135-9160-79787fd28985",
            "name": "admin4",
            "username": "admin4",
            "email": "admin4@example.com",
            "is_active": false
        }
    ],
    "meta": {
        "order": "name",
        "sort": "asc",
        "pagination": {
            "page": 1,
            "limit": 10,
            "total": 6,
            "total_pages": 1,
            "has_next": false,
            "has_prev": false
        }
    }
}
```

**Request Tanpa Parameter (Menggunakan Default)**

```bash
curl 'localhost:9000/users' -H 'Authorization: Bearer <token>'
```

* page=1
* limit=10
* order=name
* sort=asc

## 19.10 Keamanan: Mencegah SQL Injection

Perhatikan penggunaan whitelist mapping untuk field order:

```go
orderByMap := map[string]string{
    "id":         "id",
    "name":       "LOWER(name)",
    "username":   "LOWER(username)",
    "email":      "LOWER(email)",
    "is_active":  "is_active",
    "created_at": "created_at",
}

order, ok := orderByMap[order]
if !ok {
    order = "id"  // default jika tidak ada di whitelist
}
```

Ini mencegah attacker menyisipkan SQL injection melalui parameter order.

## 19.11 Catatan: Kapan Pagination Tidak Diperlukan

Untuk resource dengan jumlah data sedikit (misal: roles, config), pagination tidak diperlukan. Juga untuk list access yang mengembalikan semua data dalam format tree, pagination tidak diperlukan.

## Ringkasan Bab 19

Di bab ini kita telah belajar:

| Komponen             | File                                  | Fungsi                               |
| -------------------- | ------------------------------------- | ------------------------------------ |
| Response Enhancement | `pkg/response/response.go`            | Tambahan field Meta                  |
| Pagination Helper    | `pkg/pagination/pagination.go`        | Extract URL params & build meta      |
| Pagination DTO       | `internal/dto/pagination_response.go` | MetaResponse, PaginationResponse     |
| Pagination Model     | `internal/model/pagination.go`        | Struct untuk internal                |
| Repository           | `user_repository.go`                  | COUNT query + LIMIT/OFFSET           |
| Service              | `users.go`                            | Hitung offset, return pagination     |
| Handler              | `user_handler.go`                     | Extract params, response dengan meta |

Parameter yang Didukung:

| Parameter | Default | Contoh      |
| --------- | ------- | ----------- |
| page      | 1       | page=3      |
| limit     | 10      | limit=25    |
| order     | name    | order=email |
| sort      | asc     | sort=desc   |
| search    | (none)  | search=joh  |

Manfaat yang kita peroleh:

* ✅ Response lebih ringan (hanya data yang dibutuhkan)
* ✅ Client bisa menampilkan navigasi halaman
* ✅ Sorting dan pencarian terintegrasi
* ✅ Proteksi SQL injection dengan whitelist field
* ✅ Konsistensi response dengan field meta

Yang akan datang:

* Saat ini validasi masih sederhana
* Bab selanjutnya: Unit Testing – menguji kode secara otomatis


# Unit Testing

Unit testing adalah praktik menulis kode untuk menguji kode lainnya. Tujuannya adalah memastikan setiap unit (fungsi, method, atau struct) berperilaku sesuai yang diharapkan. Dalam konteks ini, yang menjadi fokus adalah **coverage** — seberapa banyak cabang logika (branch) dan aturan bisnis yang teruji.

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/20-unit-testing](https://github.com/jacky-htg/workshop/tree/main/20-unit-testing)

## 20.1 Mengapa Unit Testing Penting?

| Manfaat                   | Penjelasan                                                    |
| ------------------------- | ------------------------------------------------------------- |
| Mendeteksi bug lebih awal | Menemukan error sebelum kode masuk ke production              |
| Memudahkan refactoring    | Jika kode diubah, test akan memastikan tidak ada yang rusak   |
| Dokumentasi hidup         | Test menunjukkan bagaimana seharusnya fungsi digunakan        |
| Desain yang lebih baik    | Kode yang sulit di-test biasanya menandakan desain yang buruk |

## 20.2 Prinsip Unit Testing di Go

**Aturan Emas:**

1. File test harus diakhiri dengan `_test.go`
2. Nama fungsi test dimulai dengan `Test`
3. Gunakan `testing.T` sebagai parameter
4. Setiap test harus independen (tidak tergantung urutan eksekusi)

```go
func TestNamaFungsi_Skenario(t *testing.T) {
    // Arrange (setup)
    // Act (execute)
    // Assert (verify)
}
```

## 20.3 Tools yang Digunakan

| Tool                             | Kegunaan                              |
| -------------------------------- | ------------------------------------- |
| `testing`                        | Package standar Go untuk unit testing |
| `github.com/stretchr/testify`    | Assertions yang lebih ekspresif       |
| `github.com/DATA-DOG/go-sqlmock` | Mock database untuk repository test   |
| `net/http/httptest`              | Mock HTTP server untuk handler test   |

## 20.4 Test Repository

Repository memiliki ketergantungan pada `*sql.DB` dan `logger.Logger`. Karena kita sudah menerapkan **dependency injection**, kita bisa dengan mudah mengganti dependency asli dengan `mock`.

### Mock Logger

Buat logger kosong (no-op) untuk testing (`mock/mockpkg/logger.go`):

```go
package mockpkg

import (
	"context"

	"github.com/jacky-htg/go-libs/logger"
)

type MockLogger struct{}

func NewMockLogger() *MockLogger {
	return &MockLogger{}
}

func (m *MockLogger) Debug(ctx context.Context, msg string, args ...any) {}
func (m *MockLogger) Info(ctx context.Context, msg string, args ...any)  {}
func (m *MockLogger) Warn(ctx context.Context, msg string, args ...any)  {}
func (m *MockLogger) Error(ctx context.Context, msg string, args ...any) {}
func (m *MockLogger) With(args ...any) logger.Logger {
	return &MockLogger{}
}
```

### Mock Database dengan sqlmock

`sqlmock` memungkinkan kita mensimulasikan query database tanpa perlu database sungguhan.

Pola Umum Mocking:

* `ExpectQuery()` → untuk `QueryRowContext()` atau `QueryContext()`
* `ExpectExec()` → untuk `ExecContext()`
* `ExpectPrepare()` → untuk `PrepareContext()`

### Test untuk `QueryRowContext` (Create Role)

Buat file `internal/repository/role_repository_test.go`.

```go
func TestRoleRepository_Create_Success(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	log := mockpkg.NewMockLogger()
	repo := repository.NewRoleRepository(db, log)

	query := `INSERT INTO roles (name) VALUES ($1) RETURNING id`
	mock.ExpectQuery(regexp.QuoteMeta(query)).
		WithArgs("admin").
		WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))

	ctx := context.Background()
	role := &model.Role{Name: "admin"}
	err = repo.Create(ctx, role)

	assert.NoError(t, err)
	assert.Equal(t, 1, role.ID)
	assert.NoError(t, mock.ExpectationsWereMet())
}

func TestRoleRepository_Create_Error(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	log := mockpkg.NewMockLogger()
	repo := repository.NewRoleRepository(db, log)

	query := `INSERT INTO roles (name) VALUES ($1) RETURNING id`
	mock.ExpectQuery(regexp.QuoteMeta(query)).
		WithArgs("admin").
		WillReturnError(errors.New("duplicate key value violates unique constraint"))

	ctx := context.Background()
	role := &model.Role{Name: "admin"}
	err = repo.Create(ctx, role)

	assert.Error(t, err)
	assert.NoError(t, mock.ExpectationsWereMet())
}
```

### Test untuk `QueryContext` (List Roles)

```go
func TestRoleRepository_List_Success(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	log := mockpkg.NewMockLogger()
	repo := repository.NewRoleRepository(db, log)

	expectedRoles := []model.Role{
		{ID: 1, Name: "admin"},
		{ID: 2, Name: "manager"},
	}

	query := `SELECT id, name FROM roles ORDER BY name`
	mock.ExpectQuery(regexp.QuoteMeta(query)).
		WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).
			AddRow(expectedRoles[0].ID, expectedRoles[0].Name).
			AddRow(expectedRoles[1].ID, expectedRoles[1].Name),
		)

	ctx := context.Background()
	roles, err := repo.List(ctx)

	assert.NoError(t, err)
	assert.Equal(t, len(expectedRoles), len(roles))
	assert.Equal(t, expectedRoles[0].Name, roles[0].Name)
	assert.NoError(t, mock.ExpectationsWereMet())
}
```

### Test untuk `ExecContext` (Update Role)

```go
func TestRoleRepository_Update_Success(t *testing.T) {
	db, mock, err := sqlmock.New()
	require.NoError(t, err)
	defer db.Close()

	log := mockpkg.NewMockLogger()
	repo := repository.NewRoleRepository(db, log)

	query := `UPDATE roles SET name = $1 WHERE id = $2`
	mock.ExpectExec(regexp.QuoteMeta(query)).
		WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg()).
		WillReturnResult(sqlmock.NewResult(0, 1))

	ctx := context.Background()
	role := &model.Role{Name: "admin"}
	err = repo.Update(ctx, role)

	assert.NoError(t, err)
	assert.NoError(t, mock.ExpectationsWereMet())
}
```

### Menjalankan Test Repository

Untuk mengetes bisa digunakan perintah

```bash
# Jalankan satu test
go test ./internal/repository -run "TestRoleRepository_Create_Success"

# Jalankan semua test role repository
go test ./internal/repository -run "TestRoleRepository" -v

# Lihat coverage
go test -cover ./internal/repository

# Generate coverage report (HTML)
go test -coverprofile cover.out ./internal/repository
go tool cover -html cover.out
```

![](https://1500694864-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F-MaN3VdVswXpcCva69TG%2Fuploads%2Fgit-blob-bd70053e8db190bb98f1c571813d19118c50e48a%2Fcoverage.png?alt=media)

## 20.5 Test Service

Service bergantung pada `repository.RoleRepository` (interface). Kita buat **mock repository** untuk mengontrol perilaku yang diinginkan.

### Mock Repository untuk Service

buat file `mock/mockrepo/role.go`

```go
package mockrepo

import (
	"context"
	"workshop/internal/model"
)

type MockRoleRepo struct {
	CreateFunc   func(ctx context.Context, role *model.Role) error
	FindByIDFunc func(ctx context.Context, id int) (*model.Role, error)
	ListFunc     func(ctx context.Context) ([]model.Role, error)
	UpdateFunc   func(ctx context.Context, role *model.Role) error
	DeleteFunc   func(ctx context.Context, id int) error

	// Many-to-many dengan Access
	GrantAccessFunc        func(ctx context.Context, roleID, accessID int) error
	RevokeAccessFunc       func(ctx context.Context, roleID, accessID int) error
	GetAccessesByRolesFunc func(ctx context.Context, roleIDs []int) ([]model.Access, error)

	// Helper
	HasAccessFunc func(ctx context.Context, roleID, accessID int) (bool, error)
}

func (m *MockRoleRepo) Create(ctx context.Context, role *model.Role) error {
	if m.CreateFunc != nil {
		return m.CreateFunc(ctx, role)
	}
	return nil
}

func (m *MockRoleRepo) FindByID(ctx context.Context, id int) (*model.Role, error) {
	if m.FindByIDFunc != nil {
		return m.FindByIDFunc(ctx, id)
	}
	return nil, nil
}

func (m *MockRoleRepo) List(ctx context.Context) ([]model.Role, error) {
	if m.ListFunc != nil {
		return m.ListFunc(ctx)
	}
	return nil, nil
}

func (m *MockRoleRepo) Update(ctx context.Context, role *model.Role) error {
	if m.UpdateFunc != nil {
		return m.UpdateFunc(ctx, role)
	}
	return nil
}

func (m *MockRoleRepo) Delete(ctx context.Context, id int) error {
	if m.DeleteFunc != nil {
		return m.DeleteFunc(ctx, id)
	}
	return nil
}

func (m *MockRoleRepo) GrantAccess(ctx context.Context, roleID, accessID int) error {
	if m.GrantAccessFunc != nil {
		return m.GrantAccessFunc(ctx, roleID, accessID)
	}
	return nil
}

func (m *MockRoleRepo) RevokeAccess(ctx context.Context, roleID, accessID int) error {
	if m.RevokeAccessFunc != nil {
		return m.RevokeAccessFunc(ctx, roleID, accessID)
	}
	return nil
}

func (m *MockRoleRepo) GetAccessesByRoles(ctx context.Context, roleIDs []int) ([]model.Access, error) {
	if m.GetAccessesByRolesFunc != nil {
		return m.GetAccessesByRolesFunc(ctx, roleIDs)
	}
	return nil, nil
}

func (m *MockRoleRepo) HasAccess(ctx context.Context, roleID, accessID int) (bool, error) {
	if m.HasAccessFunc != nil {
		return m.HasAccessFunc(ctx, roleID, accessID)
	}
	return false, nil
}
```

### Test Service Update

Kita akan membuat unit test untuk fungsi `Update()` yang komplektitas-nya sedang. Buat file `internal/service/roles_test.go`

```go
package service_test

import (
	"context"
	"fmt"
	"testing"
	"workshop/internal/model"
	"workshop/internal/service"
	"workshop/mock/mockpkg"
	"workshop/mock/mockrepo"
	"workshop/pkg/errors"

	"github.com/stretchr/testify/assert"
)

func TestRoles_Update_Success(t *testing.T) {
	var expectedError *errors.BusinessError
	log := mockpkg.NewMockLogger()
	repo := &mockrepo.MockRoleRepo{
		FindByIDFunc: func(ctx context.Context, id int) (*model.Role, error) {
			return &model.Role{ID: id, Name: "admin"}, nil
		},
		UpdateFunc: func(ctx context.Context, role *model.Role) error {
			return nil
		},
	}

	svc := service.NewRoles(log, repo)

	role := &model.Role{ID: 1, Name: "Super Admin"}
	err := svc.Update(context.Background(), role)

	assert.Equal(t, expectedError, err)
}

func TestRoles_Update_Error_FindByID(t *testing.T) {
	setupErr := fmt.Errorf("error db")
	expectedError := errors.InternalServerErrorWrap(setupErr, "error finding role")

	log := mockpkg.NewMockLogger()
	repo := &mockrepo.MockRoleRepo{
		FindByIDFunc: func(ctx context.Context, id int) (*model.Role, error) {
			return nil, setupErr
		},
	}

	svc := service.NewRoles(log, repo)

	role := &model.Role{ID: 1, Name: "Super Admin"}
	err := svc.Update(context.Background(), role)

	assert.Equal(t, expectedError, err)
}

func TestRoles_Update_Error_NotFound(t *testing.T) {
	expectedError := errors.NotFound("role not found")

	log := mockpkg.NewMockLogger()
	repo := &mockrepo.MockRoleRepo{
		FindByIDFunc: func(ctx context.Context, id int) (*model.Role, error) {
			return nil, nil
		},
	}

	svc := service.NewRoles(log, repo)

	role := &model.Role{ID: 1, Name: "Super Admin"}
	err := svc.Update(context.Background(), role)

	assert.Equal(t, expectedError, err)
}

func TestRoles_Update_Error_Update(t *testing.T) {
	setupErr := fmt.Errorf("error db")
	expectedError := errors.InternalServerErrorWrap(setupErr, "error updating role")

	log := mockpkg.NewMockLogger()
	repo := &mockrepo.MockRoleRepo{
		FindByIDFunc: func(ctx context.Context, id int) (*model.Role, error) {
			return &model.Role{ID: id, Name: "admin"}, nil
		},
		UpdateFunc: func(ctx context.Context, role *model.Role) error {
			return setupErr
		},
	}

	svc := service.NewRoles(log, repo)

	role := &model.Role{ID: 1, Name: "Super Admin"}
	err := svc.Update(context.Background(), role)

	assert.Equal(t, expectedError, err)
}
```

## 20.6 Test Handler

Handler bergantung pada `service.Roles` (interface). Kita buat mock service dan gunakan `httptest` untuk mensimulasikan HTTP request.

### Mock Service untuk Handler

```go
package mocksvc

import (
	"context"
	"workshop/internal/model"
	"workshop/pkg/errors"
)

type MockRoles struct {
	ListFunc     func(ctx context.Context) ([]model.Role, *errors.BusinessError)
	FindByIDFunc func(ctx context.Context, id int) (*model.Role, *errors.BusinessError)
	CreateFunc   func(ctx context.Context, role *model.Role) *errors.BusinessError
	UpdateFunc   func(ctx context.Context, role *model.Role) *errors.BusinessError
	DeleteFunc   func(ctx context.Context, id int) *errors.BusinessError
	GrantFunc    func(ctx context.Context, roleID, accessID int) *errors.BusinessError
	RevokeFunc   func(ctx context.Context, roleID, accessID int) *errors.BusinessError
}

func (m *MockRoles) List(ctx context.Context) ([]model.Role, *errors.BusinessError) {
	if m.ListFunc != nil {
		return m.ListFunc(ctx)
	}
	return nil, nil
}

func (m *MockRoles) FindByID(ctx context.Context, id int) (*model.Role, *errors.BusinessError) {
	if m.FindByIDFunc != nil {
		return m.FindByIDFunc(ctx, id)
	}
	return nil, nil
}

func (m *MockRoles) Create(ctx context.Context, role *model.Role) *errors.BusinessError {
	if m.CreateFunc != nil {
		return m.CreateFunc(ctx, role)
	}
	return nil
}

func (m *MockRoles) Update(ctx context.Context, role *model.Role) *errors.BusinessError {
	if m.UpdateFunc != nil {
		return m.UpdateFunc(ctx, role)
	}
	return nil
}

func (m *MockRoles) Delete(ctx context.Context, id int) *errors.BusinessError {
	if m.DeleteFunc != nil {
		return m.DeleteFunc(ctx, id)
	}
	return nil
}

func (m *MockRoles) Grant(ctx context.Context, roleID, accessID int) *errors.BusinessError {
	if m.GrantFunc != nil {
		return m.GrantFunc(ctx, roleID, accessID)
	}
	return nil
}

func (m *MockRoles) Revoke(ctx context.Context, roleID, accessID int) *errors.BusinessError {
	if m.RevokeFunc != nil {
		return m.RevokeFunc(ctx, roleID, accessID)
	}
	return nil
}
```

### Test Handler Update

Buat file `internal/hanlder/role_handler_test.go`

```go
package handler_test

import (
	"bytes"
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
	"workshop/internal/dto"
	"workshop/internal/handler"
	"workshop/internal/model"
	"workshop/mock/mockpkg"
	"workshop/mock/mocksvc"
	"workshop/pkg/errors"
	"workshop/pkg/response"

	"github.com/go-playground/validator/v10"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestRoleHandler_Update_Success(t *testing.T) {
	// Setup
	log := mockpkg.NewMockLogger()
	validate := validator.New()

	// Mock service
	mockService := &mocksvc.MockRoles{
		UpdateFunc: func(ctx context.Context, role *model.Role) *errors.BusinessError {
			// Verify the role data
			assert.Equal(t, 1, role.ID)
			assert.Equal(t, "Super Admin", role.Name)
			return nil
		},
	}

	handler := handler.NewRoleHandler(log, validate, mockService)

	// Create request body
	reqBody := dto.RoleRequest{
		Name: "Super Admin",
	}
	jsonBody, _ := json.Marshal(reqBody)

	// Create request
	req := httptest.NewRequest(http.MethodPut, "/roles/1", bytes.NewBuffer(jsonBody))
	req.SetPathValue("id", "1")
	req = req.WithContext(context.Background())

	// Create response recorder
	w := httptest.NewRecorder()

	// Execute
	handler.Update(w, req)

	// Assert
	assert.Equal(t, http.StatusOK, w.Code)

	// Parse response
	var resp response.StandardResponse
	err := json.Unmarshal(w.Body.Bytes(), &resp)
	require.NoError(t, err)
	assert.Equal(t, "B1", resp.Status)
	assert.Equal(t, "Success", resp.Message)

	// Verify response data
	data, ok := resp.Data.(map[string]interface{})
	assert.True(t, ok)
	assert.Equal(t, float64(1), data["id"])
	assert.Equal(t, "Super Admin", data["name"])
}

func TestRoleHandler_Update_InvalidID(t *testing.T) {
	// Setup
	log := mockpkg.NewMockLogger()
	validate := validator.New()
	mockService := &mocksvc.MockRoles{}
	handler := handler.NewRoleHandler(log, validate, mockService)

	// Create request with invalid ID
	reqBody := dto.RoleRequest{Name: "Admin"}
	jsonBody, _ := json.Marshal(reqBody)

	req := httptest.NewRequest(http.MethodPut, "/roles/invalid", bytes.NewBuffer(jsonBody))
	req.SetPathValue("id", "invalid") // Invalid ID (not a number)
	req = req.WithContext(context.Background())

	w := httptest.NewRecorder()

	// Execute
	handler.Update(w, req)

	// Assert
	assert.Equal(t, http.StatusBadRequest, w.Code)

	var resp response.StandardResponse
	err := json.Unmarshal(w.Body.Bytes(), &resp)
	require.NoError(t, err)
	assert.Equal(t, "E001", resp.Status)
	assert.Contains(t, resp.Message, "Invalid id")
}

func TestRoleHandler_Update_MissingID(t *testing.T) {
	// Setup
	log := mockpkg.NewMockLogger()
	validate := validator.New()
	mockService := &mocksvc.MockRoles{}
	handler := handler.NewRoleHandler(log, validate, mockService)

	// Create request without ID
	reqBody := dto.RoleRequest{Name: "Admin"}
	jsonBody, _ := json.Marshal(reqBody)

	req := httptest.NewRequest(http.MethodPut, "/roles/", bytes.NewBuffer(jsonBody))
	// No path value set for "id"
	req = req.WithContext(context.Background())

	w := httptest.NewRecorder()

	// Execute
	handler.Update(w, req)

	// Assert
	assert.Equal(t, http.StatusBadRequest, w.Code)

	var resp response.StandardResponse
	err := json.Unmarshal(w.Body.Bytes(), &resp)
	require.NoError(t, err)
	assert.Equal(t, "E001", resp.Status)
	assert.Contains(t, resp.Message, "Missing id parameter")
}

func TestRoleHandler_Update_InvalidJSON(t *testing.T) {
	// Setup
	log := mockpkg.NewMockLogger()
	validate := validator.New()
	mockService := &mocksvc.MockRoles{}
	handler := handler.NewRoleHandler(log, validate, mockService)

	// Create request with invalid JSON
	invalidJSON := []byte(`{"name": "Admin"`) // Missing closing brace

	req := httptest.NewRequest(http.MethodPut, "/roles/1", bytes.NewBuffer(invalidJSON))
	req.SetPathValue("id", "1")
	req = req.WithContext(context.Background())

	w := httptest.NewRecorder()

	// Execute
	handler.Update(w, req)

	// Assert
	assert.Equal(t, http.StatusBadRequest, w.Code)

	var resp response.StandardResponse
	err := json.Unmarshal(w.Body.Bytes(), &resp)
	require.NoError(t, err)
	assert.Equal(t, "E001", resp.Status)
}

func TestRoleHandler_Update_ValidationError(t *testing.T) {
	// Setup
	log := mockpkg.NewMockLogger()
	validate := validator.New()
	mockService := &mocksvc.MockRoles{}
	handler := handler.NewRoleHandler(log, validate, mockService)

	// Create request with empty name (assuming validation requires name)
	reqBody := dto.RoleRequest{
		Name: "", // Empty name
	}
	jsonBody, _ := json.Marshal(reqBody)

	req := httptest.NewRequest(http.MethodPut, "/roles/1", bytes.NewBuffer(jsonBody))
	req.SetPathValue("id", "1")
	req = req.WithContext(context.Background())

	w := httptest.NewRecorder()

	// Execute
	handler.Update(w, req)

	// Assert - should return validation error if Name field has validation tag
	assert.Equal(t, http.StatusBadRequest, w.Code)

	var resp response.StandardResponse
	err := json.Unmarshal(w.Body.Bytes(), &resp)
	require.NoError(t, err)
	assert.Equal(t, "E001", resp.Status)
}

func TestRoleHandler_Update_ServiceNotFoundError(t *testing.T) {
	// Setup
	log := mockpkg.NewMockLogger()
	validate := validator.New()

	// Mock service that returns not found error
	mockService := &mocksvc.MockRoles{
		UpdateFunc: func(ctx context.Context, role *model.Role) *errors.BusinessError {
			return errors.NotFound("role not found")
		},
	}

	handler := handler.NewRoleHandler(log, validate, mockService)

	// Create request
	reqBody := dto.RoleRequest{Name: "Updated Name"}
	jsonBody, _ := json.Marshal(reqBody)

	req := httptest.NewRequest(http.MethodPut, "/roles/999", bytes.NewBuffer(jsonBody))
	req.SetPathValue("id", "999")
	req = req.WithContext(context.Background())

	w := httptest.NewRecorder()

	// Execute
	handler.Update(w, req)

	// Assert
	assert.Equal(t, http.StatusNotFound, w.Code)

	var resp response.StandardResponse
	err := json.Unmarshal(w.Body.Bytes(), &resp)
	require.NoError(t, err)
	assert.Equal(t, "E002", resp.Status)
	assert.Contains(t, resp.Message, "role not found")
}

func TestRoleHandler_Update_ServiceInternalServerError(t *testing.T) {
	// Setup
	log := mockpkg.NewMockLogger()
	validate := validator.New()

	// Mock service that returns internal server error
	mockService := &mocksvc.MockRoles{
		UpdateFunc: func(ctx context.Context, role *model.Role) *errors.BusinessError {
			return errors.InternalServerError("database connection failed")
		},
	}

	handler := handler.NewRoleHandler(log, validate, mockService)

	// Create request
	reqBody := dto.RoleRequest{Name: "Updated Name"}
	jsonBody, _ := json.Marshal(reqBody)

	req := httptest.NewRequest(http.MethodPut, "/roles/1", bytes.NewBuffer(jsonBody))
	req.SetPathValue("id", "1")
	req = req.WithContext(context.Background())

	w := httptest.NewRecorder()

	// Execute
	handler.Update(w, req)

	// Assert
	assert.Equal(t, http.StatusInternalServerError, w.Code)

	var resp response.StandardResponse
	err := json.Unmarshal(w.Body.Bytes(), &resp)
	require.NoError(t, err)
	assert.Equal(t, "E000", resp.Status)
	assert.Contains(t, resp.Message, "database connection failed")
}
```

## 20.7 Menjalankan Semua Test

```bash
# Jalankan semua test di semua package
go test ./...

# Dengan verbose (detail)
go test -v ./...

# Dengan coverage
go test -cover ./...

# Coverage ke file dan lihat di browser
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
```

## 20.8 Best Practices Unit Testing

| Praktik                   | Penjelasan                                                                        |
| ------------------------- | --------------------------------------------------------------------------------- |
| Satu skenario per test    | Satu fungsi test hanya menguji satu skenario (success atau satu jenis error)      |
| Nama test deskriptif      | TestNamaFungsi\_Skenario\_Hasil → TestRoleRepository\_Create\_DuplicateKey\_Error |
| Gunakan table-driven test | Untuk skenario yang mirip dengan input berbeda                                    |
| Mock eksternal dependency | Database, API, file system harus di-mock                                          |
| Test edge cases           | Nilai kosong, limit, negative, dll                                                |

### Contoh Table-Driven Test

```go
func TestValidateEmail(t *testing.T) {
    tests := []struct {
        name    string
        email   string
        wantErr bool
    }{
        {"valid email", "user@example.com", false},
        {"empty email", "", true},
        {"no @ symbol", "userexample.com", true},
        {"no domain", "user@", true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := validateEmail(tt.email)
            if tt.wantErr {
                assert.Error(t, err)
            } else {
                assert.NoError(t, err)
            }
        })
    }
}
```

## Ringkasan Bab 20

Di bab ini kita telah belajar:

| Komponen   | Pendekatan Test          | Tools              |
| ---------- | ------------------------ | ------------------ |
| Repository | Mock database            | sqlmock            |
| Service    | Mock repository          | Custom mock struct |
| Handler    | Mock service + HTTP mock | httptest           |

Manfaat yang kita peroleh:

* ✅ Setiap fungsi memiliki test yang memverifikasi perilaku
* ✅ Dependency eksternal (database) tidak perlu untuk testing
* ✅ Coverage bisa diukur dan ditingkatkan
* ✅ Refactoring kode lebih aman

Yang akan datang:

* Unit testing menguji komponen secara terisolasi
* Bab selanjutnya: API Testing – menguji endpoint secara end-to-end dengan database dan server sungguhan


# API Testing

API testing (atau integration testing) adalah pengujian untuk memastikan API yang dibangun sesuai dengan kontrak yang telah ditetapkan. Berbeda dengan unit testing yang menguji komponen secara terisolasi, API testing menggunakan **server dan database sungguhan** (bukan mock) untuk memverifikasi perilaku end-to-end.

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/21-apit-testing](https://github.com/jacky-htg/workshop/tree/main/21-api-testing)

## 21.1 Unit Testing vs API Testing vs E2E Testing

| Aspek             | Unit Testing                  | API Testing            | E2E Testing                           |
| ----------------- | ----------------------------- | ---------------------- | ------------------------------------- |
| Fokus             | Coverage & logika percabangan | Kontrak API & behavior | Flow satu journey (user story)        |
| Database          | Mock                          | Sungguhan (container)  | Sungguhan (container)                 |
| Server            | Tidak perlu                   | httptest               | httptest                              |
| Cakupan           | 1 fungsi/method               | 1 endpoint             | Multiple endpoint dalam satu skenario |
| Kecepatan         | Sangat cepat                  | Cepat                  | Sedang                                |
| Biaya maintenance | Rendah                        | Sedang                 | Tinggi                                |

## 21.2 Struktur Folder Testing

Kita akan mengelompokkan test berdasarkan jenisnya:

```
project/
├── internal/
│   ├── handler/
│   │   ├── role.go
│   │   └── role_test.go           ✅ Unit test (package yang sama)
│   ├── service/
│   │   ├── role.go
│   │   └── role_test.go           ✅ Unit test (package yang sama)
│   └── repository/
│       ├── role.go
│       └── role_test.go           ✅ Unit test (package yang sama)
│
└── test/                          ✅ API/E2E tests (folder terpisah)
    ├── api/
    │   ├── auth/
    │   │   ├── main_test.go
    │   │   └── login_test.go
    │   └── role/
    │       ├── main_test.go
    │       └── create_test.go
    └── e2e/
        ├── helper/
        │   ├── base_helper.go
        │   ├── auth_helper.go
        │   ├── role_helper.go
        │   ├── user_helper.go
        │   └── access_helper.go
        └── rbac/
            ├── main_test.go
            └── rbac_test.go
```

## 21.3 Database Container dengan Testcontainers

API testing membutuhkan database sungguhan. Ada dua pendekatan:

| Pendekatan          | Kelebihan             | Kekurangan                                    |
| ------------------- | --------------------- | --------------------------------------------- |
| Database dedicated  | Cepat, stabil         | Race condition antar test, data bersinggungan |
| Database on-the-fly | Isolated, clean state | Lebih lambat (start container)                |

Pendekatan database on-the-fly membuat container database saat test dimulai dan menghapusnya setelah selesai. Hal ini bisa dilakukan dengan memanfaatkan perintah `exec.Command` yang bisa digunakan untuk menjalankan perintah `docker run` maupun `docker container rm -f`.

```go
// StartContainer runs a posgres container to execute commands.
func StartContainer(t *testing.T) {
    t.Helper()

    cmd := exec.Command("docker", "run", "-d", "--name", "postgres_test", "--publish", "54320:5432", "--env", "POSTGRES_PASSWORD=1234", "postgres:16-alpine")
    var out bytes.Buffer
    cmd.Stdout = &out
    if err := cmd.Run(); err != nil {
        t.Fatalf("could not start docker : %v", err)
    }

}

// StopContainer stops and removes the specified container.
func StopContainer(t *testing.T) {
    t.Helper()

    if err := exec.Command("docker", "container", "rm", "-f", "postgres_test").Run(); err != nil {
        t.Fatalf("could not stop mysql container: %v", err)
    }
}
```

Pendekatan native `exec.Command()` mempunyai keunggulan di performance, namun untuk alasan kemudahan dalam maintenance, Kita akan menggunakan pendekatan on-the-fly dengan library `testcontainers-go`.

### PostgreSQL Container

```go
// test/containers/postgres.go
package containers

import (
	"context"
	"database/sql"
	"fmt"
	"time"

	"github.com/jacky-htg/go-libs/migration"
	_ "github.com/lib/pq"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/wait"
)

type PostgreSQLContainer struct {
	Container testcontainers.Container
	DB        *sql.DB
	Host      string
	Port      string
}

func NewPostgreSQLContainer(ctx context.Context) (*PostgreSQLContainer, error) {
	req := testcontainers.ContainerRequest{
		Image:        "postgres:16-alpine",
		ExposedPorts: []string{"5432/tcp"},
		Env: map[string]string{
			"POSTGRES_USER":     "testuser",
			"POSTGRES_PASSWORD": "testpass",
			"POSTGRES_DB":       "testdb",
		},
		WaitingFor: wait.ForLog("database system is ready to accept connections").
			WithOccurrence(2).
			WithStartupTimeout(60 * time.Second),
	}

	container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
	})
	if err != nil {
		return nil, fmt.Errorf("failed to start postgres container: %w", err)
	}

	host, err := container.Host(ctx)
	if err != nil {
		return nil, err
	}

	port, err := container.MappedPort(ctx, "5432")
	if err != nil {
		return nil, err
	}

	connStr := fmt.Sprintf("host=%s port=%s user=testuser password=testpass dbname=testdb sslmode=disable",
		host, port.Port())

	db, err := sql.Open("postgres", connStr)
	if err != nil {
		return nil, err
	}

	// Ping database
	if err := db.Ping(); err != nil {
		return nil, err
	}

	return &PostgreSQLContainer{
		Container: container,
		DB:        db,
		Host:      host,
		Port:      port.Port(),
	}, nil
}

func (p *PostgreSQLContainer) Close() error {
	if p.DB != nil {
		p.DB.Close()
	}
	return p.Container.Terminate(context.Background())
}

func (p *PostgreSQLContainer) RunMigrations(migrationsPath string) error {
	return migration.Migrate(p.DB, migrationsPath)
}
```

### Container Registry (Singleton)

Untuk mengelola banyak container (PostgreSQL, Redis, dll), buat registry dengan pattern singleton:

```go
// test/containers/registry.go
package containers

import (
	"context"
	"sync"
)

type ContainerRegistry struct {
	mu       sync.RWMutex
	postgres *PostgreSQLContainer
}

var (
	registry *ContainerRegistry
	once     sync.Once
)

func GetRegistry() *ContainerRegistry {
	once.Do(func() {
		registry = &ContainerRegistry{}
	})
	return registry
}

func (r *ContainerRegistry) StartPostgres(ctx context.Context) (*PostgreSQLContainer, error) {
	r.mu.Lock()
	defer r.mu.Unlock()

	if r.postgres == nil {
		pg, err := NewPostgreSQLContainer(ctx)
		if err != nil {
			return nil, err
		}
		r.postgres = pg
	}
	return r.postgres, nil
}

func (r *ContainerRegistry) CloseAll() {
	r.mu.Lock()
	defer r.mu.Unlock()

	if r.postgres != nil {
		r.postgres.Close()
	}
}
```

## 21.4 Setup TestMain

`TestMain` adalah fungsi khusus di Go yang dijalankan sekali sebelum semua test dalam package dieksekusi. Kita gunakan untuk:

1. Start container database
2. Jalankan migration
3. Scan access routes
4. Start HTTP test server
5. Cleanup setelah semua test selesai

```go
// test/setup/setup.go
package setup

import (
	"bytes"
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"sync"
	"testing"
	"time"
	"workshop/config"
	"workshop/internal/dto"
	"workshop/internal/repository"
	"workshop/internal/router"
	"workshop/internal/service"
	"workshop/test/containers"

	"github.com/go-playground/validator/v10"
	"github.com/jacky-htg/go-libs/logger"
	"github.com/stretchr/testify/require"
)

var (
	testServer *httptest.Server
	once       sync.Once
	initErr    error
)

// InitServer inisialisasi server sekali untuk semua test
func InitServer() error {
	once.Do(func() {
		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
		defer cancel()

		registry := containers.GetRegistry()

		pg, err := registry.StartPostgres(ctx)
		if err != nil {
			initErr = err
			return
		}

		if err := pg.RunMigrations("../../../migration"); err != nil {
			initErr = err
			return
		}

		cfg := config.Config{
			Server: config.ServerConfig{GatewayTimeout: 30 * time.Second},
			Token:  config.TokenConfig{TokenSalt: "test-secret-key", TokenExp: 1},
		}

		log := logger.InitLogger(nil)
		validate := validator.New()

		repo := repository.NewAccessRepository(pg.DB, log)
		accessSvc := service.NewAccesses(pg.DB, log, repo)
		if err := accessSvc.ScanAccess(context.Background(), "../../data/route.go"); err != nil {
			initErr = err
			return
		}

		router := router.Api(cfg, pg.DB, log, validate)
		testServer = httptest.NewServer(router)
	})
	return initErr
}

// CloseServer cleanup
func CloseServer() {
	if testServer != nil {
		testServer.Close()
	}
	registry := containers.GetRegistry()
	registry.CloseAll()
}

// GetServerURL returns test server URL
func GetServerURL() string {
	return testServer.URL
}
```

### Data Pendukung

Untuk data accesses routing, saya membuat data test `test/data/router.go`:

```go
package data_test

import (
	"fmt"
	"workshop/pkg/app"
)

func Api() {
	routes := []app.RouteDefinition{
		{Method: "GET", Path: "/accesses", Group: "accesses", Alias: "accesses::list", HandlerFunc: nil},

		{Method: "GET", Path: "/roles", Group: "roles", Alias: "roles::list", HandlerFunc: nil},
		{Method: "POST", Path: "/roles", Group: "roles", Alias: "roles::create", HandlerFunc: nil},
		{Method: "GET", Path: "/roles/{id}", Group: "roles", Alias: "roles::view", HandlerFunc: nil},
		{Method: "PUT", Path: "/roles/{id}", Group: "roles", Alias: "roles::update", HandlerFunc: nil},
		{Method: "DELETE", Path: "/roles/{id}", Group: "roles", Alias: "roles::delete", HandlerFunc: nil},
		{Method: "POST", Path: "/roles/{id}/access/{access_id}", Group: "roles", Alias: "roles::grant", HandlerFunc: nil},
		{Method: "DELETE", Path: "/roles/{id}/access/{access_id}", Group: "roles", Alias: "roles::revoke", HandlerFunc: nil},

		{Method: "GET", Path: "/users", Group: "users", Alias: "users::list", HandlerFunc: nil},
		{Method: "POST", Path: "/users", Group: "users", Alias: "users::create", HandlerFunc: nil},
		{Method: "GET", Path: "/users/{id}", Group: "users", Alias: "users::view", HandlerFunc: nil},
		{Method: "PUT", Path: "/users/{id}", Group: "users", Alias: "users::update", HandlerFunc: nil},
		{Method: "DELETE", Path: "/users/{id}", Group: "users", Alias: "users::delete", HandlerFunc: nil},
	}

	fmt.Println(routes)
}
```

### TestMain di Setiap Package Test

Setiap package test memiliki TestMain sendiri, misalnya di paket `auth`, buat file `test/api/auth/main_test.go`

```go
//go:build integration

package auth_test

import (
	"os"
	"testing"
	"workshop/test/setup"
)

func TestMain(m *testing.M) {
	if err := setup.InitServer(); err != nil {
		panic(err)
	}
	defer setup.CloseServer()
	code := m.Run()
	os.Exit(code)
}
```

## 21.5 Helper untuk API Call

Buat helper untuk memanggil API dengan mudah. Update file `test/setup/setup.go` untuk menambahkan helper ini :

```go


func CallAPI(t *testing.T, method, path, token string, body interface{}) *http.Response {
	t.Helper()

	var reqBody *bytes.Buffer = &bytes.Buffer{}
	if body != nil {
		jsonBody, err := json.Marshal(body)
		require.NoError(t, err)
		reqBody = bytes.NewBuffer(jsonBody)
	}

	req, err := http.NewRequest(method, GetServerURL()+path, reqBody)
	require.NoError(t, err)

	req.Header.Set("Content-Type", "application/json")
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}

	client := &http.Client{}
	resp, err := client.Do(req)
	require.NoError(t, err)

	return resp
}

func GetToken(t *testing.T, username, password string) string {
	bodyReq := dto.LoginRequest{
		Username: username,
		Password: password,
	}

	w := CallAPI(t, "POST", "/login", "", bodyReq)
	defer w.Body.Close()

	var resp struct {
		Data struct {
			Token string `json:"token"`
		} `json:"data"`
	}

	err := json.NewDecoder(w.Body).Decode(&resp)
	require.NoError(t, err)

	return resp.Data.Token
}
```

## 21.6 Auth API Test

### Login Scenarios

Buat file `test/api/auth/login_test.go`, pastikan seluruh skenario yang ada di api kontrak tercover dalam api testing.

```go
//go:build integration

package auth_test

import (
	"encoding/json"
	"net/http"
	"testing"
	"workshop/internal/dto"
	"workshop/pkg/errors"
	"workshop/test/setup"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

type LoginScenario struct {
	Name                 string
	Request              dto.LoginRequest
	ExpectHttpStatusCode int
	ExpectBusinessCode   string
	ExpectMessage        string
	ValidateResponse     func(t *testing.T, data json.RawMessage)
}

var loginScenarios = []LoginScenario{
	{
		Name:                 "success login - admin",
		Request:              dto.LoginRequest{Username: "admin@example.com", Password: "1234"},
		ExpectHttpStatusCode: http.StatusOK,
		ExpectBusinessCode:   "B1",
		ExpectMessage:        "Success",
		ValidateResponse: func(t *testing.T, data json.RawMessage) {
			var loginData dto.LoginResponse
			err := json.Unmarshal(data, &loginData)
			require.NoError(t, err)

			assert.NotEmpty(t, loginData.Token)
			assert.Equal(t, "019eb960-a27d-73c8-9703-b23a9f50dc83", loginData.User.ID)
			assert.Equal(t, "Admin", loginData.User.Name)
			assert.Contains(t, loginData.Accesses, "root")
		},
	},
	{
		Name:                 "invalid input - username required",
		Request:              dto.LoginRequest{Username: "", Password: "1234"},
		ExpectHttpStatusCode: http.StatusBadRequest,
		ExpectBusinessCode:   errors.InvalidInputCode,
		ExpectMessage:        errors.InvalidInputMessage,
		ValidateResponse: func(t *testing.T, data json.RawMessage) {
			var validateData map[string]string
			err := json.Unmarshal(data, &validateData)
			require.NoError(t, err)
			assert.Contains(t, validateData["username"], "Username is required")
		},
	},
	{
		Name:                 "invalid input - username not email",
		Request:              dto.LoginRequest{Username: "admin", Password: "1234"},
		ExpectHttpStatusCode: http.StatusBadRequest,
		ExpectBusinessCode:   errors.InvalidInputCode,
		ExpectMessage:        errors.InvalidInputMessage,
		ValidateResponse: func(t *testing.T, data json.RawMessage) {
			var validateData map[string]string
			err := json.Unmarshal(data, &validateData)
			require.NoError(t, err)
			assert.Contains(t, validateData["username"], "Username must be a valid email address")
		},
	},
	{
		Name:                 "invalid input - password required",
		Request:              dto.LoginRequest{Username: "admin@example.com", Password: ""},
		ExpectHttpStatusCode: http.StatusBadRequest,
		ExpectBusinessCode:   errors.InvalidInputCode,
		ExpectMessage:        errors.InvalidInputMessage,
		ValidateResponse: func(t *testing.T, data json.RawMessage) {
			var validateData map[string]string
			err := json.Unmarshal(data, &validateData)
			require.NoError(t, err)
			assert.Contains(t, validateData["password"], "Password is required")
		},
	},
	{
		Name:                 "wrong password",
		Request:              dto.LoginRequest{Username: "admin@example.com", Password: "4321"},
		ExpectHttpStatusCode: http.StatusBadRequest,
		ExpectBusinessCode:   errors.InvalidInputCode,
		ExpectMessage:        "Invalid username/password",
		ValidateResponse: func(t *testing.T, data json.RawMessage) {
			assert.Equal(t, "{}", string(data))
		},
	},
	{
		Name:                 "user not found",
		Request:              dto.LoginRequest{Username: "notfound@example.com", Password: "1234"},
		ExpectHttpStatusCode: http.StatusBadRequest,
		ExpectBusinessCode:   errors.InvalidInputCode,
		ExpectMessage:        "Invalid username/password",
		ValidateResponse: func(t *testing.T, data json.RawMessage) {
			assert.Equal(t, "{}", string(data))
		},
	},
}

func TestAuth_Login(t *testing.T) {
	for _, sc := range loginScenarios {
		t.Run(sc.Name, func(t *testing.T) {
			w := setup.CallAPI(t, "POST", "/login", "", sc.Request)
			defer w.Body.Close()

			assert.Equal(t, sc.ExpectHttpStatusCode, w.StatusCode)

			var resp struct {
				Status  string          `json:"status"`
				Message string          `json:"message"`
				Data    json.RawMessage `json:"data"`
			}
			err := json.NewDecoder(w.Body).Decode(&resp)
			require.NoError(t, err)

			assert.Equal(t, sc.ExpectBusinessCode, resp.Status)
			assert.Equal(t, sc.ExpectMessage, resp.Message)

			if sc.ValidateResponse != nil {
				sc.ValidateResponse(t, resp.Data)
			}
		})
	}
}
```

## 21.7 Role API Test

### Create Role Scenarios

Buat file `test/api/role/create_test.go` :

```go
//go:build integration

package role_test

import (
	"encoding/json"
	"net/http"
	"testing"
	"workshop/internal/dto"
	"workshop/pkg/errors"
	"workshop/test/setup"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

type RoleCreateScenario struct {
	Name                 string
	Request              dto.RoleRequest
	ExpectHttpStatusCode int
	ExpectBusinessCode   string
	ExpectMessage        string
	ValidateResponse     func(t *testing.T, data json.RawMessage)
}

var roleCreateScenarios = []RoleCreateScenario{
	{
		Name:                 "success",
		Request:              dto.RoleRequest{Name: "kasir"},
		ExpectHttpStatusCode: http.StatusCreated,
		ExpectBusinessCode:   "B1",
		ExpectMessage:        "Created",
		ValidateResponse: func(t *testing.T, data json.RawMessage) {
			var resp dto.RoleResponse
			err := json.Unmarshal(data, &resp)
			require.NoError(t, err)

			assert.Equal(t, "kasir", resp.Name)
			assert.NotEmpty(t, resp.ID)
		},
	},
	{
		Name:                 "internal error - duplicate",
		Request:              dto.RoleRequest{Name: "kasir"},
		ExpectHttpStatusCode: http.StatusInternalServerError,
		ExpectBusinessCode:   errors.InternalServerErrorCode,
		ExpectMessage:        "error creating role",
		ValidateResponse: func(t *testing.T, data json.RawMessage) {
			assert.Equal(t, "{}", string(data))
		},
	},
	{
		Name:                 "invalid input - required",
		Request:              dto.RoleRequest{Name: ""},
		ExpectHttpStatusCode: http.StatusBadRequest,
		ExpectBusinessCode:   errors.InvalidInputCode,
		ExpectMessage:        errors.InvalidInputMessage,
		ValidateResponse: func(t *testing.T, data json.RawMessage) {
			var resp map[string]string
			err := json.Unmarshal(data, &resp)
			require.NoError(t, err)
			assert.Contains(t, resp["name"], "Name is required")
		},
	},
	{
		Name:                 "invalid input - too short",
		Request:              dto.RoleRequest{Name: "ka"},
		ExpectHttpStatusCode: http.StatusBadRequest,
		ExpectBusinessCode:   errors.InvalidInputCode,
		ExpectMessage:        errors.InvalidInputMessage,
		ValidateResponse: func(t *testing.T, data json.RawMessage) {
			var resp map[string]string
			err := json.Unmarshal(data, &resp)
			require.NoError(t, err)
			assert.Contains(t, resp["name"], "Name is too short")
		},
	},
	{
		Name:                 "invalid input - too long",
		Request:              dto.RoleRequest{Name: "kasir melebihi 25 karakter"},
		ExpectHttpStatusCode: http.StatusBadRequest,
		ExpectBusinessCode:   errors.InvalidInputCode,
		ExpectMessage:        errors.InvalidInputMessage,
		ValidateResponse: func(t *testing.T, data json.RawMessage) {
			var resp map[string]string
			err := json.Unmarshal(data, &resp)
			require.NoError(t, err)
			assert.Contains(t, resp["name"], "Name is too long")
		},
	},
}

func TestRole_Create(t *testing.T) {
	token := setup.GetToken(t, "admin@example.com", "1234")
	for _, sc := range roleCreateScenarios {
		t.Run(sc.Name, func(t *testing.T) {
			w := setup.CallAPI(t, "POST", "/roles", token, sc.Request)
			defer w.Body.Close()

			assert.Equal(t, sc.ExpectHttpStatusCode, w.StatusCode)

			var resp struct {
				Status  string          `json:"status"`
				Message string          `json:"message"`
				Data    json.RawMessage `json:"data"`
			}
			err := json.NewDecoder(w.Body).Decode(&resp)
			require.NoError(t, err)

			assert.Equal(t, sc.ExpectBusinessCode, resp.Status)
			assert.Equal(t, sc.ExpectMessage, resp.Message)

			if sc.ValidateResponse != nil {
				sc.ValidateResponse(t, resp.Data)
			}
		})
	}
}
```

## 21.8 E2E Testing

Flow E2E Testing untuk RBAC :

1. Login with non-existent user (negative test)
2. Login as admin (prepare pembuatan user baru dari user yang tidak ada di langkah 1)
3. Verify role does NOT exist (mengecek bahwa role baru yang hendak diinput belum ada di database)
4. Create role (membuat role baru)
5. Verify role by ID (memverifikasi role baru telah terbuat)
6. Create user (Membuat user baru dari data user di langkah 1)
7. Verify user by ID (memverifikasi pembuatan user baru berhasil)
8. Login as new user (mendapatkan token dari user yang baru dibuat)
9. Access /accesses without permission (should be forbidden, sesuai ekspektasi user baru belum punya permissions)
10. Get permission IDs (mendapatkan AccessIDs dari ekspektasi list permission yang hendak di-grant ke role baru, persiapan sebelum memanggil langkah grant access)
11. Grant access to role (memberi akses ke role yang telah dibuat)
12. Access /accesses with permission (should succeed, GET /access seharusnya bisa diakses setelah role di-grant)

### Prinsip FailNow

Berbeda dengan api testing yang melakukan asserting, e2e testing yang merajut multiple endpoin dalam satu flow membutuhkan prinsip FailNow yang jika pada suatu titik ada yang tidak sesuai dengan ekspektasi, maka proses pengujian akan dihentikan saat itu juga. Untuk itu, kita akan menggunakan perintah require (bukan assert) dalam membandingkan ekspektasi dengan aktual.

### Pembuatan Base Helper

```go
// test/e2e/helper/base_helper.go
package helper

import (
	"encoding/json"
	"testing"
	"workshop/test/setup"

	"github.com/stretchr/testify/require"
)

type RequestConfig[T any] struct {
	Method         string
	Path           string
	Token          string
	Body           interface{}
	ExpectedStatus int
	ExpectedCode   string
	ExpectedMsg    string
	Validate       func(t *testing.T, data json.RawMessage) *T
}

func DoRequest[T any](t *testing.T, cfg RequestConfig[T]) *T {
	w := setup.CallAPI(t, cfg.Method, cfg.Path, cfg.Token, cfg.Body)
	defer w.Body.Close()

	require.Equal(t, cfg.ExpectedStatus, w.StatusCode)

	var resp struct {
		Status  string          `json:"status"`
		Message string          `json:"message"`
		Data    json.RawMessage `json:"data"`
	}
	err := json.NewDecoder(w.Body).Decode(&resp)
	require.NoError(t, err)

	require.Equal(t, cfg.ExpectedCode, resp.Status)
	require.Equal(t, cfg.ExpectedMsg, resp.Message)

	if cfg.Validate != nil {
		return cfg.Validate(t, resp.Data)
	}
	return nil
}
```

### Auth Helper

```go
// test/e2e/helper/auth_helper.go
package helper

import (
	"encoding/json"
	"net/http"
	"testing"
	"workshop/internal/dto"
	"workshop/test/setup"

	"github.com/stretchr/testify/require"
)

func Login(t *testing.T, email, password string) string {
	w := setup.CallAPI(t, "POST", "/login", "", dto.LoginRequest{Username: email, Password: password})
	defer w.Body.Close()

	require.Equal(t, http.StatusOK, w.StatusCode)

	var resp struct {
		Data struct {
			Token string `json:"token"`
		} `json:"data"`
	}
	err := json.NewDecoder(w.Body).Decode(&resp)
	require.NoError(t, err)
	require.NotEmpty(t, resp.Data.Token)

	return resp.Data.Token
}

func LoginExpectError(t *testing.T, email, password string, expectedStatus int, expectedCode, expectedMsg string) {
	DoRequest(t, RequestConfig[any]{
		Method:         "POST",
		Path:           "/login",
		Token:          "",
		Body:           dto.LoginRequest{Username: email, Password: password},
		ExpectedStatus: expectedStatus,
		ExpectedCode:   expectedCode,
		ExpectedMsg:    expectedMsg,
		Validate: func(t *testing.T, data json.RawMessage) *any {
			require.Equal(t, "{}", string(data))
			return nil
		},
	})
}
```

### Role Helper

```go
// test/e2e/helper/role_helper.go
package helper

import (
	"encoding/json"
	"fmt"
	"net/http"
	"testing"
	"workshop/internal/dto"

	"github.com/stretchr/testify/require"
)

func CreateRole(t *testing.T, token, name string) *dto.RoleResponse {
	return DoRequest(t, RequestConfig[dto.RoleResponse]{
		Method:         "POST",
		Path:           "/roles",
		Token:          token,
		Body:           dto.RoleRequest{Name: name},
		ExpectedStatus: http.StatusCreated,
		ExpectedCode:   "B1",
		ExpectedMsg:    "Created",
		Validate: func(t *testing.T, data json.RawMessage) *dto.RoleResponse {
			var resp dto.RoleResponse
			err := json.Unmarshal(data, &resp)
			require.NoError(t, err)
			require.NotZero(t, resp.ID)
			require.Equal(t, name, resp.Name)
			return &resp
		},
	})
}

func GetRole(t *testing.T, token string, roleID int) *dto.RoleResponse {
	return DoRequest(t, RequestConfig[dto.RoleResponse]{
		Method:         "GET",
		Path:           fmt.Sprintf("/roles/%d", roleID),
		Token:          token,
		Body:           nil,
		ExpectedStatus: http.StatusOK,
		ExpectedCode:   "B1",
		ExpectedMsg:    "Success",
		Validate: func(t *testing.T, data json.RawMessage) *dto.RoleResponse {
			var resp dto.RoleResponse
			err := json.Unmarshal(data, &resp)
			require.NoError(t, err)
			require.Equal(t, roleID, resp.ID)
			return &resp
		},
	})
}

func ListRoles(t *testing.T, token string) []dto.RoleResponse {
	var roles []dto.RoleResponse
	DoRequest(t, RequestConfig[any]{
		Method:         "GET",
		Path:           "/roles",
		Token:          token,
		Body:           nil,
		ExpectedStatus: http.StatusOK,
		ExpectedCode:   "B1",
		ExpectedMsg:    "Success",
		Validate: func(t *testing.T, data json.RawMessage) *any {
			err := json.Unmarshal(data, &roles)
			require.NoError(t, err)
			return nil
		},
	})
	return roles
}

func UpdateRole(t *testing.T, token string, roleID int, newName string) *dto.RoleResponse {
	return DoRequest(t, RequestConfig[dto.RoleResponse]{
		Method:         "PUT",
		Path:           fmt.Sprintf("/roles/%d", roleID),
		Token:          token,
		Body:           dto.RoleRequest{Name: newName},
		ExpectedStatus: http.StatusOK,
		ExpectedCode:   "B1",
		ExpectedMsg:    "Success",
		Validate: func(t *testing.T, data json.RawMessage) *dto.RoleResponse {
			var resp dto.RoleResponse
			err := json.Unmarshal(data, &resp)
			require.NoError(t, err)
			require.Equal(t, roleID, resp.ID)
			require.Equal(t, newName, resp.Name)
			return &resp
		},
	})
}

func DeleteRole(t *testing.T, token string, roleID int) {
	DoRequest(t, RequestConfig[any]{
		Method:         "DELETE",
		Path:           fmt.Sprintf("/roles/%d", roleID),
		Token:          token,
		Body:           nil,
		ExpectedStatus: http.StatusOK,
		ExpectedCode:   "B1",
		ExpectedMsg:    "Success",
		Validate:       nil,
	})
}

func RoleExists(t *testing.T, token, name string) bool {
	roles := ListRoles(t, token)
	for _, r := range roles {
		if r.Name == name {
			return true
		}
	}
	return false
}
```

### User Helper

```go
// test/e2e/helper/user_helper.go
package helper

import (
	"encoding/json"
	"fmt"
	"net/http"
	"testing"
	"workshop/internal/dto"

	"github.com/stretchr/testify/require"
)

func CreateUser(t *testing.T, token, name, email, password string, roles []int) *dto.UserResponse {
	return DoRequest(t, RequestConfig[dto.UserResponse]{
		Method: "POST",
		Path:   "/users",
		Token:  token,
		Body: dto.UserRequest{
			Name:     name,
			Username: email,
			Email:    email,
			Password: password,
			IsActive: true,
			Roles:    roles,
		},
		ExpectedStatus: http.StatusCreated,
		ExpectedCode:   "B1",
		ExpectedMsg:    "Created",
		Validate: func(t *testing.T, data json.RawMessage) *dto.UserResponse {
			var resp dto.UserResponse
			err := json.Unmarshal(data, &resp)
			require.NoError(t, err)
			require.NotEmpty(t, resp.ID)
			require.Equal(t, email, resp.Email)
			return &resp
		},
	})
}

func GetUser(t *testing.T, token, userID string) *dto.UserResponse {
	return DoRequest(t, RequestConfig[dto.UserResponse]{
		Method:         "GET",
		Path:           fmt.Sprintf("/users/%s", userID),
		Token:          token,
		Body:           nil,
		ExpectedStatus: http.StatusOK,
		ExpectedCode:   "B1",
		ExpectedMsg:    "Success",
		Validate: func(t *testing.T, data json.RawMessage) *dto.UserResponse {
			var resp dto.UserResponse
			err := json.Unmarshal(data, &resp)
			require.NoError(t, err)
			require.Equal(t, userID, resp.ID)
			return &resp
		},
	})
}

func DeleteUser(t *testing.T, token, userID string) {
	DoRequest(t, RequestConfig[any]{
		Method:         "DELETE",
		Path:           fmt.Sprintf("/users/%s", userID),
		Token:          token,
		Body:           nil,
		ExpectedStatus: http.StatusOK,
		ExpectedCode:   "B1",
		ExpectedMsg:    "Success",
		Validate:       nil,
	})
}
```

### Access Helper

```go
// test/e2e/helper/access_helper.go
package helper

import (
	"encoding/json"
	"fmt"
	"net/http"
	"testing"
	"workshop/internal/dto"
	"workshop/pkg/errors"

	"github.com/stretchr/testify/require"
)

func ListAccesses(t *testing.T, token string) []dto.AccessTreeResponse {
	var accesses []dto.AccessTreeResponse
	DoRequest(t, RequestConfig[any]{
		Method:         "GET",
		Path:           "/accesses",
		Token:          token,
		Body:           nil,
		ExpectedStatus: http.StatusOK,
		ExpectedCode:   "B1",
		ExpectedMsg:    "Success",
		Validate: func(t *testing.T, data json.RawMessage) *any {
			err := json.Unmarshal(data, &accesses)
			require.NoError(t, err)
			return nil
		},
	})
	return accesses
}

func ListAccessesExpectForbidden(t *testing.T, token string) {
	DoRequest(t, RequestConfig[any]{
		Method:         "GET",
		Path:           "/accesses",
		Token:          token,
		Body:           nil,
		ExpectedStatus: http.StatusForbidden,
		ExpectedCode:   errors.ForbiddenCode,
		ExpectedMsg:    "Forbidden",
		Validate:       nil,
	})
}

func GrantAccess(t *testing.T, token string, roleID, accessID int) {
	DoRequest(t, RequestConfig[any]{
		Method:         "POST",
		Path:           fmt.Sprintf("/roles/%d/access/%d", roleID, accessID),
		Token:          token,
		Body:           nil,
		ExpectedStatus: http.StatusOK,
		ExpectedCode:   "B1",
		ExpectedMsg:    "Success",
		Validate:       nil,
	})
}

func RevokeAccess(t *testing.T, token string, roleID, accessID int) {
	DoRequest(t, RequestConfig[any]{
		Method:         "DELETE",
		Path:           fmt.Sprintf("/roles/%d/access/%d", roleID, accessID),
		Token:          token,
		Body:           nil,
		ExpectedStatus: http.StatusOK,
		ExpectedCode:   "B1",
		ExpectedMsg:    "Success",
		Validate:       nil,
	})
}

func GetAccessIDs(t *testing.T, token string, permissions []string) []int {
	accessIDs := make([]int, 0)
	accesses := ListAccesses(t, token)

	for _, r := range accesses {
		for _, child := range r.Childrens {
			for _, permission := range permissions {
				if child.Alias == permission {
					accessIDs = append(accessIDs, child.ID)
				}
			}
		}
	}
	return accessIDs
}
```

### E2E Test : RBAC Flow

```go
//go:build integration

package rbac_test

import (
	"net/http"
	"testing"
	"workshop/test/e2e/helper"

	"github.com/stretchr/testify/require"
)

func TestRBAC(t *testing.T) {
	const (
		adminEmail = "admin@example.com"
		adminPass  = "1234"
		userEmail  = "manager@example.com"
		userPass   = "1234567890"
		userName   = "manger"
		roleName   = "manager"
	)

	var (
		permissions = []string{
			"accesses::list",
			"roles::list",
			"roles::view",
		}
	)

	// Step 1: Login with non-existent user (negative test)
	helper.LoginExpectError(t, userEmail, userPass, http.StatusBadRequest, "E001", "Invalid username/password")

	// Step 2: Login as admin
	tokenAdmin := helper.Login(t, adminEmail, adminPass)

	// Step 3: Verify role does NOT exist
	require.False(t, helper.RoleExists(t, tokenAdmin, roleName), "Role should not exist yet")

	// Step 4: Create role
	role := helper.CreateRole(t, tokenAdmin, roleName)
	t.Logf("✅ Role created: ID=%d, Name=%s", role.ID, role.Name)

	// Step 5: Verify role by ID
	gotRole := helper.GetRole(t, tokenAdmin, role.ID)
	require.Equal(t, role.Name, gotRole.Name)

	// Step 6: Create user
	user := helper.CreateUser(t, tokenAdmin, userName, userEmail, userPass, []int{role.ID})
	t.Logf("✅ User created: ID=%s", user.ID)

	// Step 7: Verify user by ID
	gotUser := helper.GetUser(t, tokenAdmin, user.ID)
	require.Equal(t, user.Email, gotUser.Email)

	// Step 8: Login as new user (no permissions yet)
	tokenUser := helper.Login(t, userEmail, userPass)

	// Step 9: Access /accesses without permission (should be forbidden)
	helper.ListAccessesExpectForbidden(t, tokenUser)

	// Step 10: Get permission IDs
	permissionIDs := helper.GetAccessIDs(t, tokenAdmin, permissions)
	require.Equal(t, len(permissions), len(permissionIDs), "Length of Permission IDs should be 3")

	// Step 11: Grant access to role
	for _, accessID := range permissionIDs {
		helper.GrantAccess(t, tokenAdmin, role.ID, accessID)
	}

	// Step 12: Access /accesses with permission (should succeed)
	accesses := helper.ListAccesses(t, tokenUser)
	require.NotEmpty(t, accesses, "User should have permissions after grant")
	t.Logf("✅ User now has %d permissions", len(accesses))

	// Cleanup (auto cleanup dengan t.Cleanup)
	t.Cleanup(func() {
		helper.DeleteUser(t, tokenAdmin, user.ID)
		helper.DeleteRole(t, tokenAdmin, role.ID)
		t.Log("✅ Cleanup completed")
	})
}
```

## 21.9 Build Tag

Tambahkan build tag //go:build integration di semua file API test. Ini memungkinkan kita menjalankan unit test dan API test secara terpisah.

**Konfigurasi VS Code:** Buat `.vscode/settings.json` agar build tag dikenali:

```json
{
    "go.buildTags": "integration",
    "go.testTags": "integration",
    "go.testTimeout": "120s",
    "go.testFlags": ["-v", "-count=1"],
    "gopls": {
        "build.env": {
            "GOFLAGS": "-tags=integration"
        },
        "build.directoryFilters": [
            "-node_modules",
            "-vendor",
            "-testdata"
        ],
        "ui.semanticTokens": true,
        "ui.completion.usePlaceholders": true
    }
}
```

kemudian retart gopl. (mac -> shift+cmd+p, muncul promp, kli `GO: Restart Language Server`)

**Menjalankan Test:**

```bash
# Unit test saja (tanpa build tag)
go test ./...

# API test (dengan build tag integration)
go test -tags=integration ./test/api/...

# E2E test
go test -tags=integration ./test/e2e/...

# Semua test (unit + API + E2E)
go test -tags=integration ./...
```

## Ringkasan Bab 21

Di bab ini kita telah belajar:

| Komponen             | Lokasi                      | Fungsi                                    |
| -------------------- | --------------------------- | ----------------------------------------- |
| PostgreSQL Container | test/containers/postgres.go | Membuat database container on-the-fly     |
| Container Registry   | test/containers/registry.go | Singleton manager untuk semua container   |
| Setup Test           | test/setup/setup.go         | Inisialisasi server, migrasi, scan access |
| API Helpers          | test/setup/setup.go         | CallAPI(), GetToken()                     |
| Auth Test            | test/api/auth/              | Test endpoint /login                      |
| Role Test            | test/api/role/              | Test CRUD roles                           |
| E2E Helpers          | test/e2e/helper/            | Helper untuk setiap resource              |
| E2E Test             | test/e2e/rbac/              | End-to-end flow RBAC                      |

Manfaat yang kita peroleh:

* ✅ API teruji secara end-to-end dengan database sungguhan
* ✅ Test terisolasi (container on-the-fly)
* ✅ Build tag memisahkan unit test dan integration test
* ✅ Helper yang reusable untuk berbagai skenario

Yang akan datang:

* Saat ini kita belum membahas cache
* Bab selanjutnya: Cache – meningkatkan performa dengan caching


# Cache

Dalam pengembangan aplikasi modern, caching adalah salah satu strategi paling efektif untuk meningkatkan performa dan mengurangi beban database. Buku ini akan memandu Anda melalui implementasi caching yang robust menggunakan **Valkey** (fork dari Redis).

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/22-cache](https://github.com/jacky-htg/workshop/tree/main/22-cache)

## 22.1 Masalah yang Dihadapi

Bayangkan Anda memiliki aplikasi dengan fitur daftar pengguna (List Users) yang dipanggil ribuan kali per detik. Setiap request akan:

1. Query database (`SELECT * FROM users`)
2. Mengirimkan data ke user
3. Memproses query yang sama berulang kali

**Dampak:**

* Database overload
* Response time lambat (100ms+)
* Biaya infrastruktur tinggi

## 22.2 Solusi: Caching

Dengan caching, kita menyimpan hasil query di memory (Valkey/Redis) sehingga:

| **Metrik**        | Tanpa Cache | Dengan Cache |
| ----------------- | ----------- | ------------ |
| **Response Time** | 100ms+      | 1-5ms        |
| **Database Load** | 100%        | < 10%        |
| **Skalabilitas**  | Terbatas    | Sangat baik  |

```
┌─────────────────────────────────────────────────────────────────────────┐
│  Tanpa Cache:                                                           │
│  User → API → Database (100ms) → Response                               │
│                                                                         │
│  Dengan Cache:                                                          │
│  User → API → Cache (2ms) → Response (CACHE HIT!)                       │
│  User → API → Cache (MISS) → Database (100ms) → Cache → Response        │
└─────────────────────────────────────────────────────────────────────────┘
```

## 22.3 Memilih Cache Server

Redis banyak digunakan sebagai server cache, namnun semenjak redis tidak lagi open source, komunitas opensource membuat valkey yang di-fork dari redis versi 7.2.

### Mnejalankan Valkey dengan Docker

```bash
docker run -d --name valkey -p 6379:6379 valkey/valkey:9-alpine
```

## 22.4 Memilih Library Go untuk Valkey

| Library        | Kelebihan                                                                                                                              | Kekurangan                                                              |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `valkey-glide` | Multi-language support (Rust core), AZ Affinity Routing (optimasi biaya cloud), Stable dan enterprise-ready, Consisten di semua bahasa | 1 koneksi multiplex per node, Perlu client terpisah untuk operasi besar |
| `valkey-go`    | Native Go, sangat cepat, Auto-pipelining, Client-side caching, Connection pool support                                                 | Hanya untuk Go, Fitur enterprise terbatas                               |

**Rekomendasi**

* Cloud (AWS/GCP) -> Gunakan `valkey-glide` (AZ Affinity routing menghemat biaya)
* On premise -> Gunakan `valkey-go` (Lebih ringan).

## 22.5 Arsitektur Caching

### Prinsip Dasar

Operasi terkait cache dihandle oleh **Service Layer**, bukan Handler atau Repository:

```
┌─────────────────────────────────────────────────────────────────────────┐
│                         LAYER ARSITEKTUR                                │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌─────────────────────────────────────────────────────────────────┐    │
│  │                      HANDLER LAYER                              │    │
│  │  - Menerima HTTP request                                        │    │
│  │  - Validasi input                                               │    │
│  │  - ❌ TIDAK akses cache langsung                                │    │
│  └─────────────────────────────────────────────────────────────────┘    │
│                              │                                          │
│                              ▼                                          │
│  ┌─────────────────────────────────────────────────────────────────┐    │
│  │                      SERVICE LAYER                              │    │
│  │  - Business logic                                               │    │
│  │  - ✅ BACA/TULIS cache (Cache-Aside Pattern)                    │    │
│  │  - ✅ Cache Invalidation logic                                  │    │
│  └─────────────────────────────────────────────────────────────────┘    │
│                              │                                          │
│                              ▼                                          │
│  ┌─────────────────────────────────────────────────────────────────┐    │
│  │                    REPOSITORY LAYER                             │    │
│  │  - Akses database (CRUD)                                        │    │
│  │  - ❌ TIDAK akses cache langsung                                │    │
│  └─────────────────────────────────────────────────────────────────┘    │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘
```

### Cache-Aside Pattern

Cache-Aside adalah strategi caching paling umum. Aplikasi bertanggung jawab penuh mengelola cache:

```
┌─────────────────────────────────────────────────────────────────────────┐
│                    CACHE-ASIDE PATTERN                                  │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  READ FLOW:                                                             │
│  ┌─────┐   1. Cek Cache    ┌─────────┐                                  │
│  │ App │ ─────────────────→ │  Cache  │                                 │
│  └─────┘                   └─────────┘                                  │
│     │                           │                                       │
│     │    2. Cache Miss          │                                       │
│     └───────────────────────────┘                                       │
│     │                                                                   │
│     ▼                                                                   │
│  ┌─────┐   3. Query DB      ┌─────────┐                                 │
│  │ App │ ─────────────────→ │ Database│                                 │
│  └─────┘                   └─────────┘                                  │
│     │                           │                                       │
│     │    4. Return Data         │                                       │
│     │    5. Store in Cache      │                                       │
│     └───────────────────────────┘                                       │
│                                                                         │
│  WRITE FLOW:                                                            │
│  ┌─────┐   1. Update DB     ┌─────────┐                                 │
│  │ App │ ─────────────────→ │ Database│                                 │
│  └─────┘                   └─────────┘                                  │
│     │                                                                   │
│     │    2. Invalidate/Delete Cache                                     │
│     └───────────────────────────┐                                       │
│                                 ▼                                       │
│                          ┌─────────┐                                    │
│                          │  Cache  │  ← ❌ Data dihapus/diinvalidate    │
│                          └─────────┘                                    │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘
```

## 22.6 Implementasi Cache Client

### Interface Cache Client

```go
// pkg/cache/cache.go
package cache

import (
	"context"
	"time"
	"workshop/config"
)

type CacheClient interface {
    // Basic Function
	Ping(ctx context.Context) (string, error)
	Exists(ctx context.Context, key string) (bool, error)
	Get(ctx context.Context, key string) (string, error)
	Set(ctx context.Context, key string, value string) error
	SetWithExpiry(ctx context.Context, key string, value string, expiry time.Duration) error
	Del(ctx context.Context, keys []string) (int64, error)
	Close() error

    // Custome JSON
	GetJSON(ctx context.Context, key string, dest interface{}) error
	SetJSON(ctx context.Context, key string, value interface{}) error
	SetJSONWithExpiry(ctx context.Context, key string, value interface{}, expiry time.Duration) error

    // Atomic SET Function
	SAdd(ctx context.Context, key string, value string) (bool, error)
	SMembers(ctx context.Context, key string) ([]string, error)
	SCard(ctx context.Context, key string) (int64, error)
	SRem(ctx context.Context, key string, value ...string) error
	SScan(ctx context.Context, key string, cursor string, defaultBatchSize int) ([]string, string, error)
}

func NewCache(cfg config.CacheConfig) (CacheClient, error) {
	if cfg.ClusterMode {
		var cluster clusterCache
		return cluster.open(cfg)
	}

	var standalone standaloneCache
	return standalone.open(cfg)
}
```

### Implementasi Standalone Valkey

```go
// pkg/cache/valkey_standalone.go
package cache

import (
	"context"
	"encoding/json"
	"fmt"
	"time"
	wcfg "workshop/config"

	glide "github.com/valkey-io/valkey-glide/go/v2"
	"github.com/valkey-io/valkey-glide/go/v2/config"
	"github.com/valkey-io/valkey-glide/go/v2/models"
	"github.com/valkey-io/valkey-glide/go/v2/options"
)

type standaloneCache struct {
	client *glide.Client
}

func (s *standaloneCache) open(cfg wcfg.CacheConfig) (CacheClient, error) {
	clientConfig := config.NewClientConfiguration().
		WithAddress(&config.NodeAddress{
			Host: cfg.Host,
			Port: cfg.Port,
		}).
		WithRequestTimeout(cfg.DialTimeout)

	if cfg.Password != "" {
		var creds *config.ServerCredentials
		if cfg.Username != "" {
			creds = config.NewServerCredentials(cfg.Username, cfg.Password)
		} else {
			creds = config.NewServerCredentialsWithDefaultUsername(cfg.Password)
		}
		clientConfig = clientConfig.WithCredentials(creds)
	}

	client, err := glide.NewClient(clientConfig)
	if err != nil {
		return nil, fmt.Errorf("failed to create client: %w", err)
	}

	// Test koneksi
	ctx := context.Background()
	if _, err := client.Ping(ctx); err != nil {
		client.Close()
		return nil, fmt.Errorf("ping failed: %w", err)
	}

	return &standaloneCache{client: client}, nil
}

func (s *standaloneCache) Ping(ctx context.Context) (string, error) {
	return s.client.Ping(ctx)
}

func (s *standaloneCache) Exists(ctx context.Context, key string) (bool, error) {
	count, err := s.client.Exists(ctx, []string{key})
	if err != nil {
		return false, err
	}

	if count > 0 {
		return false, nil
	}

	return true, nil
}

func (s *standaloneCache) Get(ctx context.Context, key string) (string, error) {
	result, err := s.client.Get(ctx, key)
	if err != nil {
		return "", err
	}

	if result.IsNil() {
		return "", nil
	}

	return result.Value(), nil
}

func (s *standaloneCache) Set(ctx context.Context, key string, value string) error {
	_, err := s.client.Set(ctx, key, value)
	return err
}

func (s *standaloneCache) SetWithExpiry(ctx context.Context, key string, value string, expiry time.Duration) error {
	_, err := s.client.SetWithOptions(ctx, key, value, options.SetOptions{
		Expiry: options.NewExpiryIn(expiry),
	})
	return err
}

func (s *standaloneCache) Del(ctx context.Context, keys []string) (int64, error) {
	return s.client.Del(ctx, keys)
}

func (s *standaloneCache) Close() error {
	s.client.Close()
	return nil
}

func (s *standaloneCache) GetJSON(ctx context.Context, key string, dest interface{}) error {
	val, err := s.Get(ctx, key)
	if err != nil {
		return fmt.Errorf("failed to get key %s: %w", key, err)
	}

	if err := json.Unmarshal([]byte(val), dest); err != nil {
		return fmt.Errorf("failed to unmarshal JSON for key %s: %w", key, err)
	}

	return nil
}

func (s *standaloneCache) SetJSON(ctx context.Context, key string, value interface{}) error {
	data, err := json.Marshal(value)
	if err != nil {
		return fmt.Errorf("failed to marshal JSON for key %s: %w", key, err)
	}

	if err := s.Set(ctx, key, string(data)); err != nil {
		return fmt.Errorf("failed to set key %s: %w", key, err)
	}

	return nil
}

func (s *standaloneCache) SetJSONWithExpiry(ctx context.Context, key string, value interface{}, expiry time.Duration) error {
	data, err := json.Marshal(value)
	if err != nil {
		return fmt.Errorf("failed to marshal JSON for key %s: %w", key, err)
	}

	if err := s.SetWithExpiry(ctx, key, string(data), expiry); err != nil {
		return fmt.Errorf("failed to set key %s: %w", key, err)
	}

	return nil
}

func (s *standaloneCache) SAdd(ctx context.Context, key, value string) (bool, error) {
	count, err := s.client.SAdd(ctx, key, []string{value})
	if err != nil {
		return false, err
	}

	if count == 0 {
		return false, nil
	}
	return true, nil
}

func (s *standaloneCache) SMembers(ctx context.Context, key string) ([]string, error) {
	mapStr, err := s.client.SMembers(ctx, key)
	if err != nil {
		return nil, err
	}

	members := make([]string, 0, len(mapStr))
	for member := range mapStr {
		members = append(members, member)
	}
	return members, nil
}

func (s *standaloneCache) SCard(ctx context.Context, key string) (int64, error) {
	return s.client.SCard(ctx, key)
}

func (s *standaloneCache) SRem(ctx context.Context, key string, value ...string) error {
	_, err := s.client.SRem(ctx, key, value)
	return err
}

func (s *standaloneCache) SScan(ctx context.Context, key string, cursor string, defaultBatchSize int) ([]string, string, error) {
	cursorModel := models.NewCursorFromString(cursor)
	result, err := s.client.SScan(ctx, key, cursorModel)
	if err != nil {
		return nil, "", fmt.Errorf("failed to SScan key %s: %w", key, err)
	}
	return result.Data, result.Cursor.String(), nil
}
```

### Konfigurasi Cache

```go
// config/config.go
type CacheConfig struct {
    Host        string
    Port        string
    Password    string
    Username    string
    ClusterMode bool
    DialTimeout time.Duration
}

type TTLConfig struct {
    TTLDefault time.Duration
    TTLShort   time.Duration
    TTLLong    time.Duration
}
```

## 22.7 Helper List Cache (Key Registering)

Untuk cache dengan banyak parameter (seperti List Users dengan sorting, filtering, pagination), kita perlu strategi khusus untuk mengelola key. Masalah

Pada List Users, setiap kombinasi parameter menghasilkan key berbeda:

* `users::list::order:name::sort:asc::search:admin::limit:10::page:1`
* `users::list::order:name::sort:desc::search:admin::limit:10::page:1`
* `users::list::order:email::sort:asc::search:john::limit:20::page:2`

**Masalah pada Write Flow:** Bagaimana menghapus semua key yang relevan saat data berubah?

Pendekatan yang umum digunakan ada 3:

1. **Cache dengan TTL pendek**, sehingga tidak perlu dipusingkan bagaimana melakukan invalidate, karena cache secara otomatis expired ketika TTL habis. Kelemahannya adalah data cache tidak bisa dijamin merupakan data paling baru.
2. **Versioning Number**. Dibuat cache untuk mengelola versioning dari setiap kelompok cache. Keuntungannya data cache selalu paling update, mudah diterapkan, dan tidak perlu dipusingkan dengan invalidate cache. Kelemahanya storage membengkak karena cache yang sudah tidak valid masih disimpan hingga TTL nya habis.
3. **Key Registering**, dimana setiap key didaftarkan ke dalam index\_list, sehingga key yang sudah tidak terpakai bisa dihapus. Keuntungan data cache tetap terbaru, storage tidak membengkak, namun kompleksitasnya tinggi.

### Solusi: Key Registering dengan SET

```
┌─────────────────────────────────────────────────────────────────────────┐
│                    KEY REGISTERING PATTERN                              │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  READ FLOW:                                                             │
│  1. Generate cache key                                                  │
│  2. Register key ke index (SADD)                                        │
│  3. Store data di cache                                                 │
│                                                                         │
│  WRITE FLOW:                                                            │
│  1. Get semua key dari index (SMEMBERS)                                 │
│  2. Delete semua key (DEL)                                              │
│  3. Delete index (DEL)                                                  │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘
```

### Helper List Cache

Buat helper listcache dengan fungsi :

* GenereateKey untuk konsistensi penamaan key cache
* AddKeyToIndex untuk proses registering key
* InvalidateListCache untuk proses invalidate cache
* Cleanup untuk menghapus member key yang sudah expired

Buat file `pkg/listcache/helper.go`

```go
package listcache

import (
	"context"
	"fmt"
	"log/slog"
	"time"
	"workshop/pkg/cache"

	"github.com/jacky-htg/go-libs/logger"
)

const (
	defaultBatchSize = 100 // Jumlah key yang diproses per siklus SSCAN
)

func GenerateListCacheKey(prefixKey, order, sort, search string, limit, page int) string {
	return fmt.Sprintf("%sorder:%s::sort:%s::search:%s::limit:%d::page:%d",
		prefixKey, order, sort, search, limit, page)
}

func InvalidateListCache(ctx context.Context, log logger.Logger, client cache.CacheClient, indexKey string) error {
	// 1. Get semua key dari index
	keys, err := client.SMembers(ctx, indexKey)
	if err != nil {
		return err
	}

	if len(keys) == 0 {
		return nil
	}

	// 2. Delete semua key (batch)
	batchSize := 100
	for i := 0; i < len(keys); i += batchSize {
		end := i + batchSize
		if end > len(keys) {
			end = len(keys)
		}

		batch := keys[i:end]
		if _, err := client.Del(ctx, batch); err != nil {
			log.Error(ctx, "failed to delete cache batch",
				slog.Any("error", err),
				slog.Int("batch", i/batchSize))
		}
	}

	// 3. Hapus index (reset)
	if _, err := client.Del(ctx, []string{indexKey}); err != nil {
		log.Error(ctx, "failed to delete index", slog.Any("error", err))
	}

	log.Info(ctx, "invalidated list cache", slog.Int("count", len(keys)))
	return nil
}

func AddKeyToIndex(ctx context.Context, log logger.Logger, client cache.CacheClient, cacheKey, indexKey string, maxIndexSize int) error {
	added, err := client.SAdd(ctx, indexKey, cacheKey)
	if err != nil {
		return err
	}

	if !added {
		return nil
	}

	size, err := client.SCard(ctx, indexKey)
	if err != nil {
		return err
	}

	// Jika index terlalu besar, hapus yang paling tua
	if size > int64(maxIndexSize) {
		cleanupStaleIndexBatch(ctx, log, client, indexKey)
	}

	return nil
}

func Cleanup(ctx context.Context, log logger.Logger, client cache.CacheClient, indexKey string) {
	cleanupStaleIndexBatch(ctx, log, client, indexKey)
}

func cleanupStaleIndexBatch(ctx context.Context, log logger.Logger, client cache.CacheClient, indexKey string) {
	startTime := time.Now()
	totalRemoved := 0
	totalChecked := 0
	cursor := "0" // SSCAN menggunakan string cursor

	log.Debug(ctx, "starting cleanup batch", slog.String("index_key", indexKey))

	for {
		// 1. Gunakan SSCAN untuk iterasi bertahap
		keys, nextCursor, err := client.SScan(ctx, indexKey, cursor, defaultBatchSize)
		if err != nil {
			log.Error(ctx, "failed to scan index",
				slog.String("index_key", indexKey),
				slog.Any("error", err))
			return
		}

		totalChecked += len(keys)

		// 2. Proses keys dalam batch kecil
		if len(keys) > 0 {
			staleKeys := make([]string, 0)

			for _, key := range keys {
				exists, err := client.Exists(ctx, key)
				if err != nil {
					log.Warn(ctx, "failed to check existence",
						slog.String("key", key),
						slog.Any("error", err))
					continue
				}

				if !exists {
					staleKeys = append(staleKeys, key)
				}
			}

			// 3. Hapus stale keys dari index (batch kecil)
			if len(staleKeys) > 0 {
				// Hapus dalam batch kecil (10 keys per batch)
				for i := 0; i < len(staleKeys); i += 10 {
					end := i + 10
					if end > len(staleKeys) {
						end = len(staleKeys)
					}
					err = client.SRem(ctx, indexKey, staleKeys[i:end]...)
					if err != nil {
						log.Debug(ctx, "error srem", slog.Any("error", err))
					}
				}
				totalRemoved += len(staleKeys)
			}

			// 4. Log progress setiap 50 keys
			if totalChecked%50 == 0 && totalChecked > 0 {
				log.Debug(ctx, "cleanup progress",
					slog.Int("checked", totalChecked),
					slog.Int("removed", totalRemoved))
			}
		}

		// 5. Cek cursor - jika "0" berarti selesai
		if nextCursor == "0" {
			break
		}
		cursor = nextCursor
	}

	// 6. Jika semua stale sudah dibersihkan dan index kosong, hapus index key
	if totalChecked > 0 && totalRemoved > 0 {
		remaining, err := client.SCard(ctx, indexKey)
		if err == nil && remaining == 0 {
			client.Del(ctx, []string{indexKey})
			log.Info(ctx, "index emptied, removed key",
				slog.String("index_key", indexKey))
		}
	}

	elapsed := time.Since(startTime)
	if totalChecked > 0 {
		log.Info(ctx, "cleanup batch completed",
			slog.Int("checked", totalChecked),
			slog.Int("removed", totalRemoved),
			slog.Duration("duration", elapsed))
	}
}
```

## 22.8 Implementasi Cache di Service User

```go
package service

import (
	"context"
	"database/sql"
	"fmt"
	"log/slog"
	"workshop/config"
	"workshop/internal/model"
	"workshop/internal/repository"
	"workshop/pkg/cache"
	"workshop/pkg/errors"
	"workshop/pkg/listcache"

	"github.com/jacky-htg/go-libs/logger"
	"github.com/jacky-htg/go-libs/uuid7"
	"golang.org/x/crypto/bcrypt"
)

const (
	usersListPrefix = "users::list::"
	usersIndexKey   = "users::list::index"
	maxIndexSize    = 1000 // Batasi jumlah key di index
)

type Users interface {
	List(ctx context.Context, search, order, sort string, limit, page int) ([]model.User, model.Pagination, *errors.BusinessError)
	Create(ctx context.Context, user *model.User) *errors.BusinessError
	FindByID(ctx context.Context, id string) (*model.User, *errors.BusinessError)
	Update(ctx context.Context, user *model.User) *errors.BusinessError
	Delete(ctx context.Context, id string) *errors.BusinessError
}

type users struct {
	db    *sql.DB
	cache cache.CacheClient
	log   logger.Logger
	ttl   config.TTLConfig
	repo  repository.UserRepository
}

func NewUsers(db *sql.DB, cache cache.CacheClient, log logger.Logger, ttl config.TTLConfig, repo repository.UserRepository) Users {
	return &users{db: db, cache: cache, log: log, ttl: ttl, repo: repo}
}

func (u *users) List(ctx context.Context, search, order, sort string, limit, page int) ([]model.User, model.Pagination, *errors.BusinessError) {
	cacheKey := listcache.GenerateListCacheKey(usersListPrefix, order, sort, search, limit, page)

	var cachedResult struct {
		Users      []model.User     `json:"users"`
		Pagination model.Pagination `json:"pagination"`
	}

	err := u.cache.GetJSON(ctx, cacheKey, &cachedResult)
	if err == nil {
		return cachedResult.Users, cachedResult.Pagination, nil
	}

	pagination := model.Pagination{Page: page, Limit: limit}
	offset := (pagination.Page - 1) * pagination.Limit

	users, count, err := u.repo.List(ctx, search, order, sort, pagination.Limit, offset)
	if err != nil {
		return nil, pagination, errors.InternalServerErrorWrap(err, "error listing users")
	}
	pagination.Count = count

	if err := u.saveListCache(ctx, cacheKey, users, pagination); err != nil {
		u.log.Warn(ctx, "failed to save cache", slog.Any("error", err))
	}

	return users, pagination, nil
}

func (u *users) Create(ctx context.Context, user *model.User) *errors.BusinessError {
	pass, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
	if err != nil {
		u.log.Error(ctx, "error generate password", slog.Any("error", err))
		return errors.InternalServerErrorWrap(err, "error generating password")
	}

	user.ID = uuid7.New()
	user.Password = string(pass)

	tx, err := u.db.BeginTx(ctx, nil)
	if err != nil {
		u.log.Error(ctx, "error begin tx", slog.Any("error", err))
		return errors.InternalServerErrorWrap(err)
	}
	defer tx.Rollback()

	if err := u.repo.Create(ctx, tx, user); err != nil {
		return errors.InternalServerErrorWrap(err, "error creating user")
	}

	for _, v := range user.Roles {
		if err := u.repo.AssignRole(ctx, tx, user.ID, int64(v.ID)); err != nil {
			return errors.InternalServerErrorWrap(err, "error assign role")
		}
	}

	if err := listcache.InvalidateListCache(ctx, u.log, u.cache, usersIndexKey); err != nil {
		return errors.InternalServerErrorWrap(err, "error invalidate list cache")
	}

	if err = tx.Commit(); err != nil {
		return errors.InternalServerErrorWrap(err)
	}

	return nil
}

func (u *users) FindByID(ctx context.Context, id string) (*model.User, *errors.BusinessError) {
	cacheKey := "users::" + id
	var user *model.User
	if err := u.cache.GetJSON(ctx, cacheKey, &user); err == nil {
		return user, nil
	}

	user, err := u.repo.FindByID(ctx, id)
	if err != nil {
		return nil, errors.InternalServerErrorWrap(err, "error finding user")
	}
	if user == nil {
		return nil, errors.NotFound("user not found")
	}

	if err := u.cache.SetJSONWithExpiry(ctx, cacheKey, user, u.ttl.TTLDefault); err != nil {
		u.log.Warn(ctx, "set cache failed ", slog.Any("error", err))
	}

	return user, nil
}

func (u *users) Update(ctx context.Context, user *model.User) *errors.BusinessError {
	cacheKey := "users::" + user.ID

	existUser, err := u.repo.FindByID(ctx, user.ID)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error finding user")
	}
	if existUser == nil {
		return errors.NotFound("user not found")
	}

	tx, err := u.db.BeginTx(ctx, nil)
	if err != nil {
		u.log.Error(ctx, "error begin tx", slog.Any("error", err))
		return errors.InternalServerErrorWrap(err)
	}
	defer tx.Rollback()

	err = u.repo.Update(ctx, tx, user)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error updating user")
	}

	mapExistingRoles := make(map[int]model.Role)
	mapNewRoles := make(map[int]model.Role)

	for _, v := range existUser.Roles {
		mapExistingRoles[v.ID] = v
	}

	for _, w := range user.Roles {
		if _, ok := mapExistingRoles[w.ID]; ok {
			delete(mapExistingRoles, w.ID)
		} else {
			mapNewRoles[w.ID] = w
		}
	}

	for _, val := range mapNewRoles {
		if err := u.repo.AssignRole(ctx, tx, user.ID, int64(val.ID)); err != nil {
			return errors.InternalServerErrorWrap(err, "error update assign role")
		}
	}

	for _, val := range mapExistingRoles {
		if err := u.repo.RemoveRole(ctx, tx, user.ID, int64(val.ID)); err != nil {
			return errors.InternalServerErrorWrap(err, "error update assign role")
		}
	}

	if _, err := u.cache.Del(ctx, []string{cacheKey}); err != nil {
		u.log.Error(ctx, "del cache failed", slog.Any("error", err))
		return errors.InternalServerErrorWrap(err)
	}

	if err = tx.Commit(); err != nil {
		return errors.InternalServerErrorWrap(err)
	}

	return nil
}

func (u *users) Delete(ctx context.Context, id string) *errors.BusinessError {
	cacheKey := "users::" + id
	existUser, err := u.repo.FindByID(ctx, id)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error finding user")
	}
	if existUser == nil {
		return errors.NotFound("user not found")
	}

	if _, err := u.cache.Del(ctx, []string{cacheKey}); err != nil {
		u.log.Error(ctx, "del cache failed", slog.Any("error", err))
		return errors.InternalServerErrorWrap(err)
	}

	if err := listcache.InvalidateListCache(ctx, u.log, u.cache, usersIndexKey); err != nil {
		u.log.Error(ctx, "del list cache failed", slog.Any("error", err))
		return errors.InternalServerErrorWrap(err)
	}

	err = u.repo.Delete(ctx, id)
	if err != nil {
		return errors.InternalServerErrorWrap(err, "error deleting user")
	}
	return nil
}

func (u *users) saveListCache(ctx context.Context, cacheKey string, users []model.User, pagination model.Pagination) error {
	cacheData := struct {
		Users      []model.User     `json:"users"`
		Pagination model.Pagination `json:"pagination"`
	}{
		Users:      users,
		Pagination: pagination,
	}

	if err := listcache.AddKeyToIndex(ctx, u.log, u.cache, cacheKey, usersIndexKey, maxIndexSize); err != nil {
		return fmt.Errorf("failed to update index: %w", err)
	}

	if err := u.cache.SetJSONWithExpiry(ctx, cacheKey, cacheData, u.ttl.TTLDefault); err != nil {
		u.log.Warn(ctx, "data cache failed, index will be cleaned up",
			slog.String("key", cacheKey),
			slog.Any("error", err))

		u.cache.SRem(ctx, usersIndexKey, cacheKey)
		return fmt.Errorf("failed to set data: %w", err)
	}

	return nil
}
```

## 22.9 Mengatasi Masalah Cache

### Cache Stampede

**Masalah:** Cache expired bersamaan → banyak request hit database simultaneously.

**Solusi:** Tambahkan jitter pada TTL:

```go
func (u *users) saveListCache(ctx context.Context, cacheKey string, users []model.User, pagination model.Pagination) error {
    // Base TTL + random jitter (±10%)
    baseTTL := u.ttl.TTLDefault
    jitter := time.Duration(rand.Intn(int(baseTTL/5))) - baseTTL/10
    finalTTL := baseTTL + jitter
    
    return u.cache.SetJSONWithExpiry(ctx, cacheKey, cacheData, finalTTL)
}
```

### Thundering Herd

**Masalah:** Banyak request konkuren untuk data yang sama → semua request database.

**Solusi:** Gunakan Single Flight Pattern (hanya 1 request ke database, sisanya menunggu):

```go
import "golang.org/x/sync/singleflight"

var sf singleflight.Group

func (u *users) FindByID(ctx context.Context, id string) (*model.User, *errors.BusinessError) {
    cacheKey := "users::" + id
    
    // Coba cache
    var user *model.User
    if err := u.cache.GetJSON(ctx, cacheKey, &user); err == nil {
        return user, nil
    }

    // Single flight: hanya 1 request yang query DB
    result, err, _ := sf.Do(cacheKey, func() (interface{}, error) {
        // Double-check cache
        if err := u.cache.GetJSON(ctx, cacheKey, &user); err == nil {
            return user, nil
        }
        
        user, err := u.repo.FindByID(ctx, id)
        if err != nil {
            return nil, err
        }
        
        // Simpan cache
        u.cache.SetJSONWithExpiry(ctx, cacheKey, user, u.ttl.TTLDefault)
        return user, nil
    })
    
    if err != nil {
        return nil, errors.InternalServerErrorWrap(err, "error finding user")
    }
    return result.(*model.User), nil
}
```

Lebih detail terkait pattern single flight bisa dibaca di [Single Flight Pattern](https://golang-microservices.rijalasepnugroho.com/design-pattern/03-concurrency-pattern/12-single-flight).

### Operasi Penghapusan Data yang Besar

**Masalah:** SMEMBERS + DEL untuk ribuan key bersifat blocking.

**Solusi:** Gunakan SSCAN untuk iterasi bertahap (sudah diimplementasikan di cleanupStaleIndexBatch).

### Operasi Blocking di Valkey Glide

**Masalah:** Valkey Glide menggunakan multiplex yang unggul secara throughput namun lemah terhadap operasi blcoking seperti `BLPOP` atau penghapusan data yang besar.

**Solusi:**

* On premise : gunakan valkey-go, dimana koneksi valkey otomatis dijalankan dengan multiplex untuk perintah nonblocking, namun perintah blocking akan dijalankan dengan koneksi pool connection.
* Cloud AWS/GCP : buat dua koneksi client. Pilihan koneksi diset secara manual di dalam kode agar operasi blocking tidak menghambat operasi nonblocking. Jika operasi nonblocking gunakan koneksi A, semantara operasi blocking menggunakan koneksi B.

## 22.10 Update Bootstrap dengan Cache

```go
// internal/bootstrap/app.go
func NewApp() (App, error) {
    cfg, err := config.LoadConfig()
    if err != nil {
        return App{}, fmt.Errorf("loading config: %w", err)
    }

    db, err := database.OpenDB(cfg)
    if err != nil {
        return App{}, fmt.Errorf("opening database: %w", err)
    }

    log := logger.InitLogger(nil)
    validate := validator.New()

    // Inisialisasi cache
    cacheClient, err := cache.NewCache(cfg.Cache)
    if err != nil {
        return App{}, fmt.Errorf("creating cache client: %w", err)
    }

    return App{
        Config:   cfg,
        Database: db,
        Log:      log,
        Validate: validate,
        Cache:    cacheClient,
        Cleanup: func() {
            cacheClient.Close()
            db.Close()
        },
    }, nil
}
```

## Ringkasan Bab 22

Di bab ini kita telah belajar:

| Komponen               | Fungsi                                                  |
| ---------------------- | ------------------------------------------------------- |
| Cache Client Interface | Abstraksi untuk berbagai implementasi cache             |
| Valkey Standalone      | Implementasi dengan valkey-glide                        |
| Cache-Aside Pattern    | Strategi caching dengan invalidation manual             |
| Key Registering        | Mengelola cache dengan banyak parameter menggunakan SET |
| List Cache Helper      | Generate key, add to index, invalidate, cleanup         |
| Single Flight          | Mencegah Thundering Herd                                |

Manfaat yang kita peroleh:

* ✅ Response time turun dari 100ms ke 1-5ms
* ✅ Beban database berkurang 90%+
* ✅ Cache terkelola dengan baik (invalidation otomatis)
* ✅ Index management dengan SET (SADD, SMEMBERS, SSCAN, SREM)
* ✅ Proteksi dari Cache Stampede dan Thundering Herd

Yang akan datang:

* Saat ini kita belum memanggil API eksternal
* Bab selanjutnya: Call API Third Party – memanggil service eksternal dengan retry, timeout, dan circuit breaker


# Load Testing

> **📂 Kode Lengkap Bab Ini:**\
> Seluruh kode yang dibahas di bab ini tersedia di GitHub:
>
> 🔗 [github.com/jacky-htg/workshop/tree/main/load-testing](https://github.com/jacky-htg/workshop/tree/main/load-testing)

## 1. Konsep dasar

### Apa itu Load Testing?

Load testing adalah praktik mensimulasikan lalu lintas pengguna (traffic) ke sistem untuk mengukur performa dan stabilitas aplikasi di bawah beban tertentu. Tujuannya adalah **memastikan aplikasi dapat menangani jumlah pengguna yang diharapkan tanpa degradation performa yang signifikan.**

```
┌───────────────────────────────────────────────────────────┐
│  LOAD TESTING                                             │
│                                                           │
│  "Berapa banyak pengguna yang bisa dihandle server        │
│   sebelum response time menjadi tidak dapat diterima?"    │
│                                                           │
│  Contoh:                                                  │
│  ✅ 10 user → response time 150ms                         │
│  ✅ 50 user → response time 200ms                         │
│  ⚠️  100 user → response time 500ms (threshold!)          │
│  ❌ 150 user → response time 1.5s (overload!)             │
└───────────────────────────────────────────────────────────┘
```

### Mengapa Penting?

| Manfaat           | Penjelasan                                     | Dampak Bisnis                    |
| ----------------- | ---------------------------------------------- | -------------------------------- |
| Mencegah Outage   | Mengetahui batas kapasitas sebelum server down | ⚠️ Downtime = Loss of revenue    |
| Capacity Planning | Menentukan kapan perlu scaling                 | 💰 Efisiensi biaya infrastruktur |
| User Experience   | Memastikan response time tetap cepat           | 😊 Retensi user meningkat        |
| Confidence Deploy | Mengetahui impact perubahan kode               | 🚀 Deploy lebih aman             |
| SLA Compliance    | Memastikan meeting Service Level Agreement     | 📋 Kepuasan pelanggan            |

**Contoh Kasus :**

```
Flash Sale:
- Normal: 100 RPS (response time 200ms)
- Flash Sale: 1,000 RPS (response time 5s!) ❌
- User tidak bisa checkout → Loss of revenue

Dengan load testing:
- Diketahui batas di 300 RPS
- Auto-scaling di 250 RPS
- Flash Sale tetap lancar ✅
```

### Jenis-jenis Load Test

| Jenis    | Tujuan                            | Contoh Skenario               | Durasi               |
| -------- | --------------------------------- | ----------------------------- | -------------------- |
| Baseline | Mengetahui performa normal        | 10 VU, 1 menit                | Pendek (1-5 menit)   |
| Burst    | Menguji lonjakan traffic          | 20 → 80 RPS dalam 10 detik    | Sedang (5-15 menit)  |
| Stress   | Mencari titik puncak              | Naik bertahap sampai overload | Sedang (10-30 menit) |
| Soak     | Menguji stabilitas jangka panjang | 50 RPS selama 1-8 jam         | Panjang (1-8 jam)    |
| Spike    | Menguji lonjakan mendadak         | 10 → 100 RPS dalam 5 detik    | Pendek (5-10 menit)  |

*Visualisasi Jenis Load Test:*

```
RPS
  ▲
  │                    ┌──┐
  │                    │  │
  │  ────┐             │  │
  │      │       ┌─────┘  └─────
  │      └───────┘
  │
  └─────────────────────────────────────► Waktu

  Baseline:      ──── (stabil rendah)
  Burst:         ┌──┐ (lonjakan cepat)
  Stress:        ╱╲╱╲╱╲ (naik bertahap)
  Soak:          ────────── (stabil panjang)
  Spike:         ││ (lonjakan instan)
```

### Metric Penting dalam Load Testing

1. RPS (Request Per Second)

```
Jumlah request yang berhasil diproses per detik.

Contoh:
- 1.000 request dalam 60 detik = 16.67 RPS
- 4.500 request dalam 60 detik = 75 RPS

✅ Semakin tinggi RPS → semakin baik
❌ RPS yang flatten/konstan → ada bottleneck
```

2. Latency / Response Time

Waktu yang dibutuhkan server untuk merespon request.

| Percentile   | Arti                             | Contoh |
| ------------ | -------------------------------- | ------ |
| p50 (median) | 50% request lebih cepat dari ini | 178ms  |
| p90          | 90% request lebih cepat dari ini | 405ms  |
| p95          | 95% request lebih cepat dari ini | 510ms  |
| p99          | 99% request lebih cepat dari ini | 890ms  |

* ✅ Semakin rendah → semakin baik
* ⚠️ Perhatikan p95/p99 (outlier bisa jadi indikasi masalah)

3. Error Rate

```
Persentase request yang gagal (status 4xx/5xx).

Error Rate = (Total Error / Total Request) × 100%

Contoh:
- 0 error dari 13.461 request = 0% ✅
- 100 error dari 10.000 request = 1% ❌

✅ Target: < 1% (0% lebih baik)
❌ Error rate > 1% → perlu investigasi
```

4. Throughput

```
Jumlah data yang ditransfer per detik.

Data Sent: 219 KB/s
Data Received: 325 KB/s
Total Throughput: ~544 KB/s

✅ Cek apakah bandwidth cukup
❌ Throughput flatten → bandwidth bottleneck
```

## 2. Tools

### k6 (Open Source)

k6 adalah tools load testing modern yang dikembangkan oleh Grafana Labs.

```
┌───────────────────────────────────────────────────────────┐
│  K6 OVERVIEW                                              │
│                                                           │
│  ✅ Open source & free                                    │
│  ✅ Script dengan JavaScript (mudah dipelajari)           │
│  ✅ Performance tinggi (ditulis dalam Go)                 │
│  ✅ Integrasi dengan Grafana + Prometheus                 │
│  ✅ CLI friendly & CI/CD ready                            │
│  ✅ Cloud & self-hosted options                           │
└───────────────────────────────────────────────────────────┘
```

**Instalasi k6**

```bash
# MacOS
brew install k6

# Linux (Ubuntu/Debian)
sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update
sudo apt-get install k6

# Windows (WSL atau winget)
winget install k6

# Verify
k6 version
```

### Alternatif Tools

| Tools   | Kelebihan                            | Kekurangan                         | Kapan Pakai          |
| ------- | ------------------------------------ | ---------------------------------- | -------------------- |
| k6      | Modern, script JS, integrasi Grafana | Kurang UI (CLI based)              | Tim DevOps/Backend   |
| JMeter  | UI lengkap, banyak plugin            | Berat, Java-based, script kompleks | Tim QA yang terbiasa |
| Gatling | Scala/Java, report bagus             | Learning curve tinggi              | Tim yang pakai Scala |
| Locust  | Python-based, mudah                  | Kurang performant                  | Tim Python           |

### Kenapa Pilih k6?

1. ✅ Mudah dipelajari (JavaScript basic)
2. ✅ Performa tinggi (Go-based)
3. ✅ Cloud native (CI/CD ready)
4. ✅ Integrasi mudah (Grafana, Prometheus, Datadog)
5. ✅ Community besar & aktif
6. ✅ Script portable (bisa run di mana saja)

## 3 Skenario Testing (Studi Kasus)

### Studi Kasus

```
Endpoint: 
  - /api/config/device
  - /api/user/blocked
  - /api/config

Tujuan:
  1. Mengetahui rata-rata latency di berbagai beban
  2. Menentukan kapasitas maksimum server
  3. Memberikan rekomendasi scaling

Lingkungan: 
  - Staging: 1 server
  - Database: PostgreSQL (shared)
  - Framework: PHP Laravel
```

### Struktur Script k6

```javascript
// scenario.js - Konfigurasi dasar
import http from 'k6/http';
import { check, sleep } from 'k6';

export const _options = {
  scenarios: {
    // 1️⃣ Baseline (10 VU, 1 menit)
    baseline_vus_10: {
      executor: 'constant-vus',
      vus: 10,
      duration: '1m',
      exec: 'baseline',
    },

    // 2️⃣ Burst Traffic (Target 75 RPS)
    burst_traffict: {
      executor: 'ramping-arrival-rate',
      startTime: '1m30s',
      timeUnit: '1s',
      stages: [
        { target: 20, duration: '30s' },  // Warm up
        { target: 75, duration: '10s' },  // Burst peak
        { target: 75, duration: '30s' },  // Hold peak
        { target: 20, duration: '20s' },  // Cool down
      ],
      preAllocatedVUs: 30,
      maxVUs: 80,
      exec: 'burstTraffic',
    },
  },

  thresholds: {
    http_req_failed: ['rate<0.01'],           // Error rate < 1%
    http_req_duration: [
      'p(50)<200',   // 50% request < 200ms
      'p(95)<500',   // 95% request < 500ms
      'p(99)<1000',  // 99% request < 1000ms
    ],
  },
};

// Headers & Helper Functions
export function headers(TOKEN, IDENTITY) {
  return {
    headers: {
      Authorization: `Bearer ${TOKEN}`,
      'Content-Type': 'application/json',
      'identity-key': IDENTITY,
    },
  };
}

// POST Request - Baseline (dengan think time)
export function _post_baseline(URL, TOKEN, IDENTITY, PAYLOAD) {
  const body = JSON.stringify(PAYLOAD);
  const res = http.post(URL, body, headers(TOKEN, IDENTITY));
  check(res, { 'status is 200': (r) => r.status === 200 });
  sleep(0.2); // Simulasi user think time (200ms)
}

// POST Request - Burst (tanpa think time)
export function _post_burstTraffic(URL, TOKEN, IDENTITY, PAYLOAD) {
  const body = JSON.stringify(PAYLOAD);
  const res = http.post(URL, body, headers(TOKEN, IDENTITY));
  check(res, { 'status is 200': (r) => r.status === 200 });
}
```

### Script Single Endpoint

```javascript
// config-only.js - Test endpoint /api/config
import { _options, _post_baseline, _post_burstTraffic } from "./scenario.js";

const BASE_URL = __ENV.BASE_URL || 'https://xxxxxxxxxxxxx.co.id';
const TOKEN = __ENV.TOKEN || '';
const URL = `${BASE_URL}/api/config`;

export const options = _options;

export function baseline() {
  _post_baseline(URL, TOKEN, 'MCC', {});
}

export function burstTraffic() {
  _post_burstTraffic(URL, TOKEN, 'MCC', {});
}
```

### Script Gabungan (3 Endpoint)

```javascript
// all-endpoints.js - Test 3 endpoint sekaligus
import { _options, _post_baseline, _post_burstTraffic } from "./scenario.js";
import { configDevicePayload } from "./payload/config-device.js";
import { userBlockedPayload } from "./payload/user-blocked.js";

const BASE_URL = __ENV.BASE_URL || 'https://xxxxxxxxxxxxx.co.id';
const TOKEN = __ENV.TOKEN || '';

const URL_CONFIG = `/api/config`;
const URL_CONFIG_DEVICE = `/api/config/device`;
const URL_USER_BLOCKED = `/api/user/blocked`;

export const options = _options;

export function baseline() {
  _post_baseline(`${BASE_URL}${URL_CONFIG}`, TOKEN, 'MCC', {});
  _post_baseline(`${BASE_URL}${URL_CONFIG_DEVICE}`, TOKEN, 'PVR', configDevicePayload);
  _post_baseline(`${BASE_URL}${URL_USER_BLOCKED}`, TOKEN, 'FKR', userBlockedPayload);
}

export function burstTraffic() {
  _post_burstTraffic(`${BASE_URL}${URL_CONFIG}`, TOKEN, 'MCC', {});
  _post_burstTraffic(`${BASE_URL}${URL_CONFIG_DEVICE}`, TOKEN, 'PVR', configDevicePayload);
  _post_burstTraffic(`${BASE_URL}${URL_USER_BLOCKED}`, TOKEN, 'FKR', userBlockedPayload);
}
```

### Menjalankan Test

```bash
# Single endpoint
k6 run config-only.js

# Gabungan 3 endpoint
k6 run all-endpoints.js

# Dengan environment variable
k6 run -e BASE_URL=https://staging-api.example.com -e TOKEN=your_token all-endpoints.js

# Output ke file JSON
k6 run --summary-export=result.json all-endpoints.js
```

## 4 Analisis Hasil

### Hasil Test: all-endpoints.js (Target 75 RPS)

```
running (3m00.8s), 00/80 VUs, 4487 complete and 0 interrupted iterations

  █ THRESHOLDS 

    http_req_duration
    ✓ 'p(50)<200' p(50)=178.21ms
    ✗ 'p(95)<500' p(95)=509.94ms
    ✓ 'p(99)<1000' p(99)=889.61ms

    http_req_failed
    ✓ 'rate<0.01' rate=0.00%


  █ TOTAL RESULTS 

    checks_total.......: 13461   74.434005/s
    checks_succeeded...: 100.00% 13461 out of 13461
    
    HTTP
    http_req_duration..............: avg=230.45ms min=104.58ms med=178.21ms max=4.4s
      p(90)=405.15ms p(95)=509.94ms
    http_req_failed................: 0.00% 0 out of 13461
    http_reqs......................: 13461 74.434005/s

    EXECUTION
    dropped_iterations.............: 54    0.298599/s
    iteration_duration.............: avg=773.51ms med=696.74ms p(95)=1.26s
    iterations.....................: 4487  24.811335/s
    vus............................: max=75
    vus_max........................: 80

    NETWORK
    data_received..................: 58 MB 323 kB/s
    data_sent......................: 39 MB 218 kB/s

```

### Interpretasi Metric

1. Throughput / RPS

```
HTTP Requests: 13,461 requests
Duration: 180.8 detik (3m00.8s)

RPS = 13,461 / 180.8 = 74.43 RPS

✅ 74.43 RPS tercapai (target 75 RPS hampir tercapai)
⚠️ Ada 54 dropped iterations (0.30/s) → VU kurang
```

2. Latency / Response Time

```
Average     : 230.45 ms  ✅ Masih bagus
Median (p50): 178.21 ms ✅ < 200ms
p90         : 405.15 ms  ✅ Masih di bawah 500ms
p95         : 509.94 ms  ❌ Melewati threshold 500ms!
p99         : 889.61 ms  ✅ < 1000ms

Interpretasi:
- 90% request masih cepat (< 405ms)
- 5% request mulai lambat (> 509ms) → mulai ada queueing
- Masih ada outlier sampai 4.4s → perlu investigasi
```

3. Error Rate

Error rate: 0% dari 13,461 request

* ✅ Sempurna! Tidak ada error
* ✅ Server masih bisa memproses semua request

4. Concurrent Users & Iterations

```
Iterations              : 4,487 (1 iterasi = 3 request = 3 endpoint)
Iterations/s            : 24.81
Iteration duration avg  : 773.51ms

Concurrent Users = (24.81 × 773.51) / 1000 = 19.19 users

Interpretasi:
- Server mampu menangani ~19 user aktif bersamaan
- Pada puncak (P95 iteration = 1.26s): ~31 user aktif
```

### Threshold & Alert

Threshold yang Direkomendasikan

```javascript
thresholds: {
  // Error rate harus < 1%
  http_req_failed: ['rate<0.01'],
  
  // Response time target
  http_req_duration: [
    'p(50)<200',    // Median < 200ms
    'p(90)<400',    // 90% request < 400ms
    'p(95)<500',    // 95% request < 500ms
    'p(99)<1000',   // 99% request < 1s
  ],
  
  // RPS target (opsional)
  http_reqs: ['rate>50'],  // Minimal 50 RPS
  
  // VU tidak terlalu banyak
  vus: ['value<100'],  // Max VU < 100
}
```

Alert Level

| Level        | Kondisi                       | Tindakan                  |
| ------------ | ----------------------------- | ------------------------- |
| 🟢 OK        | P95 < 400ms, Error 0%         | Normal operation          |
| 🟡 Warning   | P95 > 450ms atau Error > 0.5% | Investigasi, siap scaling |
| 🔴 Critical  | P95 > 500ms atau Error > 1%   | Scaling segera            |
| 🚨 Emergency | P95 > 1s atau Error > 5%      | Emergency response        |

### Capacity Planning

#### Perbandingan 3 Skenario

| Metric  | Target 60 RPS | Target 75 RPS | Target 80 RPS     | Analisis             |
| ------- | ------------- | ------------- | ----------------- | -------------------- |
| RPS     | 63.04         | 74.43         | 74.90             | Stabil di \~74 RPS   |
| Avg RT  | 191.51 ms     | 230.45 ms     | 274.57 ms         | Baik, mulai naik     |
| P50     | 157.68 ms     | 178.21 ms     | 193.94 ms         | ✅ < 200ms            |
| P95     | 377.64 ms     | 509.94 ms     | 642.11 ms         | ❌ Melewati threshold |
| P99     | 697.33 ms     | 889.61 ms     | 1.18s             | Mulai melewati       |
| Dropped | 77            | 54            | 238               | Meningkat di 80 RPS  |
| Status  | ✅ Semua lolos | ⚠️ P95 gagal  | ❌ P95 & P99 gagal | Kapasitas: \~74 RPS  |

#### Kapasitas Maksimum

```
Dari hasil test, kapasitas maksimum server adalah ~74 RPS

Kapasitas Stabil (Safe) :  50-55 RPS (P95 < 400ms)
Kapasitas Moderate      :  55-65 RPS (P95 400-450ms)
Kapasitas Peak          :  65-74 RPS (P95 450-500ms)
⚠️  Overload            :   > 74 RPS (P95 > 500ms)

Jika tidak memperhitungkan bottleneck connection, kita bisa memberi saran Production:
- Target maksimum: 55 RPS (safe)
- Alert jika > 60 RPS
- Auto-scaling jika > 65 RPS
```

## 5 Ekstrapolasi Staging → Production

### Mengapa Ekstrapolasi Sulit?

```
┌───────────────────────────────────────────────────────────----─┐
│  STAGING                    PRODUCTION                         │
│  ┌─────────┐                ┌─────────-------┐                 │
│  │ Server  │                │ LB             │                 │
│  │  (1x)   │                │ ┌─-┐ ┌-─┐ ┌-─┐ │ 3x              │
│  └─────────┘                │ │S1│ │S2│ │S3│ │                 │
│                             │ └─-┘ └─-┘ └─-┘ │                 │
|                             └----------------┘                 |
│  Capacity: 74 RPS            Capacity: ???                     │
│                                                                │
│  ❌ 74 × 3 = 222 RPS?        ❌ Tidak sesederhana itu!          │
└───────────────────────────────────────────────────────────----─┘
```

### Faktor-faktor yang Mempengaruhi

1. Database - Faktor Terbesar!

```
┌──────────────────────────────────────────────────────────┐
│                    SHARED DATABASE                       │
│                    ┌──────────────┐                      │
│                    │   Database   │                      │
│                    │   (1 Server) │                      │
│                    └──────┬───────┘                      │
│                           │                              │
│         ┌─────────────────┼─────────────────┐            │
│         │                 │                 │            │
│    ┌────▼────┐      ┌─────▼────-─┐    ┌─────▼─────-┐     │
│    │ Service │      │  Service   │    │  Service   │     │
│    │    A    │      │    B       │    │    C       │     │
│    │ (Diuji) │      │(Production)│    │(Production)│     │
│    └─────────┘      └───────────-┘    └───────────-┘     │
│                                                          │
│  ❌ Database capacity dibagi untuk semua service!        │
│  ❌ Service B & C tetap beroperasi saat testing          │
└──────────────────────────────────────────────────────────┘
```

Contoh Perhitungan:

```
Staging:
- Service A (diuji): 74 RPS → 370 QPS (5 queries/request)
- Service B: OFFLINE
- Service C: OFFLINE
→ Total DB QPS: 370 QPS

Production:
- Service A: 50 RPS (aktual)
- Service B: 40 RPS (aktual)
- Service C: 30 RPS (aktual)
→ Total DB QPS: (50+40+30) × 5 = 600 QPS

→ Database di production harus handle 600 QPS
→ Staging hanya handle 370 QPS
→ Staging test TIDAK valid tanpa memperhitungkan service lain!
```

2. Connection Pool

DATABASE CONNECTION POOL (Max: 100)

```
Staging:
- Service A: 80 connections
- Service B: 0 connections
- Service C: 0 connections
→ Available: 80 connections ✅

Production:
- Service A: 40 connections
- Service B: 30 connections
- Service C: 25 connections
→ Total: 95 connections (hampir habis!) ❌

→ Service A di production hanya dapat 40 connections
→ Kapasitas turun drastis!
```

3. Load Balancer Overhead

| Jumlah Server | Overhead LB | Faktor Efisiensi |
| ------------- | ----------- | ---------------- |
| 1 server      | 0%          | 1.0              |
| 2 server      | 5-10%       | 0.90-0.95        |
| 3 server      | 10-15%      | 0.85-0.90        |
| 5+ server     | 15-25%      | 0.75-0.85        |

4. Session Management

| Tipe                 | Overhead | Faktor Koreksi |
| -------------------- | -------- | -------------- |
| Stateless (JWT)      | 2-5%     | 0.95-0.98      |
| Stateful (Redis)     | 10-15%   | 0.85-0.90      |
| Stateful (In-memory) | 15-25%   | 0.75-0.85      |

5. Network Bandwidth

```
Staging   : 1 Gbps
Production: 1 Gbps (sama)

Data sent     : 218 KB/s
Data received : 323 KB/s
Total         : ~544 KB/s

→ Bandwidth masih aman (hanya 0.5% dari 1 Gbps)
→ Bukan bottleneck
```

### Formula Kalkulasi Ekstrapolasi

Formula Dasar

```
Prod Capacity = Staging Capacity × Multiplier × Faktor Koreksi
```

Faktor Koreksi Lengkap

```javascript
export function calculateProductionCapacity(stagingData, prodConfig) {
    // === AMBIL DATA DARI PARAMETER YANG BENAR ===
    const { stagingMaxRPS, stagingP95AtMax } = stagingData;
    
    const {
        prodServerCount,
        dbType,
        otherServicesRPS,
        queriesPerRequest,
        dbMaxQPS,
        dbPoolSize,
        connectionsPerService,
        hasLoadBalancer,
        sessionType,
        conservativeFactor = 0.9,
    } = prodConfig;
    
    // === VALIDASI ===
    if (!stagingMaxRPS || stagingMaxRPS <= 0) {
        throw new Error('stagingMaxRPS must be positive');
    }
    if (!stagingP95AtMax || stagingP95AtMax <= 0) {
        throw new Error('stagingP95AtMax must be positive');
    }
    if (!prodServerCount || prodServerCount <= 0) {
        throw new Error('prodServerCount must be positive');
    }
    if (dbMaxQPS <= 0 || dbPoolSize <= 0) {
        throw new Error('dbMaxQPS and dbPoolSize must be positive');
    }
    if (conservativeFactor <= 0 || conservativeFactor > 1) {
        throw new Error('conservativeFactor must be between 0 and 1');
    }
    
    // 1. Base multiplier
    let multiplier = prodServerCount;
    
    // 2. Database QPS capacity
    const otherServiceQPS = otherServicesRPS * queriesPerRequest;
    const availableQPS = dbMaxQPS - otherServiceQPS;
    
    if (availableQPS <= 0) {
        throw new Error(
            `Database overloaded! Other services already use ${otherServiceQPS} QPS, ` +
            `leaving only ${availableQPS} QPS for this service.`
        );
    }
    
    const dbCapacity = availableQPS / queriesPerRequest;
    
    // 3. Connection pool capacity
    const otherConnections = (otherServicesRPS / 10) * connectionsPerService;
    const availableConnections = dbPoolSize - otherConnections;
    
    if (availableConnections <= 0) {
        throw new Error(
            `Connection pool exhausted! Other services use ${otherConnections} connections, ` +
            `leaving ${availableConnections} for this service.`
        );
    }
    
    const connectionCapacity = availableConnections * (1000 / stagingP95AtMax);
    
    // 4. Load balancer factor
    if (hasLoadBalancer) {
        multiplier *= (prodServerCount > 3) ? 0.85 : 0.9;
    }
    
    // 5. Session factor
    multiplier *= (sessionType === 'stateful') ? 0.85 : 0.95;
    
    // 6. Database factor
    const dbFactors = {
        'single': 0.6,
        'replica': 0.8,
        'sharded': 0.95,
    };
    multiplier *= dbFactors[dbType] || 0.7;
    
    // 7. Conservative factor (safety)
    multiplier *= conservativeFactor;
    
    // 8. Hitung kapasitas
    const serverCapacity = stagingMaxRPS * multiplier;
    
    // 9. Ambil minimum (bottleneck)
    const capacity = Math.min(
        serverCapacity,
        dbCapacity,
        connectionCapacity
    );
    
    if (isNaN(capacity) || !isFinite(capacity)) {
        throw new Error(
            `Invalid capacity calculation: ` +
            `serverCapacity=${serverCapacity}, ` +
            `dbCapacity=${dbCapacity}, ` +
            `connectionCapacity=${connectionCapacity}`
        );
    }
    
    return {
        estimatedMaxRPS: Math.round(capacity),
        estimatedStableRPS: Math.round(capacity * 0.7),
        multiplier: Math.round(multiplier * 100) / 100,
        dbCapacity: Math.round(dbCapacity),
        connectionCapacity: Math.round(connectionCapacity),
        breakdown: {
            serverCapacity: Math.round(serverCapacity),
            dbCapacity: Math.round(dbCapacity),
            connectionCapacity: Math.round(connectionCapacity),
            bottleneck: capacity === serverCapacity ? 'server' :
                       capacity === dbCapacity ? 'database' : 'connection'
        }
    };
}
```

Contoh Perhitungan

```javascript
import { calculateProductionCapacity } from './calc_form.js';

const withOtherSvc = calculateProductionCapacity(
    { 
        stagingMaxRPS: 74,
        stagingP95AtMax: 509
    },
    {
        prodServerCount: 3,
        dbType: 'single',
        otherServicesRPS: 70,
        queriesPerRequest: 5,
        dbMaxQPS: 1000,
        dbPoolSize: 100,
        connectionsPerService: 10,
        hasLoadBalancer: true,
        sessionType: 'stateless',
    }
);

const onlyThisSvc = calculateProductionCapacity(
    { 
        stagingMaxRPS: 74,
        stagingP95AtMax: 509
    },
    {
        prodServerCount: 3,
        dbType: 'single',
        otherServicesRPS: 0,
        queriesPerRequest: 5,
        dbMaxQPS: 1000,
        dbPoolSize: 100,
        connectionsPerService: 10,
        hasLoadBalancer: true,
        sessionType: 'stateless',
    }
);

const conservative = calculateProductionCapacity(
    { 
        stagingMaxRPS: 74,
        stagingP95AtMax: 509
    },
    {
        prodServerCount: 3,
        dbType: 'single',
        otherServicesRPS: 70,
        queriesPerRequest: 5,
        dbMaxQPS: 1000,
        dbPoolSize: 100,
        connectionsPerService: 10,
        hasLoadBalancer: true,
        sessionType: 'stateless',
        conservativeFactor: 0.7,
    }
);

console.log(withOtherSvc);
console.log(onlyThisSvc);
console.log(conservative);

/*
Output:
{
  estimatedMaxRPS: 59,
  estimatedStableRPS: 41,
  multiplier: 1.39,
  dbCapacity: 130,
  connectionCapacity: 59,
  breakdown: {
    serverCapacity: 102,
    dbCapacity: 130,
    connectionCapacity: 59,
    bottleneck: 'connection'
  }
}
{
  estimatedMaxRPS: 102,
  estimatedStableRPS: 72,
  multiplier: 1.39,
  dbCapacity: 200,
  connectionCapacity: 196,
  breakdown: {
    serverCapacity: 102,
    dbCapacity: 200,
    connectionCapacity: 196,
    bottleneck: 'server'
  }
}
{
  estimatedMaxRPS: 59,
  estimatedStableRPS: 41,
  multiplier: 1.39,
  dbCapacity: 130,
  connectionCapacity: 59,
  breakdown: {
    serverCapacity: 102,
    dbCapacity: 130,
    connectionCapacity: 59,
    bottleneck: 'connection'
  }
}
*/
```

### Rekomendasi Kapasitas Production

| Skenario            | Staging | Production (Est.) | Target Aman |
| ------------------- | ------- | ----------------- | ----------- |
| Service A Only      | 74 RPS  | 102 RPS           | 72 RPS      |
| Dengan Service Lain | 74 RPS  | 59 RPS            | 41 RPS      |
| Conservative        | 74 RPS  | 59 RPS            | 41 RPS      |

Rekomendasi Production:

```
✅ Target Aman: 41 RPS
🟡 Alert: > 50 RPS
🔴 Scale: > 55 RPS
🚨 Emergency: > 59 RPS
```

## 6 Rekomendasi & Action Items

### Alert Configuration

Prometheus + Alertmanager

```yaml
# prometheus-alerts.yml
groups:
  - name: api_alerts
    rules:
      # 1. Response Time Alert
      - alert: HighResponseTime
        expr: histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, endpoint)) > 0.5
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High response time on {{ $labels.endpoint }}"
          description: "P95 response time is {{ $value }}s (threshold: 0.5s)"

      # 2. Error Rate Alert
      - alert: HighErrorRate
        expr: sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) > 0.01
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "High error rate detected"
          description: "Error rate is {{ $value }}% (threshold: 1%)"

      # 3. High RPS Alert
      - alert: HighRPS
        expr: sum(rate(http_requests_total[1m])) > 50
        for: 3m
        labels:
          severity: warning
        annotations:
          summary: "High RPS detected"
          description: "Current RPS is {{ $value }} (threshold: 60)"

      # 4. Dropped Requests Alert
      - alert: DroppedRequests
        expr: increase(k6_dropped_iterations_total[5m]) > 10
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "Dropped requests detected"
          description: "{{ $value }} requests dropped in the last 5 minutes"

      # 5. Database Connection Alert
      - alert: DatabaseConnections
        expr: pg_stat_database_numbackends > 50
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Database connections high"
          description: "Current connections: {{ $value }} (threshold: 80)"
```

### Scaling Strategy

Level 1: Vertical Scaling (Scale Up)

```
┌─────────────────────────────────────────────────────────┐
│  VERTICAL SCALING                                       │
│                                                         │
│  Sebelum:                 Sesudah:                      │
│  ┌──────────────┐        ┌──────────────┐               │
│  │ CPU: 4 core  │  ──▶   │ CPU: 8 core  │               │
│  │ RAM: 8 GB    │        │ RAM: 16 GB   │               │
│  └──────────────┘        └──────────────┘               │
│                                                         │
│  ✅ Mudah implementasi                                  │
│  ❌ Ada batas maksimum                                  │
│  ❌ Downtime saat upgrade                               │
└─────────────────────────────────────────────────────────┘
```

Kapan Pakai:

* RPS meningkat 20-30%
* Response time mulai naik
* CPU > 70% atau RAM > 80%

Level 2: Horizontal Scaling (Scale Out)

```
┌────────────────────────────────────────────────────────┐
│  HORIZONTAL SCALING                                    │
│                                                        │
│  ┌─────────┐     ┌─────────┐     ┌─────────┐           │
│  │ Server1 │     │ Server2 │     │ Server3 │           │
│  └────┬────┘     └────┬────┘     └────┬────┘           │
│       └───────────────┼───────────────┘                │
│                       │                                │
│                  ┌────▼────┐                           │
│                  │   LB    │                           │
│                  └─────────┘                           │
│                                                        │
│  ✅ Tanpa batas (selama ada budget)                    │
│  ✅ Zero downtime (rolling update)                     │
│  ❌ Lebih kompleks                                     │
│  ❌ Database tetap 1x (bottleneck!)                    │
└────────────────────────────────────────────────────────┘
```

Kapan Pakai:

* RPS meningkat > 50%
* Vertical scaling sudah maksimal
* Perlu high availability

Level 3: Database Scaling

```
┌─────────────────────────────────────────────────────────┐
│  DATABASE SCALING                                       │
│                                                         │
│  Tahap 1: Read Replica                                  │
│  ┌─────────┐  ┌─────────┐                               │
│  │ Master  │──▶│ Replica │  (Read-only)                 │
│  └─────────┘  └─────────┘                               │
│                                                         │
│  Tahap 2: Sharding                                      │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐                  │
│  │ Shard 1 │  │ Shard 2 │  │ Shard 3 │                  │
│  │ (User  )│  │ (Order )│  │ (Prod  )│                  │
│  └─────────┘  └─────────┘  └─────────┘                  │
│                                                         │
│  ✅ Bottleneck utama solved                             │
│  ❌ Kompleksitas tinggi                                 │
│  ❌ Application code changes                            │
└─────────────────────────────────────────────────────────┘
```

Kapan Pakai:

* Database CPU > 70%
* Connection pool hampir habis
* Query time meningkat

### Monitoring

Dashboard Grafana yang Direkomendasikan

```yaml
# 1. API Performance Dashboard
Panels:
  - RPS (Request Per Second)
  - Response Time (p50, p90, p95, p99)
  - Error Rate
  - Throughput (data sent/received)

# 2. Server Resource Dashboard
Panels:
  - CPU Usage (%)
  - Memory Usage (%)
  - Network I/O
  - Disk I/O

# 3. Database Dashboard
Panels:
  - Connections
  - Query Rate (QPS)
  - Query Duration
  - Lock Contention

# 4. Load Testing Dashboard
Panels:
  - Active VUs
  - Dropped Iterations
  - Iteration Duration
  - Iterations/s
```

Metric yang Harus Dimonitor

| Metric            | Target  | Alert   |
| ----------------- | ------- | ------- |
| RPS               | < 50    | > 60    |
| P95 Response Time | < 400ms | > 450ms |
| Error Rate        | 0%      | > 0.5%  |
| CPU               | < 60%   | > 70%   |
| Memory            | < 70%   | > 80%   |
| DB Connections    | < 60    | > 80    |
| DB Query Time     | < 100ms | > 200ms |
| Disk Usage        | < 70%   | > 80%   |

### Action Items Checklist

Immediate (Hari Ini)

```
□ Setup alert untuk P95 response time > 450ms
□ Setup alert untuk error rate > 0.5%
□ Setup alert untuk RPS > 60
□ Review database connection pool size
□ Review application thread pool size
```

Short-term (Minggu Ini)

```
□ Implementasi caching untuk endpoint berat
□ Optimasi query database (indexing)
□ Upgrade connection pool
□ Setup auto-scaling configuration
□ Dokumentasi capacity planning
```

Medium-term (Bulan Ini)

```
□ Implementasi Redis caching
□ Database read-replica (jika perlu)
□ Load balancer configuration
□ Soak test untuk cek stabilitas
□ Regular load testing di staging
```

Long-term (Quarter)

```
□ Microservices architecture (pisahkan endpoint berat)
□ Database sharding strategy
□ Full observability stack (Prometheus + Grafana + Loki)
□ Chaos engineering
□ Regular capacity review
```

## Ringkasan

### Yang Sudah Kita Pelajari

| Topik          | Poin Kunci                                     |
| -------------- | ---------------------------------------------- |
| Konsep Dasar   | Baseline, Burst, Stress, Soak, Spike           |
| Metric Penting | RPS, Latency, Error Rate, Throughput           |
| Tools          | k6 (recommended), JMeter, Gatling, Locust      |
| Skenario       | Baseline 10 VU, Burst 75 RPS                   |
| Analisis       | Interpretasi metric, Threshold, Capacity       |
| Ekstrapolasi   | DB bottleneck, Connection pool, Faktor koreksi |
| Rekomendasi    | Alert, Scaling, Monitoring                     |

### Best Practices

1. ✅ Mulai dengan baseline (ukur performa normal)
2. ✅ Naikkan beban bertahap (temukan titik limit)
3. ✅ Perhatikan p95 (bukan hanya average)
4. ✅ 0% error rate adalah target minimal
5. ✅ Monitor resource saat test (CPU, RAM, DB)
6. ✅ Ekstrapolasi dengan faktor koreksi realistis
7. ✅ Setup alert sebelum deploy ke production
8. ✅ Regular load testing (setiap release)
9. ✅ Simpan hasil test untuk perbandingan
10. ✅ Libatkan tim (Dev + QA + Ops)


