Restful CRUD with Golang for beginners

So in the previous post we finished a simple Hello World request using Golang and Gin. Today it's time for us to build something more complex - CRUD with Gin!

Hey guys, it's Sam Zhang.

So in the previous post we finished a simple Hello World request using Golang and Gin. Today it's time for us to build something more complex - CRUD with Gin!

Table of Contents

Getting started

This tutorial is made for developers who have some experience in programming but relatively new to Go/Gin. You might want to learn some common programming concepts if you're a total beginner.

So to start up, you'll need to have the hello world program finished from our last post. Or of course you could clone it from GitHub to continue.

Then let's get started!

CRUD stands for Create, Read, Update and Delete. Those are the four basic operations in the database and we will be implementing it into our Go app today.

When dealing with databases, you could just write some plain old SQL commands and execute directly it using the database drivers. But there's a problem with it: SQL injection attack. So using ORMs is normally a better option. We will be using GORM throughout this series just because it's popular and easy to get started with.

Installing GORM

As it described in the docs, simply use go get gorm.io/gorm to install it.

However, GORM needs database drivers in order to connect to databases and do operations. I will be using Postgres for now and you can use whatever database you wanted.

Note: Sqlite is not recommended since it doesn't support some complex operations natively. But for now, you can use it since we don't have the need of complex operations and you can migrate to others in the future.

So let's install the database driver too:

$ go get -u gorm.io/driver/postgres  # or other database provider

...and GORM is ready to use!

Defining database models

Like other ORMs, GORM defines a table using models.

To define a model, you need to declare a struct containing the information about the table. For example:

type <name> struct {
    <field>  <field_type>
}

is the most basic form of defining a struct in Go. I assume that you have some basic knowledge about relational database storage so we won't discuss it very much here.

In order to create a GORM model, we simply need to fill in the information required. If we wanted to store a blog post in the database, then the following fields might be helpful:

  • ID (unsigned integer, primary key, auto increment, required)
  • Title (string, required)
  • Content (string, required)
  • Created at (time, defaults to current time)

So let's create a model for blog posts based on the above fields:

// models/post.go
package models

import "time"

type Post struct {
	ID        uint      `json:"id" gorm:"primaryKey"`
	Title     string    `json:"title"`
	Content   string    `json:"content"`
	CreatedAt time.Time `json:"created_at"`
}

Okay so let me explain all these.

We defined a GORM model using a struct and declared several fields. Here uint is unsigned int in other languages and time.Time is the datetime format in Golang.

But the backticks after the field type might be a little weird for new Go users. Those strings are called tags. They are using backtick annotation to define key-value pairs.

Struct tags are small pieces of metadata attached to fields of a struct that provide instructions to other Go code that works with the struct.[^1]

The json defines with key should the JSON encoder use when serializing the current field into JSON format. And the gorm key will let GORM know some extra information about this field. For example, here we defined ID as a primary key for the model.

Connecting to database

So now we successfully created the database schema, let's connect it to a real database:

// models/setup.go
package models

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

var DB *gorm.DB

func ConnectDatabase() {
	dsn := "host=localhost user=postgres dbname=go_blog port=5432 sslmode=disable timezone=Asia/Shanghai"
	database, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})  // change the database provider if necessary

	if err != nil {
		panic("Failed to connect to database!")
	}

	database.AutoMigrate(&Post{})  // register Post model

	DB = database
}

In function ConnectDatabase(), we first defined our data source name and established the database connection. Here I used the Postgres driver and you might change it to fit your own need.

If there's any problems when connecting to our database, err will point to the error. In this case, we will call panic() to terminate the whole process. panic() is a builtin function that acts similar to raise in Python and throw in JavaScript.

Then we registered our Post model to the database and "exported" the database variable.

Note that DB is a global variable that is accessible in every file of package models, making operations with database easier without importing everything.

...and then let's call the connection function in our main.go:

// main.go
package main

import (
	"samzhangjy/go-blog/models"

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

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

	models.ConnectDatabase()  // new!

	// ...

	router.Run("localhost:8080")
}

And now we've finally made a connection to the database. Time to code the request controllers!

RESTful APIs

Unlike the first post, we are going to put all of our request logic into a separate folder called controllers and import them later in main.go to define the routes.

Create

Let's start by adding a create method:

// controllers/post.go
package controllers

import (
	"net/http"
	"samzhangjy/go-blog/models"

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

type CreatePostInput struct {
	Title   string `json:"title" binding:"required"`
	Content string `json:"content" binding:"required"`
}

func CreatePost(c *gin.Context) {
	var input CreatePostInput
	if err := c.ShouldBindJSON(&input); err != nil {
		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	post := models.Post{Title: input.Title, Content: input.Content}
	models.DB.Create(&post)

	c.JSON(http.StatusOK, gin.H{"data": post})
}

You might be wondering, why is there struct again? Well structs is an important concept in Go and you will see it a lot throughout this series. In this case, our struct CreateBlogInput defines body schema for request CreateBlog. The new tag binding is a Gin validation tag based on Validator. If you wanted to know something about Gin bindings, here's a great read: https://blog.logrocket.com/gin-binding-in-go-a-tutorial-with-example.

Then let's focus on CreateBlog(). We first validated the request body (variable input here) using context.ShouldBindJSON(). If the body is invalid, err would contain some error messages. If err contains something, then we will simply return a 400 HTTP status code and abort the request. This if err = statement; err != nil {} statement is a commonly used error handling technique in Go.

If the input is valid, we will first create a Post model with data given from the input. Then we will call database.Create() to put this record into the Post table.

Finally, we will return HTTP 200 with the newly created post schema if everything goes as expected.

And let's bind our controller to a route:

// main.go
package main

import (
	"samzhangjy/go-blog/controllers"
	"samzhangjy/go-blog/models"

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

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

	models.ConnectDatabase()

	router.POST("/posts", controllers.CreatePost)  // here!

	router.Run("localhost:8080")
}

Notice that we're passing the controller function itself to router.POST(), without parentheses.

Run your app with air and use tools like Postman to play around with this endpoint!

Read

Then let's quickly add an endpoint to view every post created:

// controllers/post.go

// ...

func FindPosts(c *gin.Context) {
	var posts []models.Post
	models.DB.Find(&posts)

	c.JSON(http.StatusOK, gin.H{"data": posts})
}

Unlike the previous request, this one has no request body. We defined array posts to store the posts created with type models.Post. DB.Find(&posts) means to find every entry that exists in the database and store the fetched result to posts. Remember to pass in the pointer instead of the actual variable!

And then quickly bind it to our router:

// main.go
package main

import (
	"samzhangjy/go-blog/controllers"
	"samzhangjy/go-blog/models"

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

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

	models.ConnectDatabase()

	router.POST("/posts", controllers.CreatePost)
	router.GET("/posts", controllers.FindPosts)

	router.Run("localhost:8080")
}

And now you could see the posts you created using CreateBlog!

Then let's create a route that fetches only one specified post by URL param:

// controllers/post.go

// ...

func FindPost(c *gin.Context) {
	var post models.Post

	if err := models.DB.Where("id = ?", c.Param("id")).First(&post).Error; err != nil {
		c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusOK, gin.H{"data": post})
}

The new methods here is DB.Where and DB.First. DB.Where() lets you write SQL query commands, replacing the dynamic data with ? and passing the actual data as the second argument. DB.First(&post), like its name, selects the first name of the given collection of data and stores the result inside post.

context.Param("<param-name>") is a Gin method to fetch the URL parameter by param name. The param name is defined like:

// main.go
package main

import (
	"samzhangjy/go-blog/controllers"
	"samzhangjy/go-blog/models"

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

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

	models.ConnectDatabase()

	router.POST("/posts", controllers.CreatePost)
	router.GET("/posts", controllers.FindPosts)
	router.GET("/posts/:id", controllers.FindPost)  // here!

	router.Run("localhost:8080")
}

The slug starting with a colon : is defined as url parameters in Gin. The string after the colon is the parameter's name, which we'll use to fetch the param value.

A route /posts/:id will match the following:

  • /posts/1
  • /posts/1/

But won't match:

  • /posts/1/abcd
  • /posts/

Update

Then it's time for updating posts:

// controllers/post.go

// ...

type UpdatePostInput struct {
	Title   string `json:"title"`
	Content string `json:"content"`
}

func UpdatePost(c *gin.Context) {
	var post models.Post
	if err := models.DB.Where("id = ?", c.Param("id")).First(&post).Error; err != nil {
		c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "record not found"})
		return
	}

	var input UpdatePostInput

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

	updatedPost := models.Post{Title: input.Title, Content: input.Content}

	models.DB.Model(&post).Updates(&updatedPost)
	c.JSON(http.StatusOK, gin.H{"data": post})
}

So we defined a struct containing request body schema again, but this time without any validation, since everything is optional. We copied the code to validate if the current post exists from the previous FindPost() method.

And if the post exists and the request body is valid, we will define a new model containing the contents of the newly generated post data. In this case, its name is updatedPost. Then we will fetch the model for the original post using DB.Model(&post) and update is using model.Updates(&updatedPost).

model.Updates() will update multiple fields and won't modify the fields that didn't defined in the updated schema (updatedPost). model.Update() will only update one field at a time.

And bind it to the router:

// main.go
package main

import (
	"samzhangjy/go-blog/controllers"
	"samzhangjy/go-blog/models"

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

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

	models.ConnectDatabase()

	router.POST("/posts", controllers.CreatePost)
	router.GET("/posts", controllers.FindPosts)
	router.GET("/posts/:id", controllers.FindPost)
	router.PATCH("/posts/:id", controllers.UpdatePost)

	router.Run("localhost:8080")
}

Delete

And finally here comes the DELETE operation at last:

// controllers/post.go

// ...

func DeletePost(c *gin.Context) {
	var post models.Post
	if err := models.DB.Where("id = ?", c.Param("id")).First(&post).Error; err != nil {
		c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "record not found"})
		return
	}

	models.DB.Delete(&post)
	c.JSON(http.StatusOK, gin.H{"data": "success"})
}

And we need to ensure that the currently given post ID is valid. Then we will call DB.Delete(&post) to delete the post entry from our database.

Then finally, bind it to our router:

// main.go
package main

import (
	"samzhangjy/go-blog/controllers"
	"samzhangjy/go-blog/models"

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

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

	models.ConnectDatabase()

	router.POST("/posts", controllers.CreatePost)
	router.GET("/posts", controllers.FindPosts)
	router.GET("/posts/:id", controllers.FindPost)
	router.PATCH("/posts/:id", controllers.UpdatePost)
	router.DELETE("/posts/:id", controllers.DeletePost)

	router.Run("localhost:8080")
}

Congrats! You've successfully built a collection of simple but working CRUD restful API routes! Play it around and try to modify some parts or add some new operations!

Conclusion

This is Part 2 of my learning Go web development. I actually learned a lot from writing this series and if there's any mistakes in the post, plz point them out!

I uploaded all the source code used in this post to GitHub. Feel free to clone it and play around with it!

I'm Sam Zhang and I'll see you guys next time!

[^1]: From [Golang] Struct Tags explained.