DEV Community

loading...
Cover image for Implement transfer money API with a custom params validator in Go

Implement transfer money API with a custom params validator in Go

TECH SCHOOL
We believe that everyone deserves a good and free education. The purpose of Tech School is to give everyone a chance to learn IT by giving free, high-quality tutorials and coding courses.
Updated on ・11 min read

In the previous lectures, we have implemented and tested the HTTP APIs to mange bank accounts for our simple bank project.

Today, we will do some more practice by implementing the most important API of our application: transfer money API.

And while doing so, I will show you how to write a custom validator to validate the input parameters of this API.

Here's:

Implement the transfer money API handler

First I will create a new transfer.go file inside the api package. The implementation of the transfer money API will be very similar to that of the create account API.

The struct to store input parameters of this API should be transferRequest. It will have several fields:

type transferRequest struct {
    FromAccountID int64  `json:"from_account_id" binding:"required,min=1"`
    ToAccountID   int64  `json:"to_account_id" binding:"required,min=1"`
    Amount        int64  `json:"amount" binding:"required,gt=0"`
    Currency      string `json:"currency" binding:"required,oneof=USD EUR CAD"`
}
Enter fullscreen mode Exit fullscreen mode
  • The FromAccountID of type int64 is the ID of the account where money is going out. This field is required, and its minimum value should be 1.
  • Similarly, the ToAccountID, also of type int64, is the ID of the account where the money is going in.
  • Next, we have the Amount field to store the amount of money to transfer between 2 accounts. For simplicity, here I just use integer type. But in reality, it could be a real number, depending on the currency. So you should keep that in mind and choose the appropriate type for your data. For the binding condition, this field is also required and it should be greater than 0.
  • The last field is the Currency of the money we want to transfer. For now, we only allow it to be either USD, EUR or CAD. And note that this currency should match the currency of both 2 accounts. We will verify that in the API handler function.

Alright, now the handler function’s name should be createTransfer. And the request variable’s type should be transferRequest.

We bind the input parameters to the request object, and return http.StatusBadRequest to the client if the any of the parameters is invalid.

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

    arg := db.TransferTxParams{
        FromAccountID: req.FromAccountID,
        ToAccountID:   req.ToAccountID,
        Amount:        req.Amount,
    }

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

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

Next we have to create a db.TransferTxParam object, where FromAccountID is request.fromAccountID, ToAccountID is request.toAccountID, and Amount is request.Amount.

With this argument, we call server.store.TransferTx() to perform the money transfer transaction. This function will return a TransferTxResult object or an error. At the end, we just need to return that result to the client if no errors occur.

Alright, this create transfer handler is almost finished except that we haven’t taken into account the last input parameter: request.Currency.

What we need to do is to compare this currency with the currency of the from account and to account to make sure that they’re all the same. So I’m gonna define a new function called validAccount() for the Server struct.

This function will check if an account with a specific ID really exists, and its currency matches the input currency. Therefore, it will have 3 input arguments: A gin.Context, an accountID, and a currency string. And it will return a bool value.

func (server *Server) validAccount(ctx *gin.Context, accountID int64, currency string) bool {
    account, err := server.store.GetAccount(ctx, accountID)
    if err != nil {
        if err == sql.ErrNoRows {
            ctx.JSON(http.StatusNotFound, errorResponse(err))
            return false
        }

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

    if account.Currency != currency {
        err := fmt.Errorf("account [%d] currency mismatch: %s vs %s", account.ID, account.Currency, currency)
        ctx.JSON(http.StatusBadRequest, errorResponse(err))
        return false
    }

    return true
}
Enter fullscreen mode Exit fullscreen mode

First, we call server.store.GetAccount() to query the account from the database. This function will return an account object or an error. If error is not nil, then there are 2 possible scenarios:

  • The first scenario is when the account doesn’t exist, then we send http.StatusNotFound to the client and return false.
  • The second scenario is when some unexpected errors occur, so we just send http.StatusInternalServerError and return false.

Otherwise, if there’s no error, we will check if the account’s currency matches the input currency or not. If it doesn’t match, we declare a new error: account currency mismatch. Then we call ctx.JSON() with the http.StatusBadRequest to send this error response to the client, and return false.

Finally, if everything is good, and the account is valid, we return true at the end of this function.

Now let’s go back to the createTransfer handler.

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

    if !server.validAccount(ctx, req.FromAccountID, req.Currency) {
        return
    }

    if !server.validAccount(ctx, req.ToAccountID, req.Currency) {
        return
    }

    arg := db.TransferTxParams{
        FromAccountID: req.FromAccountID,
        ToAccountID:   req.ToAccountID,
        Amount:        req.Amount,
    }

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

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

We call server.validAccount() to check the validity of the request.fromAccountID and currency. If it’s not valid, then we just return immediately. We do the same thing for the request.toAccountID.

And that’s it! The createTransfer handler is completed. Next we have to register a new API in the server to route requests to this handler.

Register the transfer money API route

Let’s open the api/server.go file.

I’m gonna duplicate the router.POST account API. Then change the path to /transfers, and the handler’s name to server.createTransfer.

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.listAccounts)
    router.POST("/transfers", server.createTransfer)

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

That’s all. Let’s open the terminal and run:

❯ make server
Enter fullscreen mode Exit fullscreen mode

Test the transfer money API

Then I’m gonna use Postman to test the new transfer money API.

Let's create a new request with method POST, and the URL is http://localhost:8080/transfers.

For the request body, let’s select raw, and choose JSON format. I’m gonna use this sample JSON body:

{
    "from_account_id": 1,
    "to_account_id": 2,
    "amount": 10,
    "currency": "USD"
}
Enter fullscreen mode Exit fullscreen mode

The from account id is 1, the to account id is 2, the amount is 10, and the currency is USD.

Let’s open TablePlus to see the current data of these 2 accounts.

Alt Text

Here we can see that their currencies are different. The currency of account 2 is USD, but that of account 1 is CAD.

So if we send this API request, we will get a currency mismatch error for the account 1.

Alt Text

To fix this, I will update the currency of account 1 to USD in TablePlus. Save it, and go back to Postman to send the request again.

Alt Text

This time the request is successful.

{
    "transfer": {
        "id": 110,
        "from_account_id": 1,
        "to_account_id": 2,
        "amount": 10,
        "created_at": "2020-12-07T19:57:09.61222Z"
    },
    "from_account": {
        "id": 1,
        "owner": "ojkelz",
        "balance": 734,
        "currency": "USD",
        "created_at": "2020-11-28T15:22:13.419691Z"
    },
    "to_account": {
        "id": 2,
        "owner": "ygmlfb",
        "balance": 824,
        "currency": "USD",
        "created_at": "2020-11-28T15:22:13.435304Z"
    },
    "from_entry": {
        "id": 171,
        "account_id": 1,
        "amount": -10,
        "created_at": "2020-12-07T19:57:09.61222Z"
    },
    "to_entry": {
        "id": 172,
        "account_id": 2,
        "amount": 10,
        "created_at": "2020-12-07T19:57:09.61222Z"
    }
}
Enter fullscreen mode Exit fullscreen mode

And in the response, we have a transfer record with id 110 from account 1 to account 2 with amount of 10. The new balance of account 1 is 734 USD. While that of account 2 is 824 USD.

Let’s check the database records in TablePlus. Before the transaction, the original values of account 1 and account 2 were 744 and 814 USD.

When I press command R to refresh the data, we can see that the balance of account 1 has decreased by 10, and the balance of account 2 has increased by 10.

Alt Text

Moreover, in the response, we also see 2 account entry objects:

  • One entry is to record that 10 USD has been subtracted from account 1.
  • And the other entry is to record that 10 USD has been added to account 2.

We can find these 2 records at the end of the entries table in the database. Similar for the transfer record at the bottom of the transfers table, which matches with the transfer object returned in the JSON response.

Alright, so the transfer API works perfectly. But there’s one thing I want to show you.

Implement a custom currency validator

Here, in the binding condition of the currency field, we’re hard-coding 3 constants for USD, EUR and CAD.

type transferRequest struct {
    FromAccountID int64  `json:"from_account_id" binding:"required,min=1"`
    ToAccountID   int64  `json:"to_account_id" binding:"required,min=1"`
    Amount        int64  `json:"amount" binding:"required,gt=0"`
    Currency      string `json:"currency" binding:"required,oneof=USD EUR CAD"`
}
Enter fullscreen mode Exit fullscreen mode

What if in the future we want to support 100 different types of currency? It would be very hard to read and easy to make mistakes if we put 100 currency values in this oneof tag.

Also, there will be duplications because the currency parameter can appear in many different APIs. For now, we already have 1 duplication in the create account request.

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

In order to avoid that, I’m gonna show you how to write a custom validator for the currency field.

Let’s create a new file validator.go inside the api folder. Then declare a new variable validCurrency of type validator.Func

package api

import (
    "github.com/go-playground/validator/v10"
    "github.com/techschool/simplebank/util"
)

var validCurrency validator.Func = func(fieldLevel validator.FieldLevel) bool {
    if currency, ok := fieldLevel.Field().Interface().(string); ok {
        return util.IsSupportedCurrency(currency)
    }
    return false
}

Enter fullscreen mode Exit fullscreen mode

Visual studio code has automatically imported the validator package for us. However, we have to add /v10 at the end of this import path because we want to use the version 10 of this package.

Basically, validator.Func is a function that takes a validator.FieldLevel interface as input and return true when validation succeeds. This is an interface that contains all information and helper functions to validate a field.

What we need to do is calling fieldLevel.Field() to get the value of the field. Note that it’s a reflection value, so we have to call .Interface() to get its value as an interface{}. Then we try to convert this value to a string.

The conversion will return a currency string and a ok boolean value. If ok is true then the currency is a valid string. In this case, we will have to check if that currency is supported or not. Else, if ok is false, then the field is not a string. Therefore, we just return false.

Alright, now I will create a new file currency.go inside the util package. We will implement the function IsSupportedCurrency() to check if a currency is supported or not in this file.

First I will declare some constants for the currencies that we want to support in our bank. For now let’s say we only support USD, EUR, and CAD. We can always add more currencies in the future if we want.

Then let’s write a new function IsSupportedCurrency() that takes a currency string as input and return a bool value. It will return true if the input currency is supported and false otherwise.

package util

// Constants for all supported currencies
const (
    USD = "USD"
    EUR = "EUR"
CAD = "CAD"
)

// IsSupportedCurrency returns true if the currency is supported
func IsSupportedCurrency(currency string) bool {
    switch currency {
    case USD, EUR, CAD:
        return true
    }
    return false
}

Enter fullscreen mode Exit fullscreen mode

In this function, we just use a simple switch case statement. In case the currency is USD, EUR, or CAD, we return true. Else, we return false.

And that’s it! Our custom validator validCurrency is done. Next we have to register this custom validator with Gin.

Register the custom currency validator

Let’s open the server.go file.

package api

import (
    "github.com/gin-gonic/gin"
    "github.com/gin-gonic/gin/binding"
    "github.com/go-playground/validator/v10"
)

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

    if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
        v.RegisterValidation("currency", validCurrency)
    }

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

    router.POST("/transfers", server.createTransfer)

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

Here, after creating the Gin router, we call binding.Validator.Engine() to get the current validator engine that Gin is using (binding is a sub-package of Gin).

Note that this function will return a general interface type, which by default is a pointer to the validator object of the go-playground/validator/v10 package.

So here we have to convert the output to a validator.Validate object pointer. If it is ok then we can call v.RegisterValidation() to register our custom validate function.

The first argument is the name of the validation tag: currency. And the second argument should be the validCurrency function that we have implemented before.

Use the custom currency validator

Alright, now with this new validator registered, we can start using it.

Here in the createAccountRequest struct, we can replace the oneof=USD EUR CAD tag with just currency tag:

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

And similar for this Currency field of the transferRequest struct:

type transferRequest struct {
    FromAccountID int64  `json:"from_account_id" binding:"required,min=1"`
    ToAccountID   int64  `json:"to_account_id" binding:"required,min=1"`
    Amount        int64  `json:"amount" binding:"required,gt=0"`
    Currency      string `json:"currency" binding:"required,currency"`
}
Enter fullscreen mode Exit fullscreen mode

OK, let’s restart the server and test it.

❯ make server
Enter fullscreen mode Exit fullscreen mode

I’m gonna change the currency to EUR and send the Postman request.

Alt Text

This is a valid supported currency, but it doesn’t match the currency of the accounts, so we’ve got a 400 Bad Request status with the currency mismatch error.

If I change it back to USD, then the request is successful again:

Alt Text

Now let’s try an unsupported currency, such as AUD:

Alt Text

This time, we also get 400 Bad Request status, but the error is because the field validation for the currency failed on the currency tag, which is exactly what we expected.

So our custom currency validator is working very well!

So today we have learned how to implement the transfer money API with a custom parameter binding validator. I hope it is useful for you.

Although I didn’t show you how to write unit tests for this new API in the article because it would be very similar to what we’ve learned in the previous lecture, I actually still write a lot of unit tests for it and push them to Github.

I encourage you to check them out on the simple bank repository to see how they were implemented.

Thanks a lot for reading this article, and I will see you soon in the one!


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


If you want to join me on my current amazing team at Voodoo, check out our job openings here. Remote or onsite in Paris/Amsterdam/London/Berlin/Barcelona with visa sponsorship.

Discussion (0)