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