---
title: "Building REST APIs with Go and Gin"
url: "https://vanducng.dev/2024/03/29/Building-REST-APIs-with-Go-and-Gin/"
date: "2024-03-29"
series: "go-backend"
categories: ["Backend"]
tags: ["go","api"]
---

# 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

```mermaid
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

    classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff
    classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff

    class Methods blueClass
    class Resource greenClass
```

### 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

```bash
go get -u github.com/gin-gonic/gin
```

### Basic Server

```go
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

```go
// 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

```go
// 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

```go
// 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

```go
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

```go
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

```go
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

```
api/
├── main.go
├── handlers/
│   └── user.go
├── models/
│   └── user.go
├── repositories/
│   └── user.go
└── routes/
    └── routes.go
```

### Models

```go
// 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

```go
// 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

```go
// 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

```go
// 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

```go
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

```go
// 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

```go
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":    "test@example.com",
        "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

```mermaid
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

    classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff

    class GinAPI blueClass
```

| 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](https://vanducng.dev/2024/04/05/Go-Middleware-and-Concurrency-Patterns/) - Building robust middleware and leveraging Go's concurrency.*
