In this post, we will create a minimal API using Go and the popular Gin framework. The API will have several endpoints and the idea is to create a minimal non-persistent1 TODO list API.2

Minimal Go aplication

Create a project directory and navigate to it:

1mkdir go-minimal-api
2cd go-minimal-api

Also, let’s initialize a new Go module:

1go mod init go-minimal-api

Create a new file named main.go and add the following code:

1package main
2
3import "fmt"
4
5func main() {
6    fmt.Println("Hello, World!")
7}

Check that the code is working by running the following command:

1go run main.go

You should see the following output:

1Hello, World!

Congratulations! Your project is up and running. However, it has nothing to do with the API yet.

On a road to the API

Remove print statement from the main function - we do not need it anymore. Instead, add the following code:

1func main() {
2  r := gin.Default()
3  r.Run(":8080")
4}

Note, that previously we impoted fmt package for print statement. Now, we do not need it but we do need to import the Gin framework. Add the following import statement at the top of the file: import "github.com/gin-gonic/gin"

However, this will not work yet, because we need to install the Gin framework. The simpliest way to do this is to run the tidy command:

1go mod tidy

This will automatically download necessary (used in the project) dependencies.

Alternatively, you can install the Gin framework manually by running the following command: go get -u github.com/gin-gonic/gin

After that, you can run the project again and confirm that it is working:

1go run main.go

You should see the similar output and that will mean that the project is up and running (again):

1...
2[GIN-debug] Listening and serving HTTP on :8080

Next, we need a struct to represent a TODO item. Struct is a user-defined data type in Go that allows you to group/combine items of possibly different types into a single type. We also want this struct to be a serializable object, so we need to add struct tags to it. To do so, add the following code to the main.go file:

1type Task struct {
2	Id          int    `json:"id"`
3	Name        string `json:"name"`
4	IsCompleted bool   `json:"isCompleted"`
5}

We will be using three fields for the struct: Id, Name, and IsCompleted. The json tags are used to specify the names of the fields when the struct is serialized to JSON.

Our next step is to utilize newly created struct and create a list of tasks:

1var tasks = []Task{
2	{Id: 1, Name: "Learn Go", IsCompleted: true},
3	{Id: 2, Name: "Learn Gin", IsCompleted: false},
4}

Here we created a slice of tasks and added two tasks to it. Feel free to add more tasks or change the existing ones.

Get tasks

Let’s create a route to get all those tasks now. For this, we will need a handler function that will return all tasks and some modifications to the main function. Let’s start from the handler function.

1func getTasks(c *gin.Context) {
2	c.JSON(http.StatusOK, tasks)
3}

As simple as that. The function takes a pointer to the gin.Context object and returns all tasks in JSON format with the status code 200. Please note, that http.StatusOK requires an import statement - net/http, so our imports should look like this:

1import (
2  "net/http"
3  "github.com/gin-gonic/gin"
4)

And the last one step we have to do is to add a route for our new handler function. To do so, we need to extend the main function a little:

1func main() {
2	r := gin.Default()
3	r.GET("/tasks", getTasks)
4
5	r.Run(":8080")
6}

From this point, your code should look like this one:

 1package main
 2
 3import (
 4	"net/http"
 5
 6	"github.com/gin-gonic/gin"
 7)
 8
 9type Task struct {
10	Id          int    `json:"id"`
11	Name        string `json:"name"`
12	IsCompleted bool   `json:"isCompleted"`
13}
14
15var tasks = []Task{
16	{Id: 1, Name: "Learn Go", IsCompleted: true},
17	{Id: 2, Name: "Learn Gin", IsCompleted: false},
18}
19
20func main() {
21	r := gin.Default()
22	r.GET("/tasks", getTasks)
23
24	r.Run(":8080")
25}
26
27func getTasks(c *gin.Context) {
28	c.JSON(http.StatusOK, tasks)
29}

Let’s run the project and check if it is working as expected: go run main.go

To test our new endpoint, you can use any API client, such as Postman or curl. I will be using curl in this tutorial. Open a new terminal window and run the following command:

1curl http://localhost:8080/tasks

If you see the following output, then everything is working as expected:

1[
2	{ "id": 1, "name": "Learn Go", "isCompleted": true },
3	{ "id": 2, "name": "Learn Gin", "isCompleted": false }
4]

Congratulations once again, but we still have some work to do.

Create new task

To create a new task we have to create a new handler. The createTask function will be similar to the getTasks function, but it will include additional logic to handle JSON input data.

That being said, let’s create a new function for this:

 1func createTask(c *gin.Context) {
 2	var newTask struct {
 3		Name string `json:"name" binding:"required"`
 4	}
 5
 6	if err := c.ShouldBindJSON(&newTask); err != nil {
 7		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
 8		return
 9	}
10
11	task := Task{
12		Id:          len(tasks) + 1,
13		Name:        newTask.Name,
14		IsCompleted: false,
15	}
16	tasks = append(tasks, task)
17	c.JSON(http.StatusCreated, task)
18}

It may seem a little bit complicated. So let’s break it down a little bit.

  • We create a new struct newTask to bind the JSON data from the request body.
  • The binding:"required" tag is used to specify that the Name field is required.
  • The ShouldBindJSON method is used to bind the JSON data from the request body to the newTask struct. If this binding fails, the function returns a 400 status code with an error message.
  • Otherwise we create a new task, append it to the tasks slice and return the new task with a 201 status code.

After this we need to add a new router for the createTask function. To do so, we need to extend the main function:

1func main() {
2	r := gin.Default()
3	r.GET("/tasks", getTasks)
4	r.POST("/tasks", createTask)
5
6	r.Run(":8080")
7}

Time to test it. But before that, run the API: go run main.go

And now run curl command:

1curl -X POST -H "Content-Type: application/json" -d '{"name": "Learn Rust"}' http://localhost:8080/tasks

This will create a new task with the name Learn Rust and return the following output:

1{ "id": 3, "name": "Learn Rust", "isCompleted": false }

If you want, you can check the list of tasks again by running the following command: curl http://localhost:8080/tasks

And now you should see the similar output:

1[
2	{ "id": 1, "name": "Learn Go", "isCompleted": true },
3	{ "id": 2, "name": "Learn Gin", "isCompleted": false },
4	{ "id": 3, "name": "Learn Rust", "isCompleted": false }
5]

Our new task is there! Let’s move on to the next step.

Update task

An updateTask function for updating a task is very similar to the createTask function. The only difference is that we need to get the task ID from the URL and update the task with the given ID. Let’s do this:

 1func updateTask(c *gin.Context) {
 2	id, err := strconv.Atoi(c.Param("id"))
 3	if err != nil {
 4		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task id"})
 5		return
 6	}
 7
 8	for i, task := range tasks {
 9		if task.Id == id {
10			tasks[i].IsCompleted = !tasks[i].IsCompleted
11			c.JSON(http.StatusOK, tasks[i])
12			return
13		}
14	}
15
16	c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
17}
  • Here we check if the task ID is valid and then iterate over the tasks slice to find the task with the given ID.
  • If the task is found, we update the IsCompleted field and return the updated task with a 200 status code.
  • If the task is not found, we return a 404 status code with an error message.

Also, we need to import the strconv package. I will not show the import statement here, because you already know how to do it.

As you may guess, we need to add a new route for the updateTask function as well:

1func main() {
2	r := gin.Default()
3	r.GET("/tasks", getTasks)
4	r.POST("/tasks", createTask)
5	r.PUT("/tasks/:id", updateTask)
6
7	r.Run(":8080")
8}

Run the project with go run main.go and test the new endpoint with:

1curl -X PUT http://localhost:8080/tasks/2

This should response with

1{ "id": 2, "name": "Learn Gin", "isCompleted": true }

Voila! The task with ID 2 is now completed.

Once again, if you want, you can check the list of tasks by running curl http://localhost:8080/tasks.

Delete task

This will be our last enpoint for this tutorial. The handler function for deleting a task is very similar to the updateTask function:

 1func deleteTask(c *gin.Context) {
 2	id, err := strconv.Atoi(c.Param("id"))
 3	if err != nil {
 4		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task id"})
 5		return
 6	}
 7
 8	for i, task := range tasks {
 9		if task.Id == id {
10			tasks = append(tasks[:i], tasks[i+1:]...)
11			c.JSON(http.StatusOK, gin.H{"message": "task deleted"})
12			return
13		}
14	}
15	c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
16}

Since this function is very similar to the updateTask function, I will not explain it in detail. The only difference is that we use the append function to remove the task with the given ID from the tasks slice.

The line tasks = append(tasks[:i], tasks[i+1:]...) may look a little bit tricky. The first argument of the append function is the slice to which we want to append elements, the second argument is the elements we want to append, and the third argument is the elements we want to append. The ... operator is used to unpack the elements of the slice. So in our case, we append all elements before the task with the given ID and all elements after the task with the given ID.

Alternatively, you can replace this line with tasks = slices.Delete(tasks, i, i+1) - it seems a more readable way to delete an element from a slice to me. However, you need to import the slices package.

let’s do not forget to add a new route to the main function:

1func main() {
2	r := gin.Default()
3	r.GET("/tasks", getTasks)
4	r.POST("/tasks", createTask)
5	r.PUT("/tasks/:id", updateTask)
6	r.DELETE("/tasks/:id", deleteTask)
7
8	r.Run(":8080")
9}

Now run your project with go run main.go and test the new endpoint:

1curl -X DELETE http://localhost:8080/tasks/2

This will response with

1{ "message": "task deleted" }

And just to verify, you can check the list of tasks by running curl http://localhost:8080/tasks - you will see only 1 task in the list.

Conclusion

Quite a big tutorial we got here. We created a minimal API with 4 endpoints using the Gin framework: to get all tasks, create a new task, update a task, and delete a task. We used a non-persistent list of tasks to keep things simple and focused on the API itself.

I hope you enjoyed this tutorial and learned something new.

Complete code

Here you can find the complete code for the API:

 1package main
 2
 3import (
 4	"net/http"
 5	"strconv"
 6
 7	"slices"
 8
 9	"github.com/gin-gonic/gin"
10)
11
12type Task struct {
13	Id          int    `json:"id"`
14	Name        string `json:"name"`
15	IsCompleted bool   `json:"isCompleted"`
16}
17
18var tasks = []Task{
19	{Id: 1, Name: "Learn Go", IsCompleted: true},
20	{Id: 2, Name: "Learn Gin", IsCompleted: false},
21}
22
23func main() {
24	r := gin.Default()
25	r.GET("/tasks", getTasks)
26	r.POST("/tasks", createTask)
27	r.PUT("/tasks/:id", updateTask)
28	r.DELETE("/tasks/:id", deleteTask)
29
30	if err := r.Run(":8080"); err != nil {
31		panic(err)
32	}
33}
34
35func getTasks(c *gin.Context) {
36	c.JSON(http.StatusOK, tasks)
37}
38
39func createTask(c *gin.Context) {
40	var newTask struct {
41		Name string `json:"name" binding:"required"`
42	}
43
44	if err := c.ShouldBindJSON(&newTask); err != nil {
45		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
46		return
47	}
48
49	task := Task{
50		Id:          len(tasks) + 1,
51		Name:        newTask.Name,
52		IsCompleted: false,
53	}
54	tasks = append(tasks, task)
55	c.JSON(http.StatusCreated, task)
56}
57
58func updateTask(c *gin.Context) {
59	id, err := strconv.Atoi(c.Param("id"))
60	if err != nil {
61		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task id"})
62		return
63	}
64
65	for i, task := range tasks {
66		if task.Id == id {
67			tasks[i].IsCompleted = !tasks[i].IsCompleted
68			c.JSON(http.StatusOK, tasks[i])
69			return
70		}
71	}
72
73	c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
74}
75
76func deleteTask(c *gin.Context) {
77	id, err := strconv.Atoi(c.Param("id"))
78	if err != nil {
79		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task id"})
80		return
81	}
82
83	for i, task := range tasks {
84		if task.Id == id {
85			tasks = slices.Delete(tasks, i, i+1)
86			c.JSON(http.StatusOK, gin.H{"message": "task deleted"})
87			return
88		}
89	}
90	c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
91}

  1. Non-persistent means that the data will not be stored anywhere, but in memory and will be lost when the server is restarted. This is done for simplicity and to keep the focus on the API itself. ↩︎

  2. In this particular tutorial we won’t be using any best-practices or similar due to the simplicity of the API. However, in a real-world scenario, you should always follow best practices. ↩︎