Pola Desain API
Membangun API RESTful dengan Gin tidak hanya sekadar mendefinisikan rute. API yang dirancang dengan baik menggunakan format respons yang konsisten, paginasi yang dapat diprediksi, pembuatan versi yang jelas, dan penanganan eror yang terstruktur. Panduan ini membahas pola-pola praktis yang dapat Anda terapkan pada aplikasi Gin produksi.
Format respons yang konsisten
Membungkus setiap respons dalam envelope standar memudahkan konsumen API. Mereka selalu tahu di mana menemukan data, eror, dan metadata.
package main
import ( "net/http"
"github.com/gin-gonic/gin")
// Response merupakan pembungkus API standar.type Response struct { Success bool `json:"success"` Data interface{} `json:"data,omitempty"` Error *ErrorInfo `json:"error,omitempty"` Meta *Meta `json:"meta,omitempty"`}
type ErrorInfo struct { Code string `json:"code"` Message string `json:"message"`}
type Meta struct { Page int `json:"page,omitempty"` PerPage int `json:"per_page,omitempty"` Total int `json:"total,omitempty"` TotalPages int `json:"total_pages,omitempty"`}
// OK mengirimkan respons sukses.func OK(c *gin.Context, data interface{}) { c.JSON(http.StatusOK, Response{ Success: true, Data: data, })}
// Fail mengirimkan respons eror.func Fail(c *gin.Context, status int, code, message string) { c.JSON(status, Response{ Success: false, Error: &ErrorInfo{Code: code, Message: message}, })}
func main() { r := gin.Default()
r.GET("/api/users/:id", func(c *gin.Context) { id := c.Param("id") // Simulasi pencarian if id == "0" { Fail(c, http.StatusNotFound, "USER_NOT_FOUND", "no user with that ID") return } OK(c, gin.H{"id": id, "name": "Alice"}) })
r.Run(":8080")}Paginasi
Paginasi limit/offset
Limit/offset adalah pendekatan paling sederhana. Ini bekerja dengan baik untuk dataset kecil hingga menengah di mana total jumlah baris terjangkau untuk dihitung.
package main
import ( "net/http" "strconv"
"github.com/gin-gonic/gin")
func main() { r := gin.Default()
r.GET("/api/articles", func(c *gin.Context) { limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if limit > 100 { limit = 100 // batasi ukuran halaman }
// articles, total := db.ListArticles(limit, offset)
c.JSON(http.StatusOK, gin.H{ "success": true, "data": []gin.H{}, // articles "meta": gin.H{ "limit": limit, "offset": offset, "total": 0, // total }, }) })
r.Run(":8080")}Paginasi berbasis cursor
Paginasi berbasis cursor menghindari masalah performa dari offset yang besar. Teruskan ID item terakhir (atau field unik yang dapat diurutkan lainnya) sebagai cursor.
package main
import ( "net/http" "strconv"
"github.com/gin-gonic/gin")
func main() { r := gin.Default()
r.GET("/api/events", func(c *gin.Context) { cursor := c.Query("cursor") // contoh: ID event terakhir limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if limit > 100 { limit = 100 }
// events, nextCursor := db.ListEvents(cursor, limit) _ = cursor
c.JSON(http.StatusOK, gin.H{ "success": true, "data": []gin.H{}, // events "next_cursor": "", // nextCursor (string kosong berarti tidak ada halaman lagi) }) })
r.Run(":8080")}Penyaringan dan pengurutan
Terima penyaringan dan pengurutan melalui parameter query. Jaga antarmuka tetap dapat diprediksi dengan menggunakan nama parameter yang konsisten.
package main
import ( "net/http"
"github.com/gin-gonic/gin")
func main() { r := gin.Default()
// GET /api/products?category=electronics&min_price=10&sort=price&order=asc r.GET("/api/products", func(c *gin.Context) { category := c.Query("category") minPrice := c.Query("min_price") maxPrice := c.Query("max_price") sortBy := c.DefaultQuery("sort", "created_at") order := c.DefaultQuery("order", "desc")
// Validasi field sort terhadap allow-list untuk mencegah injeksi. allowed := map[string]bool{"created_at": true, "price": true, "name": true} if !allowed[sortBy] { sortBy = "created_at" } if order != "asc" && order != "desc" { order = "desc" }
// Bangun dan jalankan query Anda menggunakan filter ini... _ = category _ = minPrice _ = maxPrice
c.JSON(http.StatusOK, gin.H{ "success": true, "data": []gin.H{}, "filters": gin.H{ "category": category, "min_price": minPrice, "max_price": maxPrice, "sort": sortBy, "order": order, }, }) })
r.Run(":8080")}Pembuatan versi API
Pembuatan versi path URL
Pembuatan versi path URL adalah strategi yang paling umum. Ini eksplisit, mudah dilakukan routing, dan sederhana untuk diuji dengan curl.
package main
import ( "net/http"
"github.com/gin-gonic/gin")
func main() { r := gin.Default()
v1 := r.Group("/api/v1") { v1.GET("/users", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"version": "v1", "users": []string{}}) }) }
v2 := r.Group("/api/v2") { v2.GET("/users", func(c *gin.Context) { // v2 mengembalikan bentuk yang berbeda c.JSON(http.StatusOK, gin.H{ "version": "v2", "data": []gin.H{}, "meta": gin.H{"total": 0}, }) }) }
r.Run(":8080")}Pembuatan versi berbasis header
Pembuatan versi header menjaga URL tetap bersih tetapi mengharuskan klien mengatur header tersuai. Middleware dapat membaca header dan menyimpan versi dalam context.
package main
import ( "net/http"
"github.com/gin-gonic/gin")
// VersionMiddleware membaca versi API dari header Accept-Version.func VersionMiddleware() gin.HandlerFunc { return func(c *gin.Context) { version := c.GetHeader("Accept-Version") if version == "" { version = "v1" // bawaan } c.Set("api_version", version) c.Next() }}
func main() { r := gin.Default() r.Use(VersionMiddleware())
r.GET("/api/users", func(c *gin.Context) { version := c.GetString("api_version")
switch version { case "v2": c.JSON(http.StatusOK, gin.H{"version": "v2", "data": []gin.H{}}) default: c.JSON(http.StatusOK, gin.H{"version": "v1", "users": []string{}}) } })
r.Run(":8080")}Pola penanganan eror
Tipe eror tersuai
Definisikan tipe eror tingkat aplikasi sehingga handler dapat mengembalikan eror yang bermakna dan terstruktur.
package main
import ( "errors" "net/http"
"github.com/gin-gonic/gin")
// AppError merepresentasikan eror API yang terstruktur.type AppError struct { Status int `json:"-"` Code string `json:"code"` Message string `json:"message"`}
func (e *AppError) Error() string { return e.Message}
var ( ErrNotFound = &AppError{Status: 404, Code: "NOT_FOUND", Message: "resource not found"} ErrUnauthorized = &AppError{Status: 401, Code: "UNAUTHORIZED", Message: "authentication required"} ErrBadRequest = &AppError{Status: 400, Code: "BAD_REQUEST", Message: "invalid request"})
// ErrorHandler adalah middleware yang menangkap set eror melalui c.Error().func ErrorHandler() gin.HandlerFunc { return func(c *gin.Context) { c.Next()
if len(c.Errors) == 0 { return }
err := c.Errors.Last().Err var appErr *AppError if errors.As(err, &appErr) { c.JSON(appErr.Status, gin.H{ "success": false, "error": gin.H{"code": appErr.Code, "message": appErr.Message}, }) } else { c.JSON(http.StatusInternalServerError, gin.H{ "success": false, "error": gin.H{"code": "INTERNAL", "message": "an unexpected error occurred"}, }) } }}
func main() { r := gin.Default() r.Use(ErrorHandler())
r.GET("/api/items/:id", func(c *gin.Context) { id := c.Param("id") if id == "0" { _ = c.Error(ErrNotFound) return } c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"id": id}}) })
r.Run(":8080")}Organisasi rute berbasis sumber daya
Seiring API Anda berkembang, organisasikan rute berdasarkan sumber daya. Setiap sumber daya mendapatkan file sendiri dengan fungsi yang mendaftarkan rute pada gin.RouterGroup.
package main
import ( "net/http"
"github.com/gin-gonic/gin")
// RegisterUserRoutes mendaftarkan semua endpoint /users.func RegisterUserRoutes(rg *gin.RouterGroup) { users := rg.Group("/users") { users.GET("/", listUsers) users.POST("/", createUser) users.GET("/:id", getUser) users.PUT("/:id", updateUser) users.DELETE("/:id", deleteUser) }}
// RegisterOrderRoutes mendaftarkan semua endpoint /orders.func RegisterOrderRoutes(rg *gin.RouterGroup) { orders := rg.Group("/orders") { orders.GET("/", listOrders) orders.POST("/", createOrder) orders.GET("/:id", getOrder) }}
func listUsers(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"action": "list_users"}) }func createUser(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"action": "create_user"}) }func getUser(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"action": "get_user"}) }func updateUser(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"action": "update_user"}) }func deleteUser(c *gin.Context) { c.Status(http.StatusNoContent) }
func listOrders(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"action": "list_orders"}) }func createOrder(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"action": "create_order"}) }func getOrder(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"action": "get_order"}) }
func main() { r := gin.Default()
api := r.Group("/api/v1") RegisterUserRoutes(api) RegisterOrderRoutes(api)
r.Run(":8080")}Dalam proyek nyata, Anda akan menempatkan RegisterUserRoutes dalam file routes/users.go dan RegisterOrderRoutes dalam routes/orders.go, lalu memanggil keduanya dari main.go. Ini menjaga setiap sumber daya tetap mandiri dan memudahkan penambahan atau penghapusan sumber daya tanpa menyentuh kode yang tidak terkait.