loading...
Cover image for Implement RESTful HTTP API in Go using Gin

Implement RESTful HTTP API in Go using Gin

techschoolguru profile image TECH SCHOOL ・19 min read

Hello and welcome back to the backend master class.

So far we have learned a lot about working with database in Go. Now it’s time to learn how to implement some RESTful HTTP APIs that will allow frontend clients to interact with our banking service backend.

Here's:

Go web frameworks and HTTP routers

Although we can use the standard net/http package to implement those APIs, It will be much easier to just take advantage of some existing web frameworks.

Here are some of the most popular golang web frameworks sorted by their number of Github stars:

Alt Text

They offer a wide range number of features such as routing, parameter binding, validation, middleware, and some of them even have a built-in ORM.

If you prefer a lightweight package with only routing feature, then here are some of the most popular HTTP routers for golang:

Alt Text

For this tutorial, I’m gonna use the most popular framework: Gin

Install Gin

Let’s open the browser and search for golang gin, then open its Github page. Scroll down a bit and select Installation.

Let’s copy this go get command, and run it in the terminal to install the package:

❯ go get -u github.com/gin-gonic/gin
Enter fullscreen mode Exit fullscreen mode

After this, in the go.mod file of our simple bank project, we can see that gin is added as a new dependency together with some other packages that it uses.

Alt Text

Define server struct

Now I’m gonna create a new folder called api. Then create a new file server.go inside it. This is where we implement our HTTP API server.

First let’s define a new Server struct. This Server will serves all HTTP requests for our banking service. It will have 2 fields:

  • The first one is a db.Store that we have implemented in previous lectures. It will allow us to interact with the database when processing API requests from clients.
  • The second field is a router of type gin.Engine. This router will help us send each API request to the correct handler for processing.
type Server struct {
    store  *db.Store
    router *gin.Engine
}
Enter fullscreen mode Exit fullscreen mode

Now let’s add a function NewServer, which takes a db.Store as input, and return a Server. This function will create a new Server instance, and setup all HTTP API routes for our service on that server.

First, we create a new Server object with the input store. Then we create a new router by calling gin.Default(). We will add routes to this router in a moment. After this step, we will assign the router object to server.router and return the server.

func NewServer(store *db.Store) *Server {
    server := &Server{store: store}
    router := gin.Default()

    // TODO: add routes to router

    server.router = router
    return server
}
Enter fullscreen mode Exit fullscreen mode

Now let’s add the first API route to create a new account. It’s gonna use POST method, so we call router.POST.

We must pass in a path for the route, which is /accounts in this case, and then one or multiple handler functions. If you pass in multiple functions, then the last one should be the real handler, and all other functions should be middlewares.

func NewServer(store *db.Store) *Server {
    server := &Server{store: store}
    router := gin.Default()

    router.POST("/accounts", server.createAccount)

    server.router = router
    return server
}
Enter fullscreen mode Exit fullscreen mode

For now, we don’t have any middlewares, so I just pass in 1 handler: server.createAccount. This is a method of the Server struct that we need to implement. The reason it needs to be a method of the Server struct is because we have to get access to the store object in order to save new account to the database.

Implement create account API

I’m gonna implement server.createAccount method in a new file account.go inside the api folder. Here we declare a function with a server pointer receiver. Its name is createAccount, and it should take a gin.Context object as input.

func (server *Server) createAccount(ctx *gin.Context) {
    ...
}
Enter fullscreen mode Exit fullscreen mode

Why does it have this function signature? Let’s look at this router.POST function of Gin:

Alt Text

Here we can see that the HandlerFunc is declared as a function with a Context input. Basically, when using Gin, everything we do inside a handler will involve this context object. It provides a lot of convenient methods to read input parameters and write out responses.

Alright, now let’s declare a new struct to store the create account request. It will have several fields, similar to the createAccountParams from account.sql.go that we used in the database in previous lecture:

type CreateAccountParams struct {
    Owner    string `json:"owner"`
    Balance  int64  `json:"balance"`
    Currency string `json:"currency"`
}
Enter fullscreen mode Exit fullscreen mode

So I’m gonna copy these fields and paste them to our createAccountRequest struct. When a new account is created, its initial balance should always be 0, so we can remove the balance field. We only allow clients to specify the owner’s name and the currency of the account. We’re gonna get these input parameters from the body of the HTTP request, Which is a JSON object, so I’m gonna keep the JSON tags.

type createAccountRequest struct {
    Owner    string `json:"owner"`
    Currency string `json:"currency"`
}

func (server *Server) createAccount(ctx *gin.Context) {
    ...
}
Enter fullscreen mode Exit fullscreen mode

Now whenever we get input data from clients, it’s always a good idea to validate them, because who knows, clients might send some invalid data that we don’t want to store in our database.

Lucky for us, Gin uses a validator package internally to perform data validation automatically under the hood. For example, we can use a binding tag to tell Gin that the field is required. And later, we call the ShouldBindJSON function to parse the input data from HTTP request body, and Gin will validate the output object to make sure it satisfy the conditions we specified in the binding tag.

I’m gonna add a binding required tag to both the owner and the currency field. Moreover, let’s say our bank only supports 2 types of currency for now: USD and EUR. So how can we tell gin to check that for us? Well, we can use the oneof condition for this purpose:

type createAccountRequest struct {
    Owner    string `json:"owner" binding:"required"`
    Currency string `json:"currency" binding:"required,oneof=USD EUR"`
}
Enter fullscreen mode Exit fullscreen mode

We use a comma to separate multiple conditions, and a space to separate the possible values for the oneof condition.

Alright, now in the createAccount function, we declare a new req variable of type createAccountRequest. Then we call ctx.ShouldBindJSON() function, and pass in this req object. This function will return an error.

If the error is not nil, then it means that the client has provided invalid data. So we should send a 400 Bad Request response to the client. To do that, we just call ctx.JSON() function to send a JSON response.

The first argument is an HTTP status code, which in this case should be http.StatusBadRequest. The second argument is the JSON object that we want to send to the client. Here we just want to send the error, so we will need a function to convert this error into a key-value object so that Gin can serialize it to JSON before returning to the client.

func (server *Server) createAccount(ctx *gin.Context) {
    var req createAccountRequest
    if err := ctx.ShouldBindJSON(&req); err != nil {
        ctx.JSON(http.StatusBadRequest, errorResponse(err))
        return
    }

    ...
}
Enter fullscreen mode Exit fullscreen mode

We’re gonna use this errorResponse() function a lot in our code later, and it can be used for other handlers as well, not just for account handlers, so I will implement it in the server.go file.

This function will take an error as input, and it will return a gin.H object, which is in fact just a shortcut for map[string]interface{}. So we can store whatever key-value data that we want in it.

For now let’s just return gin.H with only 1 key: error, and its value is the error message. Later we might check the error type and convert it to a better format if we want.

func errorResponse(err error) gin.H {
    return gin.H{"error": err.Error()}
}
Enter fullscreen mode Exit fullscreen mode

Now let’s go back to the createAccount handler. In case the input data is valid, there will be no errors. So we just go ahead to insert a new account into the database.

First we declare a CreateAccountParams object, where Owner is req.Owner, Currency is req.Currency, and Balance is 0. Then we call server.store.CreateAccount(), pass in the input context, and the argument. This function will return the created account and an error.

func (server *Server) createAccount(ctx *gin.Context) {
    var req createAccountRequest
    if err := ctx.ShouldBindJSON(&req); err != nil {
        ctx.JSON(http.StatusBadRequest, errorResponse(err))
        return
    }

    arg := db.CreateAccountParams{
        Owner:    req.Owner,
        Currency: req.Currency,
        Balance:  0,
    }

    account, err := server.store.CreateAccount(ctx, arg)
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    ctx.JSON(http.StatusOK, account)
}
Enter fullscreen mode Exit fullscreen mode

If the error is not nil, then there must be some internal issue when trying to insert to the database. Thus, we will return a 500 Internal Server Error status code to the client. We also reuse the errorResponse() function to send the error to the client, then return immediately.

If no errors occur, then the account is successfully created. We just send a 200 OK status code, and the created account object to the client. And that’s it! The createAccount handler is done.

Start HTTP server

Next, we have to add some more code to run the HTTP server. I’m gonna add a new Start() function to our Server struct. This function will take an address as input and return an error. Its role is to run the HTTP server on the input address to start listening for API requests.

func (server *Server) Start(address string) error {
    return server.router.Run(address)
}
Enter fullscreen mode Exit fullscreen mode

Gin already provided a function in the router to perform this action, so all we need to do is calling server.router.Run(), and pass in the server address.

Note that the server.router field is private, so it cannot be accessed from outside of this api package. That’s one of the reasons we have this public Start() function. For now, it has just 1 single command, but later, we might want to add some graceful shutdown logics in this function as well.

OK, now let’s create an entry point for our server in the main.go file at the root of this repository. The package name should be main, and it should have a main() function.

In order to create a Server, we need to connect to the database and create a Store first. It’s gonna be very similar to the code that we’ve written before in the main_test.go file.

So I’m gonna copy these constants for the dbDriver and dbSource, paste them to the top of our main.go file. Then also copy the block of code that establishes connections to the database and paste it inside the main function.

With this connection, we can create a new store using db.NewStore() function. Then we create a new server by calling api.NewServer() and pass in the store.

const (
    dbDriver      = "postgres"
    dbSource      = "postgresql://root:secret@localhost:5432/simple_bank?sslmode=disable"
)

func main() {
    conn, err := sql.Open(dbDriver, dbSource)
    if err != nil {
        log.Fatal("cannot connect to db:", err)
    }

    store := db.NewStore(conn)
    server := api.NewServer(store)

    ...
}
Enter fullscreen mode Exit fullscreen mode

To start the server, we just need to call server.Start() and pass in the server address. For now, I’m just gonna declare it as a constant: localhost, port 8080. In the future, we will refactor the code to load all of these configurations from environment variables or a setting file. In case some error occurs when starting the server, we just write a fatal log, saying cannot start server.

One last but very important thing we must do is to add a blank import for lib/pq driver. Without this, our code would not be able to talk to the database.

package main

import (
    "database/sql"
    "log"

    _ "github.com/lib/pq"
    "github.com/techschool/simplebank/api"
    db "github.com/techschool/simplebank/db/sqlc"
)

const (
    dbDriver      = "postgres"
    dbSource      = "postgresql://root:secret@localhost:5432/simple_bank?sslmode=disable"
    serverAddress = "0.0.0.0:8080"
)

func main() {
    conn, err := sql.Open(dbDriver, dbSource)
    if err != nil {
        log.Fatal("cannot connect to db:", err)
    }

    store := db.NewStore(conn)
    server := api.NewServer(store)

    err = server.Start(serverAddress)
    if err != nil {
        log.Fatal("cannot start server:", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Alright, so now the main entry for our server is completed. Let’s add a new make command to the Makefile to run it.

I’m gonna call it make server. And it should execute this go run main.go command. Let’s add server to the phony list.

...

server:
    go run main.go

.PHONY: postgres createdb dropdb migrateup migratedown sqlc test server
Enter fullscreen mode Exit fullscreen mode

Then open the terminal and run:

make server
Enter fullscreen mode Exit fullscreen mode

Alt Text

Voila, the server is up and running. It’s listening and serving HTTP requests on port 8080.

Test create account API with Postman

Now I’m gonna use Postman to test the create account API.

Let’s add a new request, select the POST method, fill in the URL, which is http://localhost:8080/accounts.

The parameters should be sent via a JSON body, so let’s select the Body tab, choose Raw, and select JSON format. We have to add 2 input fields: the owner’s name, I will use my name here, and a currency, let’s say USD.

{
    "owner": "Quang Pham",
    "currency": "USD"
}
Enter fullscreen mode Exit fullscreen mode

OK, then click Send.

Alt Text

Yee, it’s successful. We’ve got a 200 OK status code, and the created account object. It has ID = 1, balance = 0, with the correct owner’s name and currency.

Now let’s try to send some invalid data to see what will happen. I’m gonna set both fields to empty string, and click Send.

{
    "owner": "",
    "currency": ""
}
Enter fullscreen mode Exit fullscreen mode

Alt Text

This time, we’ve got 400 Bad Request, and an error saying the fields are required. This error message looks quite hard to read because it combines 2 validation errors of the 2 fields together. This is something we might want to improve in the future.

Next I’m gonna try to use an invalid currency code, such as xyz.

{
    "owner": "Quang Pham",
    "currency": "xyz"
}
Enter fullscreen mode Exit fullscreen mode

Alt Text

This time, we also get 400 Bad Request status code, but the error message is different. It say the validation failed on the oneof tag, which is exactly what we want, because in the code we only allow 2 possible values for the currency: USD and EUR.

It’s really great how Gin has handled all the input binding and validation for us with just a few lines of code. It also prints out a nice form of request logs, which is very easy to read for human eyes.

Alt Text

Implement get account API

Alright, next we’re gonna add an API to get a specific account by ID. It would be very similar to the create account API, so I will duplicate this routing statement:

func NewServer(store *db.Store) *Server {
    ...

    router.POST("/accounts", server.createAccount)
    router.GET("/accounts/:id", server.getAccount)

    ...
}
Enter fullscreen mode Exit fullscreen mode

Here instead of POST, we will use GET method. And this path should include the id of the account we want to get /accounts/:id. Note that we have a colon before the id. It’s how we tell Gin that id is a URI parameter.

Then we have to implement a new getAccount handler on the Server struct. Let’s move to the account.go file to do so. Similar as before, we declare a new struct called getAccountRequest to store the input parameters. It will have an ID field of type int64.

Now, since ID is a URI parameter, we cannot get it from the request body as before. Instead, we use the uri tag to tell Gin the name of the URI parameter:

type getAccountRequest struct {
    ID int64 `uri:"id" binding:"required,min=1"`
}
Enter fullscreen mode Exit fullscreen mode

We add a binding condition that this ID is a required field. Also, we don’t want client to send an invalid ID, such as a negative number. To tell Gin about this, we can use the min condition. In this case, let’s set min = 1, because it’s the smallest possible value of account ID.

OK, now in the server.getAccount handler, we will do similar as before. First we declare a new req variable of type getAccountRequest. Then here instead of ShouldBindJSON, we should call ShouldBindUri.

If there’s an error, we just return a 400 Bad Request status code. Otherwise, we call server.store.GetAccount() to get the account with ID equals to req.ID. This function will return an account and an error.

func (server *Server) getAccount(ctx *gin.Context) {
    var req getAccountRequest
    if err := ctx.ShouldBindUri(&req); err != nil {
        ctx.JSON(http.StatusBadRequest, errorResponse(err))
        return
    }

    account, err := server.store.GetAccount(ctx, req.ID)
    if err != nil {
        if err == sql.ErrNoRows {
            ctx.JSON(http.StatusNotFound, errorResponse(err))
            return
        }

        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    ctx.JSON(http.StatusOK, account)
}
Enter fullscreen mode Exit fullscreen mode

If error is not nil, then there are 2 possible scenarios.

  • The first scenario is some internal error when querying data from the database. In this case, we just return 500 Internal Server Error status code to the client.
  • The second scenario is when the account with that specific input ID doesn’t exist. In that case, the error we got should be a sql.ErrNoRows. So we just check it here, and if it’s really the case, we simply send a 404 Not Found status code to the client, and return.

If everything goes well and there’s no error, we just return a 200 OK status code and the account to the client. And that’s it! Our getAccount API is completed.

Test get account API with Postman

Let’s restart the server and open Postman to test it.

Let’s add a new request with method GET, and the URL is http://localhost:8080/accounts/1. We add a /1 at the end because we want to get the account with ID = 1. Now click send:

Alt Text

The request is successful, and we’ve got a 200 OK status code together with the found account. This is exactly the account that we’ve created before.

Now let’s try to get an account that doesn’t exist. I’m gonna change the ID to 100: http://localhost:8080/accounts/100, and click send again.

Alt Text

This time we’ve got a 404 Not Found status code, and an error: sql no rows in result set. Exactly what we expected.

Let’s try one more time with a negative ID: http://localhost:8080/accounts/-1

Alt Text

Now we got a 400 Bad Request status code with an error message about the failed validation.

Alright, so our getAccount API is working well.

Implement list account API

Next step, I’m gonna show you how to implement a list account API with pagination.

The number of accounts stored in our database can grow to a very big number over time. Therefore, we should not query and return all of them in a single API call. The idea of pagination is to divide the records into multiple pages of small size, so that the client can retrieve only 1 page per API request.

This API is a bit different because we will not get input parameters from request body or URI, but we will get them from query string instead. Here’s an example of the request:

Alt Text

We have a page_id param, which is the index number of the page we want to get, starting from page 1. And a page_size param, which is the maximum number of records can be returned in 1 page.

As you can see, the page_id and page_size are added to the request URL after a question mark: http://localhost:8080/accounts?page_id=1&page_size=5. That’s why they’re called query parameters, and not URI parameter like the account ID in the get account request.

OK, now let’s go back to our code. I’m gonna add a new route with the same GET method. But this time, the path should be /accounts only, since we’re gonna get the parameters from the query. The handler’s name should be listAccount.

func NewServer(store *db.Store) *Server {
    server := &Server{store: store}
    router := gin.Default()

    router.POST("/accounts", server.createAccount)
    router.GET("/accounts/:id", server.getAccount)
    router.GET("/accounts", server.listAccount)

    server.router = router
    return server
}
Enter fullscreen mode Exit fullscreen mode

Alright, let’s open the account.go file to implement this server.listAccount function. It’s very similar to the server.getAccount handler, so I’m gonna duplicate it. Then change the struct name to listAccountRequest.

This struct should store 2 parameters, PageID and PageSize. Now note that we’re not getting these parameters from uri, but from query string instead, so we cannot use the uri tag. Instead, we should use form tag.

type listAccountRequest struct {
    PageID   int32 `form:"page_id" binding:"required,min=1"`
    PageSize int32 `form:"page_size" binding:"required,min=5,max=10"`
    }
Enter fullscreen mode Exit fullscreen mode

Both parameters are required and the minimum PageID should be 1. For the PageSize, let’s say we don’t want it to be too big or too small, so I set its minimum constraint to be 5 records, and maximum constraint to be 10 records.

OK, now the server.listAccount handler function should be implemented like this:

func (server *Server) listAccount(ctx *gin.Context) {
    var req listAccountRequest
    if err := ctx.ShouldBindQuery(&req); err != nil {
        ctx.JSON(http.StatusBadRequest, errorResponse(err))
        return
    }

    arg := db.ListAccountsParams{
        Limit:  req.PageSize,
        Offset: (req.PageID - 1) * req.PageSize,
    }

    accounts, err := server.store.ListAccounts(ctx, arg)
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    ctx.JSON(http.StatusOK, accounts)
}
Enter fullscreen mode Exit fullscreen mode

The req variable’s type should be listAccountRequest. Then we use another binding function: ShouldBindQuery to tell Gin to get data from query string.

If an error occurs, we just return a 400 Bad Request status. Else, we call server.store.ListAccounts() to query a page of account records from the database. This function requires a ListAccountsParams as input, where we have to provide values for 2 fields: Limit and Offset.

Limit is simply the req.PageSize. While Offset is the number of records that the database should skip, wo we have to calculate it from the page id and page size using this formula: (req.PageID - 1) * req.PageSize

The ListAccounts function returns a list of accounts and an error. If an error occurs, then we just need to return 500 Internal Server Error to the client. Otherwise, we send a 200 OK status code with the output accounts list.

And that’s it, the ListAccount API is done.

Test list account API with Postman

Let’s restart the server then open Postman to test this request.

Alt Text

It’s successful, but we’ve got only 1 account on the list. That’s because our database is quite empty at the moment. We just created only 1 single account. Let’s run the database tests that we’ve written in previous lectures to have more random data.

❯ make test
Enter fullscreen mode Exit fullscreen mode

OK, now we should have a lot of accounts in our database. Let’s resend this API request.

Alt Text

Voila, now the returned list has exactly 5 accounts as expected. The account with ID 5 is not here because I think it’s got deleted in the test. We’ve got the account with ID 6 here instead.

Let’s try to get the second page.

Alt Text

Cool, now we get the next 5 accounts with ID from 7 to 11. So it’s working very well.

I’m gonna try one more time to get a page that doesn’t exist, let’s say page 100.

Alt Text

OK, so now we’ve got a null response body. Although it’s technically correct, I think it would be better if the server returns an empty list in this case. So let’s do that!

Return empty list instead of null

Here in the account.sql.go file that sqlc has generated for us:

func (q *Queries) ListAccounts(ctx context.Context, arg ListAccountsParams) ([]Account, error) {
    rows, err := q.db.QueryContext(ctx, listAccounts, arg.Limit, arg.Offset)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    var items []Account
    for rows.Next() {
        var i Account
        if err := rows.Scan(
            &i.ID,
            &i.Owner,
            &i.Balance,
            &i.Currency,
            &i.CreatedAt,
        ); err != nil {
            return nil, err
        }
        items = append(items, i)
    }
    if err := rows.Close(); err != nil {
        return nil, err
    }
    if err := rows.Err(); err != nil {
        return nil, err
    }
    return items, nil
}
Enter fullscreen mode Exit fullscreen mode

We can see that the Account items variable is declared without being initialized: var items []Account. That’s why it will remain null if no records are added.

Lucky for us, in the latest released of sqlc, which is version 1.5.0, we have a new setting that will instruct sqlc to create an empty slice instead of null.

The setting is called emit_empty_slices, and its default value is false. If we set this value to true, then the result returned by a many query will be an empty slice.

OK, so now let’s add this new setting to our sqlc.yaml file:

version: "1"
packages:
  - name: "db"
    path: "./db/sqlc"
    queries: "./db/query/"
    schema: "./db/migration/"
    engine: "postgresql"
    emit_json_tags: true
    emit_prepared_queries: false
    emit_interface: false
    emit_exact_table_names: false
    emit_empty_slices: true
Enter fullscreen mode Exit fullscreen mode

Save it, and open the terminal to upgrade sqlc to the latest version. If you’re on a Mac and using Homebrew, just run:

❯ brew upgrade sqlc
Enter fullscreen mode Exit fullscreen mode

You can check your current version by running:

❯ sqlc version
v1.5.0
Enter fullscreen mode Exit fullscreen mode

For me, it’s already the latest version: 1.5.0, so now I’m gonna regenerate the codes:

❯ make sqlc
Enter fullscreen mode Exit fullscreen mode

And back to visual studio code. Now in the account.sql.go file, we can see that the items variable is now initialized as an empty slice:

func (q *Queries) ListAccounts(ctx context.Context, arg ListAccountsParams) ([]Account, error) {
    ...

    items := []Account{}

    ...
}
Enter fullscreen mode Exit fullscreen mode

Cool! Let’s restart the server and test it on Postman. Now when I send this request, we’ve got an empty list as expected.

Alt Text

So it works!

Now I’m gonna try some invalid parameters. For example, let’s change page_size to 20, which is bigger than the maximum constraint of 10.

Alt Text

This time we’ve got 400 Bad Request status code, and an error saying the validation of page_size failed on the max tag.

Let’s try one more time with page_id = 0.

Alt Text

Now we still got 400 Bad Request status, but the error is because page_id validation failed on the required tag. What happens here is, in the validator package, any zero-value will be recognized as missing. It’s acceptable in this case because we don’t want to have the 0 page, anyway.

However, if your API has a zero value parameter, then you need to pay attention to this. I recommend you to read the documentation of validator package to learn more about it.

Alright, so today we have learned how easy it is to implement RESTful HTTP APIs in Go using Gin. You can based on this tutorial to try implementing some more routes to update or delete accounts on your own. I leave that as a practice exercise for you.

Thanks a lot for reading this article. Happy coding and I will see you soon in the next lecture!


If you like the article, please subscribe to our Youtube channel and follow us on Twitter for more tutorials in the future.

Discussion

pic
Editor guide