Web Artisan
Beranda

Progress belajar

Modul 15 dari 73

0% 0/73 modul selesai

Setelah selesai, tandai modul ini agar progres kursus tetap rapi.

Progress disimpan lokal di browser ini.

Roadmap 2 · Web API

Routing dengan chi
REST yang Rapi dan Tumbuh

Di modul ini kita naik dari handler net/http murni ke routing REST yang enak dirawat: grouping /v1, subrouter per domain, dan middleware chain untuk backend online shop skincare.

Bahasa: Go 1.26chi v5.3.0~60 menit baca
01

Kenapa chi?

Dari handler tunggal ke peta API yang jelas

Di modul sebelumnya kamu sudah menulis handler dengan standard library net/http. Sekarang kita butuh cara menyusun puluhan endpoint tanpa membuat main.go jadi papan tempel route yang panjang.

Di Express.js kamu terbiasa memakai express.Router() untuk memecah route produk, cart, dan order ke file terpisah. Di Laravel kamu menulis grup route di routes/api.php dengan Route::prefix('v1')->group(...). chi memberi pengalaman serupa di Go, tetapi tetap berdiri di atas net/http. Router chi pada akhirnya hanyalah sebuah http.Handler, jadi bisa langsung diberikan ke http.ListenAndServe tanpa lapisan ajaib di tengah.

Dokumentasi resmi chi menyebut dirinya router yang ringan, idiomatik, dan composable untuk HTTP service Go. Artinya chi bukan framework besar seperti Laravel atau NestJS. Ia fokus pada tiga hal: mencocokkan route, menyusun middleware, dan mengkomposisi handler. Validasi, business logic, dan akses database tetap urusan kamu, persis seperti di modul net/http.

🌉Jembatan: dari express.Router() ke chi.NewRouter()

Anggap chi seperti express.Router(), tetapi state request tidak pernah disimpan di object router. Data request tetap hidup di *http.Request, path parameter dibaca dari context route, dan handler tetap berbentuk func(w http.ResponseWriter, r *http.Request) yang sama persis seperti modul lalu.

Tetap http.Handler

Router, subrouter, dan middleware chi semuanya http.Handler, jadi httptest dan tooling standard library tetap jalan.

Grouping dan versioning

Prefix /v1, group customer, dan group admin tersusun rapi tanpa mengulang string path di tiap endpoint.

Middleware ergonomis

RequestID, Logger, Recoverer, Timeout, dan auth dirangkai per group dengan satu r.Use.

Mount per domain

Tiap domain (produk, cart, order) mengekspos router sendiri lalu dipasang ke root router lewat Mount.

📝Catatan versi paket

Modul ini memakai import github.com/go-chi/chi/v5 (versi v5.3.0), bukan github.com/go-chi/chi lama tanpa suffix /v5. Module proyek kita adalah github.com/kamu/skincare-backend dengan go 1.26 di go.mod.

02

Mental Model Router

Router adalah dispatcher, bukan tempat business logic

Tugas router hanya satu: memilih handler berdasarkan method dan path. Ia tidak menghitung diskon, tidak mengecek stok, dan tidak menyimpan order.

router

Komponen yang menerima HTTP request, mencocokkan kombinasi method dan path, lalu memanggil handler yang sesuai. Tidak lebih dari itu.

Dalam backend skincare, GET /v1/products harus jatuh ke handler katalog, dan POST /v1/checkout harus jatuh ke handler checkout. Router menjaga peta ini tetap eksplisit dan mudah dibaca, sehingga siapa pun yang membuka kode tahu endpoint apa saja yang ada hanya dengan melihat definisi route.

Express.js / Laravel
  • router.get('/products/:id', handler) memakai callback (req, res) atau closure controller.
  • Middleware bisa menempel data langsung ke req dengan pola bebas.
  • Banyak aplikasi mencampur routing, validasi, dan business logic di file route yang sama.
Go + chi
  • r.Get("/products/{id}", handler) menerima pattern dan http.HandlerFunc standar.
  • Data request lewat *http.Request dan context, bukan object global yang bisa dimutasi sembarangan.
  • Router idealnya hanya wiring; validasi dan business logic pindah ke handler atau service.

chi tidak mengubah cara kerja HTTP di Go. Ia hanya membuat pencocokan route, pembacaan path parameter, grouping, mounting, dan middleware terasa jauh lebih ergonomis dibanding menyusunnya manual di atas http.ServeMux.

flowchart LR
  Client["React Frontend"] -->|HTTP JSON| Router["chi Router"]
  Router -->|GET /v1/products| ProductHandler["Product Handler"]
  Router -->|POST /v1/cart/items| CartHandler["Cart Handler"]
  Router -->|POST /v1/checkout| OrderHandler["Order Handler"]
  ProductHandler --> ProductService["Product Service"]
  CartHandler --> CartService["Cart Service"]
  OrderHandler --> OrderService["Order Service"]

Gambar 1. Router hanya memilih handler. Aturan bisnis domain hidup di service, yang baru kita rapikan di Roadmap 4.

📝Soal bentuk response di modul ini

Di sini kita pakai helper sederhana writeJSON (menulis payload apa adanya) dan writeError(w, status, message) yang menghasilkan {"error":"pesan"}. Envelope response yang konsisten dan terstruktur (data, meta, error dengan kode) baru dirancang di modul berikutnya, Desain Request & Response. Fokus modul ini adalah routing, bukan format JSON-nya.

03

Method Route REST

GET, POST, PUT, PATCH, DELETE sebagai method helper

chi menyediakan helper r.Get, r.Post, r.Put, r.Patch, dan r.Delete agar mapping REST terbaca langsung dari kode.

Setiap helper menerima pattern path dan handler function. Bentuk handler tetap sama persis seperti net/http, jadi seluruh pengetahuan dari modul sebelumnya tetap dipakai tanpa perubahan.

internal/product/routes.go
r.Get("/products", h.List) r.Post("/products", h.Create) r.Get("/products/{id}", h.GetByID) r.Put("/products/{id}", h.Replace) r.Patch("/products/{id}", h.UpdatePartial) r.Delete("/products/{id}", h.Delete)

Untuk API skincare, pemilihan method menyatakan niat endpoint dengan jelas. Frontend dan mobile tahu apa yang akan terjadi hanya dari kombinasi method dan path.

GET /v1/products Daftar produk skincare, dengan filter kategori, q (pencarian), dan pagination
POST /v1/admin/products Buat produk baru dari admin dashboard
PUT /v1/admin/products/{id} Ganti representasi produk secara penuh
PATCH /v1/admin/products/{id} Ubah sebagian data produk, misalnya stok atau status
DELETE /v1/admin/products/{id} Hapus atau nonaktifkan produk dari katalog
🌉Jembatan: dari Route::apiResource ke method helper

Di Laravel, Route::apiResource('products', ...) membuat lima route REST sekaligus secara otomatis. chi sengaja tidak melakukan itu; kamu menulis tiap method helper satu per satu. Lebih verbose, tetapi setiap route eksplisit dan tidak ada method tersembunyi yang muncul tanpa kamu sadari.

💡Idiom route

Pakai method helper untuk route REST umum. Gunakan r.Method(verb, pattern, handler) atau r.MethodFunc hanya saat kamu benar-benar perlu verb non-standar. Untuk satu handler yang melayani semua method pada satu path, ada r.HandleFunc, tetapi jarang dibutuhkan di REST.

04

Path Parameter dengan URLParam

Membaca id dari URL secara eksplisit lalu memvalidasinya

Path parameter dipakai ketika identitas resource menjadi bagian dari URL: produk tertentu, item cart tertentu, atau order tertentu.

Di Express kamu membaca req.params.id, di Laravel argumen method controller. Di chi, parameter URL dibaca dengan chi.URLParam(r, "id"). Nilainya selalu string, jadi untuk ID numerik kita parse dan validasi secara eksplisit sebelum dipakai.

internal/product/handler.go
func productIDFromRequest(r *http.Request) (int64, error) { rawID := chi.URLParam(r, "id") id, err := strconv.ParseInt(rawID, 10, 64) if err != nil || id <= 0 { return 0, fmt.Errorf("product id tidak valid: %q", rawID) } return id, nil }
🌉Jembatan: req.params.id vs chi.URLParam

req.params.id di Express terasa seperti field siap pakai yang sudah tersedia. Di chi, parameter diambil dari route context yang ditanam chi ke dalam *http.Request. Hasilnya tetap eksplisit dan dekat dengan model net/http, bukan sihir framework.

⚠️Jebakan: string ID mentah

Jangan langsung memakai string dari URL untuk query database. Parse ke tipe yang tepat, validasi (di sini menolak ID &lt;= 0), lalu kirim int64 ke service atau repository. Tanpa ini, input /v1/products/abc atau /v1/products/-1 bisa lolos ke layer bawah.

🧩Wildcard dan regex

Selain {id}, chi mendukung wildcard * untuk menangkap sisa path (mis. r.Get("/files/*", ...) lalu chi.URLParam(r, "*")), dan pattern bertipe regex seperti {id:[0-9]+}. Untuk REST biasa, parameter bernama plus validasi manual sudah cukup dan lebih mudah dibaca.

05

Grouping, Mount, dan Versioning

Menyusun route berdasarkan prefix dan domain

Route grouping membuat prefix seperti /v1 tidak diulang di setiap endpoint, dan mounting membuat tiap domain bisa hidup di package sendiri.

Versioning penting karena client mobile dan frontend bisa hidup lebih lama daripada satu siklus deploy backend. Ketika nanti ada perubahan kontrak besar, kita bisa menambah /v2 tanpa mematahkan client lama yang masih memanggil /v1.

Ada dua alat yang sering tertukar: r.Route dan r.Mount. Memahami bedanya adalah kunci struktur router yang bersih.

r.Route(pattern, fn)
  • Membuat subrouter inline lewat closure func(r chi.Router).
  • Cocok untuk grouping lokal di file yang sama, mis. prefix /v1 di root router.
  • Kamu tetap menulis tiap route di dalam closure tersebut.
r.Mount(pattern, handler)
  • Memasang sebuah http.Handler (biasanya router dari package lain) di bawah prefix.
  • Cocok saat subrouter datang dari domain lain, mis. internal/product.
  • Domain itu tidak tahu prefix /v1; ia hanya tahu route relatifnya sendiri (/, /{id}).

Berikut root router yang memakai keduanya. Route("/v1", ...) membuat grup versi, lalu di dalamnya Mount memasang router tiap domain.

internal/router/router.go
func New(products, cart, orders, payments http.Handler) http.Handler { r := chi.NewRouter() r.Route("/v1", func(r chi.Router) { r.Mount("/products", products) r.Mount("/cart", cart) r.Mount("/orders", orders) r.Mount("/payments", payments) }) return r }

Karena Mount menempel router produk di /products, di dalam internal/product kamu cukup menulis route relatif / untuk koleksi dan /{id} untuk detail. Path penuh terbentuk dari gabungan prefix mount dan route relatif.

internal/product/handler.go
func (h *Handler) Routes() chi.Router { r := chi.NewRouter() r.Get("/", h.List) // -> GET /v1/products r.Get("/{id}", h.GetByID) // -> GET /v1/products/{id} return r }
flowchart TD
  Root["chi Root Router"] --> V1["Route /v1"]
  V1 --> P["Mount /products<br/>(internal/product)"]
  V1 --> C["Mount /cart<br/>(internal/cart)"]
  V1 --> O["Mount /orders<br/>(internal/order)"]
  V1 --> Pay["Mount /payments<br/>(internal/payment)"]
  P --> P1["GET / -> /v1/products"]
  P --> P2["GET /{id} -> /v1/products/{id}"]

Gambar 2. Prefix /v1 ditulis sekali di root. Tiap domain hanya tahu route relatifnya sendiri, sehingga pindah versi cukup menyentuh satu tempat.

🌉Jembatan: dari Route::prefix('v1')->group()

Di Laravel, Route::prefix('v1')->group(fn () =&gt; ...) adalah padanan langsung r.Route("/v1", func(r chi.Router) { ... }). Memasang router dari package lain dengan r.Mount("/products", productRouter) mirip memuat file route terpisah; bedanya di Go yang dipasang adalah sebuah http.Handler konkret, bukan referensi file.

📝Kapan membuat /v2?

Jangan membuat versi baru untuk perubahan kecil yang backward compatible (menambah field response, menambah endpoint baru). Buat /v2 hanya saat kontrak response berubah cara baca-nya, behavior utama bergeser, atau lifecycle resource berubah besar sehingga client lama akan rusak.

06

Middleware Chain dan Urutannya

Layer lintas request sebelum sampai ke handler

Middleware adalah fungsi yang membungkus handler untuk menjalankan logic lintas route: request id, logging, panic recovery, timeout, auth, dan CORS.

Signature middleware Go selalu sama: menerima http.Handler, mengembalikan http.Handler. Inilah alasan middleware chi tetap kompatibel dengan standard library, dan kenapa middleware yang kamu tulis sendiri di modul net/http tetap bisa dipakai di chi tanpa diubah.

middleware-signature.go
func Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // logic SEBELUM handler berjalan next.ServeHTTP(w, r) // logic SETELAH handler selesai }) }

chi membawa kumpulan middleware bawaan di subpackage github.com/go-chi/chi/v5/middleware. Untuk root router proyek, set inti yang aman dan idiomatik adalah RequestID, Logger, Recoverer, dan Timeout.

internal/router/router.go
r := chi.NewRouter() r.Use(middleware.RequestID) // beri setiap request id unik r.Use(middleware.Logger) // log method, path, status, durasi r.Use(middleware.Recoverer) // ubah panic jadi 500, bukan crash server r.Use(middleware.Timeout(15 * time.Second)) // batasi durasi request
⚠️Jangan pakai middleware.RealIP sebagai default

Banyak tutorial lama menaruh middleware.RealIP di sini. Hindari. RealIP kini ditandai deprecated karena rawan IP spoofing (advisory GHSA-3fxj-6jh8-hvhx, severity Critical): ia memutasi r.RemoteAddr dan memercayai header X-Forwarded-For apa adanya, padahal header itu bisa dipalsukan client. Pakai middleware client-IP yang eksplisit sesuai infrastruktur (mis. ClientIPFromXFFTrustedProxies(n) bila di belakang ALB atau proxy tepercaya), lalu baca hasilnya lewat GetClientIP(ctx). Kita bahas tuntas di modul Middleware.

Urutan r.Use menentukan urutan eksekusi: middleware yang didaftarkan lebih dulu membungkus yang setelahnya, seperti lapisan bawang. Recoverer harus berada cukup luar agar bisa menangkap panic dari middleware dan handler di dalamnya, dan RequestID di paling luar agar setiap log sudah membawa id.

sequenceDiagram
  participant Client as React Client
  participant ReqID as RequestID
  participant Log as Logger
  participant Recover as Recoverer
  participant Handler as Product Handler
  Client->>ReqID: GET /v1/products
  ReqID->>Log: tanam request id ke context
  Log->>Recover: catat awal request
  Recover->>Handler: lindungi dari panic, lalu teruskan
  Handler-->>Recover: tulis JSON response
  Recover-->>Log: response lewat tanpa panic
  Log-->>Client: 200 OK + durasi tercatat

Gambar 3. Middleware membungkus dari luar ke dalam. Request menembus tiap lapisan menuju handler, lalu response kembali menembus lapisan yang sama dalam urutan terbalik.

🌉Jembatan: middleware Express vs Go

Express middleware memanggil next() untuk meneruskan. Di Go, middleware memanggil next.ServeHTTP(w, r). Konsepnya identik (kontrol diteruskan ke lapisan berikutnya), tetapi bentuknya interface http.Handler, bukan function dengan callback next.

Tidak semua middleware harus global. chi punya dua cara memasang middleware pada sebagian route saja: r.Group (untuk beberapa route tanpa menambah prefix) dan r.With (untuk satu route).

internal/router/router.go
r.Route("/v1", func(r chi.Router) { // Route publik: tanpa auth r.Get("/products", productHandler.List) r.Get("/products/{id}", productHandler.GetByID) r.Post("/payments/webhook", paymentHandler.Webhook) // publik, verifikasi signature provider // Group route yang butuh login customer (tanpa prefix tambahan) r.Group(func(r chi.Router) { r.Use(authMiddleware) // hanya berlaku di group ini r.Get("/cart", cartHandler.Get) r.Post("/cart/items", cartHandler.AddItem) r.Post("/checkout", orderHandler.Checkout) }) // Satu route dengan middleware tambahan via With r.With(rateLimitWebhook).Post("/payments/refund", paymentHandler.Refund) })
💡Group vs With vs Route

r.Route(prefix, fn) membuat subrouter berprefix. r.Group(fn) membuat subrouter tanpa prefix, ideal untuk menerapkan middleware (mis. auth) ke sekumpulan route yang path-nya berbeda-beda. r.With(mw...) menempel middleware ke satu route tunggal. Webhook pembayaran sengaja diletakkan publik di luar group auth karena dipanggil server provider, bukan user login. Detail webhook lengkap kita rakit di modul peta API final.

⚠️Middleware harus didaftarkan sebelum route

Pada chi, r.Use(...) wajib dipanggil sebelum route apa pun didefinisikan di router atau group yang sama. Mendaftarkan middleware setelah route akan memicu panic saat startup. Letakkan semua r.Use di paling atas blok router.

07

NotFound dan MethodNotAllowed

Balas 404 dan 405 dengan JSON, bukan teks polos

Secara default, chi membalas route tak dikenal dengan teks 404 page not found dan method salah dengan 405 method not allowed polos. Untuk API JSON, dua respons ini sebaiknya konsisten dengan format error endpoint lain.

Frontend React kamu hampir pasti menjalankan await response.json() untuk setiap respons. Bila 404 mengembalikan teks polos, parsing JSON di sisi client gagal dan pesan error jadi membingungkan. Maka kita pasang handler kustom di root router.

internal/router/router.go
r.NotFound(func(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "rute tidak ditemukan") }) r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusMethodNotAllowed, "method tidak diizinkan untuk rute ini") })
🌉Jembatan: fallback route Express dan Laravel

Di Express kamu menulis app.use((req, res) =&gt; res.status(404).json(...)) di paling akhir. Di Laravel ada Route::fallback(...) dan exception handler untuk MethodNotAllowedHttpException. r.NotFound dan r.MethodNotAllowed chi adalah padanan langsungnya, hanya saja terdaftar di router, bukan sebagai route terakhir.

📝Beda 404 dan 405

404 Not Found berarti tidak ada route yang cocok dengan path sama sekali. 405 Method Not Allowed berarti path-nya ada tetapi method-nya salah, misalnya DELETE /v1/products padahal hanya GET dan POST yang terdaftar di path itu. chi membedakan keduanya otomatis selama kamu mendaftarkan kedua handler ini.

08

Struktur Router Proyek Skincare

Root router di satu tempat, domain router di package masing-masing

Struktur yang rapi membuat route mudah ditemukan tanpa membuat setiap package saling bergantung.

Struktur router skincare API
  • cmd/
  • api/
  • main.go entry point server HTTP
  • internal/
  • router/
  • router.go root router: middleware global, versioning, mount
  • product/
  • handler.go route katalog produk
  • cart/
  • handler.go route keranjang (butuh login)
  • order/
  • handler.go route checkout dan order
  • payment/
  • handler.go webhook pembayaran (publik)
  • go.mod

Root router mengurus hal lintas domain: middleware global, health check, versioning, mount subrouter, plus NotFound dan MethodNotAllowed. Tiap domain package mengurus route miliknya sendiri dan tidak tahu prefix /v1.

go.mod
module github.com/kamu/skincare-backend go 1.26 require github.com/go-chi/chi/v5 v5.3.0
internal/router/router.go
package router import ( "encoding/json" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) func New(products, cart, orders, payments http.Handler) http.Handler { r := chi.NewRouter() // Middleware global: daftarkan SEBELUM route apa pun. r.Use(middleware.RequestID) r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.Timeout(15 * time.Second)) // Respons 404 dan 405 yang konsisten dalam bentuk JSON. r.NotFound(func(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "rute tidak ditemukan") }) r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusMethodNotAllowed, "method tidak diizinkan untuk rute ini") }) r.Get("/healthz", healthz) r.Route("/v1", func(r chi.Router) { r.Mount("/products", products) r.Mount("/cart", cart) r.Mount("/orders", orders) r.Mount("/payments", payments) }) return r } func healthz(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) }
💡Kenapa root router menerima http.Handler?

Dengan menerima http.Handler, root router tidak peduli apakah router produk berasal dari chi, http.ServeMux, atau handler buatan sendiri. Ini menjaga dependency antar package tetap rendah dan membuat tiap domain bisa dites terisolasi.

🧩Peta route lengkap proyek

Modul ini menyusun kerangkanya: /v1/products dan /v1/products/{id} publik, group /v1/cart, /v1/checkout, dan /v1/orders butuh login customer, group /v1/admin/products butuh role admin, dan /v1/payments/webhook publik untuk provider pembayaran. Peta penuh beserta auth dan validasinya dirakit utuh di modul peta API e-commerce di akhir Roadmap 2.

09

Hands-on Router Produk

Bangun router produk yang bisa dijalankan lokal

Sekarang kita susun contoh minimum yang memperlihatkan chi.NewRouter, method route, chi.URLParam, grouping, mounting, NotFound, dan http.ListenAndServe.

Tambahkan dependency chi

Jalankan go get github.com/go-chi/chi/v5@v5.3.0 dari root project.

Buat product handler

Letakkan route produk di internal/product/handler.go agar domain produk tidak bercampur dengan root router.

Buat root router

Pakai internal/router/router.go dari section sebelumnya untuk middleware global, versioning, dan mount.

Jalankan server

Gunakan go run ./cmd/api, lalu coba endpoint dengan curl.

internal/product/handler.go
package product import ( "encoding/json" "fmt" "net/http" "strconv" "github.com/go-chi/chi/v5" ) type Handler struct{} type Product struct { ID int64 `json:"id"` Name string `json:"name"` Slug string `json:"slug"` Category string `json:"category"` PriceRupiah int64 `json:"price"` Stock int `json:"stock"` Status string `json:"status"` } type CreateProductRequest struct { Name string `json:"name"` Slug string `json:"slug"` Category string `json:"category"` PriceRupiah int64 `json:"price"` Stock int `json:"stock"` } type PatchProductRequest struct { Name *string `json:"name,omitempty"` PriceRupiah *int64 `json:"price,omitempty"` Stock *int `json:"stock,omitempty"` Status *string `json:"status,omitempty"` } func NewHandler() *Handler { return &Handler{} } func (h *Handler) Routes() chi.Router { r := chi.NewRouter() r.Get("/", h.List) r.Post("/", h.Create) r.Get("/{id}", h.GetByID) r.Put("/{id}", h.Replace) r.Patch("/{id}", h.UpdatePartial) r.Delete("/{id}", h.Delete) return r } func (h *Handler) List(w http.ResponseWriter, r *http.Request) { products := []Product{ {ID: 1, Name: "Gentle Low pH Cleanser", Slug: "gentle-low-ph-cleanser", Category: "cleanser", PriceRupiah: 129000, Stock: 24, Status: "active"}, {ID: 2, Name: "Niacinamide Barrier Serum", Slug: "niacinamide-barrier-serum", Category: "serum", PriceRupiah: 179000, Stock: 18, Status: "active"}, } writeJSON(w, http.StatusOK, products) } func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { var req CreateProductRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "request body harus berupa JSON produk yang valid") return } product := Product{ ID: 99, Name: req.Name, Slug: req.Slug, Category: req.Category, PriceRupiah: req.PriceRupiah, Stock: req.Stock, Status: "active", } writeJSON(w, http.StatusCreated, product) } func (h *Handler) GetByID(w http.ResponseWriter, r *http.Request) { id, err := productIDFromRequest(r) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } product := Product{ID: id, Name: "Gentle Low pH Cleanser", Slug: "gentle-low-ph-cleanser", Category: "cleanser", PriceRupiah: 129000, Stock: 24, Status: "active"} writeJSON(w, http.StatusOK, product) } func (h *Handler) Replace(w http.ResponseWriter, r *http.Request) { id, err := productIDFromRequest(r) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } var req CreateProductRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "request body harus berupa JSON produk yang valid") return } product := Product{ID: id, Name: req.Name, Slug: req.Slug, Category: req.Category, PriceRupiah: req.PriceRupiah, Stock: req.Stock, Status: "active"} writeJSON(w, http.StatusOK, product) } func (h *Handler) UpdatePartial(w http.ResponseWriter, r *http.Request) { id, err := productIDFromRequest(r) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } var req PatchProductRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "request body harus berupa JSON produk yang valid") return } writeJSON(w, http.StatusOK, map[string]any{"id": id, "patched": req}) } func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { if _, err := productIDFromRequest(r); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } w.WriteHeader(http.StatusNoContent) } func productIDFromRequest(r *http.Request) (int64, error) { rawID := chi.URLParam(r, "id") id, err := strconv.ParseInt(rawID, 10, 64) if err != nil || id <= 0 { return 0, fmt.Errorf("product id tidak valid: %q", rawID) } return id, nil } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) }
cmd/api/main.go
package main import ( "encoding/json" "log" "net/http" "github.com/go-chi/chi/v5" "github.com/kamu/skincare-backend/internal/product" "github.com/kamu/skincare-backend/internal/router" ) func main() { productHandler := product.NewHandler() r := router.New( productHandler.Routes(), placeholderRoutes("cart"), placeholderRoutes("orders"), placeholderRoutes("payments"), ) log.Println("listening on :8080") if err := http.ListenAndServe(":8080", r); err != nil { log.Fatal(err) } } func placeholderRoutes(name string) http.Handler { r := chi.NewRouter() r.Get("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(map[string]string{ "module": name, "status": "belum diimplementasi", }) }) return r }
Terminal
go get github.com/go-chi/chi/v5@v5.3.0 go run ./cmd/api

Coba health check, lalu daftar dan detail produk.

Terminal
curl -i http://localhost:8080/healthz curl -i http://localhost:8080/v1/products curl -i http://localhost:8080/v1/products/1

Coba membuat produk baru. Perhatikan field JSON-nya bernama price, walau di Go tipenya PriceRupiah int64.

Terminal
curl -i -X POST http://localhost:8080/v1/products \ -H 'Content-Type: application/json' \ -d '{"name":"Hydrating Toner","slug":"hydrating-toner","category":"toner","price":149000,"stock":30}'

Coba juga route yang tidak ada dan method yang salah untuk melihat 404 dan 405 dalam bentuk JSON.

Terminal
curl -i http://localhost:8080/v1/tidak-ada curl -i -X DELETE http://localhost:8080/v1/products
📝Kenapa contoh belum pakai database?

Fokus modul ini adalah routing. Data produk masih hardcoded di handler. Repository PostgreSQL dengan pgxpool masuk di Roadmap 3, lalu service layer yang rapi masuk di Roadmap 4.

10

Jebakan Umum

Hal kecil yang sering membuat route sulit dirawat

chi sederhana, tetapi pola yang salah bisa membuat API cepat berantakan.

Business logic di router

Router jangan menghitung total order, validasi stok, atau memanggil banyak query. Router cukup menyusun middleware dan route.

Path parameter tidak divalidasi

chi.URLParam mengembalikan string. Parse dan validasi sebelum masuk service atau repository.

Prefix versi tersebar

Jangan menulis /v1 di tiap package domain. Letakkan versioning di root router lewat Route("/v1", ...).

Mount path duplikat

Hindari mount dua subrouter pada path yang sama. Root router harus jadi satu sumber kebenaran daftar mount.

r.Use setelah route

Mendaftarkan middleware sesudah route memicu panic saat startup. Taruh semua r.Use di paling atas blok router.

404 dan 405 teks polos

Tanpa NotFound dan MethodNotAllowed kustom, client React gagal response.json(). Balas JSON yang konsisten.

RealIP sebagai default

middleware.RealIP deprecated dan rawan spoofing. Pakai middleware client-IP eksplisit sesuai infrastruktur.

Webhook di balik auth login

/v1/payments/webhook dipanggil server provider, bukan user. Biarkan publik, amankan dengan verifikasi signature.

⚠️Jangan simpan state request di router

Router dibuat sekali lalu melayani banyak request secara concurrent. Jangan menaruh data user login, cart sementara, atau payload request sebagai field router. State request hidup di *http.Request dan context, bukan di object router.

💡Pola yang akan kita bawa ke modul berikutnya

Mulai dari sini, tiap domain punya handler dan Routes() sendiri. Saat auth masuk di modul Autentikasi, kita cukup membungkus group yang perlu dilindungi dengan r.Group(func(r chi.Router) { r.Use(authMiddleware); ... }), tanpa menyentuh route publik seperti katalog dan webhook.

11

Ringkasan & Poin Penting

Routing dengan chi membuat API Go tetap dekat dengan net/http, tetapi jauh lebih rapi untuk proyek REST yang tumbuh.

Yang Wajib Menempel

  • chi.NewRouter() membuat router yang tetap memenuhi http.Handler, jadi httptest dan ListenAndServe jalan tanpa perubahan.
  • r.Get, r.Post, r.Put, r.Patch, dan r.Delete membuat mapping REST eksplisit, dengan handler bentuk standar net/http.
  • chi.URLParam(r, "id") mengambil path parameter sebagai string; selalu parse ke int64 dan validasi sebelum dipakai.
  • r.Route("/v1", ...) untuk grouping prefix, r.Mount("/products", router) untuk memasang subrouter domain dari package lain.
  • r.Use menyusun middleware chain global (RequestID, Logger, Recoverer, Timeout) dengan urutan luar-ke-dalam, dan harus didaftarkan sebelum route.
  • r.Group menerapkan middleware ke sekumpulan route tanpa prefix, r.With ke satu route; pakai untuk memisahkan route publik, customer, dan admin.
  • Hindari middleware.RealIP (deprecated, rawan spoofing); pasang NotFound dan MethodNotAllowed kustom agar 404 dan 405 tetap JSON.
  • Di proyek skincare, root router di internal/router memetakan /v1/products (publik), /v1/cart dan /v1/checkout (customer), /v1/admin/products (admin), serta /v1/payments/webhook (publik).

Modul ini memindahkan kita dari handler tunggal ke struktur API yang siap tumbuh. Di modul berikutnya, Desain Request & Response, kita merapikan bentuk JSON menjadi envelope yang konsisten (data, meta, dan error terstruktur), lalu lanjut ke middleware serius, validasi, autentikasi, dan akhirnya peta API e-commerce yang merakit semuanya.

Progress disimpan lokal di browser ini.