Progress belajar
Modul 20 dari 73
0% 0/73 modul selesai
Setelah selesai, tandai modul ini agar progres kursus tetap rapi.
Progress disimpan lokal di browser ini.
Desain REST API E-Commerce
Skincare yang Utuh
Chapter penutup Roadmap 2: kita satukan handler, router chi, middleware, envelope respons, validasi, dan auth menjadi satu permukaan API online shop yang konsisten dan siap dibangun.
API sebagai Permukaan Produk
Kontrak yang dipakai storefront, admin, dan payment provider
REST API adalah kontrak jangka panjang yang dipakai frontend React, mobile app, admin dashboard, dan payment provider untuk berbicara dengan backend. Ini chapter di mana semua potongan Roadmap 2 berkumpul.
Enam modul terakhir membangun satu proyek menerus: backend online shop skincare. Kamu sudah punya handler net/http, routing chi, envelope respons httpx, middleware, validasi, dan autentikasi JWT. Modul ini tidak memperkenalkan banyak konsep baru, melainkan merakit semuanya menjadi satu peta API yang utuh dan menjawab pertanyaan desain yang sebenarnya: resource apa yang kita ekspos, dan bagaimana client memahaminya.
Di React, kamu sering mulai dari kebutuhan layar: halaman katalog butuh data produk, halaman cart butuh item, halaman checkout butuh order. Di backend, kita membalik sudut pandang itu menjadi resource dan aksi HTTP yang stabil. Endpoint tidak dinamai mengikuti function internal, melainkan mengikuti cara client memahami bisnis online shop.
Route React seperti /products/:id menentukan komponen yang dirender browser, sedangkan route API seperti /v1/products/{id} menentukan resource yang dibaca atau diubah client. Keduanya kebetulan mirip, tetapi tujuannya berbeda: satu untuk navigasi UI, satu untuk kontrak data.
Di Laravel kamu terbiasa dengan resource controller dan route group. Di Go dengan net/http dan chi, ide besarnya sama, tetapi semuanya lebih eksplisit: handler menerima http.ResponseWriter dan *http.Request, router menyusun method dan path, middleware adalah function yang membungkus http.Handler.
Kumpulan endpoint publik yang menjadi kontrak antara backend dan client. Implementasi internal (database, service, cache) boleh berubah kapan saja, tetapi surface API harus stabil dan konsisten agar client lama tidak rusak.
Permukaan API Roadmap 2 mencakup katalog produk publik, autentikasi, cart customer, checkout, riwayat order, admin product management, dan webhook pembayaran. Database, transaksi, dan repository pgx diperdalam di Roadmap 3, tetapi bentuk API harus sudah jelas dan benar sejak sekarang, karena kontrak inilah yang akan dipakai frontend untuk mulai bekerja paralel.
Prinsip REST untuk E-Commerce
Bukan sekadar JSON di atas HTTP
REST yang baik membuat resource mudah ditebak, aman dikonsumsi, dan tahan terhadap retry. Untuk e-commerce, beberapa prinsip ini menentukan apakah uang dan stok tetap konsisten.
Resource dulu, bukan verb
Gunakan noun seperti products, cart/items, orders. Hindari getProducts atau doCheckout. Aksi diwakili HTTP method, bukan nama path.
Method bermakna
GET membaca (aman, tanpa efek samping), POST membuat, PATCH mengubah sebagian, PUT mengganti penuh, DELETE menghapus atau menonaktifkan.
Response konsisten
Sukses selalu {"data": ...} (plus meta untuk list), error selalu {"error": {code, message}}. Envelope ini sudah kita rancang di modul Request & Response.
ProductController@indexmemegang request, validasi ringan, dan response sekaligus.- Route group dan middleware sering ditulis terpusat di
routes/api.php. - Konvensi resource route otomatis (
Route::apiResource).
product.Handler.Listmenerima*http.Request, parse query, lalu memanggil service.- Route group chi ditulis eksplisit di package
router, terlihat jelas mana publik, customer, dan admin. - Tidak ada sihir resource route; setiap baris route kamu tulis sendiri (dan itu disengaja).
Selain itu, ada empat properti khusus e-commerce yang wajib dipikirkan sejak desain, bukan ditambal belakangan.
Checkout tidak boleh membuat order ganda saat user menekan tombol dua kali atau jaringan retry. Webhook pembayaran harus bisa menerima event yang sama berkali-kali tanpa mengubah status order dua kali.
Catalog publik bebas dibaca, cart dan order butuh login, admin product butuh role admin. Tiga lapis ini tercermin di struktur router, bukan di if tersebar dalam handler.
Order punya status (pending, paid, shipped, cancelled) yang berpindah dengan aturan jelas, bukan field bebas yang bisa diisi apa saja.
Saat produk masuk order, harga dan nama saat itu dibekukan. Mengubah harga produk besok tidak boleh mengubah total order kemarin.
Endpoint yang bagus bisa dijelaskan ke frontend tanpa membuka kode backend. Kalau frontend sulit menebak path atau shape response, biasanya surface API terlalu bocor dari detail internal (nama kolom, nama function, struktur tabel).
Peta Endpoint /v1 Penuh
Tiga lapis akses: publik, customer, admin, plus webhook
Inilah peta kanonik permukaan API skincare. Semua modul Roadmap 2 mengarah ke sini, dan Roadmap 3 sampai 5 akan mengisinya dengan implementasi nyata.
Publik (tanpa autentikasi)
/v1/products Daftar produk dengan filter kategori, q, rentang harga, sort, dan pagination /v1/products/{id} Detail satu produk skincare untuk halaman PDP /v1/auth/register Daftar akun customer baru /v1/auth/login Login dan terima access token plus refresh token /v1/auth/refresh Tukar refresh token dengan access token baru /v1/payments/webhook Terima event pembayaran dari provider, verifikasi signature, idempotent Customer (butuh login)
/v1/cart Lihat isi cart customer yang sedang login /v1/cart/items Tambah produk ke cart /v1/cart/items/{id} Ubah quantity satu item cart /v1/cart/items/{id} Hapus satu item dari cart /v1/checkout Ubah cart menjadi order dalam satu transaksi, idempotent /v1/orders Riwayat order milik customer /v1/orders/{id} Detail satu order milik customer Admin (butuh role admin)
/v1/admin/products Buat produk baru di katalog /v1/admin/products/{id} Ganti seluruh data produk /v1/admin/products/{id} Ubah sebagian field, misalnya harga atau status /v1/admin/products/{id} Arsipkan produk agar tidak tampil di katalog publik flowchart TD
subgraph Clients
FE["React storefront"]
ADM["Admin dashboard"]
PSP["Payment provider"]
end
FE -->|catalog| API
FE -->|login & cart & checkout| API
ADM -->|kelola produk| API
PSP -->|webhook signed| API
API["chi router /v1"] --> PUB["Publik<br/>products, auth, webhook"]
API --> CUST["Customer (auth)<br/>cart, checkout, orders"]
API --> ADMG["Admin (RequireRole admin)<br/>admin/products"]
PUB --> SVC["Service layer (Roadmap 4)"]
CUST --> SVC
ADMG --> SVC
SVC --> DB[("PostgreSQL (Roadmap 3)")]Gambar 1. Permukaan API dibagi tiga grup akses plus webhook publik. Pembagian ini akan kita wujudkan langsung sebagai grup middleware di router chi.
Prefix /v1 membuat kontrak API eksplisit dan bisa dievolusikan. Kalau suatu saat ada perubahan breaking pada shape response, kita bisa menambah /v2 berdampingan tanpa memutus client lama yang masih memakai /v1.
Di frontend kamu mungkin menganggap cart dan checkout satu alur. Di REST keduanya resource berbeda: cart/items dimutasi sepanjang sesi belanja, sedangkan POST /v1/checkout adalah satu aksi yang mengubah cart menjadi order. Memisahkannya membuat idempotensi checkout jauh lebih mudah dijaga.
Model Domain dan DTO
Tipe inti yang dipakai lintas seluruh permukaan API
Sebelum menulis handler, kunci dulu model domainnya. Konsistensi tipe inilah yang membuat enam modul terasa seperti satu proyek, bukan enam latihan terpisah.
Uang disimpan sebagai integer rupiah, bukan float64. Field PriceRupiah int64 dengan JSON tag price menghindari galat pembulatan yang khas pada tipe pecahan. Total order memakai TotalRupiah int64 dengan JSON tag total. Ini konvensi yang sama persis di seluruh proyek.
internal/product/model.gopackage product 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"` Description string `json:"description,omitempty"` Status string `json:"status"` // draft, active, archived }
internal/cart/model.gopackage cart type CartItem struct { ID int64 `json:"id"` ProductID int64 `json:"product_id"` Name string `json:"name"` Quantity int `json:"quantity"` PriceRupiah int64 `json:"price"` // harga satuan saat ditambahkan Subtotal int64 `json:"subtotal"` // PriceRupiah * Quantity }
internal/order/model.gopackage order import "time" type Order struct { ID int64 `json:"id"` Items []OrderItem `json:"items"` TotalRupiah int64 `json:"total"` Status string `json:"status"` // pending, paid, shipped, cancelled CreatedAt time.Time `json:"created_at"` } type OrderItem struct { ProductID int64 `json:"product_id"` Name string `json:"name"` // dibekukan saat checkout Quantity int `json:"quantity"` PriceRupiah int64 `json:"price"` // dibekukan saat checkout Subtotal int64 `json:"subtotal"` }
internal/user/model.gopackage user type User struct { ID int64 `json:"id"` Email string `json:"email"` Role string `json:"role"` // customer, admin PasswordHash string `json:"-"` // JANGAN pernah keluar ke client }
JSON tag json:"-" pada PasswordHash memastikan field ini di-skip oleh encoder. Bahkan jika kamu lalai mengoper struct User mentah ke httpx.Data, hash tidak ikut bocor. Tetap, kebiasaan terbaik adalah memakai DTO response terpisah (UserResponse) yang memang tidak punya field hash sama sekali.
Di Laravel, satu model Eloquent sering merangkap representasi database dan response API (dibatasi $hidden atau API Resource). Di Go kita pisahkan: struct domain untuk logika, dan DTO response untuk client. Pemisahan ini membuat perubahan kolom database tidak otomatis mengubah kontrak API.
Untuk response yang dibaca client, kita memakai envelope httpx yang sudah dirancang di modul Request & Response. Itu kontrak yang tidak diulang di sini, hanya dipakai.
internal/httpx/response.go (ringkasan kontrak dari modul Request & Response)package httpx // Meta menemani response list (pagination). type Meta struct { Page int `json:"page"` PerPage int `json:"per_page"` Total int64 `json:"total"` TotalPages int `json:"total_pages"` } // FieldError dipakai oleh hasil validasi. type FieldError struct { Field string `json:"field"` Message string `json:"message"` } func JSON(w http.ResponseWriter, status int, payload any) // encode + set header func Data(w http.ResponseWriter, status int, data any) // {"data": ...} func List(w http.ResponseWriter, status int, data any, meta Meta) // {"data": ..., "meta": ...} func Error(w http.ResponseWriter, status int, code, message string) // {"error": {code, message}} func ValidationFailed(w http.ResponseWriter, fields []FieldError) // 422 validation_error
Sepanjang permukaan API kita memakai kode error yang machine-readable dan konsisten: invalid_json, validation_error, unauthorized, forbidden, not_found (atau lebih spesifik product_not_found, order_not_found), conflict, dan internal_error. Frontend memetakan kode ini ke pesan UI, bukan mem-parsing teks bebas.
Struktur Folder dan Arsitektur Router
Modular monolith kecil, dipecah per domain
Di akhir Roadmap 2, struktur folder sudah menyerupai modular monolith. Setiap domain punya package sendiri yang mengumpulkan handler, request, dan response yang berubah bersama.
- cmd/
- api/
- main.go entry point: rakit handler, router, http.Server
- internal/
- httpx/
- response.go envelope Data, List, Error, ValidationFailed
- pagination.go helper Meta dari page, per_page, total
- auth/
- auth.go Claims, token sign & verify (JWT v5)
- middleware.go Authenticate, RequireRole
- context.go UserFrom(ctx) (*Claims, bool)
- product/
- model.go struct Product
- handler.go katalog publik dan admin product
- request.go parse query list dan body upsert
- response.go DTO product
- cart/
- model.go
- handler.go GET cart, POST/PATCH/DELETE item
- request.go
- order/
- model.go
- handler.go checkout dan riwayat order
- request.go
- payment/
- webhook.go handler webhook publik, verifikasi signature
- router/
- router.go semua route chi dirakit di sini
- go.mod module github.com/kamu/skincare-backend, go 1.26
Package dipisah per domain (product, cart, order), bukan per layer global (controllers/, services/, models/). Di Go, package yang sehat mengumpulkan hal yang berubah bersama. Saat kontrak katalog berubah, product/handler.go, product/request.go, dan product/response.go cenderung berubah bareng, jadi enak kalau bertetangga.
Laravel memisahkan berdasarkan peran teknis: app/Http/Controllers, app/Http/Requests, app/Http/Resources. Go cenderung memisahkan berdasarkan domain bisnis. Keuntungannya: import jadi jelas (product tidak perlu tahu cart), dan dependensi melingkar lebih mudah dihindari.
Router final memetakan langsung tiga lapis akses dari peta endpoint ke tiga konstruksi chi: route biasa untuk publik, satu r.Group dengan Authenticate untuk customer, dan satu r.Route("/admin") dengan Authenticate plus RequireRole("admin") untuk admin.
flowchart TD
ROOT["chi.NewRouter()"] --> GLOBAL["Global middleware<br/>RequestID, Logger, Recoverer, Timeout"]
GLOBAL --> V1["Route /v1"]
V1 --> P1["GET /products, /products/{id}"]
V1 --> P2["POST /auth/register, /login, /refresh"]
V1 --> P3["POST /payments/webhook"]
V1 --> GAUTH["Group: Use(auth.Authenticate)"]
GAUTH --> C1["GET /cart, cart/items..."]
GAUTH --> C2["POST /checkout, GET /orders..."]
V1 --> GADMIN["Route /admin:<br/>Use(Authenticate), Use(RequireRole admin)"]
GADMIN --> A1["POST/PUT/PATCH/DELETE /products..."]Gambar 2. Arsitektur router. Publik, customer, dan admin bukan sekadar konvensi path, tetapi grup middleware yang nyata dan terlihat di kode.
Katalog Produk: Filter dan Pagination
Endpoint paling sering dibaca, jadi kontraknya harus paling stabil
Katalog adalah endpoint yang paling sering dipanggil. Desain query dan pagination-nya menentukan apakah frontend bisa membangun halaman list yang nyaman tanpa menebak.
/v1/products List produk publik dengan filter dan pagination /v1/products/{id} Detail satu produk Query list harus mencerminkan kebutuhan UI (filter kategori, kata kunci, rentang harga, sort, halaman), bukan membuka semua kolom database mentah-mentah. Kita parse query ke struct, beri default yang aman, dan batasi per_page agar tidak ada yang meminta sejuta baris sekaligus.
internal/product/request.gopackage product import ( "net/http" "strconv" "strings" ) type ListQuery struct { Category string Search string MinPrice int64 MaxPrice int64 Sort string Page int PerPage int } func ParseListQuery(r *http.Request) ListQuery { q := r.URL.Query() lq := ListQuery{ Category: strings.TrimSpace(q.Get("category")), Search: strings.TrimSpace(q.Get("q")), MinPrice: int64FromQuery(q.Get("min_price"), 0), MaxPrice: int64FromQuery(q.Get("max_price"), 0), Sort: q.Get("sort"), Page: intFromQuery(q.Get("page"), 1), PerPage: intFromQuery(q.Get("per_page"), 20), } if lq.Page < 1 { lq.Page = 1 } if lq.PerPage < 1 || lq.PerPage > 100 { lq.PerPage = 20 } if !allowedSort(lq.Sort) { lq.Sort = "newest" } return lq } // allowedSort memakai allowlist, bukan nama kolom bebas dari client. func allowedSort(sort string) bool { switch sort { case "", "newest", "price_asc", "price_desc", "name_asc": return true default: return false } } func intFromQuery(value string, fallback int) int { if value == "" { return fallback } n, err := strconv.Atoi(value) if err != nil { return fallback } return n } func int64FromQuery(value string, fallback int64) int64 { if value == "" { return fallback } n, err := strconv.ParseInt(value, 10, 64) if err != nil { return fallback } return n }
Handler list memanggil service, lalu membungkus hasil dengan httpx.List agar response membawa data dan meta pagination. Detail produk yang tidak ditemukan membalas 404 dengan kode product_not_found, bukan 500.
internal/product/handler.gopackage product import ( "errors" "net/http" "github.com/go-chi/chi/v5" "github.com/kamu/skincare-backend/internal/httpx" ) type Handler struct { service Service } type Service interface { List(ctx context.Context, q ListQuery) (items []Product, total int64, err error) GetByID(ctx context.Context, id int64) (Product, error) } var ErrProductNotFound = errors.New("product not found") func (h *Handler) List(w http.ResponseWriter, r *http.Request) { q := ParseListQuery(r) items, total, err := h.service.List(r.Context(), q) if err != nil { httpx.Error(w, http.StatusInternalServerError, "internal_error", "gagal memuat produk") return } meta := httpx.NewMeta(q.Page, q.PerPage, total) httpx.List(w, http.StatusOK, toResponses(items), meta) } func (h *Handler) Detail(w http.ResponseWriter, r *http.Request) { id, err := parseID(chi.URLParam(r, "id")) if err != nil { httpx.Error(w, http.StatusBadRequest, "invalid_json", "id produk harus angka positif") return } product, err := h.service.GetByID(r.Context(), id) if errors.Is(err, ErrProductNotFound) { httpx.Error(w, http.StatusNotFound, "product_not_found", "produk tidak ditemukan") return } if err != nil { httpx.Error(w, http.StatusInternalServerError, "internal_error", "gagal memuat produk") return } httpx.Data(w, http.StatusOK, toDetailResponse(product)) }
Hindari menerima sort=products.price asc atau filter mentah dari query. Pakai allowlist seperti price_asc yang nanti dipetakan ke ORDER BY aman di Roadmap 3. Filter dinamis dari string client adalah pintu masuk klasik SQL injection dan query yang tidak bisa di-index.
httpx.List menaruh page, per_page, total, dan total_pages di meta. Frontend memakai total_pages untuk menggambar paginator tanpa harus menghitung sendiri. Helper httpx.NewMeta menghitung total_pages dari total dan per_page.
Cart sebagai Resource Customer
Milik user yang login, bukan dikirim dari client
Cart adalah resource milik user yang sedang login. Identitas user diambil dari token, bukan dari body request, sehingga endpoint cart tidak pernah menerima user_id dari client.
/v1/cart Lihat cart customer yang sedang login /v1/cart/items Tambah item ke cart /v1/cart/items/{id} Ubah quantity item /v1/cart/items/{id} Hapus item dari cart Perhatikan: cart/items/{id} merujuk ID item cart, bukan ID produk. Satu item cart punya metadata sendiri (quantity, harga saat dimasukkan, kelak varian), sehingga ia layak jadi resource tersendiri yang bisa di-PATCH dan di-DELETE.
Validasi memakai pola yang sudah kita sepakati di modul Validasi: hasil validasi adalah []httpx.FieldError, lalu dibalas dengan httpx.ValidationFailed (HTTP 422, kode validation_error). Validasi manual eksplisit di boundary handler, sadar akan zero value Go.
internal/cart/request.gopackage cart import "github.com/kamu/skincare-backend/internal/httpx" type AddItemRequest struct { ProductID int64 `json:"product_id"` Quantity int `json:"quantity"` } type UpdateItemRequest struct { Quantity int `json:"quantity"` } func (r AddItemRequest) Validate() []httpx.FieldError { var fields []httpx.FieldError if r.ProductID <= 0 { fields = append(fields, httpx.FieldError{Field: "product_id", Message: "product_id wajib diisi"}) } if r.Quantity < 1 { fields = append(fields, httpx.FieldError{Field: "quantity", Message: "quantity minimal 1"}) } return fields } func (r UpdateItemRequest) Validate() []httpx.FieldError { var fields []httpx.FieldError if r.Quantity < 1 { fields = append(fields, httpx.FieldError{Field: "quantity", Message: "quantity minimal 1"}) } return fields }
internal/cart/handler.gopackage cart import ( "encoding/json" "errors" "net/http" "github.com/go-chi/chi/v5" "github.com/kamu/skincare-backend/internal/auth" "github.com/kamu/skincare-backend/internal/httpx" ) type Handler struct { service Service } func (h *Handler) AddItem(w http.ResponseWriter, r *http.Request) { claims, ok := auth.UserFrom(r.Context()) if !ok { httpx.Error(w, http.StatusUnauthorized, "unauthorized", "silakan login dulu") return } var req AddItemRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httpx.Error(w, http.StatusBadRequest, "invalid_json", "body bukan JSON yang valid") return } if fields := req.Validate(); len(fields) > 0 { httpx.ValidationFailed(w, fields) return } cart, err := h.service.AddItem(r.Context(), claims.UserID, req) if errors.Is(err, ErrProductNotFound) { httpx.Error(w, http.StatusNotFound, "product_not_found", "produk tidak ditemukan") return } if err != nil { httpx.Error(w, http.StatusInternalServerError, "internal_error", "gagal menambah item") return } httpx.Data(w, http.StatusCreated, toCartResponse(cart)) } func (h *Handler) UpdateItem(w http.ResponseWriter, r *http.Request) { claims, ok := auth.UserFrom(r.Context()) if !ok { httpx.Error(w, http.StatusUnauthorized, "unauthorized", "silakan login dulu") return } itemID, err := parseID(chi.URLParam(r, "id")) if err != nil { httpx.Error(w, http.StatusBadRequest, "invalid_json", "id item harus angka positif") return } var req UpdateItemRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httpx.Error(w, http.StatusBadRequest, "invalid_json", "body bukan JSON yang valid") return } if fields := req.Validate(); len(fields) > 0 { httpx.ValidationFailed(w, fields) return } cart, err := h.service.UpdateItem(r.Context(), claims.UserID, itemID, req) if errors.Is(err, ErrItemNotFound) { httpx.Error(w, http.StatusNotFound, "not_found", "item cart tidak ditemukan") return } if err != nil { httpx.Error(w, http.StatusInternalServerError, "internal_error", "gagal mengubah item") return } httpx.Data(w, http.StatusOK, toCartResponse(cart)) }
Di React, cart sering dimulai sebagai state lokal atau store (Redux/Zustand) yang hilang saat ganti device. Di backend, cart adalah resource server-side yang konsisten lintas device dan session. Frontend boleh punya cache optimistik, tetapi sumber kebenaran tetap di server.
Karena itemID dari URL bisa milik siapa saja, service wajib memastikan item itu memang berada di cart milik claims.UserID. Kalau bukan, balas 404 (bukan 403), agar tidak membocorkan keberadaan item milik user lain. Ini pola otorisasi tingkat-resource yang penting di e-commerce.
Checkout, Order, dan Idempotensi
Batas antara belanja dan transaksi bisnis
Checkout adalah momen di mana cart berubah menjadi order. Ini titik paling rawan di seluruh API: ia menyentuh stok, harga, dan uang, jadi harus idempotent dan konsisten.
/v1/checkout Ubah cart menjadi order dalam satu transaksi /v1/orders Riwayat order customer /v1/orders/{id} Detail satu order Body checkout tidak perlu mengirim seluruh item, karena sumber kebenaran cart ada di server. Client cukup mengirim alamat pengiriman, metode pengiriman, metode pembayaran, dan satu idempotency_key yang ia bangkitkan sendiri (misalnya UUID per percobaan checkout).
internal/order/request.gopackage order import "github.com/kamu/skincare-backend/internal/httpx" type CheckoutRequest struct { ShippingAddressID int64 `json:"shipping_address_id"` ShippingMethod string `json:"shipping_method"` PaymentMethod string `json:"payment_method"` IdempotencyKey string `json:"idempotency_key"` } func (r CheckoutRequest) Validate() []httpx.FieldError { var fields []httpx.FieldError if r.ShippingAddressID <= 0 { fields = append(fields, httpx.FieldError{Field: "shipping_address_id", Message: "alamat pengiriman wajib dipilih"}) } if r.ShippingMethod == "" { fields = append(fields, httpx.FieldError{Field: "shipping_method", Message: "metode pengiriman wajib diisi"}) } if r.PaymentMethod == "" { fields = append(fields, httpx.FieldError{Field: "payment_method", Message: "metode pembayaran wajib diisi"}) } if r.IdempotencyKey == "" { fields = append(fields, httpx.FieldError{Field: "idempotency_key", Message: "idempotency_key wajib diisi"}) } return fields }
Diagram berikut menunjukkan alur checkout end-to-end. Di Roadmap 2 service masih in-memory, tetapi bentuk langkahnya sudah final. Di Roadmap 3, langkah validasi cart sampai insert order akan dibungkus satu transaksi database.
sequenceDiagram
participant FE as React Client
participant API as chi Handler
participant SVC as Order Service
participant DB as Store (pgx di Roadmap 3)
participant PSP as Payment Provider
FE->>API: POST /v1/checkout (+ idempotency_key)
API->>SVC: Checkout(userID, req)
SVC->>DB: Cek idempotency_key
alt key sudah pernah dipakai
DB-->>SVC: Order lama
SVC-->>API: Order yang sama
API-->>FE: 200 OK (order existing)
else key baru
SVC->>DB: Validasi cart & reserve stok
SVC->>DB: Insert order + order_items (harga dibekukan)
SVC->>PSP: Buat payment intent
PSP-->>SVC: payment_url
SVC-->>API: Order baru (status pending)
API-->>FE: 201 Created
endGambar 3. Idempotensi checkout: request dengan idempotency_key yang sama mengembalikan order yang sama, bukan membuat order kedua.
Handler checkout merakit semua lapis: ambil user dari context, decode dan validasi body, panggil service, lalu pilih status code yang jujur. Order yang baru dibuat membalas 201, sedangkan idempotency hit membalas 200 dengan order yang sudah ada.
internal/order/handler.gopackage order import ( "encoding/json" "errors" "net/http" "github.com/kamu/skincare-backend/internal/auth" "github.com/kamu/skincare-backend/internal/httpx" ) var ( ErrCartEmpty = errors.New("cart kosong") ErrStockNotEnough = errors.New("stok tidak cukup") ) func (h *Handler) Checkout(w http.ResponseWriter, r *http.Request) { claims, ok := auth.UserFrom(r.Context()) if !ok { httpx.Error(w, http.StatusUnauthorized, "unauthorized", "silakan login dulu") return } var req CheckoutRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httpx.Error(w, http.StatusBadRequest, "invalid_json", "body bukan JSON yang valid") return } if fields := req.Validate(); len(fields) > 0 { httpx.ValidationFailed(w, fields) return } order, created, err := h.service.Checkout(r.Context(), claims.UserID, req) switch { case errors.Is(err, ErrCartEmpty): httpx.Error(w, http.StatusConflict, "conflict", "cart kosong, tidak bisa checkout") return case errors.Is(err, ErrStockNotEnough): httpx.Error(w, http.StatusConflict, "conflict", "stok salah satu produk tidak mencukupi") return case err != nil: httpx.Error(w, http.StatusInternalServerError, "internal_error", "gagal memproses checkout") return } status := http.StatusOK if created { status = http.StatusCreated } httpx.Data(w, status, toOrderResponse(order)) }
Status order berpindah mengikuti state machine yang eksplisit. Bukan field bebas, melainkan transisi yang punya aturan.
stateDiagram-v2 [*] --> pending: checkout berhasil pending --> paid: webhook payment.paid pending --> cancelled: timeout bayar / dibatalkan paid --> shipped: admin kirim paid --> cancelled: refund sebelum kirim shipped --> [*] cancelled --> [*] note right of pending: Hanya transisi sah yang diizinkan.<br/>cancelled tidak bisa kembali ke pending.
Gambar 4. State machine order. Service menolak transisi tidak sah (misalnya shipped langsung ke pending), sehingga status tidak bisa korup.
Mobile network putus-nyambung dan tombol bisa ditekan dua kali. idempotency_key membuat service mengenali checkout yang sama, lalu mengembalikan order yang sudah ada alih-alih membuat order kedua. Ini sama pentingnya untuk checkout seperti untuk webhook pembayaran.
Di Node, kamu mungkin menjalankan beberapa operasi dengan await berurutan dan berharap semuanya sukses. Di Go (Roadmap 3), validasi cart, reserve stok, dan insert order akan dibungkus satu transaksi database: kalau salah satu gagal, semuanya di-rollback. Tidak ada order setengah jadi.
Admin Product Management
Route, validasi, dan role guard yang lebih ketat
Admin API memodifikasi katalog, jadi route-nya terpisah dan dilindungi RequireRole("admin"). Kebutuhan admin berbeda dari storefront, sehingga endpoint-nya pun berbeda.
/v1/admin/products Buat produk baru /v1/admin/products/{id} Ganti seluruh data produk /v1/admin/products/{id} Ubah sebagian field produk /v1/admin/products/{id} Arsipkan produk PUT mengganti seluruh resource, jadi setiap field wajib dikirim. PATCH mengubah sebagian, jadi field opsional. Di Go, membedakan “field tidak dikirim” dari “field dikirim bernilai kosong” penting, dan kita memakai pointer untuk itu pada DTO patch.
- Semua field wajib ada di body.
- Field yang hilang dianggap dikosongkan.
- DTO memakai tipe nilai biasa (
string,int64).
- Hanya field yang dikirim yang diubah.
- Field yang hilang tetap apa adanya.
- DTO memakai pointer (
*string,*int64) agarnilberarti “tidak diubah”.
internal/product/request.go (admin)package product import ( "unicode/utf8" "github.com/kamu/skincare-backend/internal/httpx" ) // CreateProductRequest dipakai POST dan PUT (semua field wajib). type CreateProductRequest struct { Name string `json:"name"` Category string `json:"category"` PriceRupiah int64 `json:"price"` Stock int `json:"stock"` Description string `json:"description"` Status string `json:"status"` } func (r CreateProductRequest) Validate() []httpx.FieldError { var fields []httpx.FieldError if utf8.RuneCountInString(r.Name) < 3 { fields = append(fields, httpx.FieldError{Field: "name", Message: "nama minimal 3 karakter"}) } if r.Category == "" { fields = append(fields, httpx.FieldError{Field: "category", Message: "kategori wajib diisi"}) } if r.PriceRupiah <= 0 { fields = append(fields, httpx.FieldError{Field: "price", Message: "harga harus lebih dari 0"}) } if r.Stock < 0 { fields = append(fields, httpx.FieldError{Field: "stock", Message: "stok tidak boleh negatif"}) } if !allowedStatus(r.Status) { fields = append(fields, httpx.FieldError{Field: "status", Message: "status harus draft, active, atau archived"}) } return fields } // PatchProductRequest dipakai PATCH. Pointer nil berarti "tidak diubah". type PatchProductRequest struct { Name *string `json:"name"` Category *string `json:"category"` PriceRupiah *int64 `json:"price"` Stock *int `json:"stock"` Status *string `json:"status"` } func (r PatchProductRequest) Validate() []httpx.FieldError { var fields []httpx.FieldError if r.Name != nil && utf8.RuneCountInString(*r.Name) < 3 { fields = append(fields, httpx.FieldError{Field: "name", Message: "nama minimal 3 karakter"}) } if r.PriceRupiah != nil && *r.PriceRupiah <= 0 { fields = append(fields, httpx.FieldError{Field: "price", Message: "harga harus lebih dari 0"}) } if r.Status != nil && !allowedStatus(*r.Status) { fields = append(fields, httpx.FieldError{Field: "status", Message: "status harus draft, active, atau archived"}) } return fields } func allowedStatus(status string) bool { switch status { case "draft", "active", "archived": return true default: return false } }
Di JavaScript, body.price === undefined membedakan field yang tidak dikirim dari field bernilai 0. Go tidak punya undefined: int64 selalu punya zero value 0. Pointer *int64 mengembalikan kemampuan itu. nil berarti “tidak ada di body”, sedangkan &0 berarti “dikirim bernilai 0”.
DELETE /v1/admin/products/{id} sebaiknya mengarsipkan produk (set status = archived), bukan menghapus baris. Produk yang sudah pernah dibeli masih dirujuk order_items lewat nama dan harga saat itu. Hard delete bisa merusak riwayat order yang sah secara hukum dan akuntansi.
Selain validasi manual, kamu bisa memakai go-playground/validator (v10) dengan tag validate:"required,min=3,gt=0". Hasilnya validator.ValidationErrors lalu kamu terjemahkan ke []httpx.FieldError yang sama, sehingga shape error ke client tetap identik. Pilih satu pendekatan dan konsisten; modul Validasi membahas keduanya.
Payment Webhook yang Aman
Request dari sistem lain, bukan dari user yang login
Webhook adalah panggilan dari payment provider ke API kita. Ia publik (tidak memakai JWT customer), tetapi tetap harus diverifikasi dan diproses idempotent.
/v1/payments/webhook Terima event pembayaran dari provider Tiga aturan webhook pembayaran: verifikasi signature dari raw body (bukan JSON yang sudah di-decode ulang), proses idempotent berdasarkan event ID, dan balas 200 secepat mungkin agar provider tidak menganggap gagal lalu retry berlebihan.
sequenceDiagram
participant PSP as Payment Provider
participant API as Webhook Handler
participant SVC as Payment Service
participant DB as Store
PSP->>API: POST /v1/payments/webhook (+ signature)
API->>API: Baca raw body, verifikasi signature HMAC
alt signature invalid
API-->>PSP: 401 unauthorized
else valid
API->>SVC: Handle(event)
SVC->>DB: Sudah pernah proses event.ID?
alt sudah
DB-->>SVC: ya
SVC-->>API: no-op (idempotent)
API-->>PSP: 200 OK
else belum
SVC->>DB: Tandai event diproses
SVC->>DB: Order -> paid
SVC-->>API: ok
API-->>PSP: 200 OK
end
endGambar 5. Webhook harus tahan retry. Provider bisa mengirim event yang sama beberapa kali, dan kita hanya boleh memproses efeknya sekali.
internal/payment/webhook.gopackage payment import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "github.com/kamu/skincare-backend/internal/httpx" ) type WebhookHandler struct { service Service secret []byte } type Event struct { ID string `json:"id"` Type string `json:"type"` // payment.paid, payment.failed OrderID int64 `json:"order_id"` } func (h *WebhookHandler) Handle(w http.ResponseWriter, r *http.Request) { // Batasi ukuran body webhook agar provider nakal tidak membanjiri kita. r.Body = http.MaxBytesReader(w, r.Body, 1<<20) rawBody, err := io.ReadAll(r.Body) if err != nil { httpx.Error(w, http.StatusBadRequest, "invalid_json", "gagal membaca body") return } if !verifySignature(r.Header.Get("X-Signature"), rawBody, h.secret) { httpx.Error(w, http.StatusUnauthorized, "unauthorized", "signature tidak valid") return } var event Event if err := json.Unmarshal(rawBody, &event); err != nil { httpx.Error(w, http.StatusBadRequest, "invalid_json", "body bukan event yang valid") return } // HandleEvent idempotent: cek event.ID sebelum mengubah status order. if err := h.service.HandleEvent(r.Context(), event); err != nil { httpx.Error(w, http.StatusInternalServerError, "internal_error", "gagal memproses event") return } httpx.JSON(w, http.StatusOK, map[string]string{"status": "ok"}) } // verifySignature membandingkan HMAC-SHA256 raw body dengan header signature. // hmac.Equal memakai perbandingan constant-time agar aman dari timing attack. func verifySignature(signature string, body, secret []byte) bool { if signature == "" { return false } mac := hmac.New(sha256.New, secret) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(signature)) }
Provider menghitung signature dari byte mentah body. Jika kamu json.Decode lalu json.Encode ulang untuk verifikasi, urutan field dan whitespace berubah, dan signature pasti gagal. Selalu io.ReadAll dulu, verifikasi byte itu, baru json.Unmarshal dari byte yang sama.
hmac.Equal membandingkan dalam waktu konstan, tidak short-circuit di byte pertama yang beda. Jangan pakai == biasa untuk membandingkan signature, karena membuka celah timing attack yang bisa dipakai menebak signature byte per byte.
Karena pemanggilnya provider, bukan user, route webhook diletakkan di luar grup auth: r.Post("/payments/webhook", ...) langsung di bawah /v1. Keamanannya datang dari signature, bukan dari JWT. Salah menaruhnya di grup auth justru membuat provider selalu kena 401.
Merakit Router Final dengan chi
Tiga lapis akses menjadi tiga konstruksi chi
Sekarang semua bertemu. Router chi menyusun publik, customer, dan admin sebagai grup middleware yang nyata, dan menyambungkan setiap domain ke peta endpoint.
Package net/http tetap fondasinya. chi (github.com/go-chi/chi/v5) kompatibel dengan http.Handler, jadi route tetap idiomatik Go, bukan framework tertutup. Middleware umum dipasang sekali di atas, lalu auth dan role guard dipasang per grup.
internal/router/router.gopackage router import ( "net/http" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/kamu/skincare-backend/internal/auth" "github.com/kamu/skincare-backend/internal/cart" "github.com/kamu/skincare-backend/internal/order" "github.com/kamu/skincare-backend/internal/payment" "github.com/kamu/skincare-backend/internal/product" ) type Handlers struct { Auth *auth.Handler Product *product.Handler Cart *cart.Handler Order *order.Handler Webhook *payment.WebhookHandler } func New(h Handlers) http.Handler { r := chi.NewRouter() // Middleware global, dipasang sekali untuk semua route. r.Use(middleware.RequestID) r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.Timeout(15 * time.Second)) r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) r.Route("/v1", func(r chi.Router) { // --- Publik --- r.Get("/products", h.Product.List) r.Get("/products/{id}", h.Product.Detail) r.Post("/auth/register", h.Auth.Register) r.Post("/auth/login", h.Auth.Login) r.Post("/auth/refresh", h.Auth.Refresh) r.Post("/payments/webhook", h.Webhook.Handle) // --- Customer (butuh login) --- r.Group(func(r chi.Router) { r.Use(auth.Authenticate) r.Get("/cart", h.Cart.Get) r.Post("/cart/items", h.Cart.AddItem) r.Patch("/cart/items/{id}", h.Cart.UpdateItem) r.Delete("/cart/items/{id}", h.Cart.DeleteItem) r.Post("/checkout", h.Order.Checkout) r.Get("/orders", h.Order.ListMine) r.Get("/orders/{id}", h.Order.DetailMine) }) // --- Admin (butuh role admin) --- r.Route("/admin", func(r chi.Router) { r.Use(auth.Authenticate) r.Use(auth.RequireRole("admin")) r.Post("/products", h.Product.AdminCreate) r.Put("/products/{id}", h.Product.AdminReplace) r.Patch("/products/{id}", h.Product.AdminPatch) r.Delete("/products/{id}", h.Product.AdminArchive) }) }) return r }
Di Laravel kamu menulis Route::middleware('auth:sanctum')->group(...). Di chi, r.Group(func(r chi.Router) { r.Use(auth.Authenticate); ... }) melakukan hal yang sama: middleware berlaku hanya pada route di dalam grup, tanpa memengaruhi route publik di luarnya.
middleware.RealIP kini deprecated karena celah IP spoofing (GHSA-3fxj-6jh8-hvhx, severity Critical): ia memutasi r.RemoteAddr dan memercayai header dari client. Pengganti yang benar bergantung infrastruktur, misalnya middleware yang membaca X-Forwarded-For hanya dari proxy tepercaya (ClientIPFromXFFTrustedProxies) saat di belakang ALB, lalu ambil hasilnya via GetClientIP(ctx). Bila server terhubung langsung, pakai r.RemoteAddr apa adanya. Pilih sesuai topologi deploy, jangan asal pasang RealIP.
Middleware Authenticate membaca header Authorization: Bearer ..., memverifikasi token, lalu menaruh *Claims di context. Handler mengambilnya lewat auth.UserFrom. Inilah perekat antara modul Auth dan permukaan API ini.
internal/auth/middleware.go (ringkasan dari modul Autentikasi)package auth import ( "net/http" "strings" "github.com/kamu/skincare-backend/internal/httpx" ) func Authenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") claims, err := ParseToken(raw) if err != nil { httpx.Error(w, http.StatusUnauthorized, "unauthorized", "token tidak valid") return } ctx := withClaims(r.Context(), claims) next.ServeHTTP(w, r.WithContext(ctx)) }) } func RequireRole(role string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims, ok := UserFrom(r.Context()) if !ok { httpx.Error(w, http.StatusUnauthorized, "unauthorized", "silakan login dulu") return } if claims.Role != role { httpx.Error(w, http.StatusForbidden, "forbidden", "akses ditolak") return } next.ServeHTTP(w, r) }) } }
router.use(auth)menerimareq,res,next.- Path param dibaca dari
req.params.id. - Grup route dengan
express.Router()terpisah.
- Middleware adalah
func(http.Handler) http.Handleryang membungkus handler berikutnya. - Path param dibaca
chi.URLParam(r, "id"). - Grup route dengan
r.Groupataur.Routedi dalam closure.
Hands-on: Jalankan Peta API
Route bisa diuji walau service masih in-memory
Latihan ini membuat peta route hidup walau service dan database masih dummy. Tujuannya: frontend dan backend bisa menyepakati kontrak lebih awal.
Jalankan go mod init github.com/kamu/skincare-backend lalu go mod tidy setelah menambahkan import chi.
Tambahkan internal/router/router.go, sambungkan dari cmd/api/main.go.
Untuk tiap domain, buat method handler yang membalas JSON sederhana lewat httpx.Data agar route bisa diuji dulu.
Kirim curl ke route publik, customer (dengan Bearer token), admin, dan webhook. Catat shape response sukses dan error sebagai kontrak.
cmd/api/main.gopackage main import ( "log/slog" "net/http" "os" "github.com/kamu/skincare-backend/internal/router" ) func main() { // Di proyek nyata, handler diisi service + repository. // Untuk peta route, handler stub sudah cukup. h := router.Handlers{} srv := router.New(h) addr := ":8080" slog.Info("server listening", "addr", addr) if err := http.ListenAndServe(addr, srv); err != nil { slog.Error("server stopped", "error", err) os.Exit(1) } }
Terminalgo mod init github.com/kamu/skincare-backend go get github.com/go-chi/chi/v5@v5.3.0 go mod tidy go run ./cmd/api
Uji health check dan katalog publik.
Terminalcurl -i http://localhost:8080/healthz curl -i "http://localhost:8080/v1/products?category=serum&page=1&per_page=20"
Uji route customer dengan Bearer token (token didapat dari POST /v1/auth/login).
Terminalcurl -i http://localhost:8080/v1/cart \ -H "Authorization: Bearer <ACCESS_TOKEN>" curl -i -X POST http://localhost:8080/v1/checkout \ -H "Authorization: Bearer <ACCESS_TOKEN>" \ -H "Content-Type: application/json" \ -d '{"shipping_address_id":1,"shipping_method":"regular","payment_method":"va_bca","idempotency_key":"chk-1f2e"}'
Uji route admin (token dengan role admin) dan webhook publik.
Terminalcurl -i -X POST http://localhost:8080/v1/admin/products \ -H "Authorization: Bearer <ADMIN_TOKEN>" \ -H "Content-Type: application/json" \ -d '{"name":"Niacinamide 10% Serum","category":"serum","price":189000,"stock":40,"status":"active"}' curl -i -X POST http://localhost:8080/v1/payments/webhook \ -H "X-Signature: <HMAC_HEX>" \ -H "Content-Type: application/json" \ -d '{"id":"evt_123","type":"payment.paid","order_id":1001}'
Route stub membuat frontend bisa mulai integrasi tanpa menunggu database. Saat service dan repository pgx datang di Roadmap 3, path dan shape response tidak berubah, hanya isinya yang jadi nyata.
Jebakan Umum Desain API
Bug e-commerce sering datang dari kontrak yang kabur, bukan syntax Go
Sebagian besar masalah API e-commerce bukan error kompilasi, melainkan keputusan desain yang terbawa kebiasaan dari Express atau Laravel tanpa disesuaikan.
Verb di dalam path
Hindari /v1/createProduct atau /v1/doCheckout. Pakai resource plus method: POST /v1/admin/products, POST /v1/checkout.
user_id dari body
Untuk route customer, ambil user dari context hasil Authenticate. Jangan pernah percaya user_id yang dikirim client.
Filter terlalu bebas
Query parameter harus divalidasi dan memakai allowlist, agar tidak jadi SQL dinamis yang rentan saat masuk Roadmap 3.
Webhook seperti route login
Webhook tidak memakai JWT. Verifikasi signature dari raw body dan proses idempotent berdasarkan event ID.
DELETE merusak histori
Produk yang pernah dibeli diarsipkan, bukan dihapus. order_items masih merujuk nama dan harga saat itu.
Checkout tidak idempotent
Tanpa idempotency_key, satu klik ganda atau retry jaringan bisa membuat dua order untuk pembayaran yang sama.
Harga tidak dibekukan
Mengubah harga produk hari ini tidak boleh mengubah total order kemarin. Bekukan harga ke order_items saat checkout.
Status code asal 200
Balas 201 untuk created, 422 untuk validasi, 409 untuk konflik bisnis (stok habis), 404 untuk resource tidak ada, bukan semuanya 200 atau 500.
Jangan mendesain endpoint mengikuti struktur tabel satu lawan satu. REST API adalah kontrak produk, database adalah detail penyimpanan. cart_items di database boleh berbeda dari bentuk data yang dibalas GET /v1/cart. Pemisahan inilah yang membuat kamu bisa mengganti skema database tanpa merusak frontend.
Disiplin App\Http\Resources di Laravel (memilih field yang aman dan stabil untuk client) sama persis dengan disiplin DTO response di Go. Keduanya menjaga agar perubahan internal tidak bocor ke kontrak publik. Bedanya, di Go ini eksplisit lewat struct, bukan magic method.
Ringkasan & Poin Penting
Chapter ini menutup Roadmap 2 dengan satu permukaan API e-commerce yang utuh, merakit semua yang kamu pelajari dari handler sampai auth.
Yang Wajib Menempel
- Permukaan API skincare terdiri dari katalog publik, auth, cart customer, checkout, riwayat order, admin product management, dan payment webhook, semuanya di bawah prefix
/v1. - Tiga lapis akses (publik, customer, admin) diwujudkan sebagai grup middleware chi: route biasa,
r.Groupdenganauth.Authenticate, danr.Route("/admin")denganAuthenticateplusRequireRole("admin"). - Semua response memakai envelope
httpx:Datauntuk satu objek,Listuntuk koleksi plusmetapagination,Erroruntuk error berkode, danValidationFailed(422) untuk[]FieldError. - Uang selalu integer rupiah:
PriceRupiah int64(json:"price") danTotalRupiah int64(json:"total"). Tidak adafloat64untuk uang. - Checkout dan webhook wajib idempotent:
idempotency_keyuntuk checkout, event ID untuk webhook. Webhook diverifikasi lewat HMAC raw body, bukan JWT. PUTmengganti penuh (nilai biasa),PATCHmengubah sebagian (pointer agarnilberarti “tidak diubah”).DELETEproduk berarti arsip, bukan hard delete.- Status order berpindah lewat state machine eksplisit (
pending,paid,shipped,cancelled), dan status code dipilih jujur:201,200,404,409,422.
Setelah ini, Roadmap 3 masuk ke PostgreSQL dan pgx. Peta endpoint yang sudah kita desain akan mendapat storage nyata: tabel products, cart_items, orders, order_items, payments, query pagination, transaksi checkout, dan repository yang menerima context.Context. Roadmap 4 menambah service layer dan clean architecture di antara handler dan repository.
Pakai peta route ini sebagai kontrak yang dijaga. Saat database dan service masuk, ubah implementasi internal, bukan surface API. Endpoint, status code, dan shape response yang kamu sepakati hari ini adalah janji ke frontend yang harus tetap stabil.
Progress disimpan lokal di browser ini.