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:
- Link to the full series playlist on Youtube
- And its Github repository
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"`
}
- The
FromAccountID
of typeint64
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 typeint64
, 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 eitherUSD
,EUR
orCAD
. 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)
}
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
}
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 returnfalse
. - The second scenario is when some unexpected errors occur, so we just send
http.StatusInternalServerError
and returnfalse
.
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)
}
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
}
That’s all. Let’s open the terminal and run:
❯ make server
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"
}
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.
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
.
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.
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"
}
}
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
.
Moreover, in the response, we also see 2 account entry objects:
- One entry is to record that
10 USD
has been subtracted from account1
. - And the other entry is to record that
10 USD
has been added to account2
.
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"`
}
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"`
}
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
}
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
}
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
}
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"`
}
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"`
}
OK, let’s restart the server and test it.
❯ make server
I’m gonna change the currency to EUR
and send the Postman request.
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:
Now let’s try an unsupported currency, such as AUD
:
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.
Top comments (0)