Build a RESTful API using Golang and Gin

October 18, 2022
Written by
Oluyemi Olususi
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Go has risen to be one of the most in-demand programming languages - despite being relatively young. For example, it's been used to build a wide range of applications including CLIs, compilers, APIs, and desktop apps. What's more, it has a gentler learning curve and faster execution time than most modern languages. So you'd be hard-pressed to go wrong by using Go for your next project.

This is the first in a series of articles where I will show you how to build, test, dockerize, and deploy an application written in Go. In this article, you will build a RESTful API using Golang and Gin. If you're not familiar with it, Gin is an HTTP web framework written in Go. The application will have a database powered by PostgreSQL.

What you will build

You will build an API for a personal diary app which will have the following features:

  1. Registration with a username and password
  2. Login with a username and password
  3. Create a new diary entry
  4. Retrieve all diary entries

Prerequisites

To follow this tutorial, you need the following:

Get started

To begin, in your development folder create a new folder for the project named diary_api, and navigate into it by running the following commands:

mkdir diary_api
cd diary_api

Next, initialize a Go module within the project by issuing the following command.

go mod init diary_api

This creates a go.mod file in the top-level directory of the project, where your project’s dependencies will be tracked.

Install the project’s dependencies

As mentioned, this project uses the Gin framework. Run the following command from the top-level directory of your project to install the latest version of Gin, along with the other required 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

Once the installation process is completed, you'll have Gin and the following packages available to your application:

  1. Go Cryptography: This provides supplementary Go cryptography libraries.
  2. GoDotEnv: This will help with managing environment variables.
  3. GORM: This is an ORM (Object Relational Mapper) for Golang. In addition to the library, the GORM dialect (driver) for Postgres is installed to enable connections to PostgreSQL databases.
  4. JWT-Go: A Go implementation of JSON Web Tokens.

Prepare the database and environment variables

Before writing any code, create a new PostgreSQL database named diary_app, by running the following psql command, after replacing the three placeholders.

createdb -h <DB_HOSTNAME> -p <DB_PORT> -U <DB_USER> diary_app --password

When prompted, provide the password associated with DB_USER.

Alternatively, create the database using your preferred tool of choice.

Next, in the diary_api folder, create a new file named .env and add the following to it.

# 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"

In addition to placeholders for database parameters, two variables: TOKEN_TTL and JWT_PRIVATE_KEY, are also declared. They will be used in generating a signed JWT later on.

Next, make a copy of .env named .env.local using the following command.

cp .env .env.local

.env.**.local files are ignored by Git as an accepted best practice for storing credentials outside of code to keep them safe.

Replace the placeholder values in .env.local with the respective details for your PostgreSQL database.

Prepare models

Next, you'll create the two models for the application: User and Entry. To do this, start by creating a new folder named model. In that directory, create a new file named user.go, and then add the following code to the newly created file.

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
}

The above struct is composed of the Gorm Model struct, a string for the user’s username, another string for the password, and a slice of Entry items. In this way, you're specifying a one-to-many relationship between the User struct and the Entry structs.

Notice that the JSON binding for the Password field is -. This ensures that the user’s password is not returned in the JSON response.

Next, create a new file named entry.go and add the following code to it.

package model

import "gorm.io/gorm"

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

With the models in place, create a helper function to connect to the database, by creating a new folder named database, and in it a new file named database.go. Then, add the following code to the new file.

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")
    }
}

The Connect() function retrieves the environment variables required to set up a database connection and then opens the connection using the GORM PostgreSQL driver.

Next, create a new file that will be used as the entry point of the application at the root of the folder named main.go and add the following code to it.

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")
    }
}

In the main() function, the environment variables are loaded and the connection is established with the database. If the connection was opened successfully, the AutoMigrate() function is called to create the relevant tables and columns for the User and Entry structs (if they don’t already exist).

Next, start the application by running the following command

go run main.go

After the application starts, you will see the following message.

Successfully connected to the database

If you look in your database, you will see that it now contains two new tables, as shown below.

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

Implement registration and login

Before creating the API endpoints, create a model of what is expected for authentication requests. In the model folder, create a new file called authenticationInput.go and add the following code to it.

package model

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

Registration

Next, add the following methods to the User struct in model/user.go.

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
}

The Save() function adds a new user to the database (in the absence of any errors). Before saving, any whitespace in the provided username is trimmed out and the provided password is hashed for security purposes.

Remember to update your import statement to match the code below.

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

Next, create a new folder named controller. Then, in the newly created folder, create a new file named authentication.go, and add the following code to it.

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})
}

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

Login

Next, add another method, ValidatePassword(), to the User struct, which will be used to validate a provided password for a given user. Open model/user.go and add the following code.

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
}

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 is also declared. It takes a username and 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. In it, 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)
}

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

Next, in controller/authentication.go, add the following function.

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})
}

Then, update the import list at the top of the file, to match the following:

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

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

Next, update main.go to match the following code.

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")
}

In addition to loading the environment variables and database connection, you are now creating a Gin router and declaring two routes for registration and login respectively.

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

go run main.go

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

curl -i -H "Content-Type: application/json" \
    -X POST \
    -d '{"username":"<<USERNAME>>", "password":"<<PASSWORD>>"}' \
    http://localhost:8000/auth/register

You will see an output similar to this:

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Tue, 11 Oct 2022 03:37:08 GMT
Content-Length: 166

{"user":{"ID":1,"CreatedAt":"2022-10-11T04:37:08.041626+01:00","UpdatedAt":"2022-10-11T04:37:08.041626+01:00","DeletedAt":null,"username":"yemiwebby","Entries":null}}%

To log in, use curl again, as shown below, after replacing the placeholders with your username and password, respectively.

curl -i -H "Content-Type: application/json" \
    -X POST \
    -d '{"username":"<<USERNAME>>", "password":"<<PASSWORD>>"}' \
    http://localhost:8000/auth/login

You should see output, similar to the example below, that returns the JWT.

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Tue, 11 Oct 2022 03:43:08 GMT
Content-Length: 147

{"jwt":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlYXQiOjE2NjU0NjE3ODgsImlhdCI6MTY2NTQ1OTc4OCwiaWQiOjF9.4agGQACwKSZpPCpHeXnoqXfc3WZqYtE8b0SFcoH40uo"}%

Implement middleware to handle requests to authenticated endpoints

The next endpoints you will implement require that the user be authenticated. In other words, requests to these endpoints will require a bearer token in the request header. If none is found, an error response should be returned.

To do this, you will implement middleware. This middleware will intercept requests and ensure that a valid bearer token is present in the request before the appropriate handler is called.

Before building the middleware, you’ll need to add some helper functions to make the process of extracting and validating JWTs easier. Add the following functions to helper/jwt.go.

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 ""
}

Then, ensure that the import statement looks like the following:

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

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

The getTokenFromRequest() function retrieves the bearer token from the request. Bearer tokens come in the format bearer <JWT>, hence the retrieved string is split and the JWT string is returned.

The getToken() function uses the returned token string to parse the JWT, using the private key specified in .env.local.

Using both functions, the ValidateJWT() function ensures that the incoming request contains a valid token in the request header. This function will be used by the middleware to ensure that only authenticated requests are allowed past the middleware.

The CurrentUser() function will be used to get the user associated with the provided JWT by retrieving the id key from the parsed JWT and retrieve the corresponding user from the database. It makes reference to the FindUserById() function which is currently undefined, so go ahead and add that.

In the model/user.go function, add the following code.

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
}

In addition to retrieving the user, the entries associated with the user are eagerly loaded - thus populating the Entries slice in the User struct.

To create the middleware, create a new folder named middleware in the root of the project and in it, a file named jwtAuth.go. In the newly created file, add the following code.

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()
    }
}

The JWTAuthMiddleware returns a Gin HandlerFunc function. This function expects a context for which it tries to validate the JWT in the header. If it is invalid, an error response is returned. If not, the Next() function on the context is called. In our case, the controller function for the protected route is called.

Implement a feature to add a new entry

Before declaring the route or controller for this endpoint, add a method to the Entry struct which will allow you to save a new entry. In the model/entry.go file, add the following code.

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

Then, update the import statement to match the following code.

import (
    "diary_api/database"

    "gorm.io/gorm"
)

Next, in the controller folder, create a new file named entry.go and add the following code to it.

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})
}

The AddEntry() function marshals the body of the request into an Entry struct after which it gets the currently authenticated user from the request header. Next, it sets the associated user ID for the entry and saves it. The saved details are then returned in a JSON response.

Implement a feature to get all entries for the authenticated user

In the controller/entry.go file, add the following function.

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})
}

This function retrieves the current user and returns the entries associated with them.

Add protected routes

In the main.go file, update the serveApplication() function to match the following.

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")
}

Finally, update the import statement to match the following code.

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

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

Your API is now ready for consumption!

Run your application using the go run main.go command and try adding a few entries to your diary.

Create a new entry

To create an entry, log in as shown in the previous section and then copy the JWT returned and replace the <<JWT>> placeholder in the following example with it.

curl -d '{"content":"A sample content"}' \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer <<JWT>>" \
    -X POST http://localhost:8000/api/entry

You will see output similar to this:

{"data":{"ID":1,"CreatedAt":"2022-10-12T16:43:56.169216+01:00","UpdatedAt":"2022-10-12T16:43:56.169216+01:00","DeletedAt":null,"content":"A sample content","UserID":1}}%

Retrieve the list of entries

Use curl again to retrieve the list of created entries by replacing the <<JWT>> placeholder with yours

curl -H "Content-Type: application/json" -H "Authorization: Bearer <<JWT>>" -X GET http://localhost:8000/api/entry

You should see output similar to the following

{"data":[{"ID":1,"CreatedAt":"2022-10-12T16:43:56.169216+01:00","UpdatedAt":"2022-10-12T16:43:56.169216+01:00","DeletedAt":null,"content":"A sample content","UserID":1}]}%

Conclusion

In this article, you learned how to build an API using Gin. Gin makes the process of request parsing and validation a breeze. It also provided you with the ability to group endpoints and if needed apply different middleware to the different groups in an intuitive manner.

The entire codebase for this tutorial is available on GitHub. Feel free to explore further. Happy coding!

Oluyemi is a tech enthusiast with a background in Telecommunication Engineering. With a keen interest to solve day-to-day problems encountered by users, he ventured into programming and has since directed his problem-solving skills at building software for both web and mobile.

A full-stack software engineer with a passion for sharing knowledge, Oluyemi has published a good number of technical articles and content on several blogs on the internet. Being tech-savvy, his hobbies include trying out new programming languages and frameworks.