Building REST APIs with Go and Gin

Gin is Go’s most popular web framework, offering a martini-like API with up to 40x better performance. This post covers building production-ready REST APIs with Gin, from basic routing to data validation and CRUD operations.

HTTP and REST Fundamentals

HTTP Methods

flowchart LR
    subgraph Methods["HTTP Methods"]
        GET[GET - Read]
        POST[POST - Create]
        PUT[PUT - Replace]
        PATCH[PATCH - Update]
        DELETE[DELETE - Remove]
    end

    subgraph Resource["Resource: /users"]
        GET --> R1[List/Get users]
        POST --> R2[Create user]
        PUT --> R3[Replace user]
        PATCH --> R4[Update fields]
        DELETE --> R5[Remove user]
    end

    style Methods fill:#e3f2fd
    style Resource fill:#e8f5e9

RESTful Endpoint Design

Method Endpoint Action Response
GET /users List all users 200 + array
GET /users/:id Get single user 200 + object
POST /users Create user 201 + object
PUT /users/:id Replace user 200 + object
PATCH /users/:id Update user 200 + object
DELETE /users/:id Delete user 204

Getting Started with Gin

Installation

1
go get -u github.com/gin-gonic/gin

Basic Server

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import (
"net/http"

"github.com/gin-gonic/gin"
)

func main() {
// Create router with default middleware (Logger, Recovery)
router := gin.Default()

// Simple endpoint
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "healthy",
"service": "api",
})
})

// Start server
router.Run(":8080")
}

Response Types

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// JSON response (most common)
c.JSON(http.StatusOK, gin.H{"message": "success"})

// String response
c.String(http.StatusOK, "Hello, World!")

// XML response
c.XML(http.StatusOK, user)

// HTML response
c.HTML(http.StatusOK, "index.html", gin.H{"title": "Home"})

// File response
c.File("/path/to/file.pdf")

// Redirect
c.Redirect(http.StatusMovedPermanently, "/new-url")

Request Handling

Path Parameters

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// GET /users/123
router.GET("/users/:id", func(c *gin.Context) {
id := c.Param("id")
c.JSON(http.StatusOK, gin.H{"user_id": id})
})

// GET /posts/2024/03/hello-world
router.GET("/posts/:year/:month/*slug", func(c *gin.Context) {
year := c.Param("year")
month := c.Param("month")
slug := c.Param("slug") // includes leading /
c.JSON(http.StatusOK, gin.H{
"year": year,
"month": month,
"slug": slug,
})
})

Query Parameters

1
2
3
4
5
6
7
8
9
10
11
12
// GET /search?q=golang&page=1&limit=10
router.GET("/search", func(c *gin.Context) {
query := c.Query("q") // Required
page := c.DefaultQuery("page", "1") // With default
limit := c.DefaultQuery("limit", "10")

c.JSON(http.StatusOK, gin.H{
"query": query,
"page": page,
"limit": limit,
})
})

Request Body

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type CreateUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
}

router.POST("/users", func(c *gin.Context) {
var req CreateUserRequest

// Bind JSON body to struct
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

// Process request...
c.JSON(http.StatusCreated, gin.H{
"message": "user created",
"username": req.Username,
})
})

Data Validation

Built-in Validators

1
2
3
4
5
6
7
8
9
10
type ProductRequest struct {
Name string `json:"name" binding:"required,min=1,max=255"`
Description string `json:"description" binding:"max=1000"`
Price float64 `json:"price" binding:"required,gt=0"`
Stock int `json:"stock" binding:"gte=0"`
Category string `json:"category" binding:"required,oneof=electronics clothing food"`
SKU string `json:"sku" binding:"required,alphanum,len=10"`
Email string `json:"contact_email" binding:"omitempty,email"`
URL string `json:"website" binding:"omitempty,url"`
}

Common validation tags:

Tag Description Example
required Field must be present binding:"required"
min/max String length or number value binding:"min=3,max=50"
len Exact length binding:"len=10"
gt/gte/lt/lte Comparison binding:"gt=0"
oneof Must be one of values binding:"oneof=a b c"
email Valid email format binding:"email"
url Valid URL format binding:"url"
alphanum Alphanumeric only binding:"alphanum"
omitempty Skip if empty binding:"omitempty,email"

Custom Validators

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import (
"github.com/go-playground/validator/v10"
)

// Custom validation function
func ValidateUsername(fl validator.FieldLevel) bool {
username := fl.Field().String()
// Custom logic: no spaces, specific characters allowed
for _, char := range username {
if char == ' ' || char == '@' {
return false
}
}
return true
}

func main() {
router := gin.Default()

// Register custom validator
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterValidation("username", ValidateUsername)
}

// Use in struct
type User struct {
Username string `json:"username" binding:"required,username"`
}
}

Building CRUD API

Project Structure

1
2
3
4
5
6
7
8
9
10
api/
├── main.go
├── handlers/
│ └── user.go
├── models/
│ └── user.go
├── repositories/
│ └── user.go
└── routes/
└── routes.go

Models

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// models/user.go
package models

import "time"

type User struct {
ID uint `json:"id" gorm:"primaryKey"`
Username string `json:"username" gorm:"uniqueIndex;not null"`
Email string `json:"email" gorm:"uniqueIndex;not null"`
Password string `json:"-" gorm:"not null"`
IsActive bool `json:"is_active" gorm:"default:true"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

type CreateUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
}

type UpdateUserRequest struct {
Username string `json:"username" binding:"omitempty,min=3,max=50"`
Email string `json:"email" binding:"omitempty,email"`
}

Handlers

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
// handlers/user.go
package handlers

import (
"net/http"
"strconv"

"github.com/gin-gonic/gin"
"myapp/models"
"myapp/repositories"
)

type UserHandler struct {
repo repositories.UserRepository
}

func NewUserHandler(repo repositories.UserRepository) *UserHandler {
return &UserHandler{repo: repo}
}

// GET /users
func (h *UserHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))

users, total, err := h.repo.List(page, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch users"})
return
}

c.JSON(http.StatusOK, gin.H{
"data": users,
"total": total,
"page": page,
"limit": limit,
})
}

// GET /users/:id
func (h *UserHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
}

user, err := h.repo.FindByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}

c.JSON(http.StatusOK, gin.H{"data": user})
}

// POST /users
func (h *UserHandler) Create(c *gin.Context) {
var req models.CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

user := &models.User{
Username: req.Username,
Email: req.Email,
Password: hashPassword(req.Password),
}

if err := h.repo.Create(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}

c.JSON(http.StatusCreated, gin.H{"data": user})
}

// PUT /users/:id
func (h *UserHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
}

var req models.UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

user, err := h.repo.FindByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}

// Update fields
if req.Username != "" {
user.Username = req.Username
}
if req.Email != "" {
user.Email = req.Email
}

if err := h.repo.Update(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
return
}

c.JSON(http.StatusOK, gin.H{"data": user})
}

// DELETE /users/:id
func (h *UserHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
}

if err := h.repo.Delete(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user"})
return
}

c.Status(http.StatusNoContent)
}

Routes

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// routes/routes.go
package routes

import (
"github.com/gin-gonic/gin"
"myapp/handlers"
)

func SetupRoutes(router *gin.Engine, userHandler *handlers.UserHandler) {
// API v1 group
v1 := router.Group("/api/v1")
{
users := v1.Group("/users")
{
users.GET("", userHandler.List)
users.GET("/:id", userHandler.Get)
users.POST("", userHandler.Create)
users.PUT("/:id", userHandler.Update)
users.DELETE("/:id", userHandler.Delete)
}
}
}

Main Application

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// main.go
package main

import (
"log"

"github.com/gin-gonic/gin"
"gorm.io/driver/postgres"
"gorm.io/gorm"

"myapp/handlers"
"myapp/models"
"myapp/repositories"
"myapp/routes"
)

func main() {
// Database connection
dsn := "host=localhost user=appuser password=secret dbname=myapp port=5432"
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal("Failed to connect to database:", err)
}

// Auto migrate
db.AutoMigrate(&models.User{})

// Setup dependencies
userRepo := repositories.NewUserRepository(db)
userHandler := handlers.NewUserHandler(userRepo)

// Setup router
router := gin.Default()
routes.SetupRoutes(router, userHandler)

// Start server
log.Println("Server starting on :8080")
router.Run(":8080")
}

API Response Patterns

Consistent Response Structure

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error *APIError `json:"error,omitempty"`
Meta *Meta `json:"meta,omitempty"`
}

type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
}

type Meta struct {
Page int `json:"page"`
Limit int `json:"limit"`
Total int64 `json:"total"`
TotalPages int `json:"total_pages"`
}

// Helper functions
func SuccessResponse(c *gin.Context, status int, data interface{}) {
c.JSON(status, APIResponse{Success: true, Data: data})
}

func ErrorResponse(c *gin.Context, status int, code, message string) {
c.JSON(status, APIResponse{
Success: false,
Error: &APIError{Code: code, Message: message},
})
}

func PaginatedResponse(c *gin.Context, data interface{}, page, limit int, total int64) {
totalPages := int((total + int64(limit) - 1) / int64(limit))
c.JSON(http.StatusOK, APIResponse{
Success: true,
Data: data,
Meta: &Meta{
Page: page,
Limit: limit,
Total: total,
TotalPages: totalPages,
},
})
}

Error Handling

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Custom error types
var (
ErrNotFound = errors.New("resource not found")
ErrUnauthorized = errors.New("unauthorized")
ErrBadRequest = errors.New("bad request")
)

// Error handler middleware
func ErrorHandler() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()

// Check for errors
if len(c.Errors) > 0 {
err := c.Errors.Last().Err

switch {
case errors.Is(err, ErrNotFound):
ErrorResponse(c, http.StatusNotFound, "NOT_FOUND", err.Error())
case errors.Is(err, ErrUnauthorized):
ErrorResponse(c, http.StatusUnauthorized, "UNAUTHORIZED", err.Error())
default:
ErrorResponse(c, http.StatusInternalServerError, "INTERNAL_ERROR", "An unexpected error occurred")
}
}
}
}

Testing APIs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package handlers_test

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)

func TestCreateUser(t *testing.T) {
gin.SetMode(gin.TestMode)

router := setupTestRouter()

body := map[string]string{
"username": "testuser",
"email": "[email protected]",
"password": "password123",
}
jsonBody, _ := json.Marshal(body)

req, _ := http.NewRequest("POST", "/api/v1/users", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")

w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusCreated, w.Code)

var response map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &response)
assert.True(t, response["success"].(bool))
}

Summary

flowchart TD
    subgraph GinAPI["Gin REST API"]
        R[Router] --> H[Handlers]
        H --> V[Validation]
        V --> S[Service/Repository]
        S --> DB[(Database)]
    end

    subgraph Request["Request Flow"]
        C[Client] --> |HTTP| R
        DB --> |Data| H
        H --> |JSON| C
    end

    style GinAPI fill:#e3f2fd
Concept Key Points
Routing Path params, query params, groups
Binding JSON, form, query binding
Validation Built-in and custom validators
Handlers Organized by resource/domain
Responses Consistent JSON structure
Testing httptest for integration tests

Next post: Go Middleware and Concurrency Patterns - Building robust middleware and leveraging Go’s concurrency.

Database Migrations in Go Go Middleware and Concurrency Patterns

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×