How to Create a Golang Web API with Fiber, PostgreSQL, and Gorm

Updated on November 21, 2023
How to Create a Golang Web API with Fiber, PostgreSQL, and Gorm header image

Introduction

This guide explains how to build an example web API in Go to create, update, delete book records from a database. At the end of this article, you should be able to create endpoints and understand how routing works with the Fiber framework. You'll also use Gorm for object-relational mapping with PostgreSQL.

Prerequisites

  1. Have Golang version 1.1x installed on your machine.
  2. Basic knowledge of Golang.
  3. Basic knowledge of SQL.
  4. Have PostgreSQL installed on your PC.

What is Fiber?

Fiber is an Express-inspired web framework built on top of Fasthttp, the fastest HTTP engine for Go. It's "designed to ease things up for fast development with zero memory allocation and performance in mind" according to the Fiber documentation.

Set up the Project

  1. In your terminal, create a Gofiber directory and change to it.

     $ mkdir Gofiber
     $ cd Gofiber
  2. Initialize the project.

     $ go mod init github.com/<your GitHub username>/Gofiber
  3. Generate the required folders.

     $ mkdir service storage models

Install the Required Libraries

  1. Install Gofiber.

     $ go get -u github.com/gofiber/fiber/v2
  2. Install Gorm.

     $ go get -u gorm.io/gorm
  3. Install Gorm Postgres driver.

     $ go get -u gorm.io/driver/postgres
  4. Install Godotenv, used for loading environment variables.

     $ go get github.com/joho/godotenv
  5. Install Validator.

     $ go get github.com/go-playground/validator/v10

Create the Database

PostgreSQL creates a default user to access the psql shell.

  1. Switch to the Postgres user and execute a shell.

     $ sudo -iu postgres psql
  2. Create the database.

     CREATE DATABASE gofiber;
  3. Exit the interactive shell.

     \q
  4. Create a .env file in your code editor and paste the following to it:

     DB_HOST=localhost
     DB_PORT=5432
     DB_USER=yourusername
     DB_PASS=yourpassword
     DB_NAME=gofiber
     DB_SSLMODE=disable
  5. Create a file named postgres.go and paste the following:

     package storage
    
     import (
         "fmt"
         "gorm.io/driver/postgres"
         "gorm.io/gorm"
     )
    
     type Config struct {
         Host     string
         Port     string
         Password string
         User     string
         DBName   string
         SSLMode  string
    
     }
    
     func NewConnection(config *Config) (*gorm.DB, error) {
         dsn := fmt.Sprintf(
             "host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
             config.Host, config.Port, config.User, config.Password, config.DBName, config.SSLMode,
         )
         db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
         if err != nil {
             return db, err
         }
         return db, nil
     }

The main purpose of this code is to connect the code to the database by providing the env values when calling this function in the main function. After you create a function that opens the connection to the database by passing the value from the config struct, which comes in as the argument and will be supplied when calling this function in the main function.

Create the Books Model

Create a file in the models folder and name it books.go, then add this to it:

package models

import "gorm.io/gorm"

type Books struct {
    ID        uint    `gorm:"primary key;autoIncrement" json:"id"`
    Author    *string `json:"author"`
    Title     *string `json:"title"`
    Publisher *string `json:"publisher"`
}

func MigrateBooks(db *gorm.DB) error {
    err := db.AutoMigrate(&Books{})
    return err
}

Books structs have been created here, and the migration function takes an argument of the database and migrates the table with the AutoMigrate function that also takes the struct to be migrated.

Service

This is the handler that will pass our request to the DB. We will start by creating a book struct for parsing request body and a repository struct that holds a pointer to the database value.

type Book struct {
    Author    string `json:"author" validate:"required"`
    Title     string `json:"title" validate:"required"`
    Publisher string `json:"publisher" validate:"required"`
}
type Repository struct {
    DB *gorm.DB
}

Here we have created a struct that will be what will be matched when the user sends the request from a body. The validate:required tag is what will be used to make sure the user does not omit any field when creating the values.

After that step, you will create the handlers, which will be:

  • Create books
  • Update book by id
  • Delete book by id
  • Get all books
  • Get books by id

Create Books

func (r *Repository) CreateBook(context *fiber.Ctx) error {
    book := Book{}

    err := context.BodyParser(&book)
    if err != nil {
        context.Status(http.StatusUnprocessableEntity).JSON(
            &fiber.Map{"message": "request failed"})
        return err
    }
    validator := validator.New()
    err = validator.Struct(Book{})

    if err != nil {
        context.Status(http.StatusUnprocessableEntity).JSON(
            &fiber.Map{"message": err},
        )
        return err
    }

    err = r.DB.Create(&book).Error

    if err != nil {
        context.Status(http.StatusBadRequest).JSON(
            &fiber.Map{"message": "could not create book"})
        return err
    }

    context.Status(http.StatusOK).JSON(&fiber.Map{
        "message": "book has been successfully added",
    })
    return nil
}

What is being done here is creating a new method as a handler that takes the repository struct and an argument of the Fiber context, which will be used in the function for the operations involved and matching the information passed to the book model you have created earlier. You then proceed to check if the user has any empty fields with the validator package and the validator.Struct, which takes the struct to be validated and returns an error. After that, you check the error and send back a message to the user with the Fiber method that accepts a request status and send a JSON with the error as a message. What comes after this is inserting the values provided in the database with the gorm function Create, which returns an error, and it is treated the same way the validator error is treated. If there is no error, you pass a message to the user that the book has been added to the database has been added successfully.

Update Books by ID

func (r *Repository) UpdateBook(context *fiber.Ctx) error {
    id := context.Params("id")
    if id == "" {
        context.Status(http.StatusInternalServerError).JSON(&fiber.Map{
            "message": "id cannot be empty",
        })
        return nil
    }

    bookModel := &models.Books{}
    book := Book{}


err := context.BodyParser(&book)
if err != nil {
    context.Status(http.StatusUnprocessableEntity).JSON(
        &fiber.Map{"message": "request failed"})
    return err
}

err = r.DB.Model(bookModel).Where("id = ?", id).Updates(book).Error
if err != nil {
    context.Status(http.StatusBadRequest).JSON(&fiber.Map{
        "message": "could not update book",
    })
    return err
}

    context.Status(http.StatusOK).JSON(&fiber.Map{
        "message": "book has been successfully updated",
    })
    return nil

}

What is being done here is getting the id with the Fiber function called param, which accepts an argument which is the name of the parameter expecting. If the id is not present, an error is being sent to the user with the Fiber function. What comes after that is matching the information passed to the book model with the BodyParser as you have done in the create books method. After that, you will use the Gorm function where to find where the id matches and update the whole record with the updates function and handle errors appropriately.

Delete Book by ID

func (r *Repository) DeleteBook(context *fiber.Ctx) error {
    bookModel := &models.Books{}

    id := context.Params("id")
    if id == "" {
        context.Status(http.StatusInternalServerError).JSON(&fiber.Map{
            "message": "id cannot be empty",
        })
        return nil
    }

    err := r.DB.Delete(bookModel, id)

    if err.Error != nil {
        context.Status(http.StatusBadRequest).JSON(&fiber.Map{
            "message": "could not delete book",
        })
        return err.Error
    }

    context.Status(http.StatusOK).JSON(&fiber.Map{
        "message": "book has been successfully deleted",
    })
    return nil

This process is similar to updating the book by its id but the difference is using the Delete method to delete the record.

Get All Books

func (r *Repository) GetBooks(context *fiber.Ctx) error {
    bookModels := &[]models.Books{}

    err := r.DB.Find(bookModels).Error
    if err != nil {
        context.Status(http.StatusBadRequest).JSON(
            &fiber.Map{"message": "could not get books"})
        return err
    }

    context.Status(http.StatusOK).JSON(&fiber.Map{
        "message": "books gotten successfully",
        "data":    bookModels,
    })
    return nil

}

This process involves creating a slice of the book model before using the find method to get all the records and send them back to the user.

Get Books by ID

func (r *Repository) GetBookByID(context *fiber.Ctx) error {
    id := context.Params("id")
    bookModel := &models.Books{}
    if id == "" {
        context.Status(http.StatusInternalServerError).JSON(&fiber.Map{
            "message": "id cannot be empty",
        })
        return nil
    }

    err := r.DB.Where("id = ?", id).First(bookModel).Error
    if err != nil {
        context.Status(http.StatusBadRequest).JSON(
            &fiber.Map{"message": "could not get book"})
        return err
    }

    context.Status(http.StatusOK).JSON(&fiber.Map{
        "message": "books id gotten successfully",
        "data":    bookModel,
    })
    return nil
}

This process is also similar to updating by the id record, but this time, we are getting the record by the id using the first method, which gets the record that matches the id first on the table and accepts the argument of the model to match it to, you check for the error, and you handle it as it is being done above if there is no error you will send a message and data back to the user.

Set up the Routes

func (r *Repository) SetupRoutes(app *fiber.App) {
    api := app.Group("/api")
    api.Post("/create_books", r.CreateBook)
    api.Delete("/delete_book/:id", r.DeleteBook)
    api.Put("/update_book/:id", r.UpdateBook)
    api.Get("/get_books/:id", r.GetBookByID)
    api.Get("/books", r.GetBooks)
}

What is happening here is creating a function with the repository that will set up our routes for the handlers. Here the app.Group accepts a string that will act as the parent route for the rest of the routes, and all other ones come after it e.g. localhost:8080/api/books for calling the GetBooks function that gets all the created books in the database.

Set up the Main Function

func main() {
    err := godotenv.Load(".env")
    if err != nil {
        log.Fatal(err)
    }

    config := &storage.Config{
        Host:     os.Getenv("DB_HOST"),
        Port:     os.Getenv("DB_PORT"),
        Password: os.Getenv("DB_PASS"),
        User:     os.Getenv("DB_USER"),
        SSLMode:  os.Getenv("DB_SSLMODE"),
        DBName:   os.Getenv("DB_NAME"),
    }
    db, err := storage.NewConnection(config)
    if err != nil {
        log.Fatal("could not load database")
    }

    err = models.MigrateBooks(db)
    if err != nil {
        log.Fatal("could not migrate db")
    }

    r := &service.Repository{
        DB: db,
    }
    app := fiber.New()
    r.SetupRoutes(app)

    app.Listen(":8080")

}

What comes first here is loading the .env file and filling the storage config struct with the values from the env file, that you will create the connection and migrate the database. After that comes the filling of the repository with the connected DB and then setting up the routes by passing the initialized Fiber function as the argument value in the SetupRoute function. Finally, you will start the app with the Listen function, which accepts an argument of the port number desired to run the app.

Conclusion

Now you should be able to freely use the Fiber framework to get, post, update, and delete requests and use the Gorm library to connect PostgreSQL to the code.