Using Golang and Gin Framework to Build RESTful API - 7 Steps

Nik L. - Dec 12 '23 - - Dev Community

More articles you can read:


Project Overview

1. What You Will Build

You will construct an API for a personal diary app with the following functionalities:

  • Registration with a username and password
  • Login with a username and password
  • Creation of a new diary entry
  • Retrieval of all diary entries

2. Prerequisites

Before diving in, ensure you have the following prerequisites:

  • Basic understanding of Go and JWTs
  • curl
  • Git
  • Go 1.19
  • psql
  • PostgreSQL installed on your machine

Getting Started

To initiate the project, follow these steps:

  1. Create a new folder named diary_api in your development directory and navigate into it.
mkdir diary_api
cd diary_api
Enter fullscreen mode Exit fullscreen mode
  1. Initialize a Go module with the following command.
go mod init diary_api
Enter fullscreen mode Exit fullscreen mode

This generates a go.mod file to track your project's dependencies.

3. Install Dependencies

This project utilizes the Gin framework. Run the following command in the project's root directory to install Gin and other dependencies.

go get \
    github.com/gin-gonic/gin \
    github.com/golang-jwt/jwt/v4 \
    github.com/joho/godotenv \
    golang.org/x/crypto \
    gorm.io/driver/postgres \
    gorm.io/gorm
Enter fullscreen mode Exit fullscreen mode

Once installed, your application gains access to essential packages such as:

  • Go Cryptography for supplementary cryptographic libraries.
  • GoDotEnv for managing environment variables.
  • GORM, an Object Relational Mapper for Golang, along with the PostgreSQL driver for GORM to facilitate connections to PostgreSQL databases.
  • JWT-Go, a Go implementation of JSON Web Tokens.

4. Prepare the Database and Environment Variables

Before writing any code, create a PostgreSQL database named diary_app with the provided psql command, replacing the placeholders accordingly.

createdb -h <DB_HOSTNAME> -p <DB_PORT> -U <DB_USER> diary_app --password
Enter fullscreen mode Exit fullscreen mode

Create a new file named .env in the diary_api folder and add the following content:

# Database credentials
DB_HOST="<<DB_HOST>>"
DB_USER="<<DB_USER>>"
DB_PASSWORD="<<DB_PASSWORD>>"
DB_NAME="diary_app"
DB_PORT="<<DB_PORT>>"

# Authentication credentials
TOKEN_TTL="2000"
JWT_PRIVATE_KEY="THIS_IS_NOT_SO_SECRET+YOU_SHOULD_DEFINITELY_CHANGE_IT"
Enter fullscreen mode Exit fullscreen mode

Make a copy of .env named .env.local and replace the placeholder values with your PostgreSQL database details.

5. Prepare Models

Create two models, User and Entry, for the application. Start by creating a model folder and a file named user.go. Add the following code:

package model

import "gorm.io/gorm"

type User struct {
    gorm.Model
    Username string `gorm:"size:255;not null;unique" json:"username"`
    Password string `gorm:"size:255;not null;" json:"-"`
    Entries  []Entry
}
Enter fullscreen mode Exit fullscreen mode

In the model folder, create a file named entry.go and add the following code:

package model

import "gorm.io/gorm"

type Entry struct {
    gorm.Model
    Content string `gorm:"type:text" json:"content"`
    UserID  uint
}
Enter fullscreen mode Exit fullscreen mode

6. Connect to the Database

Create a database folder and a file named database.go. Add the following code to establish a connection to the PostgreSQL database:

package database

import (
    "fmt"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
    "os"
)

var Database *gorm.DB

func Connect() {
    var err error
    host := os.Getenv("DB_HOST")
    username := os.Getenv("DB_USER")
    password := os.Getenv("DB_PASSWORD")
    databaseName := os.Getenv("DB_NAME")
    port := os.Getenv("DB_PORT")

    dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Africa/Lagos", host, username, password, databaseName, port)
    Database, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})

    if err != nil {
        panic(err)
    } else {
        fmt.Println("Successfully connected to the database")
    }
}
Enter fullscreen mode Exit fullscreen mode

This function retrieves environment variables and opens a connection to the database using the GORM PostgreSQL driver.

7. Entry Point of the Application

Create a file named main.go at the root of the project and add the following code:

package main

import (
    "diary_api/database"
    "diary_api/model"
    "github.com/joho/godotenv"
    "log"
)

func main() {
    loadEnv()
    loadDatabase()
}

func loadDatabase() {
    database.Connect()
    database.Database.AutoMigrate(&model.User{})
    database.Database.AutoMigrate(&model.Entry{})
}

func loadEnv() {
    err := godotenv.Load(".env.local")
    if err != nil {
        log.Fatal("Error loading .env file")
    }
}
Enter fullscreen mode Exit fullscreen mode

In the main() function, load environment variables, establish a connection with the database, and automatically migrate tables for User and Entry structs if they don't exist.

8. Run the Application

Start the application by running the following command:

go run main.go
Enter fullscreen mode Exit fullscreen mode

Upon successful startup, you should see the message: "Successfully connected to the database." Verify the presence of two new tables in your PostgreSQL database:

diary_app=# \dt
            List of relations
 Schema |  Name   | Type  | Owner
--------+---------+-------+-------
 public | entries | table | user
 public | users   | table | user
(2 rows)
Enter fullscreen mode Exit fullscreen mode

Congratulations! You've set up the foundation for building a RESTful API using Golang and Gin. Now let's implement user registration and login in Golang-Gin RESTful API


Authentication Model Setup

To begin, define the expected structure for authentication requests in the authenticationInput.go file within the model folder:

package model

type AuthenticationInput struct {
    Username string `json:"username" binding:"required"`
    Password string `json:"password" binding:"required"`
}
Enter fullscreen mode Exit fullscreen mode

User Registration

Extend the User struct in model/user.go by adding the following methods:

func (user *User) Save() (*User, error) {
    err := database.Database.Create(&user).Error
    if err != nil {
        return &User{}, err
    }
    return user, nil
}

func (user *User) BeforeSave(*gorm.DB) error {
    passwordHash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
    if err != nil {
        return err
    }
    user.Password = string(passwordHash)
    user.Username = html.EscapeString(strings.TrimSpace(user.Username))
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The Save() function adds a new user to the database, ensuring any whitespace in the provided username is trimmed, and the password is securely hashed. Update the import statement as follows:

import (
    "diary_api/database"
    "golang.org/x/crypto/bcrypt"
    "gorm.io/gorm"
    "html"
    "strings"
)
Enter fullscreen mode Exit fullscreen mode

Create a new folder named controller. Inside this folder, create a new file named authentication.go and include the following code:

package controller

import (
    "diary_api/model"
    "github.com/gin-gonic/gin"
    "net/http"
)

func Register(context *gin.Context) {
    var input model.AuthenticationInput

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

    user := model.User{
        Username: input.Username,
        Password: input.Password,
    }

    savedUser, err := user.Save()

    if err != nil {
        context.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    context.JSON(http.StatusCreated, gin.H{"user": savedUser})
}
Enter fullscreen mode Exit fullscreen mode

The Register() method validates the JSON request, creates a new user, and returns the saved user details as a JSON response.

User Login

Add another method, ValidatePassword(), to the User struct for password validation. Include a FindUserByUsername() function in model/user.go:

func (user *User) ValidatePassword(password string) error {
    return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
}

func FindUserByUsername(username string) (User, error) {
    var user User
    err := database.Database.Where("username=?", username).Find(&user).Error
    if err != nil {
        return User{}, err
    }
    return user, nil
}
Enter fullscreen mode Exit fullscreen mode

Using the bcrypt library, a hash is generated for the provided plaintext password and compared with the hash of the user’s password. An error is returned if they do not match. The FindUserByUsername() function queries the database to find the corresponding user.

Next, create a new folder named helper. In this folder, create a new file named jwt.go and add the following code:

package helper

import (
    "diary_api/model"
    "github.com/golang-jwt/jwt/v4"
    "os"
    "strconv"
    "time"
)

var privateKey = []byte(os.Getenv("JWT_PRIVATE_KEY"))

func GenerateJWT(user model.User) (string, error) {
    tokenTTL, _ := strconv.Atoi(os.Getenv("TOKEN_TTL"))
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
        "id":  user.ID,
        "iat": time.Now().Unix(),
        "eat": time.Now().Add(time.Second * time.Duration(tokenTTL)).Unix(),
    })
    return token.SignedString(privateKey)
}
Enter fullscreen mode Exit fullscreen mode

The GenerateJWT() function takes a user model and generates a JWT containing the user’s id (id), the time at which the token was issued (iat), and the expiry date of the token (eat). Using the JWT_PRIVATE_KEY environment variable, a signed JWT is returned as a string.

In the controller/authentication.go file, include the following function to handle user login:

package controller

import (
    "diary_api/helper"
    "diary_api/model"
    "github.com/gin-gonic/gin"
    "net/http"
)

func Login(context *gin.Context) {
    var input model.AuthenticationInput

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

    user, err := model.FindUserByUsername(input.Username)

    if err != nil {
        context.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    err = user.ValidatePassword(input.Password)

    if err != nil {
        context.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    jwt, err := helper.GenerateJWT(user)
    if err != nil {
        context.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    context.JSON(http.StatusOK, gin.H{"jwt": jwt})
}
Enter fullscreen mode Exit fullscreen mode

Ensure to update the import list at the top of the file as follows:

import (
    "diary_api/helper"
    "diary_api/model"
    "net/http"

    "github.com/gin-gonic/gin"
)
Enter fullscreen mode Exit fullscreen mode

Now, update the main.go file to include the Gin router setup for registration and login:

package main

import (
    "diary_api/controller"
    "diary_api/database"
    "diary_api/model"
    "fmt"
    "github.com/gin-gonic/gin"
    "github.com/joho/godotenv"
    "log"
)

func main() {
    loadEnv()
    loadDatabase()
    serveApplication()
}

func loadEnv() {
    err := godotenv.Load(".env.local")
    if err != nil {
        log.Fatal("Error loading .env file")
    }
}

func loadDatabase() {
    database.Connect()
    database.Database.AutoMigrate(&model.User{})
    database.Database.AutoMigrate(&model.Entry{})
}

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

    publicRoutes := router.Group("/auth")
    publicRoutes.POST("/register", controller.Register)
    publicRoutes.POST("/login", controller.Login)

    router.Run(":8000")
    fmt.Println("Server running on port 8000")
}
Enter fullscreen mode Exit fullscreen mode

This modification includes the registration and login routes in the Gin router.

To test the registration and login functionality, stop the application using CTRL + c and restart it with:

go run main.go
Enter fullscreen mode Exit fullscreen mode

Use curl to test the authentication part of the application by issuing the following commands from a new terminal session, replacing the placeholders with your username and password, respectively:

# Register
curl -i -H "Content-Type: application/json" \
    -X POST \
    -d '{"username":"your_username", "password":"your_password"}' \
    http://localhost:8000/auth/register

# Login
curl -i -H "Content-Type: application/json" \
    -X POST \
    -d '{"username":"your_username", "password":"your_password"}' \
    http://localhost:8000/auth/login
Enter fullscreen mode Exit fullscreen mode

You should see output similar to the examples provided, returning the user details for registration and a JWT for login.

Middleware for Authenticated Endpoints and Adding Entry

First, let's update the helper/jwt.go file with the provided functions:

package helper

import (
    "diary_api/model"
    "errors"
    "fmt"
    "os"
    "strconv"
    "strings"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/golang-jwt/jwt/v4"
)

var privateKey = []byte(os.Getenv("JWT_PRIVATE_KEY"))

func ValidateJWT(context *gin.Context) error {
    token, err := getToken(context)
    if err != nil {
        return err
    }
    _, ok := token.Claims.(jwt.MapClaims)
    if ok && token.Valid {
        return nil
    }
    return errors.New("invalid token provided")
}

func CurrentUser(context *gin.Context) (model.User, error) {
    err := ValidateJWT(context)
    if err != nil {
        return model.User{}, err
    }
    token, _ := getToken(context)
    claims, _ := token.Claims.(jwt.MapClaims)
    userId := uint(claims["id"].(float64))

    user, err := model.FindUserById(userId)
    if err != nil {
        return model.User{}, err
    }
    return user, nil
}

func getToken(context *gin.Context) (*jwt.Token, error) {
    tokenString := getTokenFromRequest(context)
    token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
        }

        return privateKey, nil
    })
    return token, err
}

func getTokenFromRequest(context *gin.Context) string {
    bearerToken := context.Request.Header.Get("Authorization")
    splitToken := strings.Split(bearerToken, " ")
    if len(splitToken) == 2 {
        return splitToken[1]
    }
    return ""
}
Enter fullscreen mode Exit fullscreen mode

Next, add the FindUserById function in the model/user.go file:

package model

import (
    "diary_api/model"
    "errors"
    "fmt"
    "os"
    "strconv"
    "strings"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/golang-jwt/jwt/v4"
)

func FindUserById(id uint) (User, error) {
    var user User
    err := database.Database.Preload("Entries").Where("ID=?", id).Find(&user).Error
    if err != nil {
        return User{}, err
    }
    return user, nil
}
Enter fullscreen mode Exit fullscreen mode

Now, create the middleware/jwtAuth.go file with the JWT authentication middleware:

package middleware

import (
    "diary_api/helper"
    "github.com/gin-gonic/gin"
    "net/http"
)

func JWTAuthMiddleware() gin.HandlerFunc {
    return func(context *gin.Context) {
        err := helper.ValidateJWT(context)
        if err != nil {
            context.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
            context.Abort()
            return
        }
        context.Next()
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, add the functionality to add a new entry in the controller/entry.go file:

package controller

import (
    "diary_api/helper"
    "diary_api/model"
    "github.com/gin-gonic/gin"
    "net/http"
)

func AddEntry(context *gin.Context) {
    var input model.Entry
    if err := context.ShouldBindJSON(&input); err != nil {
        context.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    user, err := helper.CurrentUser(context)

    if err != nil {
        context.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    input.UserID = user.ID

    savedEntry, err := input.Save()

    if err != nil {
        context.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    context.JSON(http.StatusCreated, gin.H{"data": savedEntry})
}
Enter fullscreen mode Exit fullscreen mode

Ensure to import the necessary packages at the top of each file.

With these changes, you have implemented middleware for handling authenticated endpoints and added the functionality to add a new entry.

Implementing Feature to Get All Entries for Authenticated User and Adding Protected Routes

Add the following function to the controller/entry.go file:

package controller

import (
    "diary_api/helper"
    "github.com/gin-gonic/gin"
    "net/http"
)

func GetAllEntries(context *gin.Context) {
    user, err := helper.CurrentUser(context)

    if err != nil {
        context.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    context.JSON(http.StatusOK, gin.H{"data": user.Entries})
}
Enter fullscreen mode Exit fullscreen mode

Update the main.go file to include the new protected routes:

package main

import (
    "diary_api/controller"
    "diary_api/database"
    "diary_api/middleware"
    "diary_api/model"
    "fmt"
    "log"

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

func main() {
    loadEnv()
    loadDatabase()
    serveApplication()
}

func loadEnv() {
    err := godotenv.Load(".env.local")
    if err != nil {
        log.Fatal("Error loading .env file")
    }
}

func loadDatabase() {
    database.Connect()
    database.Database.AutoMigrate(&model.User{})
    database.Database.AutoMigrate(&model.Entry{})
}

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

    publicRoutes := router.Group("/auth")
    publicRoutes.POST("/register", controller.Register)
    publicRoutes.POST("/login", controller.Login)

    protectedRoutes := router.Group("/api")
    protectedRoutes.Use(middleware.JWTAuthMiddleware())
    protectedRoutes.POST("/entry", controller.AddEntry)
    protectedRoutes.GET("/entry", controller.GetAllEntries)

    router.Run(":8000")
    fmt.Println("Server running on port 8000")
}
Enter fullscreen mode Exit fullscreen mode

Now, your API has protected routes for adding and retrieving entries. Run your application using go run main.go and follow the instructions to create a new entry and retrieve the list of entries as described in the provided example commands.

Ensure that you replace the <<JWT>> placeholder with the actual JWT obtained during the login process when making requests to protected routes.


Similar to this, I run a developer-centric community on Slack. Where we discuss these kinds of topics, implementations, integrations, some truth bombs, lunatic chats, virtual meets, and everything that will help a developer remain sane ;) Afterall, too much knowledge can be dangerous too.

I'm inviting you to join our free community, take part in discussions, and share your freaking experience & expertise. You can fill out this form, and a Slack invite will ring your email in a few days. We have amazing folks from some of the great companies (Atlassian, Gong, Scaler etc), and you wouldn't wanna miss interacting with them. Invite Form

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .