Introduction
In the previous post we saw how we setup our database functions using GORM. In order for these functions to be accessible we need an API, and that is we are going to build in this article.
Prepare yourself as this will again we be quite code-heavy.
In your working directory create a directory called api. In this directory create a file called operations.go.
At the top of this file make sure you have the following declaration:
package api
Preliminaries
Before we can start writing the code, we need to consider that our API can be used in two ways:
- Like a normal JSON API, and therefore producing some form of JSON
- As a HTMX API and then it will produce HTML
Since I want to cater for both, some extra logic in our methods and routes is needed.
Let’s start by importing some packages:
import (
"errors"
"fmt"
"github.com/gin-gonic/gin"
"<your username>/todolistproject/db"
"<your username>/todolistproject/models"
"net/http"
)
Line by line:
- Our API can return errors, therefore we need the errors package.
- Also we need some string formatting, and that is why we need the fmt package.
- As we are building our API on Gin, we need that too.
- We also need to import our own database functions and models.
- In the API we return all kinds of http status codes, that is why we need the net/http package.
Since I am firmly against the use of magic strings or magic numbers, we also define the following constants:
const (
HtmlFormat = "html"
JsonFormat = "json"
FormatKey = "format"
IdKey = "id"
)
Here you can see the following:
- As mentioned in the introduction, we can produce output in two formats: HTML and JSON
- In our routes, we use the format to make sure we produce the right kind of output, that is what
FormatKey
is for. - In some of our API calls we need the id of an item, that is where the
IdKey
comes in.
Some utility functions
In order to prevent code duplication as much as possible, I will define three utility functions
The checkFormat()
function
In the checkFormat()
function we check whether the provided format is correct, that is, is it html or json. Here it is:
func checkFormat(format string) error {
if format != JsonFormat && format != HtmlFormat {
return errors.New(fmt.Sprintf("Invalid format %s", format))
}
return nil
}
The render()
function
The render function has amongst other a gin.Context
as one of its parameters. This function renders the output in the desired format:
func render(c *gin.Context, format string, status int, data interface{}) {
if format == JsonFormat {
c.JSON(status, data)
} else {
c.HTML(status, "list.html", data)
}
}
Note that for the first time we come across a template file. Do not worry, I will define and implement them in the next installment of this series.
The handleError()
function
The last utility function handles errors. For now it just return a JSON with an error, or an error page (another template), but it could also be used for logging and/or alerting. Here it is:
func handleError(c *gin.Context, format string, status int, error error) {
if format == JsonFormat {
c.JSON(status, gin.H{"error": error})
} else {
c.HTML(status, "error.html", gin.H{"error": error})
}
}
The API functions
Now we can start implementing our API functions. We define functions for the following:
- Listing all items
- Finding a particular item
- Creating items
- Deleting items
- Switching the finished status of an item
Getting all the items: the ListItems()
function
This is what the ListItems()
function looks like:
func ListItems(c *gin.Context) {
format := c.Param(FormatKey)
err := checkFormat(format)
if err != nil {
handleError(c, HtmlFormat, http.StatusBadRequest, err)
}
connection, err := db.GetConnection()
if err != nil {
handleError(c, format, http.StatusInternalServerError, err)
return
}
items := connection.Fetch()
render(c, format, http.StatusOK, gin.H{"items": items})
}
Since this function is more or less a template for the following functions I will discuss it in somewhat greater detail:
- We get the format from the route and check if it is a valid format. If not the API outputs an error. For now it defaults to HTML format for the error
- Next we try to get a database connection. If an error occurs this is handled by the
handleError()
function. - Now that we know the format is correct and we have a database connection, we can fetch the items.
- We use the
render()
function to render the output in the correct format. We usegin.H
to pass the data to the output.
Finding a needle in a haystack: the Find()
function
Next is the Find()
function to find a particular item or return an error if it can not be found:
func FindItem(c *gin.Context) {
format := c.Param(FormatKey)
err := checkFormat(format)
if err != nil {
handleError(c, HtmlFormat, http.StatusBadRequest, err)
}
id := c.Param(IdKey)
connection, err := db.GetConnection()
if err != nil {
handleError(c, format, http.StatusInternalServerError, err)
return
}
result, err := connection.Find(id)
if err != nil {
handleError(c, format, http.StatusNotFound, err)
return
}
render(c, format, http.StatusOK, gin.H{"item": result})
}
This more or less the same as the Fetch()
function, however there are some differences:
- We extract the
id
from route parameters using theIdKey
- If we get an error we return a Not Found error
Creating todo items: the CreateItem()
function
If we want to find or list items, we need to be able to create them:
func CreateItem(c *gin.Context) {
format := c.Param(FormatKey)
err := checkFormat(format)
if err != nil {
handleError(c, HtmlFormat, http.StatusBadRequest, err)
}
var item models.Item
err = c.BindJSON(&item)
if err != nil {
handleError(c, format, http.StatusBadRequest, err)
return
}
connection, err := db.GetConnection()
if err != nil {
handleError(c, format, http.StatusInternalServerError, err)
return
}
_, createError := connection.Create(&item)
if createError != nil {
handleError(c, format, http.StatusInternalServerError, createError)
return
}
items := connection.Fetch()
render(c, format, http.StatusCreated, gin.H{"items": items})
}
Again this function is built along much the same lines as the previous ones but with some differences:
- The new item is supplied to us in the form of some JSON. We use the
c.BindJSON()
to make it into an item. - The
Create()
method also returns the created item and an error. For now we ignore this item, that is why we use the underscore in that spot. - After the item has been created, we fetch the updated list of items and return that using the
render()
method.
Getting rid of unwanted items: the DeleteItem()
function
A good todolist also has functionality to delete items:
func DeleteItem(c *gin.Context) {
format := c.Param(FormatKey)
err := checkFormat(format)
if err != nil {
handleError(c, HtmlFormat, http.StatusBadRequest, err)
}
id := c.Param(IdKey)
connection, err := db.GetConnection()
if err != nil {
handleError(c, format, http.StatusInternalServerError, err)
return
}
err = connection.Delete(id)
if err != nil {
handleError(c, format, http.StatusNotFound, err)
return
} else {
render(c, format, http.StatusOK, gin.H{"items": connection.Fetch()})
}
}
Again, this method is very similar to the previous methods:
- Like in the
Find()
method we extract the id from the route. - After making sure the deletion went all right, we return the updated list.
Serving the main page: the Index()
function
This is a utility to serve our main HTML page, if we are using a web interface:
func Index(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", nil)
}
Finishing the hat: the ToggleFinished()
function
This is another utility function, that updates the Finished
flag in our model and in the database. Although we could have done this with an update method, I chose to make a separate method for this, since we do not update any other fields currently:
func ToggleFinished(c *gin.Context) {
format := c.Param(FormatKey)
err := checkFormat(format)
if err != nil {
handleError(c, HtmlFormat, http.StatusBadRequest, err)
}
id := c.Param(IdKey)
connection, err := db.GetConnection()
if err != nil {
handleError(c, format, http.StatusInternalServerError, err)
return
}
err = connection.ToggleFinished(id)
if err != nil {
handleError(c, format, http.StatusNotFound, err)
return
} else {
render(c, format, http.StatusOK, gin.H{"items": connection.Fetch()})
}
}
Again very similar to the previous methods:
- We make sure there are no errors in the format, nor in obtaining the database connection.
- We call the
ToggleFinished()
method on the connection, and handle the errors, or return an updated list of items.
Putting it together
Now that we have all of these methods, let’s make sure we can call them from a RESTful API.
In order to do that we create a main.go file in our root directory.
We will start by importing our packages:
package main
import (
"fmt"
"github.com/gin-gonic/gin"
"<your github username>/todolistproject/api"
)
Make sure you replace <your github username> with your github username or other name you have chose when you initialized this project.
Next we define some constants:
const (
version = "v1"
groupName = "api"
)
Here we define the version and the groupname of our api
Next we come to the heart of our API: setting up the router and the routes:
func main() {
router := gin.Default()
router.LoadHTMLGlob("templates/*.html")
group := router.Group(fmt.Sprintf("/%s/%s", groupName, version))
group.GET("/", api.Index)
group.GET("/items/:format", api.ListItems)
group.GET("/items/:format/:id", api.FindItem)
group.POST("/items/:format/toggle/:id", api.ToggleFinished)
group.POST("/items/:format", api.CreateItem)
group.DELETE("/items/:format/:id", api.DeleteItem)
router.Run()
}
Line by line:
- We initialize a Router engine by using
gin.Default()
- Next we load the HTML templates, using the
LoadHTMLGlob()
method, so we can render them when needed. - Now we can define a group with the name ‘/api/v1’.
- After which we can define the routes. Note that since define them on a group, they are prefixed with ‘/api/v1’
- You can see that we define our routes not only by the endpoint, but also by the HTTP-verb. For example the routes for finding an item and deleting an item have the same endpoint, but a different HTTP-verb
- Also note that we can have route parameters, like :format or :id. As you have already seen these can be extracted in the handler-functions.
After we add the routes to the group, we are finally ready to run the API, using router.Run()
. This will not do much as of now, since we have not defined any html-templates, though you could experiment with the JSON format using apps like postman, or commandline tools like curl.
Conclusion
As I mentioned at the beginning, this part of the series was very code heavy. However, Gin is not that hard to understand and has excellent documentation. Now all that we have to do to get a working Web UI is define the templates and implement some HTMX. So, see you in the next part of this series.