More articles you can read:
- Using Golang to Build a Real-Time Notification System - A Step-by-Step Notification System Design Guide
- JSON is Slower. Here Are Its 4 Faster Alternatives
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:
- Create a new folder named
diary_api
in your development directory and navigate into it.
mkdir diary_api
cd diary_api
- Initialize a Go module with the following command.
go mod init diary_api
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
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
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"
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
}
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
}
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")
}
}
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")
}
}
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
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)
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"`
}
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
}
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"
)
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})
}
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
}
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)
}
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})
}
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"
)
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")
}
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
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
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 ""
}
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
}
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()
}
}
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})
}
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})
}
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")
}
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