Building a RESTful API in Go with Gin and PostgreSQL

In this tutorial, we'll build a real-world RESTful API using the Gin web framework in Go, connected to a PostgreSQL database. We'll structure our application by separating models and services, following best practices to create maintainable and scalable code.

Introduction

We'll create a simple API to manage a list of books, performing CRUD (Create, Read, Update, Delete) operations. The API will connect to a PostgreSQL database, and we'll separate concerns by organizing our code into models and services.

Prerequisites

  • Go installed (Download Go)

  • PostgreSQL installed (Download PostgreSQL)

  • Basic knowledge of Go programming

  • A code editor (e.g., VSCode, GoLand)

  • Postman or curl for testing the API

Project Setup

First, create a new directory for your project:

mkdir gin-bookstore
cd gin-bookstore

Initialize a new Go module:


go mod init github.com/yourusername/gin-bookstore

Replace github.com/yourusername/gin-bookstore with your actual GitHub username or module path.

Installing Dependencies

We'll need the following dependencies:

  1. Gin: Web framework

  2. GORM: ORM library for Go

  3. pq: PostgreSQL driver for Go

Install them using the following commands:

go get -u github.com/gin-gonic/gin
go get -u gorm.io/gorm
go get -u gorm.io/driver/postgres

Connecting to PostgreSQL

Setting Up the Database

Before we start coding, let's set up the PostgreSQL database.

  1. Create a database named bookstore.
CREATE DATABASE bookstore;
  1. Create a books table.

Connect to the bookstore database and run:

CREATE TABLE books (
    id SERIAL PRIMARY KEY,
    title VARCHAR(100),
    author VARCHAR(100),
    description TEXT
);

Configuring the Database Connection

Create a new file named database/database.go:

mkdir database
touch database/database.go

Add the following code to database/database.go:

package database

import (
    "fmt"
    "log"
    "os"

    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

var DB *gorm.DB

func Connect() {
    var err error
    dsn := "host=localhost user=yourusername password=yourpassword dbname=bookstore port=5432 sslmode=disable TimeZone=Asia/Shanghai"
    DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Fatal("Failed to connect to database:", err)
    }
    fmt.Println("Database connection established")
}

Replace yourusername and yourpassword with your PostgreSQL username and password.

Creating Models

Create a new directory for models:

mkdir models
touch models/book.go

In models/book.go, define the Book struct:

package models

type Book struct {
    ID          uint   `json:"id" gorm:"primaryKey"`
    Title       string `json:"title"`
    Author      string `json:"author"`
    Description string `json:"description"`
}

Setting Up Services

Create a directory for services:

mkdir services
touch services/book_service.go

In services/book_service.go, we'll implement functions to interact with the database.

package services

import (
    "github.com/yourusername/gin-bookstore/database"
    "github.com/yourusername/gin-bookstore/models"
)

func GetAllBooks(books *[]models.Book) error {
    if err := database.DB.Find(books).Error; err != nil {
        return err
    }
    return nil
}

func GetBookByID(book *models.Book, id string) error {
    if err := database.DB.Where("id = ?", id).First(book).Error; err != nil {
        return err
    }
    return nil
}

func CreateBook(book *models.Book) error {
    if err := database.DB.Create(book).Error; err != nil {
        return err
    }
    return nil
}

func UpdateBook(book *models.Book, id string) error {
    if err := database.DB.Model(book).Where("id = ?", id).Updates(book).Error; err != nil {
        return err
    }
    return nil
}

func DeleteBook(book *models.Book, id string) error {
    if err := database.DB.Where("id = ?", id).Delete(book).Error; err != nil {
        return err
    }
    return nil
}

Note: Replace github.com/yourusername/gin-bookstore with your module path.

Implementing API Endpoints

Setting Up the Router

In main.go, set up the router and connect to the database.

package main

import (
    "github.com/gin-gonic/gin"
    "github.com/yourusername/gin-bookstore/controllers"
    "github.com/yourusername/gin-bookstore/database"
)

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

    database.Connect()

    router.GET("/books", controllers.GetBooks)
    router.GET("/books/:id", controllers.GetBook)
    router.POST("/books", controllers.CreateBook)
    router.PUT("/books/:id", controllers.UpdateBook)
    router.DELETE("/books/:id", controllers.DeleteBook)

    router.Run(":8080")
}

Creating Controllers

Create a directory for controllers:

mkdir controllers
touch controllers/book_controller.go

In controllers/book_controller.go, implement the handler functions:

package controllers

import (
    "net/http"

    "github.com/gin-gonic/gin"
    "github.com/yourusername/gin-bookstore/models"
    "github.com/yourusername/gin-bookstore/services"
)

func GetBooks(c *gin.Context) {
    var books []models.Book
    err := services.GetAllBooks(&books)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    c.JSON(http.StatusOK, books)
}

func GetBook(c *gin.Context) {
    id := c.Param("id")
    var book models.Book
    err := services.GetBookByID(&book, id)
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "Book not found"})
        return
    }
    c.JSON(http.StatusOK, book)
}

func CreateBook(c *gin.Context) {
    var book models.Book
    if err := c.ShouldBindJSON(&book); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    err := services.CreateBook(&book)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    c.JSON(http.StatusCreated, book)
}

func UpdateBook(c *gin.Context) {
    id := c.Param("id")
    var book models.Book
    if err := c.ShouldBindJSON(&book); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    err := services.UpdateBook(&book, id)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    c.JSON(http.StatusOK, book)
}

func DeleteBook(c *gin.Context) {
    id := c.Param("id")
    var book models.Book
    err := services.DeleteBook(&book, id)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    c.JSON(http.StatusOK, gin.H{"message": "Book deleted"})
}

Auto-Migrating Models

Modify database/database.go to auto-migrate the Book model:

func Connect() {
    var err error
    dsn := "host=localhost user=yourusername password=yourpassword dbname=bookstore port=5432 sslmode=disable TimeZone=Asia/Shanghai"
    DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Fatal("Failed to connect to database:", err)
    }
    fmt.Println("Database connection established")

    // Auto-migrate the Book model
    DB.AutoMigrate(&models.Book{})
}

Don't forget to import the models package:

import (
    // other imports
    "github.com/yourusername/gin-bookstore/models"
)

Bonus: Seeding the Database with Initial Data

Seeding your database is an excellent way to populate it with initial data which you can use for development and testing. This ensures that you have a predictable and controlled data set that mimics real-world data.

Step 1: Create the Seed File

Create a new file called seeds.go in the database directory:

touch database/seeds.go

In seeds.go, define a function to seed your books:

package database

import (
    "github.com/yourusername/gin-bookstore/models"
    "gorm.io/gorm"
)

func SeedBooks(db *gorm.DB) error {
    books := []models.Book{
        {Title: "1984", Author: "George Orwell", Description: "Dystopian novel about the future."},
        {Title: "The Great Gatsby", Author: "F. Scott Fitzgerald", Description: "Narrative on the American dreams."},
        // more books
    }

    for _, book := range books {
        err := db.FirstOrCreate(&book, models.Book{Title: book.Title}).Error
        if err != nil {
            return err
        }
    }
    return nil
}

Step 2: Call the Seed Function

Modify database.go to include a call to SeedBooks right after setting up the database connection and running migrations:

func Connect() {
    // Existing database connection setup
    DB, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Fatal("Failed to connect to database:", err)
    }
    fmt.Println("Database connection established")
    DB.AutoMigrate(&models.Book{})

    // Seed the database
    if err := SeedBooks(DB); err != nil {
        log.Fatal("Failed to seed books:", err)
    }
}

Step 3: Test Your Seeding

To ensure your seeding is working as expected, start your application and use a PostgreSQL client to check if the books have been added:

SELECT * FROM books;

This will display all books that were inserted into the database during the seeding process.

Testing the API

Start the Server

Run the application:

go run main.go

Using Postman or curl

Create a New Book

  • Endpoint: POST http://localhost:8080/books

  • Body:

{
    "title": "The Hitchhiker's Guide to the Galaxy",
    "author": "Douglas Adams",
    "description": "A science fiction comedy series."
}

Get All Books

  • Endpoint: GET http://localhost:8080/books

Get a Book by ID

  • Endpoint: GET http://localhost:8080/books/1

Update a Book

  • Endpoint: PUT http://localhost:8080/books/1

  • Body:

{
    "title": "The Hitchhiker's Guide to the Galaxy (Updated)",
    "author": "Douglas Adams",
    "description": "An updated description."
}

Delete a Book

  • Endpoint: DELETE http://localhost:8080/books/1

Conclusion

You've successfully built a RESTful API in Go using Gin and PostgreSQL, complete with separated models and services. This structure is scalable and maintainable, following industry best practices.

Next Steps

  • Implement Validation: Use packages like go-playground/validator for input validation.

  • Add Authentication: Implement JWT or OAuth2 for secure API endpoints.

  • Handle Errors Gracefully: Create custom error handling middleware.

  • Dockerize the Application: Containerize your app for easy deployment.