March 29, 20267 min read

Building a REST API with Go and the Gin Framework

Build a production-grade REST API with Go and Gin. Covers project structure, middleware, database integration, validation, error handling, and testing with practical examples.

go golang gin api backend codeup
Ad 336x280

I switched from Node.js to Go for backend services about two years ago. The first thing that struck me wasn't the speed (though that was nice) -- it was how much less magic there was. No hidden middleware chain. No module system weirdness. No "why is this undefined at runtime" moments. What you write is what runs.

Gin is the most popular Go web framework, and for good reason: it's fast, the API is clean, and it stays out of your way. Let's build a proper REST API with it.

Setup

mkdir bookstore-api && cd bookstore-api
go mod init github.com/yourname/bookstore-api
go get github.com/gin-gonic/gin
go get github.com/google/uuid

Project Structure

bookstore-api/
├── main.go
├── handlers/
│   └── books.go
├── models/
│   └── book.go
├── middleware/
│   └── auth.go
├── store/
│   └── memory.go
└── go.mod

Flat, obvious, boring. Exactly how Go projects should be.

The Model

// models/book.go
package models

import "time"

type Book struct {
ID string json:"id"
Title string json:"title" binding:"required,min=1,max=200"
Author string json:"author" binding:"required,min=1,max=100"
ISBN string json:"isbn" binding:"required,len=13"
Price float64 json:"price" binding:"required,gt=0"
PublishedAt string json:"publishedAt" binding:"required"
CreatedAt time.Time json:"createdAt"
UpdatedAt time.Time json:"updatedAt"
}

type CreateBookInput struct {
Title string json:"title" binding:"required,min=1,max=200"
Author string json:"author" binding:"required,min=1,max=100"
ISBN string json:"isbn" binding:"required,len=13"
Price float64 json:"price" binding:"required,gt=0"
PublishedAt string json:"publishedAt" binding:"required"
}

type UpdateBookInput struct {
Title *string json:"title" binding:"omitempty,min=1,max=200"
Author *string json:"author" binding:"omitempty,min=1,max=100"
Price *float64 json:"price" binding:"omitempty,gt=0"
PublishedAt *string json:"publishedAt"
}

The binding tags use Gin's built-in validator (which wraps go-playground/validator). Notice UpdateBookInput uses pointer fields -- this lets us distinguish between "field not provided" (nil) and "field set to zero value."

The Store

// store/memory.go
package store

import (
"fmt"
"sync"

"github.com/yourname/bookstore-api/models"
)

type MemoryStore struct {
mu sync.RWMutex
books map[string]models.Book
}

func NewMemoryStore() *MemoryStore {
return &MemoryStore{
books: make(map[string]models.Book),
}
}

func (s *MemoryStore) GetAll() []models.Book {
s.mu.RLock()
defer s.mu.RUnlock()

books := make([]models.Book, 0, len(s.books))
for _, b := range s.books {
books = append(books, b)
}
return books
}

func (s *MemoryStore) GetByID(id string) (models.Book, error) {
s.mu.RLock()
defer s.mu.RUnlock()

book, exists := s.books[id]
if !exists {
return models.Book{}, fmt.Errorf("book not found")
}
return book, nil
}

func (s *MemoryStore) Create(book models.Book) {
s.mu.Lock()
defer s.mu.Unlock()
s.books[book.ID] = book
}

func (s *MemoryStore) Update(id string, book models.Book) error {
s.mu.Lock()
defer s.mu.Unlock()

if _, exists := s.books[id]; !exists {
return fmt.Errorf("book not found")
}
s.books[id] = book
return nil
}

func (s *MemoryStore) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()

if _, exists := s.books[id]; !exists {
return fmt.Errorf("book not found")
}
delete(s.books, id)
return nil
}

The sync.RWMutex is essential -- Go's maps aren't safe for concurrent access, and Gin handles requests in goroutines. Read-write locks let multiple goroutines read simultaneously but ensure exclusive access for writes.

The Handlers

// handlers/books.go
package handlers

import (
"net/http"
"time"

"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/yourname/bookstore-api/models"
"github.com/yourname/bookstore-api/store"
)

type BookHandler struct {
store *store.MemoryStore
}

func NewBookHandler(s store.MemoryStore) BookHandler {
return &BookHandler{store: s}
}

func (h BookHandler) List(c gin.Context) {
books := h.store.GetAll()
c.JSON(http.StatusOK, gin.H{
"data": books,
"count": len(books),
})
}

func (h BookHandler) Get(c gin.Context) {
book, err := h.store.GetByID(c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Book not found"})
return
}
c.JSON(http.StatusOK, gin.H{"data": book})
}

func (h BookHandler) Create(c gin.Context) {
var input models.CreateBookInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

now := time.Now()
book := models.Book{
ID: uuid.New().String(),
Title: input.Title,
Author: input.Author,
ISBN: input.ISBN,
Price: input.Price,
PublishedAt: input.PublishedAt,
CreatedAt: now,
UpdatedAt: now,
}

h.store.Create(book)
c.JSON(http.StatusCreated, gin.H{"data": book})
}

func (h BookHandler) Update(c gin.Context) {
id := c.Param("id")
book, err := h.store.GetByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Book not found"})
return
}

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

if input.Title != nil {
book.Title = *input.Title
}
if input.Author != nil {
book.Author = *input.Author
}
if input.Price != nil {
book.Price = *input.Price
}
if input.PublishedAt != nil {
book.PublishedAt = *input.PublishedAt
}
book.UpdatedAt = time.Now()

h.store.Update(id, book)
c.JSON(http.StatusOK, gin.H{"data": book})
}

func (h BookHandler) Delete(c gin.Context) {
if err := h.store.Delete(c.Param("id")); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Book not found"})
return
}
c.JSON(http.StatusNoContent, nil)
}

Middleware

// middleware/auth.go
package middleware

import (
"net/http"
"strings"

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

func APIKeyAuth(validKey string) gin.HandlerFunc {
return func(c *gin.Context) {
key := c.GetHeader("Authorization")
key = strings.TrimPrefix(key, "Bearer ")

if key != validKey {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "Invalid or missing API key",
})
return
}
c.Next()
}
}

func RequestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path

c.Next()

latency := time.Since(start)
status := c.Writer.Status()

log.Printf("%s %s %d %v", c.Request.Method, path, status, latency)
}
}

Wiring It Together

// main.go
package main

import (
"os"

"github.com/gin-gonic/gin"
"github.com/yourname/bookstore-api/handlers"
"github.com/yourname/bookstore-api/middleware"
"github.com/yourname/bookstore-api/store"
)

func main() {
r := gin.Default() // includes Logger and Recovery middleware

s := store.NewMemoryStore()
h := handlers.NewBookHandler(s)

// Public routes
api := r.Group("/api/v1")
{
api.GET("/books", h.List)
api.GET("/books/:id", h.Get)
}

// Protected routes
apiKey := os.Getenv("API_KEY")
if apiKey == "" {
apiKey = "dev-key-change-me"
}

protected := r.Group("/api/v1")
protected.Use(middleware.APIKeyAuth(apiKey))
{
protected.POST("/books", h.Create)
protected.PUT("/books/:id", h.Update)
protected.DELETE("/books/:id", h.Delete)
}

port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
r.Run(":" + port)
}

Testing

Go has excellent built-in testing, and Gin makes it easy to test handlers without starting a server:

// handlers/books_test.go
package handlers

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

"github.com/gin-gonic/gin"
"github.com/yourname/bookstore-api/store"
)

func setupRouter() (gin.Engine, BookHandler) {
gin.SetMode(gin.TestMode)
r := gin.New()
s := store.NewMemoryStore()
h := NewBookHandler(s)

r.POST("/books", h.Create)
r.GET("/books", h.List)
r.GET("/books/:id", h.Get)
r.PUT("/books/:id", h.Update)
r.DELETE("/books/:id", h.Delete)

return r, h
}

func TestCreateBook(t *testing.T) {
r, _ := setupRouter()

body := {
"title": "The Go Programming Language",
"author": "Donovan & Kernighan",
"isbn": "0134190440000",
"price": 34.99,
"publishedAt": "2015-10-26"
}

req := httptest.NewRequest("POST", "/books", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()

r.ServeHTTP(w, req)

if w.Code != http.StatusCreated {
t.Errorf("Expected 201, got %d", w.Code)
}

var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)

data := resp["data"].(map[string]interface{})
if data["title"] != "The Go Programming Language" {
t.Errorf("Expected title to match")
}
}

func TestCreateBookValidation(t *testing.T) {
r, _ := setupRouter()

body := {"title": "", "price": -5}
req := httptest.NewRequest("POST", "/books", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()

r.ServeHTTP(w, req)

if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400, got %d", w.Code)
}
}

func TestGetBookNotFound(t *testing.T) {
r, _ := setupRouter()

req := httptest.NewRequest("GET", "/books/nonexistent", nil)
w := httptest.NewRecorder()

r.ServeHTTP(w, req)

if w.Code != http.StatusNotFound {
t.Errorf("Expected 404, got %d", w.Code)
}
}

Run tests:

go test ./... -v

Adding Pagination

For the list endpoint, you'll want pagination once your dataset grows:

func (h BookHandler) List(c gin.Context) {
    page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
    perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))

if page < 1 { page = 1 }
if perPage < 1 || perPage > 100 { perPage = 20 }

books := h.store.GetAll()
total := len(books)

start := (page - 1) * perPage
end := start + perPage
if start > total { start = total }
if end > total { end = total }

c.JSON(http.StatusOK, gin.H{
"data": books[start:end],
"page": page,
"perPage": perPage,
"total": total,
})
}

Error Handling Done Right

Instead of scattering c.JSON(http.StatusXxx, ...) everywhere, define custom error types:

type AppError struct {
    Code    int    json:"-"
    Message string json:"error"
}

func (e *AppError) Error() string {
return e.Message
}

func NotFound(msg string) *AppError {
return &AppError{Code: 404, Message: msg}
}

func BadRequest(msg string) *AppError {
return &AppError{Code: 400, Message: msg}
}

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

if len(c.Errors) > 0 {
err := c.Errors.Last().Err
if appErr, ok := err.(*AppError); ok {
c.JSON(appErr.Code, appErr)
} else {
c.JSON(500, gin.H{"error": "Internal server error"})
}
}
}
}

Go and Gin give you a clean, fast, and predictable foundation for APIs. No magic, no hidden behavior, no "it works in development but explodes in production" surprises. The compile-time type checking catches most bugs before you even run the code, and the standard library covers 80% of what you need.

Ad 728x90